Skip to content
Merged
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
2 changes: 1 addition & 1 deletion backend/src/control_center/compliance/csv_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def render_report_csv(context: dict[str, Any]) -> str:
buffer = io.StringIO()
writer = csv.writer(buffer)

_writerow(writer, ["HIPAA Basic Compliance Report"])
_writerow(writer, ["HIPAA Organization Audit Report"])
_writerow(writer, ["Organization", f"{context['organization_name']} (org #{context['organization_id']})"])
_writerow(writer, ["Period", f"{context['from_date']} to {context['to_date']}"])
_writerow(writer, ["Generated", f"{context['generated_at']} by {context['generated_by']}"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>HIPAA Readiness Report</title>
<title>HIPAA Organization Audit Report</title>
<style>
@page {
size: Letter;
Expand Down Expand Up @@ -130,7 +130,7 @@
</svg>
<span class="cover-brand-text">Omni<span>BioAI</span></span>
</div>
<div class="cover-title">HIPAA Readiness Report</div>
<div class="cover-title">HIPAA Organization Audit Report</div>
<div class="cover-meta"><strong>Organization:</strong> {{ organization_name }} (org #{{ organization_id }})</div>
<div class="cover-meta"><strong>Period:</strong> {{ from_date }} &ndash; {{ to_date }}</div>
<div class="cover-meta"><strong>Generated:</strong> {{ generated_at }} by {{ generated_by }}</div>
Expand Down
8 changes: 8 additions & 0 deletions backend/tests/test_compliance_csv_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@
}


def test_title_matches_the_admin_page_name():
"""The CSV's first row names the report the way the admin console
does (Compliance > HIPAA Audit Report, page heading "HIPAA
Organization Audit Report")."""
first_row = next(csv.reader(io.StringIO(render_report_csv(_CONTEXT))))
assert first_row == ["HIPAA Organization Audit Report"]


def test_includes_org_and_period_header():
"""The CSV output includes the organization name and the reporting
period's dates."""
Expand Down
9 changes: 9 additions & 0 deletions backend/tests/test_compliance_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ def test_render_report_html_includes_org_and_period():
assert "alice@kumc.edu" in html


def test_render_report_html_title_matches_the_admin_page_name():
"""The PDF's document title and cover use the same name as the
admin console page, not the old "HIPAA Readiness Report"."""
html = render_report_html(_MINIMAL_CONTEXT)
assert "<title>HIPAA Organization Audit Report</title>" in html
assert '<div class="cover-title">HIPAA Organization Audit Report</div>' in html
assert "HIPAA Readiness Report" not in html


def test_render_report_html_includes_omnibioai_logo_mark():
"""The rendered HTML includes the inline SVG hexagon logo mark
shared with main.py's own header/AdminLogo.tsx."""
Expand Down
12 changes: 12 additions & 0 deletions frontend/cc-ui/src/apps/AdminApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,17 @@ function AdminDashboard() {
if (key !== 'roles') setRolesOrgHint(null)
}

// Global search: open an organization's or user's detail page directly.
const handleOpenRecord = (kind: 'user' | 'org', id: number) => {
if (kind === 'user') {
handleNavigate('users')
setSelectedUserId(id)
} else {
handleNavigate('organizations')
setSelectedOrgId(id)
}
}

const handleViewTeams = (orgId: number) => {
setTeamsOrgHint(orgId)
setActive('teams')
Expand Down Expand Up @@ -402,6 +413,7 @@ function AdminDashboard() {
onNavigate={handleNavigate}
user={user}
onSignOut={handleSignOut}
onOpenRecord={canSeeUsers ? handleOpenRecord : undefined}
extraActions={<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{hasPlatformAdminAccess() && <button type="button" onClick={handleOpenLims} disabled={limsOpening} className="shell-action-button">
{limsOpening ? 'Opening LIMS…' : 'Open LIMS'}
Expand Down
6 changes: 5 additions & 1 deletion frontend/cc-ui/src/components/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { PageKey } from '../../navigation'
import { findNavItem, NAVIGATION } from '../../navigation'
import SidebarNav from './SidebarNav'
import TopAppBar from './TopAppBar'
import type { RecordKind } from './GlobalSearch'
import Footer from './Footer'
import PageErrorBoundary from '../ui/PageErrorBoundary'

Expand All @@ -13,6 +14,8 @@ interface Props {
user: SessionUser | null
onSignOut: () => void
extraActions?: ReactNode
/** Opens a user's or organization's detail page from the global search. */
onOpenRecord?: (kind: RecordKind, id: number) => void
children: ReactNode
}

Expand All @@ -30,7 +33,7 @@ function breadcrumbFor(key: PageKey): string[] {
* into via `children`. Wraps AdminApp only; ControlApp is unaffected
* (still Header.tsx's flat tab strip, unchanged).
*/
export default function AppShell({ active, onNavigate, user, onSignOut, extraActions, children }: Props) {
export default function AppShell({ active, onNavigate, user, onSignOut, extraActions, onOpenRecord, children }: Props) {
const [mobileNavOpen, setMobileNavOpen] = useState(false)

const handleNavigate = (key: PageKey) => {
Expand All @@ -53,6 +56,7 @@ export default function AppShell({ active, onNavigate, user, onSignOut, extraAct
onSignOut={onSignOut}
onMenuToggle={() => setMobileNavOpen(o => !o)}
onNavigate={handleNavigate}
onOpenRecord={onOpenRecord && ((kind, id) => { onOpenRecord(kind, id); setMobileNavOpen(false) })}
extraActions={extraActions}
/>
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
Expand Down
76 changes: 72 additions & 4 deletions frontend/cc-ui/src/components/shell/GlobalSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as auth from '../../auth'
import * as orgs from '../../organizations'
import * as users from '../../users'
import GlobalSearch, { matchPages, searchablePages } from './GlobalSearch'

vi.mock('../../organizations', () => ({ fetchPlatformOrgs: vi.fn() }))
vi.mock('../../users', () => ({ fetchPlatformUsers: vi.fn() }))

vi.mock('../../auth', async (orig) => {
const actual = await orig<typeof import('../../auth')>()
return { ...actual, hasPlatformAdminAccess: vi.fn(() => true), hasOrganizationsAccess: vi.fn(() => true), hasAdminAccess: vi.fn(() => true) }
})

describe('GlobalSearch', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(auth.hasPlatformAdminAccess).mockReturnValue(true)
})

it('ranks name matches before section matches', () => {
const results = matchPages(searchablePages(), 'hipaa').map(r => r.label)
Expand Down Expand Up @@ -49,8 +57,68 @@ describe('GlobalSearch', () => {

fireEvent.click(screen.getByRole('button', { name: /Search/ }))
fireEvent.change(screen.getByRole('combobox', { name: 'Search pages' }), { target: { value: 'zzzz' } })
expect(screen.getByText(/No pages match/)).toBeInTheDocument()
expect(screen.getByText(/Nothing matches/)).toBeInTheDocument()
fireEvent.keyDown(screen.getByRole('combobox', { name: 'Search pages' }), { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})

describe('organizations and users', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.mocked(orgs.fetchPlatformOrgs).mockResolvedValue({
items: [{ id: 7, name: 'KUMC Research', member_count: 12 }], total: 1, page: 1, page_size: 5, total_pages: 1,
} as never)
vi.mocked(users.fetchPlatformUsers).mockResolvedValue({
items: [{ id: 42, email: 'kumar@kumc.edu', status: 'active' }], total: 1, page: 1, page_size: 5, total_pages: 1,
} as never)
})
afterEach(() => vi.useRealTimers())

async function typeAndSettle(value: string) {
fireEvent.change(screen.getByRole('combobox', { name: 'Search pages' }), { target: { value } })
await act(async () => { await vi.advanceTimersByTimeAsync(300) })
}

it('finds organizations and users by name/email and opens their detail page', async () => {
const onOpenRecord = vi.fn()
render(<GlobalSearch onNavigate={vi.fn()} onOpenRecord={onOpenRecord} />)
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
await typeAndSettle('kum')

expect(orgs.fetchPlatformOrgs).toHaveBeenCalledWith(expect.objectContaining({ search: 'kum', pageSize: 5 }))
expect(users.fetchPlatformUsers).toHaveBeenCalledWith(expect.objectContaining({ search: 'kum', pageSize: 5 }))
expect(screen.getByText('Organizations & users')).toBeInTheDocument()
expect(screen.getByRole('option', { name: /KUMC Research/ })).toBeInTheDocument()

fireEvent.mouseDown(screen.getByRole('option', { name: /kumar@kumc.edu/ }))
expect(onOpenRecord).toHaveBeenCalledWith('user', 42)
})

it('waits for two characters and debounces typing into one request', async () => {
render(<GlobalSearch onNavigate={vi.fn()} onOpenRecord={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
await typeAndSettle('k')
expect(orgs.fetchPlatformOrgs).not.toHaveBeenCalled()

fireEvent.change(screen.getByRole('combobox', { name: 'Search pages' }), { target: { value: 'ku' } })
fireEvent.change(screen.getByRole('combobox', { name: 'Search pages' }), { target: { value: 'kum' } })
await act(async () => { await vi.advanceTimersByTimeAsync(300) })
expect(orgs.fetchPlatformOrgs).toHaveBeenCalledTimes(1)
})

it('does not search records for non-platform admins or without a handler', async () => {
vi.mocked(auth.hasPlatformAdminAccess).mockReturnValue(false)
const { unmount } = render(<GlobalSearch onNavigate={vi.fn()} onOpenRecord={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
await typeAndSettle('kum')
unmount()

vi.mocked(auth.hasPlatformAdminAccess).mockReturnValue(true)
render(<GlobalSearch onNavigate={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
await typeAndSettle('kum')
expect(orgs.fetchPlatformOrgs).not.toHaveBeenCalled()
expect(users.fetchPlatformUsers).not.toHaveBeenCalled()
})
})
})
122 changes: 93 additions & 29 deletions frontend/cc-ui/src/components/shell/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Search } from 'lucide-react'
import { hasPlatformAdminAccess } from '../../auth'
import { NAVIGATION, isNavItemVisible, type PageKey } from '../../navigation'
import { fetchPlatformOrgs } from '../../organizations'
import { fetchPlatformUsers } from '../../users'

export type RecordKind = 'user' | 'org'
interface RecordHit { kind: RecordKind; id: number; label: string; detail: string }
type Result = { type: 'page'; entry: Entry } | { type: 'record'; hit: RecordHit }

interface Entry { key: PageKey; label: string; section: string }

Expand Down Expand Up @@ -43,15 +50,38 @@ export function matchPages(entries: Entry[], query: string): Entry[] {
.map(([, e]) => e)
}

/** Organizations and users whose name/email match, from the same
* /platform/orgs and /platform/users search the Organizations and Users
* pages use. Platform admins only -- both endpoints are
* manage_all_orgs-gated. A failing source just contributes no rows. */
export async function searchRecords(query: string): Promise<RecordHit[]> {
const [orgs, users] = await Promise.all([
fetchPlatformOrgs({ search: query, pageSize: 5, sortBy: 'name', sortOrder: 'asc' }).catch(() => null),
fetchPlatformUsers({ search: query, pageSize: 5, sortBy: 'email', sortOrder: 'asc' }).catch(() => null),
])
return [
...(orgs?.items ?? []).map(o => ({ kind: 'org' as const, id: o.id, label: o.name, detail: `Organization · ${o.member_count} members` })),
...(users?.items ?? []).map(u => ({ kind: 'user' as const, id: u.id, label: u.email, detail: `User · ${u.status}` })),
]
}

const RECORD_MIN_CHARS = 2
const RECORD_DEBOUNCE_MS = 250

const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)

/**
* Admin Console global search: jump to any page by name. A compact
* Admin Console global search: jump to any page by name, and (for
* platform admins) to an organization or user by name/email. A compact
* trigger in the top bar (so it doesn't squeeze the breadcrumb) opens a
* panel with a live-filtered list; Ctrl+K / Cmd+K opens it from anywhere,
* arrow keys move, Enter opens, Esc closes.
*/
export default function GlobalSearch({ onNavigate }: { onNavigate: (key: PageKey) => void }) {
export default function GlobalSearch({ onNavigate, onOpenRecord }: {
onNavigate: (key: PageKey) => void
/** Opens a user's or organization's detail page. Without it, only pages are searched. */
onOpenRecord?: (kind: RecordKind, id: number) => void
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [index, setIndex] = useState(0)
Expand All @@ -60,7 +90,27 @@ export default function GlobalSearch({ onNavigate }: { onNavigate: (key: PageKey

// Recomputed per open so visibility follows the current session.
const entries = useMemo(() => (open ? searchablePages() : []), [open])
const results = useMemo(() => matchPages(entries, query), [entries, query])
const [records, setRecords] = useState<RecordHit[]>([])
const [recordsLoading, setRecordsLoading] = useState(false)
const canSearchRecords = open && !!onOpenRecord && hasPlatformAdminAccess()

useEffect(() => {
const q = query.trim()
if (!canSearchRecords || q.length < RECORD_MIN_CHARS) { setRecords([]); setRecordsLoading(false); return }
let cancelled = false
setRecordsLoading(true)
const timer = setTimeout(() => {
searchRecords(q).then(hits => {
if (!cancelled) { setRecords(hits); setRecordsLoading(false) }
})
}, RECORD_DEBOUNCE_MS)
return () => { cancelled = true; clearTimeout(timer) }
}, [query, canSearchRecords])

const results: Result[] = useMemo(() => [
...matchPages(entries, query).map(entry => ({ type: 'page' as const, entry })),
...records.map(hit => ({ type: 'record' as const, hit })),
], [entries, query, records])

useEffect(() => {
const onKey = (e: KeyboardEvent) => {
Expand All @@ -85,11 +135,13 @@ export default function GlobalSearch({ onNavigate }: { onNavigate: (key: PageKey
return () => document.removeEventListener('mousedown', onDown)
}, [open])

const go = (entry: Entry | undefined) => {
if (!entry) return
onNavigate(entry.key)
const go = (result: Result | undefined) => {
if (!result) return
if (result.type === 'page') onNavigate(result.entry.key)
else onOpenRecord?.(result.hit.kind, result.hit.id)
setOpen(false)
}
const resultId = (r: Result) => r.type === 'page' ? `global-search-${r.entry.key}` : `global-search-${r.hit.kind}-${r.hit.id}`

const onInputKey = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setIndex(i => Math.min(i + 1, results.length - 1)) }
Expand Down Expand Up @@ -135,38 +187,50 @@ export default function GlobalSearch({ onNavigate }: { onNavigate: (key: PageKey
value={query}
onChange={e => { setQuery(e.target.value); setIndex(0) }}
onKeyDown={onInputKey}
placeholder="Go to page…"
placeholder={onOpenRecord && hasPlatformAdminAccess() ? 'Pages, organizations, users…' : 'Go to page…'}
aria-label="Search pages"
role="combobox"
aria-expanded
aria-controls="global-search-results"
aria-activedescendant={results[index] ? `global-search-${results[index].key}` : undefined}
aria-activedescendant={results[index] ? resultId(results[index]) : undefined}
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: 'var(--text)', fontSize: 13 }}
/>
</div>
<ul id="global-search-results" role="listbox" style={{ listStyle: 'none', maxHeight: 360, overflowY: 'auto', padding: 4 }}>
{results.length === 0 && (
<li style={{ padding: '12px 10px', fontSize: 12, color: 'var(--muted)' }}>No pages match “{query}”.</li>
{results.length === 0 && !recordsLoading && (
<li style={{ padding: '12px 10px', fontSize: 12, color: 'var(--muted)' }}>Nothing matches “{query}”.</li>
)}
{results.map((r, i) => {
const first = i === 0 || results[i - 1].type !== r.type
return (
<li key={resultId(r)} role="presentation">
{first && (
<div style={{ padding: '8px 10px 4px', fontSize: 10, fontWeight: 700, letterSpacing: '0.07em', textTransform: 'uppercase', color: 'var(--muted)' }}>
{r.type === 'page' ? 'Pages' : 'Organizations & users'}
</div>
)}
<div
id={resultId(r)}
role="option"
aria-selected={i === index}
onMouseEnter={() => setIndex(i)}
onMouseDown={e => { e.preventDefault(); go(r) }}
style={{
display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 10px', borderRadius: 6,
cursor: 'pointer', fontSize: 13,
background: i === index ? 'var(--accent-dim, rgba(0,212,170,0.1))' : 'transparent',
color: i === index ? 'var(--text)' : 'var(--text2)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.type === 'page' ? r.entry.label : r.hit.label}</span>
<span style={{ fontSize: 11, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{r.type === 'page' ? r.entry.section : r.hit.detail}</span>
</div>
</li>
)
})}
{recordsLoading && (
<li style={{ padding: '8px 10px', fontSize: 11, color: 'var(--muted)' }}>Searching organizations and users…</li>
)}
{results.map((r, i) => (
<li
key={r.key}
id={`global-search-${r.key}`}
role="option"
aria-selected={i === index}
onMouseEnter={() => setIndex(i)}
onMouseDown={e => { e.preventDefault(); go(r) }}
style={{
display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 10px', borderRadius: 6,
cursor: 'pointer', fontSize: 13,
background: i === index ? 'var(--accent-dim, rgba(0,212,170,0.1))' : 'transparent',
color: i === index ? 'var(--text)' : 'var(--text2)',
}}
>
<span>{r.label}</span>
<span style={{ fontSize: 11, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{r.section}</span>
</li>
))}
</ul>
</div>
)}
Expand Down
Loading
Loading