Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions dashboard/src/pages/AdminBotPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AdminBotPage from './AdminBotPage'
import type { ProfileResponse } from '../types'

const mockListProfiles = vi.fn()
const mockMonitorStatus = vi.fn()
const mockToggleWatchdog = vi.fn()
const mockToggleAlerts = vi.fn()

vi.mock('../api', () => ({
api: {
listProfiles: () => mockListProfiles(),
monitorStatus: () => mockMonitorStatus(),
toggleWatchdog: (enabled: boolean) => mockToggleWatchdog(enabled),
toggleAlerts: (enabled: boolean) => mockToggleAlerts(enabled),
},
}))

function profile(
id: string,
name: string,
adminMode: boolean,
running: boolean,
): ProfileResponse {
return {
id,
name,
enabled: true,
data_dir: null,
parent_id: null,
public_subdomain: null,
config: {
channels: [],
gateway: {},
env_vars: {},
admin_mode: adminMode,
},
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
status: {
running,
pid: running ? 123 : null,
started_at: running ? '2026-05-01T00:00:00Z' : null,
uptime_secs: running ? 30 : null,
},
email: null,
}
}

function renderPage() {
return render(
<MemoryRouter initialEntries={['/admin-bot']}>
<AdminBotPage />
</MemoryRouter>,
)
}

describe('AdminBotPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMonitorStatus.mockResolvedValue({
watchdog_enabled: true,
alerts_enabled: false,
})
})

it('lists admin-mode profiles and highlights the running admin bot', async () => {
mockListProfiles.mockResolvedValue([
profile('ops-admin', 'Ops Admin', true, true),
profile('backup-admin', 'Backup Admin', true, false),
profile('regular-user', 'Regular User', false, true),
])

renderPage()

expect(await screen.findAllByText('Ops Admin')).toHaveLength(2)
expect(screen.getByText('Backup Admin')).toBeInTheDocument()
expect(screen.queryByText('Regular User')).not.toBeInTheDocument()
expect(screen.getByText('Running')).toBeInTheDocument()
expect(screen.getByText('Stopped')).toBeInTheDocument()
expect(screen.getByText('Active admin bot:')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Create admin profile' })).toHaveAttribute(
'href',
'/profiles/new?adminMode=true',
)
})

it('shows an empty state when no admin-mode profile exists', async () => {
mockListProfiles.mockResolvedValue([
profile('regular-user', 'Regular User', false, true),
])

renderPage()

expect(await screen.findByText('No admin-mode profiles found.')).toBeInTheDocument()
expect(screen.getByText('None running')).toBeInTheDocument()
})
})
67 changes: 59 additions & 8 deletions dashboard/src/pages/AdminBotPage.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
import { useToast } from '../components/Toast'
import type { MonitorStatus } from '../types'
import type { MonitorStatus, ProfileResponse } from '../types'

export default function AdminBotPage() {
const { toast } = useToast()
const [loading, setLoading] = useState(true)
const [monitorStatus, setMonitorStatus] = useState<MonitorStatus>({ watchdog_enabled: false, alerts_enabled: false })
const [profiles, setProfiles] = useState<ProfileResponse[]>([])

const loadData = useCallback(async () => {
try {
const monitor = await api.monitorStatus().catch(() => ({ watchdog_enabled: false, alerts_enabled: false }))
const [monitor, profileList] = await Promise.all([
api.monitorStatus().catch(() => ({ watchdog_enabled: false, alerts_enabled: false })),
api.listProfiles(),
])
setMonitorStatus(monitor)
setProfiles(profileList)
} catch (e: any) {
toast(e.message, 'error')
} finally {
Expand Down Expand Up @@ -51,6 +57,9 @@ export default function AdminBotPage() {
)
}

const adminProfiles = profiles.filter((profile) => profile.config.admin_mode)
const activeAdminProfile = adminProfiles.find((profile) => profile.status.running)

return (
<div className="max-w-3xl">
<div className="mb-6">
Expand Down Expand Up @@ -79,12 +88,54 @@ export default function AdminBotPage() {
</div>

<div className="bg-surface rounded-xl border border-gray-700/50 p-5">
<h2 className="text-sm font-semibold text-white mb-2">Admin Bot Profile</h2>
<p className="text-sm text-gray-400">
To set up an admin bot, create a regular profile and enable <strong className="text-white">Admin Mode</strong> in
its settings. Admin mode restricts the gateway to admin-only tools (profile management,
monitoring, logs) and uses a built-in admin system prompt.
</p>
<div className="flex items-center justify-between gap-3 mb-4">
<div>
<h2 className="text-sm font-semibold text-white">Admin Bot Profile</h2>
<p className="text-xs text-gray-500 mt-1">
Active admin bot:{' '}
<span className={activeAdminProfile ? 'text-green-400' : 'text-gray-400'}>
{activeAdminProfile ? activeAdminProfile.name : 'None running'}
</span>
</p>
</div>
<Link
to="/profiles/new?adminMode=true"
className="shrink-0 px-3 py-2 text-xs font-medium rounded-lg bg-accent text-white hover:bg-accent-light transition"
>
Create admin profile
</Link>
</div>

{adminProfiles.length > 0 ? (
<div className="divide-y divide-gray-700/50">
{adminProfiles.map((profile) => (
<div key={profile.id} className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
<div className="min-w-0">
<Link
to={`/profile/${profile.id}`}
className="text-sm font-medium text-white hover:text-accent transition"
>
{profile.name}
</Link>
<p className="text-xs text-gray-500 font-mono mt-1 truncate">{profile.id}</p>
</div>
<span
className={`shrink-0 inline-flex px-2 py-0.5 text-[10px] font-medium rounded-full ${
profile.status.running
? 'bg-green-500/15 text-green-400'
: 'bg-gray-500/15 text-gray-400'
}`}
>
{profile.status.running ? 'Running' : 'Stopped'}
</span>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-gray-700/70 px-4 py-5 text-sm text-gray-400">
No admin-mode profiles found.
</div>
)}
</div>
</div>
)
Expand Down
57 changes: 57 additions & 0 deletions dashboard/src/pages/NewProfile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NewProfile from './NewProfile'

const mockCreateProfile = vi.fn()

vi.mock('../api', () => ({
api: {
createProfile: (data: unknown) => mockCreateProfile(data),
},
}))

function renderNewProfile(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/profiles/new" element={<NewProfile />} />
<Route path="/profile/:id" element={<div>Profile created</div>} />
</Routes>
</MemoryRouter>,
)
}

describe('NewProfile admin mode shortcut', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateProfile.mockResolvedValue({})
})

it('preselects admin mode from the query string and submits admin config', async () => {
const user = userEvent.setup()
renderNewProfile('/profiles/new?adminMode=true')

expect(screen.getByRole('checkbox', { name: 'Admin Mode' })).toBeChecked()

await user.type(screen.getAllByPlaceholderText('alice-bot')[0], 'ops-admin')
await user.type(screen.getByPlaceholderText("Alice's Bot"), 'Ops Admin')
await user.click(screen.getByRole('button', { name: 'Create Profile' }))

await waitFor(() => {
expect(mockCreateProfile).toHaveBeenCalledWith(
expect.objectContaining({
id: 'ops-admin',
name: 'Ops Admin',
config: expect.objectContaining({
admin_mode: true,
channels: [],
gateway: {},
env_vars: {},
}),
}),
)
})
})
})
26 changes: 25 additions & 1 deletion dashboard/src/pages/NewProfile.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useToast } from '../components/Toast'
import { api } from '../api'

export default function NewProfile() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { toast } = useToast()
const adminModeRequested = searchParams.get('adminMode') === 'true'
const [loading, setLoading] = useState(false)
const [id, setId] = useState('')
const [name, setName] = useState('')
const [publicSubdomain, setPublicSubdomain] = useState('')
const [enabled, setEnabled] = useState(true)
const [adminMode, setAdminMode] = useState(adminModeRequested)

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
Expand All @@ -21,6 +24,16 @@ export default function NewProfile() {
name,
public_subdomain: publicSubdomain.trim() || null,
enabled,
...(adminMode
? {
config: {
channels: [],
gateway: {},
env_vars: {},
admin_mode: true,
},
}
: {}),
})
toast('Profile created')
navigate(`/profile/${id}`)
Expand Down Expand Up @@ -93,6 +106,17 @@ export default function NewProfile() {
<span className="text-sm text-gray-400">Auto-start gateway when server starts</span>
</label>
</div>
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={adminMode}
onChange={(e) => setAdminMode(e.target.checked)}
className="w-4 h-4 rounded bg-surface-dark border-gray-600 text-accent focus:ring-accent"
/>
<span className="text-sm text-gray-400">Admin Mode</span>
</label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-700/50">
<button
type="button"
Expand Down
Loading