diff --git a/README.md b/README.md index 5e59b13..0647231 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Routing changes often require fast but traceable checks across multiple external ## Current Alpha Status -RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.6.6-beta**. +RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.7.0-beta**. ## Quickstart with Docker Compose diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bc1930f..8d70d1a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,13 @@ +## v0.7.0-beta +- Added Projects / Change Cases lifecycle (draft, in_review, approved, closed). +- Added Change Case API, UI navigation and detail workflow. +- Checks and reports can be attached to Change Cases for local RouteForge workflow traceability. +- Added audit events for Change Case and attachment operations. +- Security: Change Cases are local workflow metadata only; no writes to RIPE DB, RPKI, or routers are performed. + # Release Notes -## v0.6.6-beta +## v0.7.0-beta **Audit Log UI & Session Hardening** diff --git a/ROADMAP.md b/ROADMAP.md index 3072aee..44f53cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # RouteForge Roadmap ## Current Status -v0.6.6-beta, audit log UI/API and session hardening completed, read-only +v0.7.0-beta, audit log UI/API and session hardening completed, read-only ## v0.7.0-beta - projects/change cases diff --git a/backend/alembic/versions/0003_change_cases.py b/backend/alembic/versions/0003_change_cases.py new file mode 100644 index 0000000..2b6ec88 --- /dev/null +++ b/backend/alembic/versions/0003_change_cases.py @@ -0,0 +1,31 @@ +"""add change cases + +Revision ID: 0003_change_cases +Revises: 0002_users_and_report_ownership +Create Date: 2026-05-20 +""" +from alembic import op +import sqlalchemy as sa + +revision = '0003_change_cases' +down_revision = '0002_users_and_report_ownership' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.create_table('change_cases', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False, server_default='draft'), + sa.Column('created_by_user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + ) + op.add_column('checks', sa.Column('change_case_id', sa.Integer(), nullable=True)) + op.create_foreign_key('fk_checks_change_case_id', 'checks', 'change_cases', ['change_case_id'], ['id']) + +def downgrade() -> None: + op.drop_constraint('fk_checks_change_case_id', 'checks', type_='foreignkey') + op.drop_column('checks', 'change_case_id') + op.drop_table('change_cases') diff --git a/backend/app/api/routes_change_cases.py b/backend/app/api/routes_change_cases.py new file mode 100644 index 0000000..75861b0 --- /dev/null +++ b/backend/app/api/routes_change_cases.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.core.auth import require_role +from app.core.audit import write_audit_log +from app.database import get_db +from app.models import ChangeCase, Check, Report +from app.schemas import ChangeCaseCreate, ChangeCaseRead, ChangeCaseUpdate + +router = APIRouter(prefix='/api/change-cases', tags=['change-cases']) +VALID = {'draft', 'in_review', 'approved', 'closed'} +TRANSITIONS = { + 'draft': {'in_review', 'closed'}, + 'in_review': {'approved', 'draft', 'closed'}, + 'approved': {'closed', 'in_review'}, + 'closed': set(), +} + +@router.get('') +def list_change_cases(db: Session = Depends(get_db), _=Depends(require_role('viewer','operator','admin'))): + return db.query(ChangeCase).order_by(ChangeCase.updated_at.desc()).limit(100).all() + +@router.post('', response_model=ChangeCaseRead) +def create_change_case(payload: ChangeCaseCreate, db: Session = Depends(get_db), user=Depends(require_role('operator','admin'))): + cc = ChangeCase(title=payload.title.strip(), description=payload.description, status='draft', created_by_user_id=user.id) + db.add(cc); db.commit(); db.refresh(cc) + write_audit_log(db, user_id=user.id, action='change_case_created', target_type='change_case', target_id=str(cc.id), details_json={'status': cc.status}) + return cc + +@router.get('/{change_case_id}', response_model=ChangeCaseRead) +def get_change_case(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() + if not cc: raise HTTPException(status_code=404, detail='Change Case not found') + return cc + +@router.patch('/{change_case_id}', response_model=ChangeCaseRead) +def patch_change_case(change_case_id: int, payload: ChangeCaseUpdate, 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') + old_status = cc.status + if payload.title is not None: cc.title = payload.title.strip() + if payload.description is not None: cc.description = payload.description + if payload.status is not None: + if payload.status not in VALID: raise HTTPException(status_code=400, detail='Invalid status') + if payload.status != cc.status and payload.status not in TRANSITIONS.get(cc.status, set()): + raise HTTPException(status_code=400, detail='Invalid status transition') + cc.status = payload.status + db.commit(); db.refresh(cc) + write_audit_log(db, user_id=user.id, action='change_case_updated', target_type='change_case', target_id=str(cc.id), details_json={}) + if old_status != cc.status: + 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.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() + if not cc: raise HTTPException(status_code=404, detail='Change Case not found') + rows = db.query(Report, Check).join(Check, Report.check_id == Check.id).filter(Check.change_case_id == change_case_id).all() + return [{'report_id': r.id, 'check_id': c.id, 'check_type': c.check_type, 'summary': c.summary, 'status': c.status, 'created_at': r.created_at.isoformat()} for r, c in rows] diff --git a/backend/app/api/routes_checks.py b/backend/app/api/routes_checks.py index 424abba..f700168 100644 --- a/backend/app/api/routes_checks.py +++ b/backend/app/api/routes_checks.py @@ -5,7 +5,7 @@ from app.core.auth import require_operator_or_admin from app.core.audit import write_audit_log from app.database import get_db -from app.models import Check, Report +from app.models import ChangeCase, Check, Report from app.schemas import AsnCheckRequest, AsnRpkiBatchRequest, CheckResponse, PrefixCheckRequest, PreflightCheckRequest from app.services.asn_checker import AsnChecker from app.services.prefix_checker import PrefixChecker @@ -20,7 +20,7 @@ def check_asn(payload: AsnCheckRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse: try: result = AsnChecker(RipeStatClient(db)).check(payload.asn) - return _store_and_respond(db, "asn", payload.asn, None, result, user.id) + return _store_and_respond(db, "asn", payload.asn, None, result, user.id, payload.change_case_id) except HTTPException: raise except Exception as exc: @@ -31,7 +31,7 @@ def check_asn(payload: AsnCheckRequest, db: Session = Depends(get_db), user=Depe def check_asn_rpki(payload: AsnRpkiBatchRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse: try: result = AsnChecker(RipeStatClient(db)).check_rpki_batch(payload.asn, payload.limit) - return _store_and_respond(db, "asn-rpki", payload.asn, None, result, user.id) + return _store_and_respond(db, "asn-rpki", payload.asn, None, result, user.id, payload.change_case_id) except HTTPException: raise except Exception as exc: @@ -42,7 +42,7 @@ def check_asn_rpki(payload: AsnRpkiBatchRequest, db: Session = Depends(get_db), def check_prefix(payload: PrefixCheckRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse: try: result = PrefixChecker(RipeStatClient(db)).check(payload.prefix, payload.origin_as) - return _store_and_respond(db, "prefix", payload.prefix, payload.origin_as, result, user.id) + return _store_and_respond(db, "prefix", payload.prefix, payload.origin_as, result, user.id, payload.change_case_id) except HTTPException: raise except Exception as exc: @@ -54,16 +54,21 @@ def check_prefix(payload: PrefixCheckRequest, db: Session = Depends(get_db), use def check_preflight(payload: PreflightCheckRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse: try: result = PreflightChecker(RipeStatClient(db)).check(payload.prefix, payload.planned_origin_as) - return _store_and_respond(db, "preflight", payload.prefix, payload.planned_origin_as, result, user.id) + return _store_and_respond(db, "preflight", payload.prefix, payload.planned_origin_as, result, user.id, payload.change_case_id) except HTTPException: raise except Exception as exc: raise HTTPException(status_code=500, detail=f"Preflight-Prüfung fehlgeschlagen: {exc}") from exc -def _store_and_respond(db: Session, ctype: str, resource: str, origin_as: str | None, result: dict, user_id: int | None = None) -> CheckResponse: +def _store_and_respond(db: Session, ctype: str, resource: str, origin_as: str | None, result: dict, user_id: int | None = None, change_case_id: int | None = None) -> CheckResponse: try: - check = Check(check_type=ctype, input_resource=resource, origin_as=origin_as, status=result["status"], summary=result["summary"], created_by_user_id=user_id) + change_case = None + if change_case_id is not None: + change_case = db.query(ChangeCase).filter(ChangeCase.id == change_case_id).first() + if not change_case: + raise HTTPException(status_code=404, detail="Change Case not found") + check = Check(check_type=ctype, input_resource=resource, origin_as=origin_as, status=result["status"], summary=result["summary"], created_by_user_id=user_id, change_case_id=change_case_id) db.add(check) db.commit() db.refresh(check) @@ -74,6 +79,9 @@ def _store_and_respond(db: Session, ctype: str, resource: str, origin_as: str | db.refresh(report) write_audit_log(db, user_id=user_id, action='check_executed', target_type='check', target_id=str(check.id), details_json={'check_type': ctype, 'input_resource': resource, 'status': result.get('status')}) write_audit_log(db, user_id=user_id, action='report_generated', target_type='report', target_id=str(report.id), details_json={'check_id': check.id, 'check_type': ctype}) + if change_case_id is not None: + write_audit_log(db, user_id=user_id, action='check_attached_to_change_case', target_type='change_case', target_id=str(change_case_id), details_json={'check_id': check.id}) + write_audit_log(db, user_id=user_id, action='report_attached_to_change_case', target_type='change_case', target_id=str(change_case_id), details_json={'report_id': report.id}) return CheckResponse(report_id=report.id, status=result["status"], summary=result["summary"], explanation=result.get("explanation"), risk=result.get("risk"), recommendations=result["recommendations"], input=result.get("input"), checks=result.get("checks"), details=result["details"], markdown=md, html=html) except OperationalError as exc: db.rollback() diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py index b83e513..5dd6e8c 100644 --- a/backend/app/api/routes_system.py +++ b/backend/app/api/routes_system.py @@ -12,7 +12,7 @@ def system_info(): return { 'name': 'RouteForge', - 'version': 'v0.6.6-beta', + 'version': 'v0.7.0-beta', 'demo_mode': settings.demo_mode, 'read_only': True, 'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'], diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py index 2b23c69..4fb5d83 100644 --- a/backend/app/core/system_status.py +++ b/backend/app/core/system_status.py @@ -139,7 +139,7 @@ def build_system_status(engine: Engine | None) -> dict: return { "status": "ok", "name": settings.app_name, - "version": "v0.6.6-beta", + "version": "v0.7.0-beta", "read_only": True, "mode": "demo" if settings.demo_mode else "live", "demo_mode": settings.demo_mode, diff --git a/backend/app/main.py b/backend/app/main.py index fdfd85d..2379f29 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,6 +6,7 @@ from app.api.routes_audit import router as audit_router from app.api.routes_auth import router as auth_router +from app.api.routes_change_cases import router as change_cases_router from app.api.routes_checks import router as checks_router from app.api.routes_health import router as health_router from app.api.routes_reports import router as reports_router @@ -18,7 +19,7 @@ logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO)) logger = logging.getLogger("routeforge") -app = FastAPI(title="RouteForge", version="0.6.6") +app = FastAPI(title="RouteForge", version="0.7.0") app.add_middleware( CORSMiddleware, @@ -49,6 +50,7 @@ def startup() -> None: app.include_router(health_router) app.include_router(checks_router) app.include_router(reports_router) +app.include_router(change_cases_router) app.include_router(system_router) app.include_router(auth_router) diff --git a/backend/app/models.py b/backend/app/models.py index aebcb29..3409fc5 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -19,6 +19,18 @@ class User(Base): last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) +class ChangeCase(Base): + __tablename__ = "change_cases" + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft") + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + checks: Mapped[list["Check"]] = relationship(back_populates="change_case") + + class Check(Base): __tablename__ = "checks" id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) @@ -29,6 +41,8 @@ class Check(Base): summary: Mapped[str] = mapped_column(Text, nullable=False) created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + change_case_id: Mapped[int | None] = mapped_column(ForeignKey("change_cases.id"), nullable=True) + change_case: Mapped["ChangeCase | None"] = relationship(back_populates="checks") report: Mapped["Report"] = relationship(back_populates="check", uselist=False) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 5f33973..62717e2 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -6,7 +6,29 @@ from app.core.normalize import normalize_asn, validate_prefix +class ChangeCaseCreate(BaseModel): + title: str = Field(min_length=1, max_length=200) + description: str | None = None + + +class ChangeCaseUpdate(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=200) + description: str | None = None + status: str | None = None + + +class ChangeCaseRead(BaseModel): + id: int + title: str + description: str | None + status: str + created_by_user_id: int | None + created_at: datetime + updated_at: datetime + + class AsnCheckRequest(BaseModel): + change_case_id: int | None = None asn: str @field_validator("asn") @@ -19,6 +41,7 @@ def valid_asn(cls, v: str) -> str: class AsnRpkiBatchRequest(BaseModel): + change_case_id: int | None = None asn: str limit: int = Field(default=25, ge=1, le=100) @@ -30,6 +53,7 @@ def valid_asn(cls, v: str) -> str: class PrefixCheckRequest(BaseModel): + change_case_id: int | None = None prefix: str origin_as: str | None = None @@ -49,6 +73,7 @@ def valid_origin(cls, v: str | None) -> str | None: class PreflightCheckRequest(BaseModel): + change_case_id: int | None = None prefix: str planned_origin_as: str diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index ad9d957..7b206ef 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -177,7 +177,7 @@ def test_system_status_endpoint() -> None: response = client.get('/api/system/status') assert response.status_code == 200 payload = response.json() - assert payload.get('version') == 'v0.6.6-beta' + assert payload.get('version') == 'v0.7.0-beta' assert payload.get('read_only') is True assert payload.get('database', {}).get('status') assert payload.get('ripestat', {}).get('cache_ttl_seconds') is not None @@ -369,3 +369,36 @@ def test_cookie_name_and_logout_cookie_settings() -> None: assert out.status_code == 200 logout_cookie = out.headers.get('set-cookie', '') assert 'rf_cookie_test=' in logout_cookie + +def test_change_case_crud_and_roles() -> None: + client = _client() + _setup_and_login(client) + create = client.post('/api/change-cases', json={'title': 'Case 1', 'description': 'desc'}) + assert create.status_code == 200 + cid = create.json()['id'] + assert client.get('/api/change-cases').status_code == 200 + patch = client.patch(f'/api/change-cases/{cid}', json={'status': 'in_review'}) + assert patch.status_code == 200 + + client.post('/api/users', json={'username': 'viewer2', 'email': 'viewer2@example.org', 'password': 'ViewerPass123!', 'role': 'viewer'}) + client.post('/api/auth/logout') + assert client.post('/api/auth/login', json={'username': 'viewer2', 'password': 'ViewerPass123!'}).status_code == 200 + assert client.get('/api/change-cases').status_code == 200 + assert client.post('/api/change-cases', json={'title': 'Nope'}).status_code == 403 + + +def test_change_case_invalid_status_and_check_attach_and_audit() -> None: + client = _client() + _setup_and_login(client) + cid = client.post('/api/change-cases', json={'title': 'Case 2', 'description': ''}).json()['id'] + bad = client.patch(f'/api/change-cases/{cid}', json={'status': 'invalid'}) + assert bad.status_code == 400 + check = client.post('/api/check/asn', json={'asn': 'AS3320', 'change_case_id': cid}) + assert check.status_code == 200 + reports = client.get(f'/api/change-cases/{cid}/reports') + assert reports.status_code == 200 + assert isinstance(reports.json(), list) + audit = client.get('/api/audit-log') + 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 diff --git a/frontend/package.json b/frontend/package.json index 053bbbc..3c354dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "routeforge-frontend", - "version": "0.6.6", + "version": "0.7.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5621ba5..90042b6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,8 +10,9 @@ import { SetupView } from './components/SetupView' import { StatusBadge } from './components/StatusBadge' import type { ReportListItem, SystemInfo, SystemStatus, User, UserRole } from './types' import { UsersView } from './components/UsersView' +import { ChangeCasesView } from './components/ChangeCasesView' -type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'users' | 'audit' | 'about' +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' type AuthMode = 'loading' | 'setup' | 'login' | 'app' | 'error' export default function App() { @@ -70,8 +71,8 @@ export default function App() { if (authMode === 'login') return if (authMode === 'error') return
{authError}
- const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.6.6-beta · read-only preflight checks' - const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', users: 'User Management', audit: 'Audit Log', about: 'About RouteForge' }[active] + const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.7.0-beta · read-only preflight checks' + const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', 'change-cases': 'Change Cases', system: 'System Status', users: 'User Management', audit: 'Audit Log', about: 'About RouteForge' }[active] const proxyStatus = systemStatusError ? 'ERROR' : 'OK' const migrationStatus = systemStatus?.database?.migration_status || 'unknown' const migrationsBlocked = migrationStatus === 'behind' || migrationStatus === 'error' @@ -80,20 +81,21 @@ export default function App() { const canAccess = (view: NavKey) => { if (role === 'admin') return true if (role === 'operator') return !['users', 'audit'].includes(view) - return ['dashboard', 'reports', 'about'].includes(view) + return ['dashboard', 'reports', 'change-cases', 'about'].includes(view) } - const allowedActions = role === 'admin' ? 'You can run checks, manage users, view reports and system status.' : role === 'operator' ? 'You can run checks and view reports.' : 'You can view reports.' + const allowedActions = role === 'admin' ? 'You can run checks, manage users, view reports and system status.' : role === 'operator' ? 'You can run checks and view reports.' : 'You can view reports and change cases.' return - {active === 'dashboard' &&

RouteForge v0.6.6-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

Logged in as: {currentUser?.username}
Role: {currentUser?.role}
Allowed actions: {allowedActions}
{migrationsBlocked &&
Database migrations are required before using RouteForge.
}
} + {active === 'dashboard' &&

RouteForge v0.7.0-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

Logged in as: {currentUser?.username}
Role: {currentUser?.role}
Allowed actions: {allowedActions}
{migrationsBlocked &&
Database migrations are required before using RouteForge.
}
} {!canAccess(active) &&
You do not have permission to access this section.
} {active === 'asn' && canAccess('asn') && } {active === 'prefix' && canAccess('prefix') && } {active === 'preflight' && canAccess('preflight') && } {active === 'reports' &&

Reports

{reports.length===0 ?
Noch keine Reports vorhanden.
:
{reports.map(r=>)}
{r.summary}
}
} + {active === 'change-cases' && canAccess('change-cases') && } {active === 'system' && canAccess('system') &&
{systemStatusError &&
{systemStatusError}
}{migrationsBlocked &&
Database migrations are required before using RouteForge.
}{systemStatus &&
Version: {systemStatus.version}
Mode: {systemStatus.mode}
API Proxy: {proxyStatus}
Migration Status: {migrationStatus}
}
} {active === 'users' && canAccess('users') && } {active === 'audit' && canAccess('audit') && } - {active === 'about' &&

Version: v0.6.6-beta

} + {active === 'about' &&

Version: v0.7.0-beta

}
} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a009d02..2a29bdf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { AuditLogEntry, CheckResponse, ReportListItem, SystemInfo, SystemStatus, User, UserCreatePayload, UserUpdatePayload } from './types' +import type { AuditLogEntry, ChangeCaseItem, CheckResponse, ReportListItem, SystemInfo, SystemStatus, User, UserCreatePayload, UserUpdatePayload } from './types' const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '') @@ -74,3 +74,9 @@ export const getAuditLog = (params: { action?: string; user_id?: number; target_ const suffix = search.toString() return requestJson<{ items: AuditLogEntry[]; limit: number; offset: number }>(apiUrl('/api/audit-log' + (suffix ? `?${suffix}` : '')), { method: 'GET' }) } + +export const listChangeCases = () => requestJson(apiUrl('/api/change-cases'), { method: 'GET' }) +export const createChangeCase = (payload: { title: string; description?: string }) => requestJson(apiUrl('/api/change-cases'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) +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 }) }) diff --git a/frontend/src/components/ChangeCasesView.tsx b/frontend/src/components/ChangeCasesView.tsx new file mode 100644 index 0000000..31b479d --- /dev/null +++ b/frontend/src/components/ChangeCasesView.tsx @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react' +import { createChangeCase, getChangeCaseReports, listChangeCases, runAsnCheck, updateChangeCase } from '../api' +import type { ChangeCaseItem, UserRole } from '../types' + +export function ChangeCasesView({ role }: { role: UserRole }) { + const [items, setItems] = useState([]) + const [selected, setSelected] = useState(null) + 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) } } + useEffect(() => { load() }, []) + useEffect(() => { if (selected) getChangeCaseReports(selected.id).then(setReports).catch(()=>setReports([])) }, [selected]) + return
+

Change Cases

{canEdit && }
+ {loading ?
Loading…
: items.length===0 ?
No change cases yet.
: {items.map(i=>setSelected(i)}>)}
TitleStatusOwnerCreatedUpdated
{i.title}{i.status}{i.created_by_user_id ?? '—'}{new Date(i.created_at).toLocaleString()}{new Date(i.updated_at).toLocaleString()}
} + {selected &&

{selected.title}

{selected.description || '—'}

Status: {selected.status}
{canEdit &&
}
Reports: {reports.length}
} +
+} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 4d8e55a..9e9ef15 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { ReactNode } from 'react' import type { UserRole } from '../types' -type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'users' | 'audit' | 'about' +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' const nav: { key: NavKey; label: string; desc: string }[] = [ { key: 'dashboard', label: 'Dashboard', desc: 'Overview & quick actions' }, @@ -9,6 +9,7 @@ const nav: { key: NavKey; label: string; desc: string }[] = [ { key: 'prefix', label: 'Prefix Check', desc: 'Prefix + origin validation' }, { key: 'preflight', label: 'Preflight', desc: 'Planned prefix-origin validation' }, { key: 'reports', label: 'Reports', desc: 'History and outcomes' }, + { key: 'change-cases', label: 'Change Cases', desc: 'Project-bound routing cases' }, { key: 'system', label: 'System', desc: 'Operational checks' }, { key: 'users', label: 'Users', desc: 'Admin user management' }, { key: 'audit', label: 'Audit Log', desc: 'Admin audit trail' }, @@ -20,7 +21,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode, c if (!currentUser) return ['dashboard', 'about'].includes(item.key) if (currentUser.role === 'admin') return true if (currentUser.role === 'operator') return !['users', 'audit'].includes(item.key) - return ['dashboard', 'reports', 'about'].includes(item.key) + return ['dashboard', 'reports', 'change-cases', 'about'].includes(item.key) }) return
@@ -44,7 +45,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode, c {currentUser && Angemeldet als {currentUser.username} · {currentUser.role}} {demoMode ? 'DEMO' : 'LIVE'} READ-ONLY - v0.6.6-beta + v0.7.0-beta
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c2f2afd..7d6b5bb 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -132,3 +132,6 @@ export type AuditLogEntry = { user_agent?: string | null details_json?: Record | null } + +export type ChangeCaseStatus = 'draft' | 'in_review' | 'approved' | 'closed' +export type ChangeCaseItem = { id:number; title:string; description?:string|null; status:ChangeCaseStatus; created_by_user_id?:number|null; created_at:string; updated_at:string }