feat(tests,users): blocking procedure-suggestion review on test detail, multi-role switcher, Power Automate webhook payload
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- 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.
This commit is contained in:
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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() == []
|
||||
|
||||
@@ -7,6 +7,13 @@ export async function getProcedureSuggestions(): Promise<ProcedureSuggestion[]>
|
||||
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<ProcedureSuggestion[]> {
|
||||
const { data } = await client.get<ProcedureSuggestion[]>(`/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<ProcedureSuggestion> {
|
||||
const { data } = await client.post<ProcedureSuggestion>(`/procedure-suggestions/${id}/approve`);
|
||||
|
||||
@@ -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<PasswordWebhookConfigOut> {
|
||||
@@ -209,8 +210,15 @@ export async function getPasswordWebhookConfig(): Promise<PasswordWebhookConfigO
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updatePasswordWebhookConfig(url: string): Promise<PasswordWebhookConfigOut> {
|
||||
const { data } = await client.patch<PasswordWebhookConfigOut>("/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<PasswordWebhookConfigOut> {
|
||||
const { data } = await client.patch<PasswordWebhookConfigOut>("/system/password-webhook-config", {
|
||||
url,
|
||||
api_key: apiKey || undefined,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UserOut> {
|
||||
const { data } = await client.post<UserOut>("/users/me/switch-role", { role });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
<header className="flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-gray-900 px-6">
|
||||
<NotificationBell />
|
||||
<RoleSwitcher />
|
||||
<span className="text-sm text-gray-300">{user?.full_name || user?.username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, Repeat, Loader2 } from "lucide-react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: "Admin",
|
||||
red_tech: "Red Tech",
|
||||
blue_tech: "Blue Tech",
|
||||
red_lead: "Red Lead",
|
||||
blue_lead: "Blue Lead",
|
||||
manager: "Manager",
|
||||
viewer: "Viewer",
|
||||
};
|
||||
|
||||
/** Dropdown in the top bar letting a multi-role user switch which single
|
||||
* role is currently active — only rendered when the user has extra_roles;
|
||||
* roles never mix, this just changes which one is in effect. */
|
||||
export default function RoleSwitcher() {
|
||||
const { user, switchRole } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
|
||||
const extraRoles = user?.extra_roles ?? [];
|
||||
if (extraRoles.length === 0) return null;
|
||||
|
||||
const allRoles = [user!.role, ...extraRoles];
|
||||
|
||||
const handleSwitch = async (role: string) => {
|
||||
if (role === user!.role || switching) return;
|
||||
setSwitching(true);
|
||||
try {
|
||||
await switchRole(role);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={switching}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-300 transition-colors hover:bg-gray-800 disabled:opacity-50"
|
||||
title="Switch active role"
|
||||
>
|
||||
{switching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Repeat className="h-3.5 w-3.5" />}
|
||||
{ROLE_LABELS[user!.role] || user!.role}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-30 mt-1 w-44 rounded-lg border border-gray-700 bg-gray-900 p-1.5 shadow-xl">
|
||||
{allRoles.map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => handleSwitch(role)}
|
||||
className={`block w-full rounded px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
role === user!.role
|
||||
? "bg-cyan-500/10 text-cyan-400"
|
||||
: "text-gray-300 hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
{ROLE_LABELS[role] || role}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg rounded-xl border border-yellow-500/30 bg-gray-900 p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Lightbulb className="h-5 w-5 text-yellow-400" />
|
||||
<h3 className="text-lg font-semibold text-white">Procedure Suggestion Needs Your Review</h3>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-gray-400">
|
||||
This test proposed an update to{" "}
|
||||
<span className="font-medium text-gray-300">{suggestion.template_name || "its template"}</span>.
|
||||
Approve or reject it before continuing.
|
||||
</p>
|
||||
|
||||
{suggestion.template_current_text && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium uppercase text-gray-500">Current</p>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-950 p-2 font-mono text-xs text-gray-400">
|
||||
{suggestion.template_current_text}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-medium uppercase text-gray-500">Suggested</p>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-950 p-2 font-mono text-xs text-green-400">
|
||||
{suggestion.suggested_text}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onReject}
|
||||
disabled={isBusy}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/40 disabled:opacity-50"
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <XCircle className="h-4 w-4" />}
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
onClick={onApprove}
|
||||
disabled={isBusy}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-500 disabled:opacity-50"
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
|
||||
Approve
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | undefined>(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}
|
||||
|
||||
@@ -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,18 +1724,36 @@ function PasswordWebhookSection() {
|
||||
<Mail className="h-5 w-5 text-cyan-400" /> Password Setup Email Webhook
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
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{" "}
|
||||
<code className="text-gray-400">{"{ to, subject, body }"}</code> 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 <code className="text-gray-400">x-api-key</code>{" "}
|
||||
header.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => 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"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">
|
||||
API Key {data?.api_key_set && <span className="text-gray-600">(already set — leave blank to keep it)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
@@ -1743,6 +1763,7 @@ function PasswordWebhookSection() {
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!data?.configured && (
|
||||
<p className="mt-2 text-xs text-amber-400/80">
|
||||
Not configured yet — "Send Email" in User Management will fail until a URL is saved here.
|
||||
|
||||
@@ -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 && (
|
||||
<PendingSuggestionModal
|
||||
suggestion={blockingSuggestion}
|
||||
isBusy={approveSuggestionMutation.isPending || rejectSuggestionMutation.isPending}
|
||||
onApprove={() => approveSuggestionMutation.mutate(blockingSuggestion.id)}
|
||||
onReject={() => rejectSuggestionMutation.mutate(blockingSuggestion.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Resolve Dispute Modal (approver flips vote to reject, picks a queue) */}
|
||||
{resolveDisputeModalOpen && (
|
||||
<ResolveDisputeModal
|
||||
|
||||
@@ -159,6 +159,7 @@ export default function UsersPage() {
|
||||
{user.email || "—"}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span
|
||||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||
roleBadgeColors[user.role] || roleBadgeColors.viewer
|
||||
@@ -166,6 +167,17 @@ export default function UsersPage() {
|
||||
>
|
||||
{user.role.replace(/_/g, " ")}
|
||||
</span>
|
||||
{user.extra_roles?.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium opacity-60 ${
|
||||
roleBadgeColors[role] || roleBadgeColors.viewer
|
||||
}`}
|
||||
>
|
||||
{role.replace(/_/g, " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{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
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Additional Roles <span className="text-gray-500">(switchable via the top-bar role switcher)</span>
|
||||
</label>
|
||||
<div className="mt-1.5 grid grid-cols-2 gap-1.5">
|
||||
{ROLES.filter((r) => r.value !== formData.role).map((role) => (
|
||||
<label
|
||||
key={role.value}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1.5 text-sm text-gray-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.extra_roles.includes(role.value)}
|
||||
onChange={() => toggleExtraRole(role.value)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-600 bg-gray-900 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
{role.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface User {
|
||||
username: string;
|
||||
full_name?: string | null;
|
||||
role: string;
|
||||
extra_roles?: string[];
|
||||
must_change_password?: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user