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
7 changes: 7 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,10 @@ Key capabilities in v0.2.x:
- Added visible logout action.
- Improved migration warnings in Dashboard/System views.
- Improved handling of stale database schemas.

## v0.7.0-beta hotfix: Change Cases UX polish

- Added `DELETE /api/change-cases/{id}` for operators/admins with check detachment and audit logging (`change_case_deleted`).
- Improved Change Cases frontend UX with in-app create/edit forms, status workflow buttons, check execution inputs, report table, and delete action with confirmation.
- Added frontend API helpers for deleting cases and running prefix/preflight checks with `change_case_id`.
- Added backend tests for change-case deletion authorization, detachment behavior, and audit event emission.
13 changes: 13 additions & 0 deletions backend/app/api/routes_change_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ def patch_change_case(change_case_id: int, payload: ChangeCaseUpdate, db: Sessio
write_audit_log(db, user_id=user.id, action='change_case_status_changed', target_type='change_case', target_id=str(cc.id), details_json={'from': old_status, 'to': cc.status})
return cc

@router.delete('/{change_case_id}')
def delete_change_case(change_case_id: int, db: Session = Depends(get_db), user=Depends(require_role('operator', 'admin'))):
cc = db.query(ChangeCase).filter(ChangeCase.id == change_case_id).first()
if not cc:
raise HTTPException(status_code=404, detail='Change Case not found')

detached_checks = db.query(Check).filter(Check.change_case_id == change_case_id).update({Check.change_case_id: None}, synchronize_session=False)
details = {'title': cc.title, 'status': cc.status, 'detached_checks': detached_checks}
db.delete(cc)
db.commit()
write_audit_log(db, user_id=user.id, action='change_case_deleted', target_type='change_case', target_id=str(change_case_id), details_json=details)
return {'ok': True, 'detached_checks': detached_checks}

@router.get('/{change_case_id}/reports')
def list_change_case_reports(change_case_id: int, db: Session = Depends(get_db), _=Depends(require_role('viewer','operator','admin'))):
cc = db.query(ChangeCase).filter(ChangeCase.id == change_case_id).first()
Expand Down
53 changes: 53 additions & 0 deletions backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,56 @@ def test_change_case_invalid_status_and_check_attach_and_audit() -> None:
actions = [i.get('action') for i in audit.json().get('items', [])]
assert 'check_attached_to_change_case' in actions
assert 'report_attached_to_change_case' in actions

def test_change_case_delete_admin_detaches_checks_and_audit() -> None:
client = _client()
_setup_and_login(client)
cid = client.post('/api/change-cases', json={'title': 'Delete Me', 'description': 'desc'}).json()['id']
check = client.post('/api/check/asn', json={'asn': 'AS3320', 'change_case_id': cid})
assert check.status_code == 200

before_reports = client.get('/api/reports')
assert before_reports.status_code == 200
report_count_before = len(before_reports.json())

deleted = client.delete(f'/api/change-cases/{cid}')
assert deleted.status_code == 200
assert deleted.json().get('ok') is True
assert deleted.json().get('detached_checks') == 1

assert client.get(f'/api/change-cases/{cid}').status_code == 404

after_reports = client.get('/api/reports')
assert after_reports.status_code == 200
assert len(after_reports.json()) == report_count_before

reports_for_case = client.get(f'/api/change-cases/{cid}/reports')
assert reports_for_case.status_code == 404

audit = client.get('/api/audit-log')
events = [i for i in audit.json().get('items', []) if i.get('action') == 'change_case_deleted']
assert events
details = events[0].get('details_json', {})
assert details.get('title') == 'Delete Me'
assert details.get('status') == 'draft'
assert details.get('detached_checks') == 1


def test_change_case_delete_operator_allowed_viewer_forbidden() -> None:
client = _client()
_setup_and_login(client)
client.post('/api/users', json={'username': 'op3', 'email': 'op3@example.org', 'password': 'OperatorPass123!', 'role': 'operator'})
client.post('/api/users', json={'username': 'vw4', 'email': 'vw4@example.org', 'password': 'ViewerPass123!', 'role': 'viewer'})
cid = client.post('/api/change-cases', json={'title': 'Role Delete', 'description': ''}).json()['id']

client.post('/api/auth/logout')
assert client.post('/api/auth/login', json={'username': 'op3', 'password': 'OperatorPass123!'}).status_code == 200
assert client.delete(f'/api/change-cases/{cid}').status_code == 200

cid2 = client.post('/api/change-cases', json={'title': 'Role Delete 2', 'description': ''})
assert cid2.status_code == 200
cid2v = cid2.json()['id']

client.post('/api/auth/logout')
assert client.post('/api/auth/login', json={'username': 'vw4', 'password': 'ViewerPass123!'}).status_code == 200
assert client.delete(f'/api/change-cases/{cid2v}').status_code == 403
8 changes: 6 additions & 2 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ async function requestText(url: string, options: RequestInit): Promise<string> {
const apiUrl = (path: string) => (API_BASE_URL ? `${API_BASE_URL}${path}` : path)

export const checkAsn = (asn: string) => requestJson<CheckResponse>(apiUrl('/api/check/asn'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asn }) })
export const checkPrefix = (prefix: string, origin_as?: string) => requestJson<CheckResponse>(apiUrl('/api/check/prefix'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, origin_as: origin_as || null }) })
export const checkPrefix = (prefix: string, origin_as?: string, change_case_id?: number) => requestJson<CheckResponse>(apiUrl('/api/check/prefix'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, origin_as: origin_as || null, change_case_id: change_case_id ?? null }) })
export const checkAsnRpki = (asn: string, limit = 25) => requestJson<CheckResponse>(apiUrl('/api/check/asn-rpki'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asn, limit }) })
export const getReports = () => requestJson<ReportListItem[]>(apiUrl('/api/reports'), { method: 'GET' })
export const getSystemInfo = () => requestJson<SystemInfo>(apiUrl('/api/system/info'), { method: 'GET' })
export const getReportMarkdown = (reportId: number) => requestText(apiUrl(`/api/reports/${reportId}/markdown`), { method: 'GET' })
export const getReportHtml = (reportId: number) => requestText(apiUrl(`/api/reports/${reportId}/html`), { method: 'GET' })
export const getReportSummary = (reportId: number) => requestText(apiUrl(`/api/reports/${reportId}/summary`), { method: 'GET' })

export const checkPreflight = (prefix: string, planned_origin_as: string) => requestJson<CheckResponse>(apiUrl('/api/check/preflight'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, planned_origin_as }) })
export const checkPreflight = (prefix: string, planned_origin_as: string, change_case_id?: number) => requestJson<CheckResponse>(apiUrl('/api/check/preflight'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, planned_origin_as, change_case_id: change_case_id ?? null }) })

export const getSystemStatus = () => requestJson<SystemStatus>(apiUrl('/api/system/status'), { method: 'GET' })
export const getSetupRequired = () => requestJson<{ setup_required: boolean }>(apiUrl('/api/auth/setup-required'), { method: 'GET' })
Expand Down Expand Up @@ -80,3 +80,7 @@ export const createChangeCase = (payload: { title: string; description?: string
export const updateChangeCase = (id: number, payload: { title?: string; description?: string; status?: string }) => requestJson<ChangeCaseItem>(apiUrl(`/api/change-cases/${id}`), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
export const getChangeCaseReports = (id: number) => requestJson<any[]>(apiUrl(`/api/change-cases/${id}/reports`), { method: 'GET' })
export const runAsnCheck = (asn: string, change_case_id?: number) => requestJson<CheckResponse>(apiUrl('/api/check/asn'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asn, change_case_id: change_case_id ?? null }) })

export const deleteChangeCase = (id: number) => requestJson<{ ok: boolean; detached_checks: number }>(apiUrl(`/api/change-cases/${id}`), { method: 'DELETE' })
export const runPrefixCheck = (prefix: string, origin_as?: string, change_case_id?: number) => checkPrefix(prefix, origin_as, change_case_id)
export const runPreflightCheck = (prefix: string, planned_origin_as: string, change_case_id?: number) => checkPreflight(prefix, planned_origin_as, change_case_id)
94 changes: 87 additions & 7 deletions frontend/src/components/ChangeCasesView.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,99 @@
import { useEffect, useState } from 'react'
import { createChangeCase, getChangeCaseReports, listChangeCases, runAsnCheck, updateChangeCase } from '../api'
import { ApiError, createChangeCase, deleteChangeCase, getChangeCaseReports, getReportHtml, getReportMarkdown, getReportSummary, listChangeCases, runAsnCheck, runPrefixCheck, runPreflightCheck, updateChangeCase } from '../api'
import type { ChangeCaseItem, UserRole } from '../types'

type ChangeCaseReport = { report_id:number; check_id:number; check_type:string; summary:string; status:string; created_at:string }

const statusActions: Record<string, Array<{label:string; to:string}>> = {
draft: [{ label: 'To Review', to: 'in_review' }, { label: 'Close', to: 'closed' }],
in_review: [{ label: 'Approve', to: 'approved' }, { label: 'Back to Draft', to: 'draft' }, { label: 'Close', to: 'closed' }],
approved: [{ label: 'Back to Review', to: 'in_review' }, { label: 'Close', to: 'closed' }],
closed: []
}

export function ChangeCasesView({ role }: { role: UserRole }) {
const canEdit = role === 'admin' || role === 'operator'
const [items, setItems] = useState<ChangeCaseItem[]>([])
const [selected, setSelected] = useState<ChangeCaseItem | null>(null)
const [reports, setReports] = useState<any[]>([])
const [reports, setReports] = useState<ChangeCaseReport[]>([])
const [loading, setLoading] = useState(false)
const canEdit = role === 'admin' || role === 'operator'
const load = async () => { setLoading(true); try { setItems(await listChangeCases()) } finally { setLoading(false) } }
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newDescription, setNewDescription] = useState('')
const [editing, setEditing] = useState(false)
const [editTitle, setEditTitle] = useState('')
const [editDescription, setEditDescription] = useState('')
const [asn, setAsn] = useState('')
const [prefix, setPrefix] = useState('')
const [originAs, setOriginAs] = useState('')
const [preflightPrefix, setPreflightPrefix] = useState('')
const [plannedOriginAs, setPlannedOriginAs] = useState('')

const load = async () => { setLoading(true); setError(null); try { const next = await listChangeCases(); setItems(next); if (selected) { const found = next.find(i => i.id === selected.id) || null; setSelected(found) } } catch (e) { setError((e as Error).message) } finally { setLoading(false) } }
const loadReports = async (id: number) => { try { setReports(await getChangeCaseReports(id)) } catch { setReports([]) } }

useEffect(() => { load() }, [])
useEffect(() => { if (selected) getChangeCaseReports(selected.id).then(setReports).catch(()=>setReports([])) }, [selected])
useEffect(() => { if (selected) { setEditTitle(selected.title); setEditDescription(selected.description || ''); loadReports(selected.id) } else { setReports([]) } }, [selected?.id])

const runAction = async (work:()=>Promise<void>, ok:string) => {
setError(null); setSuccess(null)
try { await work(); setSuccess(ok) } catch (e) { setError((e as Error).message) }
}

return <section className='rf-card p-4 space-y-3'>
<div className='flex justify-between'><h2 className='text-xl font-semibold'>Change Cases</h2>{canEdit && <button className='rf-btn-secondary' onClick={async()=>{const title=prompt('Title?'); if(!title) return; await createChangeCase({title, description:''}); await load()}}>New Change Case</button>}</div>
<div className='flex justify-between items-center'><h2 className='text-xl font-semibold'>Change Cases</h2>{canEdit && <button className='rf-btn-secondary' onClick={()=>setCreating(v=>!v)}>{creating ? 'Cancel' : 'New Change Case'}</button>}</div>
{error && <p className='rf-alert border-rose-200 bg-rose-50 text-rose-700'>{error}</p>}
{success && <p className='rf-alert border-emerald-200 bg-emerald-50 text-emerald-700'>{success}</p>}

{canEdit && creating && <article className='border rounded p-3 space-y-2'>
<h3 className='font-semibold'>Create Change Case</h3>
<input className='rf-input' placeholder='Title' value={newTitle} onChange={e=>setNewTitle(e.target.value)} />
<textarea className='rf-input min-h-20' placeholder='Description' value={newDescription} onChange={e=>setNewDescription(e.target.value)} />
<div className='flex gap-2'>
<button className='rf-btn-primary' onClick={()=>runAction(async()=>{await createChangeCase({title:newTitle, description:newDescription}); setNewTitle(''); setNewDescription(''); setCreating(false); await load()}, 'Change Case created.')} disabled={loading || !newTitle.trim()}>Create</button>
<button className='rf-btn-secondary' onClick={()=>setCreating(false)}>Cancel</button>
</div>
</article>}

{loading ? <div>Loading…</div> : items.length===0 ? <div className='text-sm text-slate-500'>No change cases yet.</div> : <table className='w-full text-sm'><thead><tr><th>Title</th><th>Status</th><th>Owner</th><th>Created</th><th>Updated</th></tr></thead><tbody>{items.map(i=><tr key={i.id} className='border-t cursor-pointer' onClick={()=>setSelected(i)}><td>{i.title}</td><td>{i.status}</td><td>{i.created_by_user_id ?? '—'}</td><td>{new Date(i.created_at).toLocaleString()}</td><td>{new Date(i.updated_at).toLocaleString()}</td></tr>)}</tbody></table>}
{selected && <article className='border rounded p-3 space-y-2'><h3 className='font-semibold'>{selected.title}</h3><p>{selected.description || '—'}</p><div>Status: {selected.status}</div>{canEdit && <div className='flex gap-2'><button className='rf-btn-secondary' onClick={async()=>{await updateChangeCase(selected.id,{status:'in_review'}); await load()}}>To Review</button><button className='rf-btn-secondary' onClick={async()=>{await runAsnCheck('AS3320', selected.id); await getChangeCaseReports(selected.id).then(setReports)}}>Run ASN Check</button></div>}<div className='text-sm'>Reports: {reports.length}</div></article>}

{selected && <article className='border rounded p-3 space-y-3'>
<div className='flex justify-between items-center'>
<div>
<h3 className='font-semibold'>{selected.title}</h3>
<span className='inline-block text-xs px-2 py-1 rounded bg-slate-100'>{selected.status}</span>
</div>
{canEdit && <button className='rf-btn-secondary' onClick={()=>setEditing(v=>!v)}>{editing ? 'Cancel' : 'Edit'}</button>}
</div>
<p>{selected.description || '—'}</p>

{canEdit && editing && <div className='space-y-2'>
<input className='rf-input' value={editTitle} onChange={e=>setEditTitle(e.target.value)} />
<textarea className='rf-input min-h-20' value={editDescription} onChange={e=>setEditDescription(e.target.value)} />
<div className='flex gap-2'><button className='rf-btn-primary' onClick={()=>runAction(async()=>{await updateChangeCase(selected.id,{title:editTitle,description:editDescription}); setEditing(false); await load();}, 'Change Case updated.')}>Save</button><button className='rf-btn-secondary' onClick={()=>setEditing(false)}>Cancel</button></div>
</div>}

{canEdit && <div className='flex flex-wrap gap-2'>
{(statusActions[selected.status] || []).map((action)=><button key={action.to} className='rf-btn-secondary' onClick={()=>runAction(async()=>{await updateChangeCase(selected.id,{status:action.to}); await load();}, `Status changed to ${action.to}.`)}>{action.label}</button>)}
{selected.status === 'closed' && <p className='text-sm text-slate-500'>Case is closed (read-only workflow state).</p>}
</div>}

{canEdit && <div className='grid md:grid-cols-3 gap-3 border-t pt-3'>
<div className='space-y-2'><h4 className='font-medium'>Run ASN Check</h4><input className='rf-input' placeholder='AS3320' value={asn} onChange={e=>setAsn(e.target.value)} /><button className='rf-btn-primary' onClick={()=>runAction(async()=>{await runAsnCheck(asn, selected.id); await loadReports(selected.id)}, 'ASN check started.')}>Run ASN Check</button></div>
<div className='space-y-2'><h4 className='font-medium'>Run Prefix Check</h4><input className='rf-input' placeholder='203.0.113.0/24' value={prefix} onChange={e=>setPrefix(e.target.value)} /><input className='rf-input' placeholder='Origin AS (optional)' value={originAs} onChange={e=>setOriginAs(e.target.value)} /><button className='rf-btn-primary' onClick={()=>runAction(async()=>{await runPrefixCheck(prefix, originAs || undefined, selected.id); await loadReports(selected.id)}, 'Prefix check started.')}>Run Prefix Check</button></div>
<div className='space-y-2'><h4 className='font-medium'>Run Preflight Check</h4><input className='rf-input' placeholder='203.0.113.0/24' value={preflightPrefix} onChange={e=>setPreflightPrefix(e.target.value)} /><input className='rf-input' placeholder='Planned Origin AS' value={plannedOriginAs} onChange={e=>setPlannedOriginAs(e.target.value)} /><button className='rf-btn-primary' onClick={()=>runAction(async()=>{await runPreflightCheck(preflightPrefix, plannedOriginAs, selected.id); await loadReports(selected.id)}, 'Preflight check started.')}>Run Preflight Check</button></div>
</div>}

<div className='border-t pt-3'>
<h4 className='font-medium mb-2'>Reports</h4>
{reports.length === 0 ? <p className='text-sm text-slate-500'>No reports for this Change Case yet.</p> : <table className='w-full text-sm'><thead><tr><th>Type</th><th>Status</th><th>Summary</th><th>Created</th><th>Open</th></tr></thead><tbody>{reports.map(r=><tr key={r.report_id} className='border-t'><td>{r.check_type}</td><td>{r.status}</td><td>{r.summary}</td><td>{new Date(r.created_at).toLocaleString()}</td><td className='space-x-1'><button className='rf-btn-secondary' onClick={()=>runAction(async()=>{await getReportSummary(r.report_id)}, 'Summary loaded.')}>Summary</button><button className='rf-btn-secondary' onClick={()=>runAction(async()=>{await getReportMarkdown(r.report_id)}, 'Markdown loaded.')}>Markdown</button><button className='rf-btn-secondary' onClick={()=>runAction(async()=>{await getReportHtml(r.report_id)}, 'HTML loaded.')}>HTML</button></td></tr>)}</tbody></table>}
</div>

{canEdit && <div className='border-t pt-3'>
<button className='rf-btn-secondary' onClick={()=>runAction(async()=>{if (!confirm('Delete this Change Case? Checks/Reports stay and will be detached.')) return; await deleteChangeCase(selected.id); setSelected(null); await load()}, 'Change Case deleted.')}>Delete Change Case</button>
</div>}
</article>}
</section>
}
Loading