From bbe7f49c8654b8642ab12bacd9cfaec8e931b300 Mon Sep 17 00:00:00 2001 From: kitos Date: Mon, 20 Jul 2026 09:29:36 +0200 Subject: [PATCH] feat(tests,users): blocking procedure-suggestion review on test detail, multi-role switcher, Power Automate webhook payload - A lead opening a test with a pending procedure suggestion awaiting their review now gets a blocking popup (approve/reject only) instead of discovering it later in a separate queue. - Users can be granted more than one role via extra_roles; only one is ever active at a time (no permission mixing) and a top-bar switcher lets the user swap which one, taking effect immediately since role is read fresh from the DB on every request. - The password-setup/reset webhook now posts the agreed Power Automate contract ({to, subject, body} with the platform's standard greeting and signature template) and supports an admin-configured API key sent as an x-api-key header. --- .../alembic/versions/b066_user_extra_roles.py | 32 +++++++ backend/app/models/user.py | 4 + backend/app/routers/procedure_suggestions.py | 19 ++++ backend/app/routers/system.py | 24 ++++- backend/app/routers/users.py | 35 +++++++ backend/app/schemas/auth.py | 1 + backend/app/schemas/user.py | 11 +++ .../app/services/password_setup_service.py | 61 ++++++++++-- .../services/procedure_suggestion_service.py | 10 +- backend/app/services/user_service.py | 29 ++++++ backend/tests/test_multi_role_switch.py | 96 +++++++++++++++++++ backend/tests/test_password_setup_flow.py | 31 +++++- backend/tests/test_procedure_suggestions.py | 64 +++++++++++++ frontend/src/api/procedure-suggestions.ts | 7 ++ frontend/src/api/settings.ts | 12 ++- frontend/src/api/users.ts | 8 ++ frontend/src/components/Layout.tsx | 2 + frontend/src/components/RoleSwitcher.tsx | 71 ++++++++++++++ .../test-detail/PendingSuggestionModal.tsx | 65 +++++++++++++ frontend/src/context/AuthContext.tsx | 11 +++ frontend/src/pages/SettingsPage.tsx | 65 ++++++++----- frontend/src/pages/TestDetailPage.tsx | 39 ++++++++ frontend/src/pages/UsersPage.tsx | 59 ++++++++++-- frontend/src/types/models.ts | 1 + 24 files changed, 708 insertions(+), 49 deletions(-) create mode 100644 backend/alembic/versions/b066_user_extra_roles.py create mode 100644 backend/tests/test_multi_role_switch.py create mode 100644 frontend/src/components/RoleSwitcher.tsx create mode 100644 frontend/src/components/test-detail/PendingSuggestionModal.tsx diff --git a/backend/alembic/versions/b066_user_extra_roles.py b/backend/alembic/versions/b066_user_extra_roles.py new file mode 100644 index 0000000..e439f6a --- /dev/null +++ b/backend/alembic/versions/b066_user_extra_roles.py @@ -0,0 +1,32 @@ +"""Add users.extra_roles for multi-role support. + +A user can be granted more than one role, but only ever acts under a +single "active" role at a time (``users.role``, unchanged — every +existing permission check keeps reading it as-is). ``extra_roles`` holds +the *other* roles a user can switch into via the top-bar role switcher; +switching swaps the active role with one from this list. + +Revision ID: b066 +Revises: b065 +Create Date: 2026-07-20 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "b066" +down_revision = "b065" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("extra_roles", postgresql.JSONB(), nullable=False, server_default="[]"), + ) + + +def downgrade() -> None: + op.drop_column("users", "extra_roles") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8703b78..5a47bb2 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -37,6 +37,10 @@ class User(Base): hashed_password = Column(String, nullable=False) # Assign role = Column(String, nullable=False, default="viewer") role = Column(String, nullable=False, default="viewer") + # Other roles this user can switch into (the currently-active role + # lives in `role` above and is what every permission check reads — + # switching just swaps which one is active, never grants both at once). + extra_roles = Column(JSONB, nullable=False, default=list, server_default="[]") # Assign is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True) # Assign must_change_password = Column(Boolean, default=True) diff --git a/backend/app/routers/procedure_suggestions.py b/backend/app/routers/procedure_suggestions.py index da2f34c..d409faa 100644 --- a/backend/app/routers/procedure_suggestions.py +++ b/backend/app/routers/procedure_suggestions.py @@ -73,6 +73,25 @@ def list_suggestions( return [_to_out(s, template_by_id) for s in suggestions] +@router.get("/for-test/{test_id}", response_model=list[ProcedureSuggestionOut]) +def list_suggestions_for_test( + test_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")), +) -> list[ProcedureSuggestionOut]: + """List pending suggestions tied to a specific test, scoped to the + caller's team (admins see both) — powers the blocking review popup a + lead sees when opening a test that has one awaiting them.""" + team = _team_for_role(current_user) + suggestions = list_pending_suggestions(db, team=team, source_test_id=test_id) + template_ids = {s.template_id for s in suggestions} + templates = ( + db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else [] + ) + template_by_id = {t.id: t for t in templates} + return [_to_out(s, template_by_id) for s in suggestions] + + @router.post("/{suggestion_id}/approve", response_model=ProcedureSuggestionOut) def approve_suggestion( suggestion_id: uuid.UUID, diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 37c5cf9..d19ac8d 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -347,10 +347,13 @@ def update_jira_config( class PasswordWebhookConfigOut(BaseModel): configured: bool url: str + api_key_set: bool class PasswordWebhookConfigUpdate(BaseModel): url: str + # Optional — omit or send "" to leave the currently-stored key unchanged. + api_key: Optional[str] = None @router.get("/password-webhook-config", response_model=PasswordWebhookConfigOut) @@ -360,12 +363,15 @@ def get_password_webhook_config( ): """Return the configured webhook URL used to email set-password links. + The API key itself is never returned, only whether one is set. + **Requires** the ``admin`` role. """ - from app.services.password_setup_service import get_password_webhook_url + from app.services.password_setup_service import get_password_webhook_api_key, get_password_webhook_url url = get_password_webhook_url(db) or "" - return PasswordWebhookConfigOut(configured=bool(url), url=url) + api_key_set = bool(get_password_webhook_api_key(db)) + return PasswordWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set) @router.patch("/password-webhook-config", response_model=PasswordWebhookConfigOut) @@ -374,16 +380,24 @@ def update_password_webhook_config( db: Session = Depends(get_db), current_user: User = Depends(require_role("admin")), ): - """Set the webhook URL used to email set-password links. + """Set the webhook URL (and optionally API key) used to email set-password links. **Requires** the ``admin`` role. """ - from app.services.password_setup_service import get_password_webhook_url, set_password_webhook_url + from app.services.password_setup_service import ( + get_password_webhook_api_key, + get_password_webhook_url, + set_password_webhook_api_key, + set_password_webhook_url, + ) set_password_webhook_url(db, payload.url) + if payload.api_key: + set_password_webhook_api_key(db, payload.api_key) db.commit() url = get_password_webhook_url(db) or "" - return PasswordWebhookConfigOut(configured=bool(url), url=url) + api_key_set = bool(get_password_webhook_api_key(db)) + return PasswordWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set) @router.post("/jira-test") diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 7b41e1a..655e046 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -28,6 +28,7 @@ from app.schemas.user import ( UserPreferencesUpdate, UserOperatorOut, SendPasswordEmailOut, + SwitchRoleRequest, ) from app.services.audit_service import log_action @@ -36,6 +37,7 @@ from app.services.user_service import ( create_user_without_password, get_user_or_raise, list_users, + switch_active_role, update_user, ) from app.services.password_setup_service import send_password_setup_email @@ -85,6 +87,39 @@ def get_me( return current_user +# --------------------------------------------------------------------------- +# POST /users/me/switch-role — swap the caller's active role +# --------------------------------------------------------------------------- + + +@router.post("/me/switch-role", response_model=UserOut) +def switch_my_role( + payload: SwitchRoleRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> UserOut: + """Switch the caller's active role to one of their granted extra_roles. + + Roles never mix — this changes which single role is active, it never + grants permissions from more than one role at once. Takes effect + immediately since every permission check reads ``role`` fresh from the + DB on each request. + """ + with UnitOfWork(db) as uow: + user = switch_active_role(db, current_user, payload.role) + log_action( + db, + user_id=current_user.id, + action="switch_role", + entity_type="user", + entity_id=current_user.id, + details={"new_role": payload.role}, + ) + uow.commit() + db.refresh(user) + return user + + # --------------------------------------------------------------------------- # GET /users — list all users # --------------------------------------------------------------------------- diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 5b84ee3..4e25fbe 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -44,6 +44,7 @@ class UserOut(BaseModel): email: str | None = None # role: str role: str + extra_roles: list[str] = [] # is_active: bool is_active: bool # Assign must_change_password = True diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 9530810..491fd27 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -182,6 +182,8 @@ class UserUpdate(BaseModel): full_name: str | None = None # Assign role = None role: str | None = None + # Other roles this user can switch into via the top-bar switcher. + extra_roles: list[str] | None = None # Assign is_active = None is_active: bool | None = None # Assign password = None @@ -265,6 +267,7 @@ class UserOut(BaseModel): email: str | None = None # role: str role: str + extra_roles: list[str] = Field(default_factory=list) # is_active: bool is_active: bool # Assign must_change_password = True @@ -335,3 +338,11 @@ class SetPasswordRequest(BaseModel): @classmethod def new_password_strength(cls, v: str) -> str: return _validate_password_strength(v) + + +# ── Multi-role switching ───────────────────────────────────────────── + +class SwitchRoleRequest(BaseModel): + """Payload for switching the caller's currently-active role.""" + + role: str diff --git a/backend/app/services/password_setup_service.py b/backend/app/services/password_setup_service.py index 6485964..4b98ed3 100644 --- a/backend/app/services/password_setup_service.py +++ b/backend/app/services/password_setup_service.py @@ -27,8 +27,26 @@ from app.models.user import User logger = logging.getLogger(__name__) _WEBHOOK_URL_CONFIG_KEY = "password_setup.webhook_url" +_WEBHOOK_API_KEY_CONFIG_KEY = "password_setup.webhook_api_key" _TOKEN_TTL = timedelta(hours=24) +# Standard footer appended to every Power-Automate-delivered notification +# email, matching the platform's established template. +_EMAIL_SIGNATURE = ( + "\n\nRegards,\n" + "AEGIS Security Platform\n" + "Purple Team Engineering\n" + "Owned and operated by Enterprise Corp.\n\n" + "Assume breach. Validate controls. Improve continuously.\n\n" + "This is an automated notification. Please do not reply." +) + + +def _build_email_body(full_name: str | None, message: str) -> str: + """Wrap *message* in the standard greeting + signature template.""" + greeting = full_name or "there" + return f"HI {greeting}\n\n{message}{_EMAIL_SIGNATURE}" + def _read_system_config(db: Session, key: str) -> str | None: from app.models.system_config import SystemConfig # avoid circular at import time @@ -37,6 +55,16 @@ def _read_system_config(db: Session, key: str) -> str | None: return row.value if row else None +def _write_system_config(db: Session, key: str, value: str) -> None: + from app.models.system_config import SystemConfig + + row = db.query(SystemConfig).filter(SystemConfig.key == key).first() + if row: + row.value = value + else: + db.add(SystemConfig(key=key, value=value)) + + def get_password_webhook_url(db: Session) -> str | None: """Return the configured Power-Automate-style webhook URL, or None.""" return _read_system_config(db, _WEBHOOK_URL_CONFIG_KEY) or None @@ -44,13 +72,17 @@ def get_password_webhook_url(db: Session) -> str | None: def set_password_webhook_url(db: Session, url: str) -> None: """Persist the webhook URL. Does not commit; caller commits.""" - from app.models.system_config import SystemConfig + _write_system_config(db, _WEBHOOK_URL_CONFIG_KEY, url) - row = db.query(SystemConfig).filter(SystemConfig.key == _WEBHOOK_URL_CONFIG_KEY).first() - if row: - row.value = url - else: - db.add(SystemConfig(key=_WEBHOOK_URL_CONFIG_KEY, value=url)) + +def get_password_webhook_api_key(db: Session) -> str | None: + """Return the configured webhook API key, or None.""" + return _read_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY) or None + + +def set_password_webhook_api_key(db: Session, api_key: str) -> None: + """Persist the webhook API key. Does not commit; caller commits.""" + _write_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY, api_key) def create_setup_token(db: Session, user: User) -> PasswordSetupToken: @@ -88,15 +120,26 @@ def send_password_setup_email(db: Session, user: User) -> None: token = create_setup_token(db, user) set_password_url = f"{settings.PLATFORM_URL}/set-password?token={token.token}" + is_reset = not user.must_change_password + subject = "Reset Your Password" if is_reset else "Set Your Password" + message = ( + f'Click the link below to {"reset" if is_reset else "set"} your Aegis password:\n\n' + f"{set_password_url}\n\n" + "This link expires in 24 hours and can only be used once." + ) + + api_key = get_password_webhook_api_key(db) + headers = {"x-api-key": api_key} if api_key else {} try: requests.post( webhook_url, json={ - "email": user.email, - "full_name": user.full_name, - "set_password_url": set_password_url, + "to": user.email, + "subject": subject, + "body": _build_email_body(user.full_name, message), }, + headers=headers, timeout=10, ) except requests.RequestException: diff --git a/backend/app/services/procedure_suggestion_service.py b/backend/app/services/procedure_suggestion_service.py index f829775..6e951be 100644 --- a/backend/app/services/procedure_suggestion_service.py +++ b/backend/app/services/procedure_suggestion_service.py @@ -131,11 +131,17 @@ def create_suggestion_from_submission(db: Session, test: Test, team: str) -> Pro return suggestion -def list_pending_suggestions(db: Session, *, team: str | None = None) -> list[ProcedureSuggestion]: - """List pending suggestions, optionally filtered to one team.""" +def list_pending_suggestions( + db: Session, *, team: str | None = None, source_test_id: uuid.UUID | None = None, +) -> list[ProcedureSuggestion]: + """List pending suggestions, optionally filtered to one team and/or the + specific test that originated them (used to block a lead's test-detail + view on a suggestion awaiting their review).""" query = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.status == "pending") if team is not None: query = query.filter(ProcedureSuggestion.team == team) + if source_test_id is not None: + query = query.filter(ProcedureSuggestion.source_test_id == source_test_id) return query.order_by(ProcedureSuggestion.created_at.asc()).all() diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index a22ebfb..ac82136 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -159,6 +159,13 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User: f"Invalid role '{update_data['role']}'. Must be one of: {', '.join(sorted(VALID_ROLES))}" ) + if update_data.get("extra_roles") is not None: + invalid = [r for r in update_data["extra_roles"] if r not in VALID_ROLES] + if invalid: + raise BusinessRuleViolation( + f"Invalid role(s) {', '.join(invalid)}. Must be one of: {', '.join(sorted(VALID_ROLES))}" + ) + # Check: "password" in update_data if "password" in update_data: # Assign update_data["hashed_password"] = hash_password(str(update_data.pop("password"))) @@ -171,3 +178,25 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User: # Return user return user + + +def switch_active_role(db: Session, user: User, new_role: str) -> User: + """Swap *user*'s currently-active role with one from their extra_roles. + + Roles never mix — the user acts under exactly one role at a time, this + just changes which one. Raises BusinessRuleViolation if new_role isn't + one this user has been granted. Does not commit; caller commits. + """ + available = {user.role, *(user.extra_roles or [])} + if new_role not in available: + raise BusinessRuleViolation(f"Role '{new_role}' is not available to this user") + + if new_role == user.role: + return user + + old_role = user.role + remaining = [r for r in (user.extra_roles or []) if r != new_role] + remaining.append(old_role) + user.role = new_role + user.extra_roles = remaining + return user diff --git a/backend/tests/test_multi_role_switch.py b/backend/tests/test_multi_role_switch.py new file mode 100644 index 0000000..b73ac23 --- /dev/null +++ b/backend/tests/test_multi_role_switch.py @@ -0,0 +1,96 @@ +"""A user can be granted more than one role but only ever acts under a +single active one at a time — switching swaps which role is active +instead of ever mixing permissions from more than one. +""" + + +def test_admin_can_grant_extra_roles(api, auth_headers): + created = api( + "post", "/api/v1/users", auth_headers, + json={"full_name": "Multi Role User", "email": "multirole@test.com", "role": "red_tech"}, + ) + assert created.status_code == 201, created.text + user_id = created.json()["id"] + + resp = api( + "patch", f"/api/v1/users/{user_id}", auth_headers, + json={"extra_roles": ["blue_tech", "red_lead"]}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["role"] == "red_tech" + assert set(resp.json()["extra_roles"]) == {"blue_tech", "red_lead"} + + +def test_admin_cannot_grant_invalid_extra_role(api, auth_headers): + created = api( + "post", "/api/v1/users", auth_headers, + json={"full_name": "Bad Extra Role", "email": "badextrarole@test.com", "role": "viewer"}, + ) + user_id = created.json()["id"] + + resp = api( + "patch", f"/api/v1/users/{user_id}", auth_headers, + json={"extra_roles": ["superuser"]}, + ) + assert resp.status_code == 400 + + +def test_user_can_switch_to_a_granted_extra_role(api, db, auth_headers): + from app.auth import hash_password + from app.models.user import User + + user = User( + username="switcher@test.com", + email="switcher@test.com", + full_name="Switcher", + hashed_password=hash_password("SwitcherPass123!@#"), + role="red_tech", + extra_roles=["blue_tech"], + must_change_password=False, + ) + db.add(user) + db.commit() + + login = api( + "post", "/api/v1/auth/login", {}, + data={"username": "switcher@test.com", "password": "SwitcherPass123!@#"}, + ) + assert login.status_code == 200, login.text + token = login.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + resp = api("post", "/api/v1/users/me/switch-role", headers, json={"role": "blue_tech"}) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["role"] == "blue_tech" + assert body["extra_roles"] == ["red_tech"] + + me = api("get", "/api/v1/auth/me", headers) + assert me.json()["role"] == "blue_tech" + + +def test_user_cannot_switch_to_an_ungranted_role(api, db, auth_headers): + from app.auth import hash_password + from app.models.user import User + + user = User( + username="switcher2@test.com", + email="switcher2@test.com", + full_name="Switcher Two", + hashed_password=hash_password("SwitcherPass123!@#"), + role="red_tech", + extra_roles=["blue_tech"], + must_change_password=False, + ) + db.add(user) + db.commit() + + login = api( + "post", "/api/v1/auth/login", {}, + data={"username": "switcher2@test.com", "password": "SwitcherPass123!@#"}, + ) + token = login.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + resp = api("post", "/api/v1/users/me/switch-role", headers, json={"role": "admin"}) + assert resp.status_code == 400 diff --git a/backend/tests/test_password_setup_flow.py b/backend/tests/test_password_setup_flow.py index cff3031..c9bfc58 100644 --- a/backend/tests/test_password_setup_flow.py +++ b/backend/tests/test_password_setup_flow.py @@ -36,6 +36,29 @@ def test_admin_can_configure_password_webhook(api, auth_headers): assert get_resp.json()["url"] == "https://example.com/power-automate-hook" +def test_admin_can_configure_webhook_api_key(api, auth_headers): + resp = api( + "patch", "/api/v1/system/password-webhook-config", auth_headers, + json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["api_key_set"] is True + # The key itself is never echoed back. + assert "api_key" not in resp.json() + assert "super-secret-key" not in resp.text + + +def test_send_password_email_sends_api_key_header(api, db, auth_headers, new_user_id): + api( + "patch", "/api/v1/system/password-webhook-config", auth_headers, + json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"}, + ) + with patch("app.services.password_setup_service.requests.post") as mock_post: + api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers) + call_kwargs = mock_post.call_args + assert call_kwargs.kwargs["headers"] == {"x-api-key": "super-secret-key"} + + def test_send_password_email_posts_to_webhook_and_issues_token(api, db, auth_headers, new_user_id): api( "patch", "/api/v1/system/password-webhook-config", auth_headers, @@ -48,8 +71,12 @@ def test_send_password_email_posts_to_webhook_and_issues_token(api, db, auth_hea mock_post.assert_called_once() call_kwargs = mock_post.call_args assert call_kwargs.args[0] == "https://example.com/power-automate-hook" - assert call_kwargs.kwargs["json"]["email"] == "setpw@test.com" - assert "token=" in call_kwargs.kwargs["json"]["set_password_url"] + payload = call_kwargs.kwargs["json"] + assert payload["to"] == "setpw@test.com" + assert payload["subject"] == "Set Your Password" + assert "HI Set Password User" in payload["body"] + assert "token=" in payload["body"] + assert "Purple Team Engineering" in payload["body"] from app.models.password_setup_token import PasswordSetupToken token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first() diff --git a/backend/tests/test_procedure_suggestions.py b/backend/tests/test_procedure_suggestions.py index 65f3d37..d9f0d9e 100644 --- a/backend/tests/test_procedure_suggestions.py +++ b/backend/tests/test_procedure_suggestions.py @@ -306,3 +306,67 @@ def test_detect_procedure_defaults_to_template_expected_detection( ) assert resp.status_code == 201, resp.text assert resp.json()["detect_procedure"] == "Check process creation logs." + + +class TestSuggestionsForTest: + """GET /procedure-suggestions/for-test/{test_id} — powers the blocking + review popup a lead sees when opening a test with a pending suggestion.""" + + def test_returns_pending_suggestion_for_the_right_test( + self, client, db, api, test_from_template, red_tech_headers, red_lead_headers, + ): + test_id = test_from_template + api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers) + api( + "patch", f"/api/v1/tests/{test_id}/red", red_tech_headers, + json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."}, + ) + _add_evidence(db, test_id, TeamSide.red) + api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers) + + resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers) + assert resp.status_code == 200, resp.text + suggestions = resp.json() + assert len(suggestions) == 1 + assert suggestions[0]["suggested_text"] == "whoami /priv" + + def test_returns_empty_for_a_test_with_no_suggestion( + self, client, db, api, test_from_template, red_lead_headers, + ): + resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_from_template}", red_lead_headers) + assert resp.status_code == 200, resp.text + assert resp.json() == [] + + def test_blue_lead_does_not_see_a_red_suggestion_for_the_test( + self, client, db, api, test_from_template, red_tech_headers, blue_lead_headers, + ): + test_id = test_from_template + api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers) + api( + "patch", f"/api/v1/tests/{test_id}/red", red_tech_headers, + json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."}, + ) + _add_evidence(db, test_id, TeamSide.red) + api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers) + + resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", blue_lead_headers) + assert resp.status_code == 200, resp.text + assert resp.json() == [] + + def test_suggestion_disappears_from_for_test_once_approved( + self, client, db, api, test_from_template, red_tech_headers, red_lead_headers, + ): + test_id = test_from_template + api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers) + api( + "patch", f"/api/v1/tests/{test_id}/red", red_tech_headers, + json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."}, + ) + _add_evidence(db, test_id, TeamSide.red) + api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers) + + suggestion = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers).json()[0] + api("post", f"/api/v1/procedure-suggestions/{suggestion['id']}/approve", red_lead_headers) + + resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers) + assert resp.json() == [] diff --git a/frontend/src/api/procedure-suggestions.ts b/frontend/src/api/procedure-suggestions.ts index 6977d23..e37c1b1 100644 --- a/frontend/src/api/procedure-suggestions.ts +++ b/frontend/src/api/procedure-suggestions.ts @@ -7,6 +7,13 @@ export async function getProcedureSuggestions(): Promise return data; } +/** List pending suggestions tied to a specific test — powers the blocking + * review popup a lead sees when opening a test that has one awaiting them. */ +export async function getProcedureSuggestionsForTest(testId: string): Promise { + const { data } = await client.get(`/procedure-suggestions/for-test/${testId}`); + return data; +} + /** Approve a suggestion — writes it into the template's suggested-procedure field. */ export async function approveProcedureSuggestion(id: string): Promise { const { data } = await client.post(`/procedure-suggestions/${id}/approve`); diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 56a3c0a..98bbcc7 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -202,6 +202,7 @@ export async function testJiraConnection(): Promise<{ export interface PasswordWebhookConfigOut { configured: boolean; url: string; + api_key_set: boolean; } export async function getPasswordWebhookConfig(): Promise { @@ -209,8 +210,15 @@ export async function getPasswordWebhookConfig(): Promise { - const { data } = await client.patch("/system/password-webhook-config", { url }); +/** apiKey is optional — omit or pass "" to leave the currently-stored key unchanged. */ +export async function updatePasswordWebhookConfig( + url: string, + apiKey?: string, +): Promise { + const { data } = await client.patch("/system/password-webhook-config", { + url, + api_key: apiKey || undefined, + }); return data; } diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 5046396..7090a39 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -6,6 +6,7 @@ export interface UserOut { full_name: string | null; email: string | null; role: string; + extra_roles: string[]; is_active: boolean; must_change_password: boolean; created_at: string | null; @@ -22,6 +23,7 @@ export interface UserUpdatePayload { email?: string; full_name?: string; role?: string; + extra_roles?: string[]; is_active?: boolean; // Not sent by the current UI — the set-password-email flow replaces // admin-set passwords — but still supported server-side. @@ -64,3 +66,9 @@ export async function sendPasswordEmail(userId: string): Promise<{ detail: strin const { data } = await client.post<{ detail: string }>(`/users/${userId}/send-password-email`); return data; } + +/** Switch the current user's active role to one of their granted extra_roles. */ +export async function switchRole(role: string): Promise { + const { data } = await client.post("/users/me/switch-role", { role }); + return data; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 8c3e0a5..5c49fbf 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -3,6 +3,7 @@ import { LogOut, AlertTriangle, RefreshCw } from "lucide-react"; import { useAuth } from "../context/AuthContext"; import Sidebar from "./Sidebar"; import NotificationBell from "./NotificationBell"; +import RoleSwitcher from "./RoleSwitcher"; import React from "react"; /* ── Error Boundary ────────────────────────────────────────────────── @@ -71,6 +72,7 @@ export default function Layout() { {/* Header */}
+ {user?.full_name || user?.username} + + {open && ( +
+ {allRoles.map((role) => ( + + ))} +
+ )} + + ); +} diff --git a/frontend/src/components/test-detail/PendingSuggestionModal.tsx b/frontend/src/components/test-detail/PendingSuggestionModal.tsx new file mode 100644 index 0000000..f3d81f8 --- /dev/null +++ b/frontend/src/components/test-detail/PendingSuggestionModal.tsx @@ -0,0 +1,65 @@ +import { Lightbulb, CheckCircle, XCircle, Loader2 } from "lucide-react"; +import type { ProcedureSuggestion } from "../../types/models"; + +interface Props { + suggestion: ProcedureSuggestion; + onApprove: () => void; + onReject: () => void; + isBusy: boolean; +} + +/** Blocking popup shown to a lead opening a test that has a pending + * procedure-improvement suggestion awaiting their review — no backdrop + * click-to-close, no X button; the rest of the page stays inert until + * they approve or reject it. */ +export default function PendingSuggestionModal({ suggestion, onApprove, onReject, isBusy }: Props) { + return ( +
+
+
+ +

Procedure Suggestion Needs Your Review

+
+

+ This test proposed an update to{" "} + {suggestion.template_name || "its template"}. + Approve or reject it before continuing. +

+ + {suggestion.template_current_text && ( +
+

Current

+
+              {suggestion.template_current_text}
+            
+
+ )} +
+

Suggested

+
+            {suggestion.suggested_text}
+          
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index c785f33..5b9715a 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -12,6 +12,7 @@ import { getMe, refreshToken as apiRefreshToken, } from "../api/auth"; +import { switchRole as apiSwitchRole } from "../api/users"; import type { User } from "../types/models"; import ChangePasswordModal from "../components/ChangePasswordModal"; @@ -23,6 +24,10 @@ interface AuthState { isLoading: boolean; login: (username: string, password: string) => Promise; logout: () => void; + /** Switch the active role to one of the user's granted extra_roles. + * Reloads the page afterward so every role-gated component (nav, route + * guards, etc.) re-evaluates cleanly against the new role. */ + switchRole: (role: string) => Promise; } const AuthContext = createContext(undefined); @@ -72,6 +77,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null); }, []); + const switchRole = useCallback(async (role: string) => { + await apiSwitchRole(role); + window.location.reload(); + }, []); + const mustChangePassword = user?.must_change_password === true; return ( @@ -82,6 +92,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isLoading, login, logout, + switchRole, }} > {children} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 8df7c4f..a4a950b 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1688,6 +1688,7 @@ function SystemInfoSection() { function PasswordWebhookSection() { const qc = useQueryClient(); const [url, setUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); const { data, isLoading } = useQuery({ @@ -1700,12 +1701,13 @@ function PasswordWebhookSection() { }, [data]); const saveMutation = useMutation({ - mutationFn: () => updatePasswordWebhookConfig(url), + mutationFn: () => updatePasswordWebhookConfig(url, apiKey), onSuccess: () => { qc.invalidateQueries({ queryKey: ["password-webhook-config"] }); - setToast({ msg: "Webhook URL saved", type: "success" }); + setApiKey(""); + setToast({ msg: "Webhook configuration saved", type: "success" }); }, - onError: () => setToast({ msg: "Failed to save webhook URL", type: "error" }), + onError: () => setToast({ msg: "Failed to save webhook configuration", type: "error" }), }); if (isLoading) { @@ -1722,26 +1724,45 @@ function PasswordWebhookSection() { Password Setup Email Webhook

- When an admin sends a set-password or password-reset link from User Management, a - payload with the link is POSTed to this URL — point it at your Power Automate flow (or - similar) that actually delivers the email. + When an admin sends a set-password or password-reset link from User Management, a{" "} + {"{ to, subject, body }"} payload is POSTed to + this URL — point it at your Power Automate flow (or similar) that actually delivers the + email. The API key, if set, is sent as an x-api-key{" "} + header.

-
- setUrl(e.target.value)} - placeholder="https://prod-00.westeurope.logic.azure.com/..." - className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" - /> - +
+
+ + setUrl(e.target.value)} + placeholder="https://prod-00.westeurope.logic.azure.com/..." + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" + /> +
+
+ + setApiKey(e.target.value)} + placeholder={data?.api_key_set ? "••••••••" : "Optional"} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" + /> +
+
+ +
{!data?.configured && (

diff --git a/frontend/src/pages/TestDetailPage.tsx b/frontend/src/pages/TestDetailPage.tsx index 545654e..6f6598b 100644 --- a/frontend/src/pages/TestDetailPage.tsx +++ b/frontend/src/pages/TestDetailPage.tsx @@ -43,6 +43,12 @@ import ConfirmDialog from "../components/ConfirmDialog"; import JiraLinkPanel from "../components/JiraLinkPanel"; import TestPhaseTimeline from "../components/TestPhaseTimeline"; import { createTemplate } from "../api/test-templates"; +import { + getProcedureSuggestionsForTest, + approveProcedureSuggestion, + rejectProcedureSuggestion, +} from "../api/procedure-suggestions"; +import PendingSuggestionModal from "../components/test-detail/PendingSuggestionModal"; import { isoToDatetimeLocal, datetimeLocalToIso } from "../utils/datetime"; // ── Page Component ───────────────────────────────────────────────── @@ -146,6 +152,28 @@ export default function TestDetailPage() { staleTime: 5 * 60 * 1000, }); + // A lead opening a test with a pending procedure suggestion (from either + // team, scoped server-side to their own) must resolve it before doing + // anything else on the page — see PendingSuggestionModal below. + const isReviewLead = user?.role === "red_lead" || user?.role === "blue_lead"; + const { data: pendingSuggestions = [] } = useQuery({ + queryKey: ["procedure-suggestions-for-test", testId], + queryFn: () => getProcedureSuggestionsForTest(testId!), + enabled: !!testId && isReviewLead, + }); + const blockingSuggestion = pendingSuggestions[0]; + + const invalidateSuggestions = () => + queryClient.invalidateQueries({ queryKey: ["procedure-suggestions-for-test", testId] }); + const approveSuggestionMutation = useMutation({ + mutationFn: approveProcedureSuggestion, + onSuccess: invalidateSuggestions, + }); + const rejectSuggestionMutation = useMutation({ + mutationFn: rejectProcedureSuggestion, + onSuccess: invalidateSuggestions, + }); + // Hydrate drafts from test data — only once per test, on first load or // when navigating to a different test. Refetches triggered by evidence // uploads, timer ticks, etc. must NOT re-run this, or they'd stomp @@ -879,6 +907,17 @@ export default function TestDetailPage() { /> )} + {/* Blocking popup — a pending procedure suggestion for this test must + be resolved before the lead can do anything else on the page. */} + {blockingSuggestion && ( + approveSuggestionMutation.mutate(blockingSuggestion.id)} + onReject={() => rejectSuggestionMutation.mutate(blockingSuggestion.id)} + /> + )} + {/* Resolve Dispute Modal (approver flips vote to reject, picks a queue) */} {resolveDisputeModalOpen && ( - - {user.role.replace(/_/g, " ")} - +

+ + {user.role.replace(/_/g, " ")} + + {user.extra_roles?.map((role) => ( + + {role.replace(/_/g, " ")} + + ))} +
{user.is_active ? ( @@ -408,14 +420,25 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse full_name: user.full_name || "", email: user.email || "", role: user.role, + extra_roles: user.extra_roles || [], }); + const toggleExtraRole = (role: string) => { + setFormData((f) => ({ + ...f, + extra_roles: f.extra_roles.includes(role) + ? f.extra_roles.filter((r) => r !== role) + : [...f.extra_roles, role], + })); + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSubmit({ full_name: formData.full_name || undefined, email: formData.email || undefined, role: formData.role, + extra_roles: formData.extra_roles, }); }; @@ -471,6 +494,28 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse
+
+ +
+ {ROLES.filter((r) => r.value !== formData.role).map((role) => ( + + ))} +
+
+