diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8d70d1a..c86cb20 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. diff --git a/backend/app/api/routes_change_cases.py b/backend/app/api/routes_change_cases.py index 75861b0..7eee137 100644 --- a/backend/app/api/routes_change_cases.py +++ b/backend/app/api/routes_change_cases.py @@ -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() diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index 7b206ef..0622218 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -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 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2a29bdf..c23fff2 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -43,7 +43,7 @@ async function requestText(url: string, options: RequestInit): Promise { const apiUrl = (path: string) => (API_BASE_URL ? `${API_BASE_URL}${path}` : path) export const checkAsn = (asn: string) => requestJson(apiUrl('/api/check/asn'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asn }) }) -export const checkPrefix = (prefix: string, origin_as?: string) => requestJson(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(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(apiUrl('/api/check/asn-rpki'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asn, limit }) }) export const getReports = () => requestJson(apiUrl('/api/reports'), { method: 'GET' }) export const getSystemInfo = () => requestJson(apiUrl('/api/system/info'), { method: 'GET' }) @@ -51,7 +51,7 @@ export const getReportMarkdown = (reportId: number) => requestText(apiUrl(`/api/ 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(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(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(apiUrl('/api/system/status'), { method: 'GET' }) export const getSetupRequired = () => requestJson<{ setup_required: boolean }>(apiUrl('/api/auth/setup-required'), { method: 'GET' }) @@ -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(apiUrl(`/api/change-cases/${id}`), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) export const getChangeCaseReports = (id: number) => requestJson(apiUrl(`/api/change-cases/${id}/reports`), { method: 'GET' }) export const runAsnCheck = (asn: string, change_case_id?: number) => requestJson(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) diff --git a/frontend/src/components/ChangeCasesView.tsx b/frontend/src/components/ChangeCasesView.tsx index 31b479d..17b989e 100644 --- a/frontend/src/components/ChangeCasesView.tsx +++ b/frontend/src/components/ChangeCasesView.tsx @@ -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> = { + 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([]) const [selected, setSelected] = useState(null) - const [reports, setReports] = useState([]) + const [reports, setReports] = useState([]) 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(null) + const [success, setSuccess] = useState(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, ok:string) => { + setError(null); setSuccess(null) + try { await work(); setSuccess(ok) } catch (e) { setError((e as Error).message) } + } + return
-

Change Cases

{canEdit && }
+

Change Cases

{canEdit && }
+ {error &&

{error}

} + {success &&

{success}

} + + {canEdit && creating &&
+

Create Change Case

+ setNewTitle(e.target.value)} /> +