updateAppSettings.mutate(partial)} />}
+ >
+ )
+}
+
+function AuditLogSection({ settings, isLoading, error, isSaving, onUpdate }: AppSettingsSectionProps) {
+ return (
+
+
+
Audit log
+ {isSaving && }
-
+
+
+
+ {settings && (
+ onUpdate({ audit_retention_days: v })}
+ description="Entries older than this are deleted once a day."
+ />
+ )}
+
)
}
@@ -249,12 +311,14 @@ function NumberSetting({
onChange,
description,
min = 0,
+ max,
}: {
label: string
value: number
onChange: (v: number) => void
description?: string
min?: number
+ max?: number
}) {
const [draft, setDraft] = useState(String(value))
const skipNextBlurCommit = useRef(false)
@@ -275,7 +339,7 @@ function NumberSetting({
return
}
const parsed = Number(draft)
- if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < min) {
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < min || (max !== undefined && parsed > max)) {
setDraft(String(value))
return
}
@@ -288,6 +352,7 @@ function NumberSetting({
void) | null = null
vi.mock('./BatchSourceDialog', () => ({
BatchSourceDialog: ({ onClose, onSourceCreated }: { onClose: () => void; onSourceCreated: (id: string) => void }) =>
@@ -31,8 +33,8 @@ vi.mock('@/hooks/useApi', async () => {
useSourceBatches: () => ({ data: mockBatchPage, isLoading: false, refetch: vi.fn() }),
useCreateSource: () => ({ mutate: vi.fn(), reset: vi.fn(), isPending: false }),
useUpdateSource: () => ({ mutate: vi.fn(), isPending: false }),
- useDeleteSource: () => ({ mutate: vi.fn(), isPending: false }),
- useReindexSource: () => ({ mutate: vi.fn(), isPending: false }),
+ useDeleteSource: () => ({ mutate: deleteMutate, isPending: false }),
+ useReindexSource: () => ({ mutate: reindexMutate, isPending: false }),
useLocalSourceRoots: () => localRootsState,
}
})
@@ -434,3 +436,50 @@ describe('SourcesPage list', () => {
expect(screen.getByRole('button', { name: 'More batches' })).toBeInTheDocument()
})
})
+
+describe('SourcesPage action errors', () => {
+ const remoteSource = {
+ id: 'remote-source',
+ name: 'Remote docs',
+ root_path: '/srv/docs',
+ location_type: 'agent',
+ agent_id: 'agent-1',
+ agent_status: 'online',
+ processing_mode: null,
+ created_at: '',
+ updated_at: '',
+ } as Source
+
+ beforeEach(() => {
+ mockSources = [remoteSource]
+ mockBatchPage = { batches: [], next_cursor: null }
+ deleteMutate.mockReset()
+ reindexMutate.mockReset()
+ })
+
+ it('explains a refused agent reindex in plain words', () => {
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Reindex Remote docs' }))
+ act(() => reindexMutate.mock.calls[0][1].onError(new ApiError('agent_unavailable', 409, 'agent_unavailable')))
+ expect(screen.getByRole('alert')).toHaveTextContent('The agent is offline. Try again once it reconnects.')
+ })
+
+ it('keeps the full reindex dialog open and shows why it failed', () => {
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Full reindex Remote docs' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Full reindex' }))
+ act(() => reindexMutate.mock.calls[0][1].onError(new ApiError('remote_agents_disabled', 409, 'remote_agents_disabled')))
+ expect(screen.getByText('Full reindex source?')).toBeInTheDocument()
+ expect(within(screen.getByRole('dialog')).getByText('Remote agents are turned off. Turn them on to reindex this source.')).toBeInTheDocument()
+ })
+
+ it('keeps the delete dialog open and shows the API message', () => {
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Delete Remote docs' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+ const message = 'Failed to remove source data; try again.'
+ act(() => deleteMutate.mock.calls[0][1].onError(new ApiError(message, 502, message)))
+ expect(screen.getByText('Delete Source')).toBeInTheDocument()
+ expect(screen.getByRole('alert')).toHaveTextContent(message)
+ })
+})
diff --git a/frontend/src/pages/admin/SourcesPage.tsx b/frontend/src/pages/admin/SourcesPage.tsx
index ed731918..0661f0cb 100644
--- a/frontend/src/pages/admin/SourcesPage.tsx
+++ b/frontend/src/pages/admin/SourcesPage.tsx
@@ -8,7 +8,7 @@ import type { Agent, ProcessingMode, Source, SourceCreate, SourceUpdate, SourceP
import { RemotePathPicker } from '@/components/agents/RemotePathPicker'
import { LocalFolderPicker } from '@/components/LocalFolderPicker'
import { cn, formatRelativeTime } from '@/lib/utils'
-import { browseSourceDirectory, dismissSourceBatch, listSourceBatches } from '@/lib/api'
+import { ApiError, browseSourceDirectory, dismissSourceBatch, listSourceBatches } from '@/lib/api'
import { BatchSourceDialog } from './BatchSourceDialog'
import type { SourceBatchSummary } from '@/types/api'
import { AgentUnavailableBadge } from '@/components/agents/AgentUnavailableBadge'
@@ -344,6 +344,20 @@ export function SourceForm({
)
}
+// The API sends bare codes for some refused reindexes, so map those to plain text.
+const REINDEX_REFUSAL_MESSAGES: Record
= {
+ agent_unavailable: 'The agent is offline. Try again once it reconnects.',
+ remote_agents_disabled: 'Remote agents are turned off. Turn them on to reindex this source.',
+}
+
+function actionErrorMessage(error: unknown, fallback: string): string {
+ if (error instanceof ApiError && error.detail && REINDEX_REFUSAL_MESSAGES[error.detail]) {
+ return REINDEX_REFUSAL_MESSAGES[error.detail]
+ }
+ if (error instanceof Error && error.message) return error.message
+ return fallback
+}
+
// Delete confirmation dialog
function DeleteConfirmDialog({
source,
@@ -351,12 +365,14 @@ function DeleteConfirmDialog({
onOpenChange,
onConfirm,
isLoading,
+ error,
}: {
source: Source | null
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
isLoading: boolean
+ error?: string | null
}) {
return (
@@ -368,6 +384,12 @@ function DeleteConfirmDialog({
This action cannot be undone.
+ {error && (
+
+
+ {error}
+
+ )}
onOpenChange(false)} disabled={isLoading}>
Cancel
@@ -397,6 +419,9 @@ export default function SourcesPage() {
const [deletingSource, setDeletingSource] = useState(null)
const [fullReindexSource, setFullReindexSource] = useState(null)
const [reindexingId, setReindexingId] = useState(null)
+ const [reindexError, setReindexError] = useState(null)
+ const [fullReindexError, setFullReindexError] = useState(null)
+ const [deleteError, setDeleteError] = useState(null)
// Queries and mutations
const { data: sources, isLoading: isLoadingSources, error: sourcesError, refetch: refetchSources } = useSources()
@@ -462,16 +487,24 @@ export default function SourcesPage() {
const handleDelete = () => {
if (!deletingSource) return
+ setDeleteError(null)
deleteMutation.mutate(deletingSource.id, {
onSuccess: () => {
setDeletingSource(null)
},
+ onError: (error) => {
+ setDeleteError(actionErrorMessage(error, 'Could not delete this source.'))
+ },
})
}
const handleReindex = (id: string) => {
+ setReindexError(null)
setReindexingId(id)
reindexMutation.mutate({ id }, {
+ onError: (error) => {
+ setReindexError(actionErrorMessage(error, 'Could not start the reindex.'))
+ },
onSettled: () => {
setReindexingId(null)
},
@@ -480,11 +513,17 @@ export default function SourcesPage() {
const handleFullReindex = () => {
if (!fullReindexSource) return
+ setFullReindexError(null)
setReindexingId(fullReindexSource.id)
reindexMutation.mutate({ id: fullReindexSource.id, full: true }, {
+ onSuccess: () => {
+ setFullReindexSource(null)
+ },
+ onError: (error) => {
+ setFullReindexError(actionErrorMessage(error, 'Could not start the full reindex.'))
+ },
onSettled: () => {
setReindexingId(null)
- setFullReindexSource(null)
},
})
}
@@ -544,6 +583,13 @@ export default function SourcesPage() {
+ {reindexError && (
+
+
+ {reindexError}
+
+ )}
+
{hasSources ? (
// Sources table — @container so columns show based on table width, not viewport
@@ -634,7 +680,7 @@ export default function SourcesPage() {
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-muted-foreground hover:text-brand hover:bg-brand/10 rounded-lg transition-all active:scale-95 disabled:opacity-50 disabled:active:scale-100"
title="Clear this source from the index and rebuild every matching file from scratch."
aria-label={`Full reindex ${source.name}`}
- onClick={() => setFullReindexSource(source)}
+ onClick={() => { setFullReindexError(null); setFullReindexSource(source) }}
disabled={reindexingId === source.id}
>
@@ -651,7 +697,7 @@ export default function SourcesPage() {
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all active:scale-95"
title="Delete this source and remove its indexed documents."
aria-label={`Delete ${source.name}`}
- onClick={() => setDeletingSource(source)}
+ onClick={() => { setDeleteError(null); setDeletingSource(source) }}
>
@@ -771,6 +817,12 @@ export default function SourcesPage() {
)}
+ {fullReindexError && (
+
+
+ {fullReindexError}
+
+ )}
setFullReindexSource(null)} disabled={reindexMutation.isPending}>
Cancel
@@ -790,6 +842,7 @@ export default function SourcesPage() {
onOpenChange={(open) => !open && setDeletingSource(null)}
onConfirm={handleDelete}
isLoading={deleteMutation.isPending}
+ error={deleteError}
/>
)
diff --git a/frontend/src/pages/admin/StatusPage.test.tsx b/frontend/src/pages/admin/StatusPage.test.tsx
new file mode 100644
index 00000000..57f6b7a4
--- /dev/null
+++ b/frontend/src/pages/admin/StatusPage.test.tsx
@@ -0,0 +1,46 @@
+// Copyright (C) 2026 demigodmode
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { render, screen } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { describe, expect, it, vi } from 'vitest'
+import StatusPage from './StatusPage'
+import type { SourceStatus } from '@/types/api'
+
+let mockSources: SourceStatus[] = []
+vi.mock('@/hooks/useApi', () => ({
+ useHealth: () => ({ data: undefined, isLoading: false, error: null }),
+ useStatus: () => ({ data: { sources: mockSources }, isLoading: false, error: null }),
+}))
+
+const base = {
+ source_name: 'Docs',
+ total_files: 10,
+ successful: 10,
+ failed: 0,
+ skipped: 0,
+ last_indexed_at: '2026-11-01T10:00:00Z',
+ failed_files: [],
+}
+
+function renderStatus() {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('StatusPage next scan', () => {
+ it('shows the next scan for an interval or default schedule', () => {
+ mockSources = [{ ...base, source_id: 'docs', scan_schedule: null, next_scan_at: '2026-11-04T10:00:00Z' }]
+ renderStatus()
+ expect(screen.getByText(/Next scan/)).toBeInTheDocument()
+ })
+
+ it('hides the next scan when no scan is queued', () => {
+ mockSources = [{ ...base, source_id: 'docs', scan_schedule: null, next_scan_at: null }]
+ renderStatus()
+ expect(screen.queryByText(/Next scan/)).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/admin/StatusPage.tsx b/frontend/src/pages/admin/StatusPage.tsx
index 0b7013bf..7715a06c 100644
--- a/frontend/src/pages/admin/StatusPage.tsx
+++ b/frontend/src/pages/admin/StatusPage.tsx
@@ -127,7 +127,7 @@ function SourceStatusCard({ source, index }: { source: SourceStatus; index: numb
{source.source_name}
Last indexed {formatDate(source.last_indexed_at)}
- {source.scan_schedule && source.next_scan_at && (
+ {source.next_scan_at && (
· Next scan {formatRelativeTime(source.next_scan_at)}
diff --git a/frontend/src/pages/admin/UserActionsMenu.tsx b/frontend/src/pages/admin/UserActionsMenu.tsx
new file mode 100644
index 00000000..f9eb2165
--- /dev/null
+++ b/frontend/src/pages/admin/UserActionsMenu.tsx
@@ -0,0 +1,52 @@
+// Copyright (C) 2026 demigodmode
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { MoreHorizontal } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
+import type { AdminUser } from '@/types/api'
+
+interface UserActionsMenuProps {
+ user: AdminUser
+ onChangeRole: () => void
+ onToggleActive: () => void
+ onResetPassword: () => void
+ onViewTokens: () => void
+ onDelete: () => void
+}
+
+export function UserActionsMenu({
+ user,
+ onChangeRole,
+ onToggleActive,
+ onResetPassword,
+ onViewTokens,
+ onDelete,
+}: UserActionsMenuProps) {
+ return (
+
+
+
+
+
+
+
+
+ {user.role === 'admin' ? 'Make member' : 'Make admin'}
+
+
+ {user.is_active ? 'Disable' : 'Enable'}
+
+
+ Reset password
+
+
+ Tokens
+
+
+ Delete
+
+
+
+ )
+}
diff --git a/frontend/src/pages/admin/UserTokensDialog.tsx b/frontend/src/pages/admin/UserTokensDialog.tsx
new file mode 100644
index 00000000..50d743b2
--- /dev/null
+++ b/frontend/src/pages/admin/UserTokensDialog.tsx
@@ -0,0 +1,66 @@
+// Copyright (C) 2026 demigodmode
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { useState } from 'react'
+import { ApiTokenTable } from '@/components/account/ApiTokenTable'
+import { RevokeApiTokenDialog } from '@/components/account/ApiTokenDialogs'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { useRevokeUserToken, useUserTokens } from '@/hooks/useApi'
+import type { AdminUser, ApiToken } from '@/types/api'
+
+interface UserTokensDialogProps {
+ user: AdminUser | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function UserTokensDialog({ user, open, onOpenChange }: UserTokensDialogProps) {
+ const tokens = useUserTokens(user?.id)
+ const revokeToken = useRevokeUserToken()
+ // The target stays set after the dialog closes so its title doesn't change mid-animation.
+ const [revokeTarget, setRevokeTarget] = useState(null)
+ const [revokeOpen, setRevokeOpen] = useState(false)
+
+ if (!user) return null
+
+ return (
+
+
+
+ {`${user.username}'s API tokens`}
+ Tokens this user has made to search documents
+
+
+ {tokens.isLoading && Loading tokens...
}
+ {tokens.error && Unable to load tokens.
}
+
+ {tokens.data && tokens.data.length === 0 && (
+ {user.username} has no API tokens.
+ )}
+
+ {tokens.data && tokens.data.length > 0 && (
+ {
+ setRevokeTarget(token)
+ setRevokeOpen(true)
+ }}
+ />
+ )}
+
+ revokeToken.mutateAsync({ userId: user.id, tokenId: token.id })}
+ />
+
+
+ )
+}
diff --git a/frontend/src/pages/admin/UsersPage.test.tsx b/frontend/src/pages/admin/UsersPage.test.tsx
new file mode 100644
index 00000000..684d62f0
--- /dev/null
+++ b/frontend/src/pages/admin/UsersPage.test.tsx
@@ -0,0 +1,346 @@
+// Copyright (C) 2026 demigodmode
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import UsersPage from './UsersPage'
+import type { AdminUser, ApiToken, User } from '@/types/api'
+
+const api = vi.hoisted(() => ({
+ listUsers: vi.fn(),
+ createUser: vi.fn(),
+ updateUser: vi.fn(),
+ resetUserPassword: vi.fn(),
+ deleteUser: vi.fn(),
+ listUserTokens: vi.fn(),
+ revokeUserToken: vi.fn(),
+}))
+vi.mock('@/lib/api', async (importOriginal) => ({ ...(await importOriginal()), ...api }))
+
+const signedIn = vi.hoisted(() => ({ user: null as User | null }))
+vi.mock('@/contexts/useAuth', () => ({ useAuth: () => ({ user: signedIn.user, logout: vi.fn() }) }))
+
+const alice: AdminUser = { id: 1, username: 'alice', role: 'admin', is_active: true, must_change_password: false, created_at: '2026-01-02T10:00:00Z', created_by: null, last_login_at: '2026-01-03T10:00:00Z' }
+const bob: AdminUser = { id: 2, username: 'bob', role: 'member', is_active: false, must_change_password: true, created_at: '2026-01-04T10:00:00Z', created_by: 'alice', last_login_at: null }
+const carol: AdminUser = { id: 3, username: 'carol', role: 'admin', is_active: true, must_change_password: true, created_at: '2026-01-05T10:00:00Z', created_by: 'alice', last_login_at: null }
+const laptop: ApiToken = { id: 7, name: 'Laptop', scope: 'search', display_prefix: 'os_AbCd', created_at: '2026-01-02T10:00:00Z', expires_at: null, last_used_at: null, revoked: false, revoked_at: null, status: 'active' }
+
+function renderPage() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
+ return render(
+
+
+ ,
+ )
+}
+
+async function openActions(username: string) {
+ await userEvent.click(screen.getByRole('button', { name: `Actions for ${username}` }))
+}
+
+describe('UsersPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ signedIn.user = { id: 1, username: 'alice', is_active: true, created_at: '2026-01-02T10:00:00Z', role: 'admin', must_change_password: false }
+ api.listUsers.mockResolvedValue([alice, bob])
+ })
+
+ it('lists users and shows Never when there is no last login', async () => {
+ renderPage()
+ const bobRow = within(await screen.findByRole('row', { name: /bob/ }))
+ expect(bobRow.getByText('Disabled')).toBeInTheDocument()
+ expect(bobRow.getByText('Never')).toBeInTheDocument()
+ expect(bobRow.getByText('alice')).toBeInTheDocument()
+ expect(within(screen.getByRole('row', { name: /^alice/ })).getByText('Active')).toBeInTheDocument()
+ })
+
+ it('creates a user, closes the form and refreshes the list', async () => {
+ api.listUsers.mockResolvedValueOnce([alice, bob]).mockResolvedValueOnce([alice, bob, carol])
+ api.createUser.mockResolvedValue(carol)
+ renderPage()
+ await screen.findByText('bob')
+
+ await userEvent.click(screen.getByRole('button', { name: 'Create user' }))
+ await userEvent.type(screen.getByLabelText('Username'), 'carol')
+ fireEvent.change(screen.getByLabelText('Role'), { target: { value: 'admin' } })
+ await userEvent.type(screen.getByLabelText('Temporary password'), 'temp-pass-1')
+ await userEvent.click(screen.getByRole('button', { name: 'Create user' }))
+
+ await waitFor(() => expect(api.createUser).toHaveBeenCalledWith({ username: 'carol', password: 'temp-pass-1', role: 'admin' }))
+ expect(await screen.findByRole('row', { name: /^carol/ })).toBeInTheDocument()
+ expect(api.listUsers).toHaveBeenCalledTimes(2)
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('shows the duplicate username error and keeps the form open', async () => {
+ const { ApiError } = await import('@/lib/api')
+ api.createUser.mockRejectedValue(new ApiError('Username already exists', 409, 'Username already exists'))
+ renderPage()
+ await screen.findByText('bob')
+
+ await userEvent.click(screen.getByRole('button', { name: 'Create user' }))
+ await userEvent.type(screen.getByLabelText('Username'), 'alice')
+ await userEvent.type(screen.getByLabelText('Temporary password'), 'temp-pass-1')
+ await userEvent.click(screen.getByRole('button', { name: 'Create user' }))
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Username already exists')
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('has no actions on your own row', async () => {
+ renderPage()
+ await screen.findByText('bob')
+
+ expect(screen.queryByRole('button', { name: 'Actions for alice' })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Actions for bob' })).toBeInTheDocument()
+ })
+
+ it('opens the actions with Enter and returns focus to the trigger on Escape', async () => {
+ renderPage()
+ await screen.findByText('bob')
+
+ const trigger = screen.getByRole('button', { name: 'Actions for bob' })
+ trigger.focus()
+ await userEvent.keyboard('{Enter}')
+ expect(await screen.findByRole('menuitem', { name: 'Make admin' })).toBeInTheDocument()
+
+ await userEvent.keyboard('{Escape}')
+ await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument())
+ expect(trigger).toHaveFocus()
+ })
+
+ it('moves into the actions with ArrowDown', async () => {
+ renderPage()
+ await screen.findByText('bob')
+
+ screen.getByRole('button', { name: 'Actions for bob' }).focus()
+ await userEvent.keyboard('{ArrowDown}')
+ expect(await screen.findByRole('menuitem', { name: 'Make admin' })).toHaveFocus()
+ })
+
+ it('makes a member an admin and refreshes the list', async () => {
+ api.listUsers.mockResolvedValueOnce([alice, bob]).mockResolvedValueOnce([alice, { ...bob, role: 'admin' }])
+ api.updateUser.mockResolvedValue({ ...bob, role: 'admin' })
+ renderPage()
+ await screen.findByText('bob')
+
+ await openActions('bob')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Make admin' }))
+
+ await waitFor(() => expect(api.updateUser).toHaveBeenCalledWith(2, { role: 'admin' }))
+ await waitFor(() => expect(api.listUsers).toHaveBeenCalledTimes(2))
+ })
+
+ it('makes an admin a member', async () => {
+ api.listUsers.mockResolvedValue([alice, carol])
+ api.updateUser.mockResolvedValue({ ...carol, role: 'member' })
+ renderPage()
+ await screen.findByText('carol')
+
+ await openActions('carol')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Make member' }))
+
+ await waitFor(() => expect(api.updateUser).toHaveBeenCalledWith(3, { role: 'member' }))
+ })
+
+ it('enables a disabled user', async () => {
+ api.listUsers.mockResolvedValueOnce([alice, bob]).mockResolvedValueOnce([alice, { ...bob, is_active: true }])
+ api.updateUser.mockResolvedValue({ ...bob, is_active: true })
+ renderPage()
+ await screen.findByText('bob')
+
+ await openActions('bob')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Enable' }))
+
+ await waitFor(() => expect(api.updateUser).toHaveBeenCalledWith(2, { is_active: true }))
+ await waitFor(() => expect(api.listUsers).toHaveBeenCalledTimes(2))
+ })
+
+ it('disables an active user', async () => {
+ api.listUsers.mockResolvedValue([alice, carol])
+ api.updateUser.mockResolvedValue({ ...carol, is_active: false })
+ renderPage()
+ await screen.findByText('carol')
+
+ await openActions('carol')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Disable' }))
+
+ await waitFor(() => expect(api.updateUser).toHaveBeenCalledWith(3, { is_active: false }))
+ })
+
+ it('shows the last-admin error from the API when a change is refused', async () => {
+ const { ApiError } = await import('@/lib/api')
+ const detail = 'There has to be at least one active admin'
+ api.listUsers.mockResolvedValue([alice, carol])
+ api.updateUser.mockRejectedValue(new ApiError(detail, 409, detail))
+ renderPage()
+ await screen.findByText('carol')
+
+ await openActions('carol')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Disable' }))
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(detail)
+ })
+
+ it('resets a password from a dialog and refreshes the list', async () => {
+ api.listUsers.mockResolvedValueOnce([alice, carol]).mockResolvedValueOnce([alice, carol])
+ api.resetUserPassword.mockResolvedValue(carol)
+ renderPage()
+ await screen.findByText('carol')
+
+ await openActions('carol')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Reset password' }))
+
+ const dialog = screen.getByRole('dialog', { name: 'Reset password' })
+ expect(within(dialog).getByText('At least 8 characters')).toBeInTheDocument()
+ await userEvent.type(within(dialog).getByLabelText('New temporary password'), 'temp-reset-2')
+ await userEvent.click(within(dialog).getByRole('button', { name: 'Reset password' }))
+
+ await waitFor(() => expect(api.resetUserPassword).toHaveBeenCalledWith(3, { password: 'temp-reset-2' }))
+ await waitFor(() => expect(api.listUsers).toHaveBeenCalledTimes(2))
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('rejects a short temporary password before sending it', async () => {
+ api.listUsers.mockResolvedValue([alice, carol])
+ renderPage()
+ await screen.findByText('carol')
+
+ await openActions('carol')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Reset password' }))
+ const dialog = screen.getByRole('dialog', { name: 'Reset password' })
+ await userEvent.type(within(dialog).getByLabelText('New temporary password'), 'short')
+ await userEvent.click(within(dialog).getByRole('button', { name: 'Reset password' }))
+
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('Password must be at least 8 characters')
+ expect(api.resetUserPassword).not.toHaveBeenCalled()
+ })
+
+ it('deletes a user after confirming and refreshes the list', async () => {
+ api.listUsers.mockResolvedValueOnce([alice, bob]).mockResolvedValueOnce([alice])
+ api.deleteUser.mockResolvedValue(undefined)
+ renderPage()
+ await screen.findByText('bob')
+
+ await openActions('bob')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }))
+
+ const dialog = screen.getByRole('dialog', { name: 'Delete bob?' })
+ expect(within(dialog).getByText("This can't be undone. Disabling keeps their history.")).toBeInTheDocument()
+ await userEvent.click(within(dialog).getByRole('button', { name: 'Delete user' }))
+
+ await waitFor(() => expect(api.deleteUser).toHaveBeenCalledWith(2))
+ await waitFor(() => expect(api.listUsers).toHaveBeenCalledTimes(2))
+ expect(screen.queryByRole('row', { name: /bob/ })).not.toBeInTheDocument()
+ })
+
+ it('does not delete when the confirm dialog is cancelled', async () => {
+ renderPage()
+ await screen.findByText('bob')
+
+ await openActions('bob')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }))
+ await userEvent.click(within(screen.getByRole('dialog', { name: 'Delete bob?' })).getByRole('button', { name: 'Cancel' }))
+
+ expect(api.deleteUser).not.toHaveBeenCalled()
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('shows the API error inside the delete dialog', async () => {
+ const { ApiError } = await import('@/lib/api')
+ const detail = 'There has to be at least one active admin'
+ api.listUsers.mockResolvedValue([alice, carol])
+ api.deleteUser.mockRejectedValue(new ApiError(detail, 409, detail))
+ renderPage()
+ await screen.findByText('carol')
+
+ await openActions('carol')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }))
+ await userEvent.click(within(screen.getByRole('dialog', { name: 'Delete carol?' })).getByRole('button', { name: 'Delete user' }))
+
+ expect(await within(screen.getByRole('dialog', { name: 'Delete carol?' })).findByRole('alert')).toHaveTextContent(detail)
+ })
+
+ it('keeps the username in the dialog while it animates closed', async () => {
+ // jsdom has no CSS animations. Report an exit animation once the dialog is closed,
+ // so Radix keeps it mounted until animationend fires, like a browser would.
+ const realGetComputedStyle = window.getComputedStyle
+ const styles = vi.spyOn(window, 'getComputedStyle').mockImplementation((el, pseudo) => {
+ const style = realGetComputedStyle(el, pseudo)
+ if (el instanceof HTMLElement && el.hasAttribute('data-state')) {
+ Object.defineProperty(style, 'animationName', { get: () => (el.getAttribute('data-state') === 'closed' ? 'exit' : 'none') })
+ }
+ return style
+ })
+ try {
+ api.listUsers.mockResolvedValue([alice, bob])
+ renderPage()
+ await screen.findByText('bob')
+
+ await openActions('bob')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }))
+ const dialog = screen.getByRole('dialog', { name: 'Delete bob?' })
+ await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+
+ expect(screen.getByRole('heading', { name: 'Delete bob?' })).toBeInTheDocument()
+ const animationEnd = Object.assign(new Event('animationend'), { animationName: 'exit' })
+ dialog.dispatchEvent(animationEnd)
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ } finally {
+ styles.mockRestore()
+ }
+ })
+
+ it("opens the tokens dialog from the row menu and lists that user's tokens", async () => {
+ api.listUsers.mockResolvedValue([alice, bob])
+ api.listUserTokens.mockResolvedValue([laptop])
+ renderPage()
+ await screen.findByText('bob')
+
+ await openActions('bob')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Tokens' }))
+
+ const dialog = within(await screen.findByRole('dialog', { name: "bob's API tokens" }))
+ expect(api.listUserTokens).toHaveBeenCalledWith(2)
+ const row = within(await dialog.findByRole('row', { name: /Laptop/ }))
+ expect(row.getAllByText('Never')).toHaveLength(2)
+ expect(row.getByRole('button', { name: 'Revoke' })).toBeInTheDocument()
+ })
+
+ it('shows the empty state when the user has no tokens', async () => {
+ api.listUsers.mockResolvedValue([alice, bob])
+ api.listUserTokens.mockResolvedValue([])
+ renderPage()
+ await screen.findByText('bob')
+
+ await openActions('bob')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Tokens' }))
+
+ expect(await screen.findByText('bob has no API tokens.')).toBeInTheDocument()
+ })
+
+ it("revokes a user's token after confirming and refreshes their list", async () => {
+ api.listUsers.mockResolvedValue([alice, bob])
+ api.listUserTokens
+ .mockResolvedValueOnce([laptop])
+ .mockResolvedValue([{ ...laptop, revoked: true, revoked_at: '2026-11-06T09:00:00Z', status: 'revoked' }])
+ api.revokeUserToken.mockResolvedValue(undefined)
+ renderPage()
+ await screen.findByText('bob')
+
+ await openActions('bob')
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Tokens' }))
+ await userEvent.click(within(await screen.findByRole('row', { name: /Laptop/ })).getByRole('button', { name: 'Revoke' }))
+ const confirm = within(screen.getByRole('dialog', { name: 'Revoke Laptop?' }))
+ expect(confirm.getByText('Anything using this token will stop working straight away.')).toBeInTheDocument()
+ await userEvent.click(confirm.getByRole('button', { name: 'Revoke token' }))
+
+ await waitFor(() => expect(api.revokeUserToken).toHaveBeenCalledWith(2, 7))
+ const row = within(await screen.findByRole('row', { name: /Laptop/ }))
+ expect(row.getByText(/^Revoked /)).toBeInTheDocument()
+ expect(row.queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument()
+ expect(api.listUserTokens).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/frontend/src/pages/admin/UsersPage.tsx b/frontend/src/pages/admin/UsersPage.tsx
new file mode 100644
index 00000000..fcab201b
--- /dev/null
+++ b/frontend/src/pages/admin/UsersPage.tsx
@@ -0,0 +1,119 @@
+// Copyright (C) 2026 demigodmode
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { useState } from 'react'
+import { Plus } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import { useAuth } from '@/contexts/useAuth'
+import { useUpdateUser, useUsers } from '@/hooks/useApi'
+import { formatDateTime } from '@/lib/utils'
+import type { AdminUser, UpdateUserRequest } from '@/types/api'
+import { CreateUserDialog } from '@/pages/admin/CreateUserDialog'
+import { DeleteUserDialog } from '@/pages/admin/DeleteUserDialog'
+import { ResetPasswordDialog } from '@/pages/admin/ResetPasswordDialog'
+import { UserActionsMenu } from '@/pages/admin/UserActionsMenu'
+import { UserTokensDialog } from '@/pages/admin/UserTokensDialog'
+
+// The one place that decides what a row's role and active toggles change to.
+const roleToggle = (user: AdminUser): UpdateUserRequest => ({ role: user.role === 'admin' ? 'member' : 'admin' })
+const activeToggle = (user: AdminUser): UpdateUserRequest => ({ is_active: !user.is_active })
+
+export default function UsersPage() {
+ const users = useUsers()
+ const updateUser = useUpdateUser()
+ const { user: me } = useAuth()
+ const [createOpen, setCreateOpen] = useState(false)
+ // The target stays set after a dialog closes so its title doesn't change mid-animation.
+ const [resetTarget, setResetTarget] = useState(null)
+ const [resetOpen, setResetOpen] = useState(false)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [deleteOpen, setDeleteOpen] = useState(false)
+ const [tokensTarget, setTokensTarget] = useState(null)
+ const [tokensOpen, setTokensOpen] = useState(false)
+ const [actionError, setActionError] = useState('')
+
+ const changeUser = async (user: AdminUser, data: UpdateUserRequest) => {
+ setActionError('')
+ try {
+ await updateUser.mutateAsync({ id: user.id, data })
+ } catch (err) {
+ setActionError(err instanceof Error ? err.message : 'Could not update user')
+ }
+ }
+
+ return (
+
+
+
+
Users
+
Accounts that can sign in to OneSearch
+
+
setCreateOpen(true)}>
+
+ Create user
+
+
+
+ {users.isLoading &&
Loading users...
}
+ {users.error &&
Unable to load users.
}
+ {actionError &&
{actionError}
}
+
+ {users.data && (
+
+
+
+
+ Username
+ Role
+ Status
+ Created by
+ Created
+ Last login
+ Actions
+
+
+
+ {users.data.map((user) => (
+
+ {user.username}
+ {user.role === 'admin' ? 'Admin' : 'Member'}
+ {user.is_active ? 'Active' : 'Disabled'}
+ {user.created_by ?? '—'}
+ {formatDateTime(user.created_at)}
+ {user.last_login_at ? formatDateTime(user.last_login_at) : 'Never'}
+
+ {me && me.id !== user.id && (
+ changeUser(user, roleToggle(user))}
+ onToggleActive={() => changeUser(user, activeToggle(user))}
+ onResetPassword={() => {
+ setResetTarget(user)
+ setResetOpen(true)
+ }}
+ onViewTokens={() => {
+ setTokensTarget(user)
+ setTokensOpen(true)
+ }}
+ onDelete={() => {
+ setDeleteTarget(user)
+ setDeleteOpen(true)
+ }}
+ />
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 7b84bf47..b46eaa16 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -261,6 +261,7 @@ export interface AppSettings {
long_text_pagination_threshold_chars: number
default_scan_schedule?: ScheduleConfig | null
remote_agents_enabled: boolean
+ audit_retention_days: number
}
export type AppSettingsUpdate = Partial
@@ -307,11 +308,6 @@ export interface HealthResponse {
status: string
[key: string]: unknown
}
- config: {
- database: string
- meilisearch_url: string
- log_level: string
- }
}
/**
@@ -395,6 +391,124 @@ export interface AuthResponse {
expires_in: number
}
+/**
+ * Request body for changing your own password
+ */
+export interface ChangePasswordRequest {
+ current_password: string
+ new_password: string
+}
+
+/**
+ * An API token as its owner sees it. The secret is never included.
+ */
+export interface ApiToken {
+ id: number
+ name: string
+ scope: 'search'
+ display_prefix: string
+ created_at: string
+ expires_at: string | null
+ last_used_at: string | null
+ revoked: boolean
+ revoked_at: string | null
+ status: ApiTokenStatus
+}
+
+/**
+ * Revoked wins over expired, the same as the server checks.
+ */
+export type ApiTokenStatus = 'active' | 'revoked' | 'expired'
+
+/**
+ * A newly created API token. `token` is the full secret and is only sent once.
+ */
+export interface ApiTokenCreated extends ApiToken {
+ token: string
+}
+
+/**
+ * Request body for creating an API token. null means it never expires.
+ */
+export interface CreateApiTokenRequest {
+ name: string
+ expires_in_days: 30 | 90 | 365 | null
+}
+
+export type UserRole = 'admin' | 'member'
+
+/**
+ * One audit log entry. actor_username is kept after the user is deleted; it is null for failed sign-ins.
+ */
+export interface AuditEvent {
+ id: number
+ created_at: string
+ actor_user_id: number | null
+ actor_username: string | null
+ action: string
+ target_type: string | null
+ target_id: number | null
+ target_label: string | null
+ detail: Record | null
+ ip: string | null
+}
+
+/**
+ * A page of audit entries, newest first. Pass next_before as `before` to get the next page.
+ */
+export interface AuditPage {
+ events: AuditEvent[]
+ next_before: number | null
+}
+
+/**
+ * Filters for the audit log. Leave a field out to not filter on it.
+ */
+export interface AuditQuery {
+ user?: string
+ action?: string
+ before?: number
+ limit?: number
+}
+
+/**
+ * A user as listed on the admin Users screen
+ */
+export interface AdminUser {
+ id: number
+ username: string
+ role: UserRole
+ is_active: boolean
+ must_change_password: boolean
+ created_at: string
+ created_by: string | null
+ last_login_at: string | null
+}
+
+/**
+ * Request body for an admin creating a user
+ */
+export interface CreateUserRequest {
+ username: string
+ password: string
+ role: UserRole
+}
+
+/**
+ * Request body for an admin changing a user's role or active state
+ */
+export interface UpdateUserRequest {
+ role?: UserRole
+ is_active?: boolean
+}
+
+/**
+ * Request body for an admin setting a temporary password
+ */
+export interface ResetUserPasswordRequest {
+ password: string
+}
+
/**
* User info response from /api/auth/me
*/
@@ -403,6 +517,8 @@ export interface User {
username: string
is_active: boolean
created_at: string
+ role: 'admin' | 'member'
+ must_change_password: boolean
}
/**