diff --git a/backend/alembic/versions/b023_add_must_change_password.py b/backend/alembic/versions/b023_add_must_change_password.py new file mode 100644 index 0000000..7daafa2 --- /dev/null +++ b/backend/alembic/versions/b023_add_must_change_password.py @@ -0,0 +1,30 @@ +"""add_must_change_password + +Revision ID: b023mustchgpwd +Revises: b022osintitems +Create Date: 2026-02-17 23:00:00.000000 + +Add must_change_password column to users table to force password +change on first login. +""" + +from alembic import op + +revision = "b023mustchgpwd" +down_revision = "b022osintitems" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT true; + """) + + +def downgrade() -> None: + op.execute(""" + ALTER TABLE users + DROP COLUMN IF EXISTS must_change_password; + """) diff --git a/backend/app/dependencies/auth.py b/backend/app/dependencies/auth.py index ed3ffd8..36e174c 100644 --- a/backend/app/dependencies/auth.py +++ b/backend/app/dependencies/auth.py @@ -91,6 +91,22 @@ async def get_current_user( # --------------------------------------------------------------------------- +async def require_password_changed( + current_user: User = Depends(get_current_user), +) -> User: + """Block all requests when the user still needs to change their password. + + Only ``/auth/change-password`` and ``/auth/me`` are exempt — those + endpoints do **not** depend on this function. + """ + if getattr(current_user, "must_change_password", False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="PASSWORD_CHANGE_REQUIRED", + ) + return current_user + + def require_role(required_role: str): """Return a FastAPI dependency that enforces *required_role*. diff --git a/backend/app/models/user.py b/backend/app/models/user.py index a60fe32..ac91870 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -27,5 +27,6 @@ class User(Base): hashed_password = Column(String, nullable=False) role = Column(String, nullable=False, default="viewer") is_active = Column(Boolean, default=True) + must_change_password = Column(Boolean, default=True) created_at = Column(DateTime, default=datetime.utcnow) last_login = Column(DateTime, nullable=True) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 9fddad8..4ee93f5 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -17,12 +17,13 @@ from sqlalchemy.orm import Session from jose import jwt, JWTError -from app.auth import verify_password, create_access_token, blacklist_token +from app.auth import verify_password, hash_password, create_access_token, blacklist_token from app.config import settings from app.database import get_db from app.dependencies.auth import get_current_user from app.models.user import User from app.schemas.auth import TokenResponse, UserOut +from app.schemas.user import PasswordChange # Rate limiter instance (shares backend state via app.state.limiter) limiter = Limiter(key_func=get_remote_address) @@ -137,3 +138,33 @@ def logout( def read_current_user(current_user: User = Depends(get_current_user)): """Return the profile of the currently authenticated user.""" return current_user + + +# --------------------------------------------------------------------------- +# POST /auth/change-password +# --------------------------------------------------------------------------- + + +@router.post("/change-password") +def change_password( + body: PasswordChange, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Change the current user's password. + + Requires the current password for verification. On success the + ``must_change_password`` flag is cleared so the user can proceed + normally. + """ + if not verify_password(body.current_password, current_user.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect", + ) + + current_user.hashed_password = hash_password(body.new_password) + current_user.must_change_password = False + db.commit() + + return {"detail": "Password changed successfully"} diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index 082fae2..1d62a51 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -198,7 +198,7 @@ def list_campaigns( def create_campaign( payload: CampaignCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("red_tech", "admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Create a new campaign.""" campaign = Campaign( @@ -254,7 +254,7 @@ def update_campaign( campaign_id: str, payload: CampaignUpdate, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("red_tech", "admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Update a campaign. Only allowed in draft or active state.""" campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() @@ -299,7 +299,7 @@ def add_test_to_campaign( campaign_id: str, payload: AddTestPayload, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("red_tech", "admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Add a test to a campaign with optional ordering and dependency.""" campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() @@ -370,7 +370,7 @@ def remove_test_from_campaign( campaign_id: str, campaign_test_id: str, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("red_tech", "admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Remove a test from a campaign.""" campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() @@ -414,7 +414,7 @@ def remove_test_from_campaign( def activate_campaign( campaign_id: str, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("red_tech", "admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Activate a campaign, moving it from draft to active.""" campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() @@ -524,7 +524,7 @@ def get_campaign_progress_endpoint( def generate_campaign_from_actor( actor_id: str, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("red_tech", "admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Auto-generate a campaign from a threat actor's uncovered techniques. @@ -558,7 +558,7 @@ def schedule_campaign( campaign_id: str, payload: SchedulePayload, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Configure or update the recurrence schedule for a campaign. diff --git a/backend/app/routers/evidence.py b/backend/app/routers/evidence.py index 29d68a4..58b37fb 100644 --- a/backend/app/routers/evidence.py +++ b/backend/app/routers/evidence.py @@ -98,11 +98,10 @@ def _validate_upload_permission( return if team == TeamSide.red: - # Only red_tech can upload red evidence - if user.role != "red_tech": + if user.role not in ("red_tech", "red_lead"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Only red_tech or admin can upload red evidence", + detail="Only red_tech, red_lead or admin can upload red evidence", ) if test.state not in _RED_EDITABLE_STATES: raise HTTPException( @@ -111,11 +110,10 @@ def _validate_upload_permission( f"(allowed in: draft, red_executing)", ) elif team == TeamSide.blue: - # Only blue_tech can upload blue evidence - if user.role != "blue_tech": + if user.role not in ("blue_tech", "blue_lead"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Only blue_tech or admin can upload blue evidence", + detail="Only blue_tech, blue_lead or admin can upload blue evidence", ) if test.state not in _BLUE_EDITABLE_STATES: raise HTTPException( @@ -150,7 +148,7 @@ def _validate_delete_permission( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete red evidence outside draft/red_executing", ) - if user.role != "red_tech" and evidence.uploaded_by != user.id: + if user.role not in ("red_tech", "red_lead") and evidence.uploaded_by != user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions to delete this evidence", @@ -161,7 +159,7 @@ def _validate_delete_permission( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete blue evidence outside blue_evaluating", ) - if user.role != "blue_tech" and evidence.uploaded_by != user.id: + if user.role not in ("blue_tech", "blue_lead") and evidence.uploaded_by != user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions to delete this evidence", diff --git a/backend/app/routers/professional_reports.py b/backend/app/routers/professional_reports.py index 438ff53..ad4ed9f 100644 --- a/backend/app/routers/professional_reports.py +++ b/backend/app/routers/professional_reports.py @@ -25,7 +25,7 @@ def generate_purple_report( campaign_id: UUID, format: str = Query("pdf", pattern="^(pdf|docx|html)$"), db: Session = Depends(get_db), - user: User = Depends(require_any_role("red_lead", "blue_lead")), + user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), ): """Generate a Purple Team campaign assessment report.""" filepath = report_generation_service.generate_purple_campaign_report( @@ -42,7 +42,7 @@ def generate_purple_report( def generate_coverage_report( format: str = Query("pdf", pattern="^(pdf|docx|html)$"), db: Session = Depends(get_db), - user: User = Depends(get_current_user), + user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), ): """Generate an organization-wide MITRE ATT&CK coverage report.""" filepath = report_generation_service.generate_coverage_report( @@ -59,7 +59,7 @@ def generate_coverage_report( def generate_executive_report( format: str = Query("pdf", pattern="^(pdf|docx|html)$"), db: Session = Depends(get_db), - user: User = Depends(require_any_role("red_lead", "blue_lead")), + user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), ): """Generate an executive security summary report.""" filepath = report_generation_service.generate_executive_summary( diff --git a/backend/app/routers/test_templates.py b/backend/app/routers/test_templates.py index 73591ac..6f55d33 100644 --- a/backend/app/routers/test_templates.py +++ b/backend/app/routers/test_templates.py @@ -30,7 +30,7 @@ from sqlalchemy import func, or_ from sqlalchemy.orm import Session from app.database import get_db -from app.dependencies.auth import get_current_user, require_role +from app.dependencies.auth import get_current_user, require_role, require_any_role from app.models.test_template import TestTemplate from app.models.user import User from app.schemas.test_template import ( @@ -103,7 +103,7 @@ def list_templates( @router.get("/stats") def template_stats( db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Return catalog statistics: totals by source, platform, active/inactive.""" @@ -151,9 +151,9 @@ def template_stats( def bulk_activate_templates( activate: bool = Query(True, description="True to activate all, False to deactivate all"), db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): - """Set all templates to active or inactive. Admin only.""" + """Set all templates to active or inactive.""" count = ( db.query(TestTemplate) .filter(TestTemplate.is_active != activate) @@ -235,9 +235,9 @@ def get_template( def create_template( payload: TestTemplateCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): - """Create a custom test template. Admin only.""" + """Create a custom test template.""" template = TestTemplate(**payload.model_dump()) db.add(template) db.commit() @@ -269,9 +269,9 @@ def update_template( template_id: uuid.UUID, payload: TestTemplateCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): - """Update fields of an existing test template. Admin only.""" + """Update fields of an existing test template.""" template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first() if template is None: raise HTTPException( @@ -307,9 +307,9 @@ def update_template( def toggle_template_active( template_id: uuid.UUID, db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): - """Toggle a template between active and inactive. Admin only.""" + """Toggle a template between active and inactive.""" template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first() if template is None: raise HTTPException( @@ -342,9 +342,9 @@ def toggle_template_active( def delete_template( template_id: uuid.UUID, db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): - """Soft-delete a test template by setting ``is_active=False``. Admin only.""" + """Soft-delete a test template by setting ``is_active=False``.""" template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first() if template is None: raise HTTPException( diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 1363bd2..4ad1ca5 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -143,7 +143,7 @@ def list_tests( def create_test( payload: TestCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("red_tech")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Create a new test linked to an existing technique. @@ -190,7 +190,7 @@ def create_test( def create_test_from_template( payload: TestTemplateInstantiate, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("red_tech")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Instantiate a real Test from an existing TestTemplate. @@ -289,11 +289,11 @@ def update_test( test_id: uuid.UUID, payload: TestUpdate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Update one or more fields of an existing test. - Only the original creator or an admin can update. + Only leads or admins can update general test fields. The test must be in ``draft`` or ``rejected`` state. """ test = _get_test_or_404(db, test_id) @@ -343,7 +343,7 @@ def update_test_red( test_id: uuid.UUID, payload: TestRedUpdate, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("red_tech")), + current_user: User = Depends(require_any_role("red_tech", "red_lead")), ): """Red Team updates their fields (allowed in ``draft`` and ``red_executing``).""" test = _get_test_or_404(db, test_id) @@ -387,7 +387,7 @@ def update_test_blue( test_id: uuid.UUID, payload: TestBlueUpdate, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("blue_tech")), + current_user: User = Depends(require_any_role("blue_tech", "blue_lead")), ): """Blue Team updates their fields (allowed only in ``blue_evaluating``).""" test = _get_test_or_404(db, test_id) @@ -430,7 +430,7 @@ def update_test_blue( def start_execution( test_id: uuid.UUID, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("red_tech")), + current_user: User = Depends(require_any_role("red_tech", "red_lead")), ): """Move a test from ``draft`` to ``red_executing``.""" test = _get_test_or_404(db, test_id) @@ -448,7 +448,7 @@ def start_execution( def submit_red( test_id: uuid.UUID, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("red_tech")), + current_user: User = Depends(require_any_role("red_tech", "red_lead")), ): """Red Team finalises — move from ``red_executing`` to ``blue_evaluating``.""" test = _get_test_or_404(db, test_id) @@ -466,7 +466,7 @@ def submit_red( def submit_blue( test_id: uuid.UUID, db: Session = Depends(get_db), - current_user: User = Depends(require_any_role("blue_tech")), + current_user: User = Depends(require_any_role("blue_tech", "blue_lead")), ): """Blue Team finalises — move from ``blue_evaluating`` to ``in_review``.""" test = _get_test_or_404(db, test_id) @@ -484,7 +484,7 @@ def submit_blue( def pause_timer( test_id: uuid.UUID, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")), ): """Pause the running timer for the current phase (red_executing or blue_evaluating).""" test = _get_test_or_404(db, test_id) @@ -502,7 +502,7 @@ def pause_timer( def resume_timer( test_id: uuid.UUID, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")), ): """Resume the paused timer for the current phase.""" test = _get_test_or_404(db, test_id) @@ -595,9 +595,9 @@ def update_remediation( test_id: uuid.UUID, payload: TestRemediationUpdate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): - """Update remediation fields on a test (any authenticated user). + """Update remediation fields on a test. When ``remediation_status`` transitions to ``'completed'``, an automatic re-test is created (subject to ``MAX_RETEST_COUNT``). diff --git a/backend/app/routers/worklogs.py b/backend/app/routers/worklogs.py index d4efdca..be84999 100644 --- a/backend/app/routers/worklogs.py +++ b/backend/app/routers/worklogs.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.database import get_db -from app.dependencies.auth import get_current_user +from app.dependencies.auth import get_current_user, require_any_role from app.domain.exceptions import EntityNotFoundError from app.models.user import User from app.models.worklog import Worklog @@ -56,7 +56,7 @@ class WorklogOut(BaseModel): def create( body: WorklogCreate, db: Session = Depends(get_db), - user: User = Depends(get_current_user), + user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")), ): """Create a manually-logged worklog entry.""" wl = worklog_service.create_worklog( diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 719c0df..14224cb 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -28,6 +28,7 @@ class UserOut(BaseModel): email: str | None = None role: str is_active: bool + must_change_password: bool = True class Config: from_attributes = True diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index bb9fc9c..4ac6334 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -83,6 +83,18 @@ class UserUpdate(BaseModel): # ── Read (full) ───────────────────────────────────────────────────── +class PasswordChange(BaseModel): + """Payload for changing the current user's password.""" + + current_password: str + new_password: str + + @field_validator("new_password") + @classmethod + def new_password_strength(cls, v: str) -> str: + return _validate_password_strength(v) + + class UserOut(BaseModel): """Complete representation returned by the API.""" @@ -91,6 +103,7 @@ class UserOut(BaseModel): email: str | None = None role: str is_active: bool + must_change_password: bool = True created_at: datetime | None = None last_login: datetime | None = None diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 10ea33a..5c3e474 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -34,3 +34,14 @@ export async function getMe(): Promise { const { data } = await client.get("/auth/me"); return data; } + +/** Change the current user's password. */ +export async function changePassword( + currentPassword: string, + newPassword: string, +): Promise { + await client.post("/auth/change-password", { + current_password: currentPassword, + new_password: newPassword, + }); +} diff --git a/frontend/src/components/ChangePasswordModal.tsx b/frontend/src/components/ChangePasswordModal.tsx new file mode 100644 index 0000000..14f01f0 --- /dev/null +++ b/frontend/src/components/ChangePasswordModal.tsx @@ -0,0 +1,148 @@ +import { useState, useMemo } from "react"; +import { changePassword } from "../api/auth"; + +interface PasswordRule { + label: string; + test: (pw: string) => boolean; +} + +const PASSWORD_RULES: PasswordRule[] = [ + { label: "At least 12 characters", test: (pw) => pw.length >= 12 }, + { label: "At least one uppercase letter", test: (pw) => /[A-Z]/.test(pw) }, + { label: "At least one lowercase letter", test: (pw) => /[a-z]/.test(pw) }, + { label: "At least one digit", test: (pw) => /[0-9]/.test(pw) }, + { + label: "At least one special character (!@#$%^&*…)", + test: (pw) => /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(pw), + }, +]; + +export function PasswordPolicyChecklist({ password }: { password: string }) { + return ( +
    + {PASSWORD_RULES.map((rule) => { + const ok = password.length > 0 && rule.test(password); + return ( +
  • + + {ok ? "✓" : "○"} + + + {rule.label} + +
  • + ); + })} +
+ ); +} + +interface Props { + onSuccess: () => void; + isForced?: boolean; +} + +export default function ChangePasswordModal({ onSuccess, isForced }: Props) { + const [currentPw, setCurrentPw] = useState(""); + const [newPw, setNewPw] = useState(""); + const [confirmPw, setConfirmPw] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const allRulesPass = useMemo( + () => PASSWORD_RULES.every((r) => r.test(newPw)), + [newPw], + ); + + const canSubmit = + currentPw.length > 0 && + allRulesPass && + newPw === confirmPw && + !loading; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!canSubmit) return; + + setLoading(true); + setError(null); + try { + await changePassword(currentPw, newPw); + onSuccess(); + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { detail?: string } } })?.response?.data + ?.detail ?? "Failed to change password"; + setError(msg); + } finally { + setLoading(false); + } + } + + return ( +
+
+

+ Change Password +

+ {isForced && ( +

+ You must change your password before continuing. +

+ )} + + {error && ( +
+ {error} +
+ )} + + + setCurrentPw(e.target.value)} + autoFocus + /> + + + setNewPw(e.target.value)} + /> + + + + setConfirmPw(e.target.value)} + /> + {confirmPw.length > 0 && newPw !== confirmPw && ( +

Passwords do not match

+ )} + + + +
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 489b265..b002e03 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -33,7 +33,7 @@ interface NavItem { const mainLinks: NavItem[] = [ { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, - { to: "/executive-dashboard", label: "Executive Dashboard", icon: Gauge, roles: ["admin", "red_lead", "blue_lead"] }, + { to: "/executive-dashboard", label: "Executive Dashboard", icon: Gauge, roles: ["admin", "red_lead", "blue_lead", "viewer"] }, { to: "/matrix", label: "ATT&CK Matrix", icon: Grid3X3 }, { to: "/tests", @@ -48,7 +48,7 @@ const mainLinks: NavItem[] = [ { to: "/campaigns", label: "Campaigns", icon: Zap }, { to: "/threat-actors", label: "Threat Actors", icon: Crosshair }, { to: "/compliance", label: "Compliance", icon: ShieldCheck }, - { to: "/comparison", label: "Comparison", icon: GitCompareArrows, roles: ["admin", "red_lead", "blue_lead"] }, + { to: "/comparison", label: "Comparison", icon: GitCompareArrows, roles: ["admin", "red_lead", "blue_lead", "viewer"] }, { to: "/reports", label: "Reports", icon: BarChart3 }, ]; diff --git a/frontend/src/components/test-detail/TeamTabs.tsx b/frontend/src/components/test-detail/TeamTabs.tsx index caab22a..c0921d2 100644 --- a/frontend/src/components/test-detail/TeamTabs.tsx +++ b/frontend/src/components/test-detail/TeamTabs.tsx @@ -119,11 +119,11 @@ export default function TeamTabs({ const canEditRed = RED_EDITABLE_STATES.includes(test.state) && - (role === "red_tech" || role === "admin"); + (role === "red_tech" || role === "red_lead" || role === "admin"); const canEditBlue = BLUE_EDITABLE_STATES.includes(test.state) && - (role === "blue_tech" || role === "admin"); + (role === "blue_tech" || role === "blue_lead" || role === "admin"); // ── Red Team Tab ───────────────────────────────────────────────── diff --git a/frontend/src/components/test-detail/TestDetailHeader.tsx b/frontend/src/components/test-detail/TestDetailHeader.tsx index ca717c0..22332da 100644 --- a/frontend/src/components/test-detail/TestDetailHeader.tsx +++ b/frontend/src/components/test-detail/TestDetailHeader.tsx @@ -91,10 +91,10 @@ export default function TestDetailHeader({ const renderActions = () => { const buttons: React.ReactNode[] = []; - // Red Tech in draft -> Start Execution + // Red Team in draft -> Start Execution if ( test.state === "draft" && - (role === "red_tech" || role === "admin") + (role === "red_tech" || role === "red_lead" || role === "admin") ) { buttons.push(