feat(users): passwordless user creation via emailed set-password link, display full name instead of username
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

Admins now create users with a name + email (no password) and role;
a Send Email action (per-user, also reusable for resets) issues a
one-time token and POSTs it to an admin-configurable webhook URL —
intended for a Power Automate flow that delivers the actual email.
The old admin-sets-the-password flow is kept server-side
(LegacyUserCreateWithPassword) but no longer wired into the UI.

Every user-facing surface (top bar, sidebar, user management, audit
log, assignee pickers, dispute notifications) now shows full_name
instead of username, falling back to username when unset.

Also fixes long attack_procedure/expected_detection/suggested_text
text overflowing its container in the template review panels and
Red/Blue team fields (missing break-words on whitespace-pre-wrap
blocks).
This commit is contained in:
kitos
2026-07-17 16:58:25 +02:00
parent 60ab2e558f
commit 4dea19cae9
36 changed files with 974 additions and 159 deletions
@@ -0,0 +1,43 @@
"""Add users.full_name and a password_setup_tokens table.
Users are now created with a name + email instead of a directly-set
password — the admin's "Send Email" action issues a one-time token (this
table) for the user to set their own password via a webhook-delivered
link. The same mechanism is reused for password resets.
Revision ID: b065
Revises: b064
Create Date: 2026-07-17
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "b065"
down_revision = "b064"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("full_name", sa.String(), nullable=True))
op.create_table(
"password_setup_tokens",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
sa.Column("token", sa.String(128), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_password_setup_tokens_token", "password_setup_tokens", ["token"], unique=True)
op.create_index("ix_password_setup_tokens_user_id", "password_setup_tokens", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_password_setup_tokens_user_id", table_name="password_setup_tokens")
op.drop_index("ix_password_setup_tokens_token", table_name="password_setup_tokens")
op.drop_table("password_setup_tokens")
op.drop_column("users", "full_name")
+2
View File
@@ -48,6 +48,7 @@ from app.models.test_template import TestTemplate
from app.models.procedure_suggestion import ProcedureSuggestion from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.template_suggestion import TemplateSuggestion from app.models.template_suggestion import TemplateSuggestion
from app.models.user import User from app.models.user import User
from app.models.password_setup_token import PasswordSetupToken
# Assign __all__ = [ # Assign __all__ = [
__all__ = [ __all__ = [
@@ -92,4 +93,5 @@ __all__ = [
"TestRoundHistory", "TestRoundHistory",
"ProcedureSuggestion", "ProcedureSuggestion",
"TemplateSuggestion", "TemplateSuggestion",
"PasswordSetupToken",
] ]
@@ -0,0 +1,30 @@
"""SQLAlchemy model for one-time password setup/reset tokens.
Issued when an admin sends a "set your password" email — a passwordless
user (freshly created, or one whose password an admin wants to reset)
uses the token to pick their own password without ever having a temporary
one shared with them.
"""
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class PasswordSetupToken(Base):
"""A one-time token letting its owning user set their own password."""
__tablename__ = "password_setup_tokens"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
token = Column(String(128), unique=True, nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", foreign_keys=[user_id])
+3
View File
@@ -30,6 +30,9 @@ class User(Base):
username = Column(String, unique=True, nullable=False) username = Column(String, unique=True, nullable=False)
# Assign email = Column(String, nullable=True) # Assign email = Column(String, nullable=True)
email = Column(String, nullable=True) email = Column(String, nullable=True)
# Display name shown everywhere in the UI instead of username (which is
# now just an internal login identifier, auto-set to the user's email).
full_name = Column(String, nullable=True)
# Assign hashed_password = Column(String, nullable=False) # Assign hashed_password = Column(String, nullable=False)
hashed_password = Column(String, nullable=False) hashed_password = Column(String, nullable=False)
# Assign role = Column(String, nullable=False, default="viewer") # Assign role = Column(String, nullable=False, default="viewer")
+41 -1
View File
@@ -57,7 +57,7 @@ from app.models.user import User
from app.schemas.auth import TokenResponse, UserOut from app.schemas.auth import TokenResponse, UserOut
# Import PasswordChange from app.schemas.user # Import PasswordChange from app.schemas.user
from app.schemas.user import PasswordChange from app.schemas.user import PasswordChange, SetPasswordRequest, SetPasswordTokenValidateOut
# Import log_action from app.services.audit_service # Import log_action from app.services.audit_service
from app.services.audit_service import log_action from app.services.audit_service import log_action
@@ -72,6 +72,12 @@ from app.services.auth_service import (
change_password as auth_change_password, change_password as auth_change_password,
) )
from app.domain.errors import EntityNotFoundError
from app.services.password_setup_service import (
consume_token_and_set_password,
validate_token as validate_password_setup_token,
)
# Assign router = APIRouter(prefix="/auth", tags=["auth"]) # Assign router = APIRouter(prefix="/auth", tags=["auth"])
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -381,3 +387,37 @@ def change_password(
# Return {"detail": "Password changed successfully"} # Return {"detail": "Password changed successfully"}
return {"detail": "Password changed successfully"} return {"detail": "Password changed successfully"}
# ---------------------------------------------------------------------------
# Set password via a one-time emailed link — public (no auth), rate-limited
# ---------------------------------------------------------------------------
@router.get("/set-password/validate", response_model=SetPasswordTokenValidateOut)
@limiter.limit("20/minute")
def validate_set_password_token(request: Request, token: str, db: Session = Depends(get_db)) -> SetPasswordTokenValidateOut:
"""Check a set-password token before rendering the form. Public endpoint."""
try:
user = validate_password_setup_token(db, token)
except (EntityNotFoundError, BusinessRuleViolation):
return SetPasswordTokenValidateOut(valid=False)
return SetPasswordTokenValidateOut(valid=True, full_name=user.full_name)
@router.post("/set-password")
@limiter.limit("10/minute")
def set_password(request: Request, body: SetPasswordRequest, db: Session = Depends(get_db)) -> dict:
"""Complete a password setup/reset using a one-time token. Public endpoint."""
with UnitOfWork(db) as uow:
user = consume_token_and_set_password(db, body.token, body.new_password)
log_action(
db,
user.id,
"SET_PASSWORD_VIA_TOKEN",
"auth",
str(user.id),
details={},
)
uow.commit()
return {"detail": "Password set successfully — you can now log in"}
+42
View File
@@ -344,6 +344,48 @@ def update_jira_config(
) )
class PasswordWebhookConfigOut(BaseModel):
configured: bool
url: str
class PasswordWebhookConfigUpdate(BaseModel):
url: str
@router.get("/password-webhook-config", response_model=PasswordWebhookConfigOut)
def get_password_webhook_config(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return the configured webhook URL used to email set-password links.
**Requires** the ``admin`` role.
"""
from app.services.password_setup_service import get_password_webhook_url
url = get_password_webhook_url(db) or ""
return PasswordWebhookConfigOut(configured=bool(url), url=url)
@router.patch("/password-webhook-config", response_model=PasswordWebhookConfigOut)
def update_password_webhook_config(
payload: PasswordWebhookConfigUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Set the webhook URL 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
set_password_webhook_url(db, payload.url)
db.commit()
url = get_password_webhook_url(db) or ""
return PasswordWebhookConfigOut(configured=bool(url), url=url)
@router.post("/jira-test") @router.post("/jira-test")
def test_jira_connection( def test_jira_connection(
db: Session = Depends(get_db), db: Session = Depends(get_db),
+2
View File
@@ -1814,6 +1814,7 @@ def request_discussion(
if rejector_id else None if rejector_id else None
) )
rejector_name = rejector.username if rejector else rejector_label rejector_name = rejector.username if rejector else rejector_label
rejector_full_name = getattr(rejector, "full_name", None) if rejector else None
rejector_email = getattr(rejector, "email", None) if rejector else None rejector_email = getattr(rejector, "email", None) if rejector else None
# Notify the rejecting lead # Notify the rejecting lead
@@ -1852,6 +1853,7 @@ def request_discussion(
"status": "notification_sent", "status": "notification_sent",
"message": f"Discussion request sent to {rejector_name}", "message": f"Discussion request sent to {rejector_name}",
"rejector_username": rejector_name, "rejector_username": rejector_name,
"rejector_full_name": rejector_full_name,
"rejector_email": rejector_email, "rejector_email": rejector_email,
"rejector_role": rejector_label, "rejector_role": rejector_label,
} }
+49 -12
View File
@@ -21,16 +21,24 @@ from app.domain.unit_of_work import UnitOfWork
# Import User from app.models.user # Import User from app.models.user
from app.models.user import User from app.models.user import User
from app.dependencies.auth import get_current_user from app.dependencies.auth import get_current_user
from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate, UserOperatorOut from app.schemas.user import (
UserCreate,
UserUpdate,
UserOut,
UserPreferencesUpdate,
UserOperatorOut,
SendPasswordEmailOut,
)
from app.services.audit_service import log_action from app.services.audit_service import log_action
# Import from app.services.user_service # Import from app.services.user_service
from app.services.user_service import ( from app.services.user_service import (
create_user, create_user_without_password,
get_user_or_raise, get_user_or_raise,
list_users, list_users,
update_user, update_user,
) )
from app.services.password_setup_service import send_password_setup_email
# Assign router = APIRouter(prefix="/users", tags=["users"]) # Assign router = APIRouter(prefix="/users", tags=["users"])
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -110,19 +118,17 @@ def create_user_route(
# Entry: current_user # Entry: current_user
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_role("admin")),
) -> UserOut: ) -> UserOut:
"""Create a new user. **Requires admin role.**.""" """Create a new user. **Requires admin role.**.
No password is set — use ``POST /users/{id}/send-password-email``
afterward to let the user set their own via a one-time link.
"""
# Open context manager # Open context manager
with UnitOfWork(db) as uow: with UnitOfWork(db) as uow:
# Assign user = create_user( user = create_user_without_password(
user = create_user(
db, db,
# Keyword argument: username full_name=payload.full_name,
username=payload.username,
# Keyword argument: email
email=payload.email, email=payload.email,
# Keyword argument: password
password=payload.password,
# Keyword argument: role
role=payload.role, role=payload.role,
) )
# Call log_action() # Call log_action()
@@ -137,7 +143,7 @@ def create_user_route(
# Keyword argument: entity_id # Keyword argument: entity_id
entity_id=user.id, entity_id=user.id,
# Keyword argument: details # Keyword argument: details
details={"username": user.username, "role": user.role}, details={"full_name": user.full_name, "email": user.email, "role": user.role},
) )
# Call uow.commit() # Call uow.commit()
uow.commit() uow.commit()
@@ -241,3 +247,34 @@ def update_user_route(
# Return user # Return user
return user return user
# ---------------------------------------------------------------------------
# POST /users/{id}/send-password-email — set-password / reset link
# ---------------------------------------------------------------------------
@router.post("/{user_id}/send-password-email", response_model=SendPasswordEmailOut)
def send_password_email_route(
user_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
) -> SendPasswordEmailOut:
"""Email a one-time set-password link to a user. **Requires admin role.**.
Works for both a freshly-created passwordless user and an existing
one whose password needs resetting — same link, same flow either way.
"""
user = get_user_or_raise(db, user_id)
with UnitOfWork(db) as uow:
send_password_setup_email(db, user)
log_action(
db,
user_id=current_user.id,
action="send_password_setup_email",
entity_type="user",
entity_id=user.id,
details={},
)
uow.commit()
return SendPasswordEmailOut(detail=f"Password setup email sent to {user.email}")
+1
View File
@@ -21,6 +21,7 @@ class AuditLogOut(BaseModel):
user_id: uuid.UUID | None = None user_id: uuid.UUID | None = None
# Assign username = None # Populated from user relationship # Assign username = None # Populated from user relationship
username: str | None = None # Populated from user relationship username: str | None = None # Populated from user relationship
full_name: str | None = None # Populated from user relationship
# action: str # action: str
action: str action: str
# Assign entity_type = None # Assign entity_type = None
+1
View File
@@ -39,6 +39,7 @@ class UserOut(BaseModel):
id: uuid.UUID id: uuid.UUID
# username: str # username: str
username: str username: str
full_name: str | None = None
# Assign email = None # Assign email = None
email: str | None = None email: str | None = None
# role: str # role: str
+74 -33
View File
@@ -112,49 +112,57 @@ def _validate_password_strength(password: str) -> str:
# ── Create ────────────────────────────────────────────────────────── # ── Create ──────────────────────────────────────────────────────────
class UserCreate(BaseModel): class UserCreate(BaseModel):
"""Payload for creating a new user.""" """Payload for creating a new user.
# username: str No password is set here — the admin creates the user with just a
username: str name/email/role, then uses "Send Email" to let the user set their own
# Assign email = None password via a one-time link. ``username`` is no longer admin-supplied;
email: str | None = None the service layer derives it from ``email`` since it's still needed
# password: str internally as the login identifier, but it is never shown in the UI.
password: str """
full_name: str
email: str
# Assign role = "viewer" # Assign role = "viewer"
role: str = "viewer" role: str = "viewer"
# Apply the @field_validator decorator @field_validator("full_name")
@field_validator("username") @classmethod
# Apply the @classmethod decorator def full_name_not_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Full name is required")
return v
@field_validator("email")
@classmethod
def email_format(cls, v: str) -> str:
v = v.strip()
if "@" not in v or v.startswith("@") or v.endswith("@"):
raise ValueError("A valid email address is required")
return v
# ── Legacy direct-password creation (hidden from the UI, kept for a
# possible future revert — see UserCreate above for the active path) ──
class LegacyUserCreateWithPassword(BaseModel):
"""The old admin-sets-the-password creation payload. Not wired to any
active endpoint; retained only so this path can be restored quickly."""
username: str
email: str | None = None
password: str
role: str = "viewer"
@field_validator("username")
@classmethod @classmethod
# Define function username_format
def username_format(cls, v: str) -> str: def username_format(cls, v: str) -> str:
"""Validate the username field against the platform policy.
Args:
v (str): Raw username value from the request body.
Returns:
str: The validated username.
"""
# Return _validate_username(v)
return _validate_username(v) return _validate_username(v)
# Apply the @field_validator decorator
@field_validator("password") @field_validator("password")
# Apply the @classmethod decorator
@classmethod @classmethod
# Define function password_strength
def password_strength(cls, v: str) -> str: def password_strength(cls, v: str) -> str:
"""Validate the password field against the complexity policy.
Args:
v (str): Raw password value from the request body.
Returns:
str: The validated password.
"""
# Return _validate_password_strength(v)
return _validate_password_strength(v) return _validate_password_strength(v)
@@ -163,11 +171,15 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
"""Payload for partially updating an existing user. """Payload for partially updating an existing user.
Every field is optional so callers send only what changed. Every field is optional so callers send only what changed. ``password``
is intentionally still supported server-side (see
LegacyUserCreateWithPassword) but the current UI never sends it —
password changes go through the email-a-set-password-link flow instead.
""" """
# Assign email = None # Assign email = None
email: str | None = None email: str | None = None
full_name: str | None = None
# Assign role = None # Assign role = None
role: str | None = None role: str | None = None
# Assign is_active = None # Assign is_active = None
@@ -248,6 +260,7 @@ class UserOut(BaseModel):
id: uuid.UUID id: uuid.UUID
# username: str # username: str
username: str username: str
full_name: str | None = None
# Assign email = None # Assign email = None
email: str | None = None email: str | None = None
# role: str # role: str
@@ -291,6 +304,34 @@ class UserOperatorOut(BaseModel):
id: uuid.UUID id: uuid.UUID
username: str username: str
full_name: str | None = None
role: str role: str
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# ── Password setup / reset (via emailed one-time token) ─────────────
class SendPasswordEmailOut(BaseModel):
"""Response after an admin triggers a set-password email for a user."""
detail: str
class SetPasswordTokenValidateOut(BaseModel):
"""Response for checking a set-password token before rendering the form."""
valid: bool
full_name: str | None = None
class SetPasswordRequest(BaseModel):
"""Payload for completing a password setup/reset via a one-time token."""
token: str
new_password: str
@field_validator("new_password")
@classmethod
def new_password_strength(cls, v: str) -> str:
return _validate_password_strength(v)
@@ -92,6 +92,7 @@ def list_logs(
"user_id": log.user_id, "user_id": log.user_id,
# Literal argument value # Literal argument value
"username": log.user.username if log.user else None, "username": log.user.username if log.user else None,
"full_name": log.user.full_name if log.user else None,
# Literal argument value # Literal argument value
"action": log.action, "action": log.action,
# Literal argument value # Literal argument value
@@ -0,0 +1,138 @@
"""Passwordless-user onboarding — one-time set-password links sent by webhook.
An admin creates a user with just a name/email/role (see
``user_service.create_user_without_password``). To let that user in, the
admin clicks "Send Email": this service issues a one-time token and POSTs
a payload carrying it to an admin-configured webhook URL (intended for a
Power Automate flow that actually sends the email the exact webhook
contract is still being finalized, hence the configurable URL rather than
a hardcoded integration). The same mechanism, and the same button, is
reused for password resets on existing users.
"""
from __future__ import annotations
import logging
import secrets
from datetime import datetime, timedelta
import requests
from sqlalchemy.orm import Session
from app.config import settings
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError
from app.models.password_setup_token import PasswordSetupToken
from app.models.user import User
logger = logging.getLogger(__name__)
_WEBHOOK_URL_CONFIG_KEY = "password_setup.webhook_url"
_TOKEN_TTL = timedelta(hours=24)
def _read_system_config(db: Session, key: str) -> str | None:
from app.models.system_config import SystemConfig # avoid circular at import time
row = db.query(SystemConfig).filter(SystemConfig.key == key).first()
return row.value if row else None
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
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
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 create_setup_token(db: Session, user: User) -> PasswordSetupToken:
"""Issue a fresh one-time token for *user*, invalidating any unused ones.
Does not commit; caller commits.
"""
db.query(PasswordSetupToken).filter(
PasswordSetupToken.user_id == user.id,
PasswordSetupToken.used_at.is_(None),
).delete()
token = PasswordSetupToken(
user_id=user.id,
token=secrets.token_urlsafe(32),
expires_at=datetime.utcnow() + _TOKEN_TTL,
)
db.add(token)
db.flush()
return token
def send_password_setup_email(db: Session, user: User) -> None:
"""Issue a token and POST it to the configured webhook.
Raises BusinessRuleViolation if no webhook URL is configured yet.
Does not commit; caller commits (the token must be persisted even if
the webhook call itself fails, so a fresh "Send Email" retry works).
"""
webhook_url = get_password_webhook_url(db)
if not webhook_url:
raise BusinessRuleViolation(
"No password-setup webhook is configured yet — set one in Settings first."
)
token = create_setup_token(db, user)
set_password_url = f"{settings.PLATFORM_URL}/set-password?token={token.token}"
try:
requests.post(
webhook_url,
json={
"email": user.email,
"full_name": user.full_name,
"set_password_url": set_password_url,
},
timeout=10,
)
except requests.RequestException:
# Best-effort: the token still exists, so the admin can retry
# "Send Email" once the webhook endpoint is reachable again.
logger.warning("Password-setup webhook call failed for user %s", user.id, exc_info=True)
def _get_valid_token_or_raise(db: Session, token: str) -> PasswordSetupToken:
row = db.query(PasswordSetupToken).filter(PasswordSetupToken.token == token).first()
if row is None:
raise EntityNotFoundError("Password setup token", token)
if row.used_at is not None:
raise BusinessRuleViolation("This link has already been used")
if row.expires_at < datetime.utcnow():
raise BusinessRuleViolation("This link has expired — ask an admin to send a new one")
return row
def validate_token(db: Session, token: str) -> User:
"""Return the token's owning user if the token is valid, else raise."""
row = _get_valid_token_or_raise(db, token)
return row.user
def consume_token_and_set_password(db: Session, token: str, new_password: str) -> User:
"""Set *new_password* for the token's user and mark the token used.
Does not commit; caller commits.
"""
from app.auth import hash_password
row = _get_valid_token_or_raise(db, token)
user = row.user
user.hashed_password = hash_password(new_password)
user.must_change_password = False
row.used_at = datetime.utcnow()
db.flush()
return user
+37
View File
@@ -10,6 +10,9 @@ from __future__ import annotations
# Import uuid # Import uuid
import uuid import uuid
# Import secrets
import secrets
# Import Session from sqlalchemy.orm # Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -88,6 +91,40 @@ def create_user(
return user return user
def create_user_without_password(db: Session, *, full_name: str, email: str, role: str) -> User:
"""Create a new user without a password — they set their own via an
admin-triggered, emailed one-time link (see password_setup_service).
``username`` is derived from ``email`` since it's still needed
internally as the login identifier, but it's never surfaced in the UI.
Raises DuplicateEntityError if a user with this email/username already
exists. Raises BusinessRuleViolation if role is invalid. Does not
commit; the router handles that.
"""
existing = db.query(User).filter(User.username == email).first()
if existing:
raise DuplicateEntityError("User", "email", email)
if role not in VALID_ROLES:
raise BusinessRuleViolation(
f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}"
)
user = User(
username=email,
email=email,
full_name=full_name,
# Unusable random password — the user can only ever get in via the
# emailed set-password link, never by guessing/being told a value.
hashed_password=hash_password(secrets.token_urlsafe(32)),
role=role,
must_change_password=True,
)
db.add(user)
return user
# Define function get_user_or_raise # Define function get_user_or_raise
def get_user_or_raise(db: Session, user_id: uuid.UUID) -> User: def get_user_or_raise(db: Session, user_id: uuid.UUID) -> User:
"""Return a user by ID or raise EntityNotFoundError.""" """Return a user by ID or raise EntityNotFoundError."""
+132
View File
@@ -0,0 +1,132 @@
"""End-to-end coverage for the passwordless user creation + emailed
set-password-link flow (also reused for password resets).
"""
import uuid
from unittest.mock import patch
import pytest
@pytest.fixture
def new_user_id(api, auth_headers):
resp = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "Set Password User", "email": "setpw@test.com", "role": "viewer"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
def test_send_password_email_requires_webhook_configured(api, auth_headers, new_user_id):
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
assert resp.status_code == 400
assert "webhook" in resp.text.lower()
def test_admin_can_configure_password_webhook(api, auth_headers):
resp = api(
"patch", "/api/v1/system/password-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["configured"] is True
get_resp = api("get", "/api/v1/system/password-webhook-config", auth_headers)
assert get_resp.json()["url"] == "https://example.com/power-automate-hook"
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,
json={"url": "https://example.com/power-automate-hook"},
)
with patch("app.services.password_setup_service.requests.post") as mock_post:
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
assert resp.status_code == 200, resp.text
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"]
from app.models.password_setup_token import PasswordSetupToken
token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first()
assert token_row is not None
assert token_row.used_at is None
def test_set_password_with_valid_token_succeeds(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"},
)
with patch("app.services.password_setup_service.requests.post"):
api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
from app.models.password_setup_token import PasswordSetupToken
token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first()
validate = api("get", f"/api/v1/auth/set-password/validate?token={token_row.token}", {})
assert validate.status_code == 200
assert validate.json()["valid"] is True
assert validate.json()["full_name"] == "Set Password User"
resp = api(
"post", "/api/v1/auth/set-password", {},
json={"token": token_row.token, "new_password": "BrandNewPass123!@#"},
)
assert resp.status_code == 200, resp.text
login = api(
"post", "/api/v1/auth/login", {},
data={"username": "setpw@test.com", "password": "BrandNewPass123!@#"},
)
assert login.status_code == 200, login.text
def test_set_password_token_cannot_be_reused(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"},
)
with patch("app.services.password_setup_service.requests.post"):
api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
from app.models.password_setup_token import PasswordSetupToken
token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first()
first = api(
"post", "/api/v1/auth/set-password", {},
json={"token": token_row.token, "new_password": "BrandNewPass123!@#"},
)
assert first.status_code == 200
second = api(
"post", "/api/v1/auth/set-password", {},
json={"token": token_row.token, "new_password": "AnotherPass456!@#"},
)
assert second.status_code == 400
def test_set_password_invalid_token_rejected(api):
validate = api("get", "/api/v1/auth/set-password/validate?token=not-a-real-token", {})
assert validate.status_code == 200
assert validate.json()["valid"] is False
resp = api(
"post", "/api/v1/auth/set-password", {},
json={"token": "not-a-real-token", "new_password": "BrandNewPass123!@#"},
)
assert resp.status_code == 404
def test_password_webhook_config_requires_admin(api, red_lead_headers):
resp = api("get", "/api/v1/system/password-webhook-config", red_lead_headers)
assert resp.status_code == 403
def test_send_password_email_requires_admin(api, red_lead_headers, new_user_id):
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", red_lead_headers)
assert resp.status_code == 403
+1 -1
View File
@@ -10,7 +10,7 @@ if backend_dir not in sys.path:
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from app.schemas.user import UserCreate from app.schemas.user import LegacyUserCreateWithPassword as UserCreate
# ── Username validation ────────────────────────────────────────────── # ── Username validation ──────────────────────────────────────────────
+37 -32
View File
@@ -1,28 +1,30 @@
"""API-level validation tests for user creation (SEC-004, SEC-007).""" """API-level validation tests for user creation (SEC-004, SEC-007).
Users are created with a name + email no password (see
password_setup_service for the emailed set-password link flow) and no
admin-supplied username (username is auto-derived from email).
"""
def test_create_user_weak_password_rejected(client, admin_user, admin_token): def test_create_user_missing_full_name_rejected(client, admin_user, admin_token):
response = client.post( response = client.post(
"/api/v1/users", "/api/v1/users",
json={ json={
"username": "newuser", "full_name": "",
"password": "123",
"email": "new@test.com", "email": "new@test.com",
"role": "viewer", "role": "viewer",
}, },
headers={"Authorization": f"Bearer {admin_token}"}, headers={"Authorization": f"Bearer {admin_token}"},
) )
assert response.status_code == 422 assert response.status_code == 422
assert "password" in response.text.lower()
def test_create_user_reserved_username(client, admin_user, admin_token): def test_create_user_invalid_email_rejected(client, admin_user, admin_token):
response = client.post( response = client.post(
"/api/v1/users", "/api/v1/users",
json={ json={
"username": "system", "full_name": "New User",
"password": "SecurePass123!@#", "email": "not-an-email",
"email": "sys@test.com",
"role": "viewer", "role": "viewer",
}, },
headers={"Authorization": f"Bearer {admin_token}"}, headers={"Authorization": f"Bearer {admin_token}"},
@@ -30,33 +32,21 @@ def test_create_user_reserved_username(client, admin_user, admin_token):
assert response.status_code == 422 assert response.status_code == 422
def test_create_user_invalid_username_chars(client, admin_user, admin_token): def test_create_user_valid_request_accepted(client, admin_user, admin_token):
response = client.post( response = client.post(
"/api/v1/users", "/api/v1/users",
json={ json={
"username": "../admin", "full_name": "Valid User",
"password": "SecurePass123!@#",
"email": "bad@test.com",
"role": "viewer",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 422
def test_create_user_valid_password_accepted(client, admin_user, admin_token):
response = client.post(
"/api/v1/users",
json={
"username": "validuser99",
"password": "ValidPass123!@#",
"email": "valid@test.com", "email": "valid@test.com",
"role": "viewer", "role": "viewer",
}, },
headers={"Authorization": f"Bearer {admin_token}"}, headers={"Authorization": f"Bearer {admin_token}"},
) )
assert response.status_code == 201 assert response.status_code == 201, response.text
assert response.json()["username"] == "validuser99" body = response.json()
assert body["full_name"] == "Valid User"
assert body["username"] == "valid@test.com"
assert body["must_change_password"] is True
def test_create_user_with_manager_role_accepted(client, admin_user, admin_token): def test_create_user_with_manager_role_accepted(client, admin_user, admin_token):
@@ -65,8 +55,7 @@ def test_create_user_with_manager_role_accepted(client, admin_user, admin_token)
response = client.post( response = client.post(
"/api/v1/users", "/api/v1/users",
json={ json={
"username": "newmanager", "full_name": "New Manager",
"password": "ValidPass123!@#",
"email": "newmanager@test.com", "email": "newmanager@test.com",
"role": "manager", "role": "manager",
}, },
@@ -80,8 +69,7 @@ def test_create_user_invalid_role_rejected(client, admin_user, admin_token):
response = client.post( response = client.post(
"/api/v1/users", "/api/v1/users",
json={ json={
"username": "badroleuser", "full_name": "Bad Role User",
"password": "ValidPass123!@#",
"email": "badrole@test.com", "email": "badrole@test.com",
"role": "superuser", "role": "superuser",
}, },
@@ -89,3 +77,20 @@ def test_create_user_invalid_role_rejected(client, admin_user, admin_token):
) )
assert response.status_code == 400 assert response.status_code == 400
assert "Invalid role" in response.text assert "Invalid role" in response.text
def test_create_user_duplicate_email_rejected(client, admin_user, admin_token):
payload = {
"full_name": "Dup User",
"email": "dup@test.com",
"role": "viewer",
}
first = client.post(
"/api/v1/users", json=payload, headers={"Authorization": f"Bearer {admin_token}"},
)
assert first.status_code == 201, first.text
second = client.post(
"/api/v1/users", json=payload, headers={"Authorization": f"Bearer {admin_token}"},
)
assert second.status_code == 409
+2
View File
@@ -6,6 +6,7 @@ import ProtectedRoute from "./components/ProtectedRoute";
/* ── Eagerly loaded (core pages) ──────────────────────────────────── */ /* ── Eagerly loaded (core pages) ──────────────────────────────────── */
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import SetPasswordPage from "./pages/SetPasswordPage";
import DashboardPage from "./pages/DashboardPage"; import DashboardPage from "./pages/DashboardPage";
/* ── Lazy loaded (V1-V3 pages) ────────────────────────────────────── */ /* ── Lazy loaded (V1-V3 pages) ────────────────────────────────────── */
@@ -39,6 +40,7 @@ export default function App() {
<Routes> <Routes>
{/* Public */} {/* Public */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
{/* Protected — wrapped in shared Layout */} {/* Protected — wrapped in shared Layout */}
<Route <Route
+1
View File
@@ -4,6 +4,7 @@ export interface AuditLogOut {
id: string; id: string;
user_id: string | null; user_id: string | null;
username: string | null; username: string | null;
full_name: string | null;
action: string; action: string;
entity_type: string | null; entity_type: string | null;
entity_id: string | null; entity_id: string | null;
+16
View File
@@ -50,3 +50,19 @@ export async function changePassword(
new_password: newPassword, new_password: newPassword,
}); });
} }
/** Check a set-password token before rendering the form. Public — no auth. */
export async function validateSetPasswordToken(
token: string,
): Promise<{ valid: boolean; full_name: string | null }> {
const { data } = await client.get<{ valid: boolean; full_name: string | null }>(
"/auth/set-password/validate",
{ params: { token } },
);
return data;
}
/** Complete a password setup/reset using a one-time token. Public — no auth. */
export async function setPassword(token: string, newPassword: string): Promise<void> {
await client.post("/auth/set-password", { token, new_password: newPassword });
}
+16
View File
@@ -130,6 +130,7 @@ export interface UserPreferencesUpdate {
export interface UserMeOut { export interface UserMeOut {
id: string; id: string;
username: string; username: string;
full_name: string | null;
email: string | null; email: string | null;
role: string; role: string;
is_active: boolean; is_active: boolean;
@@ -198,6 +199,21 @@ export async function testJiraConnection(): Promise<{
return data; return data;
} }
export interface PasswordWebhookConfigOut {
configured: boolean;
url: string;
}
export async function getPasswordWebhookConfig(): Promise<PasswordWebhookConfigOut> {
const { data } = await client.get<PasswordWebhookConfigOut>("/system/password-webhook-config");
return data;
}
export async function updatePasswordWebhookConfig(url: string): Promise<PasswordWebhookConfigOut> {
const { data } = await client.patch<PasswordWebhookConfigOut>("/system/password-webhook-config", { url });
return data;
}
export async function testTempoConnection(): Promise<{ export async function testTempoConnection(): Promise<{
status: "ok" | "error" | "disabled"; status: "ok" | "error" | "disabled";
message?: string; message?: string;
+1
View File
@@ -475,6 +475,7 @@ export async function requestDiscussion(testId: string): Promise<{
status: string; status: string;
message: string; message: string;
rejector_username: string; rejector_username: string;
rejector_full_name: string | null;
rejector_email: string | null; rejector_email: string | null;
rejector_role: string; rejector_role: string;
}> { }> {
+15 -4
View File
@@ -3,24 +3,28 @@ import client from "./client";
export interface UserOut { export interface UserOut {
id: string; id: string;
username: string; username: string;
full_name: string | null;
email: string | null; email: string | null;
role: string; role: string;
is_active: boolean; is_active: boolean;
must_change_password: boolean;
created_at: string | null; created_at: string | null;
last_login: string | null; last_login: string | null;
} }
export interface UserCreatePayload { export interface UserCreatePayload {
username: string; full_name: string;
email?: string; email: string;
password: string;
role: string; role: string;
} }
export interface UserUpdatePayload { export interface UserUpdatePayload {
email?: string; email?: string;
full_name?: string;
role?: string; role?: string;
is_active?: boolean; is_active?: boolean;
// Not sent by the current UI — the set-password-email flow replaces
// admin-set passwords — but still supported server-side.
password?: string; password?: string;
} }
@@ -33,6 +37,7 @@ export async function getUsers(): Promise<UserOut[]> {
export interface OperatorOut { export interface OperatorOut {
id: string; id: string;
username: string; username: string;
full_name: string | null;
role: string; role: string;
} }
@@ -42,7 +47,7 @@ export async function getOperators(): Promise<OperatorOut[]> {
return data; return data;
} }
/** Create a new user (admin only). */ /** Create a new user (admin only). No password — see sendPasswordEmail(). */
export async function createUser(payload: UserCreatePayload): Promise<UserOut> { export async function createUser(payload: UserCreatePayload): Promise<UserOut> {
const { data } = await client.post<UserOut>("/users", payload); const { data } = await client.post<UserOut>("/users", payload);
return data; return data;
@@ -53,3 +58,9 @@ export async function updateUser(userId: string, payload: UserUpdatePayload): Pr
const { data } = await client.patch<UserOut>(`/users/${userId}`, payload); const { data } = await client.patch<UserOut>(`/users/${userId}`, payload);
return data; return data;
} }
/** Email a one-time set-password link to a user (admin only). Also used for password resets. */
export async function sendPasswordEmail(userId: string): Promise<{ detail: string }> {
const { data } = await client.post<{ detail: string }>(`/users/${userId}/send-password-email`);
return data;
}
+1 -1
View File
@@ -71,7 +71,7 @@ export default function Layout() {
{/* Header */} {/* Header */}
<header className="flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-gray-900 px-6"> <header className="flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-gray-900 px-6">
<NotificationBell /> <NotificationBell />
<span className="text-sm text-gray-300">{user?.username}</span> <span className="text-sm text-gray-300">{user?.full_name || user?.username}</span>
<button <button
onClick={logout} onClick={logout}
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-gray-400 transition-colors hover:bg-gray-800 hover:text-white" className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
+1 -1
View File
@@ -80,7 +80,7 @@ const mdComponents: Components = {
const isBlock = className?.startsWith("language-"); const isBlock = className?.startsWith("language-");
if (isBlock) { if (isBlock) {
return ( return (
<code className="block rounded-md bg-gray-800 px-3 py-2 font-mono text-xs text-gray-200 whitespace-pre-wrap"> <code className="block rounded-md bg-gray-800 px-3 py-2 font-mono text-xs text-gray-200 whitespace-pre-wrap break-words">
{children} {children}
</code> </code>
); );
+1 -1
View File
@@ -214,7 +214,7 @@ export default function Sidebar() {
{/* Footer */} {/* Footer */}
<div className="border-t border-gray-800 px-5 py-4"> <div className="border-t border-gray-800 px-5 py-4">
<p className="truncate text-xs text-gray-500"> <p className="truncate text-xs text-gray-500">
{user?.username ?? "—"} · {role || "—"} {(user?.full_name || user?.username) ?? "—"} · {role || "—"}
</p> </p>
</div> </div>
</aside> </aside>
@@ -51,7 +51,8 @@ export default function AssigneeControl({
}: Props) { }: Props) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const current = operators.find((o) => o.id === assigneeId); const current = operators.find((o) => o.id === assigneeId);
const label = current?.username ?? (assigneeId ? assigneeUsername ?? "Unassigned" : "Unassigned"); const label =
(current?.full_name || current?.username) ?? (assigneeId ? assigneeUsername ?? "Unassigned" : "Unassigned");
const eligible = operators.filter((o) => ELIGIBLE_ROLES[kind][side].includes(o.role)); const eligible = operators.filter((o) => ELIGIBLE_ROLES[kind][side].includes(o.role));
const sideLabel = LABEL_PREFIX[kind][side]; const sideLabel = LABEL_PREFIX[kind][side];
@@ -106,7 +107,7 @@ export default function AssigneeControl({
op.id === assigneeId ? "bg-gray-800 text-white" : "text-gray-400 hover:bg-gray-800 hover:text-white" op.id === assigneeId ? "bg-gray-800 text-white" : "text-gray-400 hover:bg-gray-800 hover:text-white"
}`} }`}
> >
{op.username} {op.full_name || op.username}
<span className="ml-1.5 text-[10px] text-gray-500">({op.role.replace(/_/g, " ")})</span> <span className="ml-1.5 text-[10px] text-gray-500">({op.role.replace(/_/g, " ")})</span>
</button> </button>
))} ))}
@@ -228,7 +228,7 @@ export default function TeamTabs({
placeholder="Describe the attack procedure..." placeholder="Describe the attack procedure..."
/> />
) : ( ) : (
<pre className="whitespace-pre-wrap rounded-lg bg-gray-800 p-4 font-mono text-sm text-gray-300"> <pre className="whitespace-pre-wrap break-words rounded-lg bg-gray-800 p-4 font-mono text-sm text-gray-300">
{test.procedure_text || "No procedure documented."} {test.procedure_text || "No procedure documented."}
</pre> </pre>
)} )}
@@ -426,7 +426,7 @@ export default function TeamTabs({
placeholder="Describe how you detected the attack..." placeholder="Describe how you detected the attack..."
/> />
) : ( ) : (
<pre className="whitespace-pre-wrap rounded-lg bg-gray-800 p-4 font-mono text-sm text-gray-300"> <pre className="whitespace-pre-wrap break-words rounded-lg bg-gray-800 p-4 font-mono text-sm text-gray-300">
{test.detect_procedure || "No detection procedure documented."} {test.detect_procedure || "No detection procedure documented."}
</pre> </pre>
)} )}
@@ -133,14 +133,14 @@ export default function TestDetailHeader({
const [showDiscussModal, setShowDiscussModal] = useState(false); const [showDiscussModal, setShowDiscussModal] = useState(false);
const [discussionSent, setDiscussionSent] = useState(false); const [discussionSent, setDiscussionSent] = useState(false);
const [discussResult, setDiscussResult] = useState<{ const [discussResult, setDiscussResult] = useState<{
username: string; email: string | null; role: string; displayName: string; email: string | null; role: string;
} | null>(null); } | null>(null);
const discussMutation = useMutation({ const discussMutation = useMutation({
mutationFn: () => requestDiscussion(test.id), mutationFn: () => requestDiscussion(test.id),
onSuccess: (data) => { onSuccess: (data) => {
setDiscussResult({ setDiscussResult({
username: data.rejector_username, displayName: data.rejector_full_name || data.rejector_username,
email: data.rejector_email, email: data.rejector_email,
role: data.rejector_role, role: data.rejector_role,
}); });
@@ -850,7 +850,7 @@ export default function TestDetailHeader({
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-4 space-y-2"> <div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-4 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-amber-400">Contact details</p> <p className="text-xs font-semibold uppercase tracking-wider text-amber-400">Contact details</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">{discussResult?.username}</span> <span className="text-sm font-medium text-white">{discussResult?.displayName}</span>
<span className="rounded-full border border-gray-600 bg-gray-800 px-2 py-0.5 text-[10px] text-gray-400"> <span className="rounded-full border border-gray-600 bg-gray-800 px-2 py-0.5 text-[10px] text-gray-400">
{discussResult?.role} {discussResult?.role}
</span> </span>
+1 -1
View File
@@ -214,7 +214,7 @@ export default function AuditLogPage() {
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className="text-gray-200"> <span className="text-gray-200">
{log.username || "System"} {log.full_name || log.username || "System"}
</span> </span>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
+125
View File
@@ -0,0 +1,125 @@
import { useState, type FormEvent } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Loader2, CheckCircle } from "lucide-react";
import { validateSetPasswordToken, setPassword } from "../api/auth";
import { PasswordPolicyChecklist } from "../components/ChangePasswordModal";
/** Public page for completing a password setup/reset via an emailed one-time link. */
export default function SetPasswordPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get("token") || "";
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const { data: validation, isLoading } = useQuery({
queryKey: ["set-password-validate", token],
queryFn: () => validateSetPasswordToken(token),
enabled: !!token,
retry: false,
});
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
return;
}
setSubmitting(true);
try {
await setPassword(token, newPassword);
setDone(true);
} catch (err) {
const message =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
"Could not set your password — the link may have expired.";
setError(message);
} finally {
setSubmitting(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-950 px-4">
<div className="w-full max-w-sm space-y-8">
<div className="flex flex-col items-center gap-3">
<img src="/aegis-logo.png" alt="Aegis" className="h-16 w-16 rounded-full" />
<h1 className="text-2xl font-bold tracking-wide text-white">Aegis</h1>
<p className="text-sm text-gray-400">Set your password</p>
</div>
{!token ? (
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-400">
This link is missing its token ask an admin to send a new one.
</p>
) : isLoading ? (
<div className="flex justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : !validation?.valid ? (
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-400">
This link has expired or already been used ask an admin to send a new one.
</p>
) : done ? (
<div className="space-y-4 text-center">
<CheckCircle className="mx-auto h-10 w-10 text-green-400" />
<p className="text-sm text-gray-300">Your password has been set.</p>
<button
onClick={() => navigate("/login", { replace: true })}
className="w-full rounded-lg bg-cyan-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-cyan-500"
>
Go to login
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{validation.full_name && (
<p className="text-center text-sm text-gray-400">Welcome, {validation.full_name}</p>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">New Password</label>
<input
type="password"
required
autoFocus
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500"
/>
<PasswordPolicyChecklist password={newPassword} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Confirm Password</label>
<input
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500"
/>
</div>
{error && (
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-400">{error}</p>
)}
<button
type="submit"
disabled={submitting}
className="w-full rounded-lg bg-cyan-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:opacity-50"
>
{submitting ? "Setting password…" : "Set Password"}
</button>
</form>
)}
</div>
</div>
);
}
+75 -2
View File
@@ -53,6 +53,8 @@ import {
getJiraConfig, getJiraConfig,
updateJiraConfig, updateJiraConfig,
testJiraConnection, testJiraConnection,
getPasswordWebhookConfig,
updatePasswordWebhookConfig,
testTempoConnection, testTempoConnection,
type EmailConfigUpdate, type EmailConfigUpdate,
type WebhookCreate, type WebhookCreate,
@@ -843,8 +845,8 @@ function ProfileSection() {
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<p className="text-xs text-gray-500 mb-1">Username</p> <p className="text-xs text-gray-500 mb-1">Name</p>
<p className="text-gray-300 font-medium">{me?.username}</p> <p className="text-gray-300 font-medium">{me?.full_name || me?.username}</p>
</div> </div>
<div> <div>
<p className="text-xs text-gray-500 mb-1">Role</p> <p className="text-xs text-gray-500 mb-1">Role</p>
@@ -1683,6 +1685,76 @@ function SystemInfoSection() {
); );
} }
function PasswordWebhookSection() {
const qc = useQueryClient();
const [url, setUrl] = useState("");
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["password-webhook-config"],
queryFn: getPasswordWebhookConfig,
});
useEffect(() => {
if (data) setUrl(data.url);
}, [data]);
const saveMutation = useMutation({
mutationFn: () => updatePasswordWebhookConfig(url),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["password-webhook-config"] });
setToast({ msg: "Webhook URL saved", type: "success" });
},
onError: () => setToast({ msg: "Failed to save webhook URL", type: "error" }),
});
if (isLoading) {
return (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
);
}
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-2 text-lg font-semibold text-white flex items-center gap-2">
<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.
</p>
<div className="flex gap-3">
<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"
/>
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:opacity-50"
>
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save
</button>
</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.
</p>
)}
{toast && (
<Toast message={toast.msg} type={toast.type} onClose={() => setToast(null)} />
)}
</div>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main SettingsPage // Main SettingsPage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1780,6 +1852,7 @@ export default function SettingsPage() {
{activeTab === "system" && isAdmin && ( {activeTab === "system" && isAdmin && (
<div className="space-y-6"> <div className="space-y-6">
<SystemInfoSection /> <SystemInfoSection />
<PasswordWebhookSection />
<ExportImportSection /> <ExportImportSection />
</div> </div>
)} )}
+3 -3
View File
@@ -486,7 +486,7 @@ function TemplateSuggestionsPanel() {
{s.attack_procedure && ( {s.attack_procedure && (
<div className="mt-2"> <div className="mt-2">
<p className="text-xs font-medium uppercase text-gray-500">Suggested Attack Procedure</p> <p className="text-xs font-medium uppercase text-gray-500">Suggested Attack Procedure</p>
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-300"> <pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
{s.attack_procedure} {s.attack_procedure}
</pre> </pre>
</div> </div>
@@ -494,7 +494,7 @@ function TemplateSuggestionsPanel() {
{s.expected_detection && ( {s.expected_detection && (
<div className="mt-2"> <div className="mt-2">
<p className="text-xs font-medium uppercase text-gray-500">Expected Detection</p> <p className="text-xs font-medium uppercase text-gray-500">Expected Detection</p>
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-300"> <pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
{s.expected_detection} {s.expected_detection}
</pre> </pre>
</div> </div>
@@ -502,7 +502,7 @@ function TemplateSuggestionsPanel() {
{s.suggested_remediation && ( {s.suggested_remediation && (
<div className="mt-2"> <div className="mt-2">
<p className="text-xs font-medium uppercase text-gray-500">Suggested Remediation</p> <p className="text-xs font-medium uppercase text-gray-500">Suggested Remediation</p>
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-300"> <pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
{s.suggested_remediation} {s.suggested_remediation}
</pre> </pre>
</div> </div>
+2 -2
View File
@@ -859,14 +859,14 @@ function ProcedureSuggestionsPanel() {
{s.template_current_text && ( {s.template_current_text && (
<div className="mt-2"> <div className="mt-2">
<p className="text-xs font-medium uppercase text-gray-500">Current</p> <p className="text-xs font-medium uppercase text-gray-500">Current</p>
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-400"> <pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-gray-400">
{s.template_current_text} {s.template_current_text}
</pre> </pre>
</div> </div>
)} )}
<div className="mt-2"> <div className="mt-2">
<p className="text-xs font-medium uppercase text-gray-500">Suggested</p> <p className="text-xs font-medium uppercase text-gray-500">Suggested</p>
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-green-400"> <pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-green-400">
{s.suggested_text} {s.suggested_text}
</pre> </pre>
</div> </div>
+71 -58
View File
@@ -3,16 +3,23 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Loader2, Loader2,
AlertCircle, AlertCircle,
Users,
Plus, Plus,
X, X,
Check, Check,
UserX, UserX,
UserCheck, UserCheck,
Edit, Edit,
Mail,
} from "lucide-react"; } from "lucide-react";
import { getUsers, createUser, updateUser, type UserOut, type UserCreatePayload } from "../api/users"; import {
import { PasswordPolicyChecklist } from "../components/ChangePasswordModal"; getUsers,
createUser,
updateUser,
sendPasswordEmail,
type UserOut,
type UserCreatePayload,
} from "../api/users";
import { useToast } from "../components/Toast";
const ROLES = [ const ROLES = [
{ value: "viewer", label: "Viewer" }, { value: "viewer", label: "Viewer" },
@@ -36,6 +43,7 @@ const roleBadgeColors: Record<string, string> = {
export default function UsersPage() { export default function UsersPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToast();
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState<UserOut | null>(null); const [editingUser, setEditingUser] = useState<UserOut | null>(null);
@@ -65,6 +73,12 @@ export default function UsersPage() {
}, },
}); });
const sendPasswordEmailMutation = useMutation({
mutationFn: sendPasswordEmail,
onSuccess: (data) => showToast("success", data.detail),
onError: (err: Error) => showToast("error", err.message),
});
const toggleUserActive = (user: UserOut) => { const toggleUserActive = (user: UserOut) => {
updateMutation.mutate({ updateMutation.mutate({
userId: user.id, userId: user.id,
@@ -123,7 +137,7 @@ export default function UsersPage() {
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-gray-800"> <tr className="border-b border-gray-800">
<th className="px-6 py-4 font-medium text-gray-400">Username</th> <th className="px-6 py-4 font-medium text-gray-400">Name</th>
<th className="px-6 py-4 font-medium text-gray-400">Email</th> <th className="px-6 py-4 font-medium text-gray-400">Email</th>
<th className="px-6 py-4 font-medium text-gray-400">Role</th> <th className="px-6 py-4 font-medium text-gray-400">Role</th>
<th className="px-6 py-4 font-medium text-gray-400">Status</th> <th className="px-6 py-4 font-medium text-gray-400">Status</th>
@@ -139,7 +153,7 @@ export default function UsersPage() {
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors" className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
> >
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className="font-medium text-white">{user.username}</span> <span className="font-medium text-white">{user.full_name || user.username}</span>
</td> </td>
<td className="px-6 py-4 text-gray-400"> <td className="px-6 py-4 text-gray-400">
{user.email || "—"} {user.email || "—"}
@@ -165,6 +179,9 @@ export default function UsersPage() {
Inactive Inactive
</span> </span>
)} )}
{user.must_change_password && (
<span className="mt-0.5 block text-[10px] text-amber-400/80">Awaiting password setup</span>
)}
</td> </td>
<td className="px-6 py-4 text-gray-400"> <td className="px-6 py-4 text-gray-400">
{formatDate(user.created_at)} {formatDate(user.created_at)}
@@ -181,6 +198,14 @@ export default function UsersPage() {
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</button> </button>
<button
onClick={() => sendPasswordEmailMutation.mutate(user.id)}
disabled={sendPasswordEmailMutation.isPending}
className="rounded p-1.5 text-gray-400 hover:bg-gray-800 hover:text-cyan-400 disabled:opacity-50"
title={user.must_change_password ? "Send set-password email" : "Send password reset email"}
>
<Mail className="h-4 w-4" />
</button>
<button <button
onClick={() => toggleUserActive(user)} onClick={() => toggleUserActive(user)}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
@@ -238,6 +263,12 @@ export default function UsersPage() {
} }
// ── Create User Modal ────────────────────────────────────────────── // ── Create User Modal ──────────────────────────────────────────────
//
// No password field — the admin provides a name + email, then uses the
// "Send Email" action (Mail icon in the table) to let the user set their
// own password via a one-time link. The old admin-sets-the-password flow
// is intentionally not wired up here anymore (see LegacyUserCreateWithPassword
// on the backend, kept only so this can be restored quickly if needed).
interface CreateUserModalProps { interface CreateUserModalProps {
onClose: () => void; onClose: () => void;
@@ -248,18 +279,18 @@ interface CreateUserModalProps {
function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserModalProps) { function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserModalProps) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: "", full_name: "",
email: "", email: "",
password: "",
role: "viewer", role: "viewer",
}); });
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const validate = () => { const validate = () => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!formData.username.trim()) newErrors.username = "Username is required"; if (!formData.full_name.trim()) newErrors.full_name = "Full name is required";
if (!formData.password) newErrors.password = "Password is required"; if (!formData.email.trim() || !formData.email.includes("@")) {
if (formData.password.length < 6) newErrors.password = "Password must be at least 6 characters"; newErrors.email = "A valid email is required";
}
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
@@ -268,9 +299,8 @@ function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserM
e.preventDefault(); e.preventDefault();
if (validate()) { if (validate()) {
onSubmit({ onSubmit({
username: formData.username, full_name: formData.full_name,
email: formData.email || undefined, email: formData.email,
password: formData.password,
role: formData.role, role: formData.role,
}); });
} }
@@ -294,42 +324,31 @@ function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserM
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-300">Username *</label> <label className="block text-sm font-medium text-gray-300">Full Name *</label>
<input <input
type="text" type="text"
value={formData.username} value={formData.full_name}
onChange={(e) => setFormData({ ...formData, username: e.target.value })} onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
className={`mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 ${ className={`mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 ${
errors.username ? "border-red-500" : "border-gray-700" errors.full_name ? "border-red-500" : "border-gray-700"
}`} }`}
/> />
{errors.username && <p className="mt-1 text-sm text-red-400">{errors.username}</p>} {errors.full_name && <p className="mt-1 text-sm text-red-400">{errors.full_name}</p>}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-300">Email</label> <label className="block text-sm font-medium text-gray-300">Email *</label>
<input <input
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300">Password *</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className={`mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 ${ className={`mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 ${
errors.password ? "border-red-500" : "border-gray-700" errors.email ? "border-red-500" : "border-gray-700"
}`} }`}
/> />
<PasswordPolicyChecklist password={formData.password} /> {errors.email && <p className="mt-1 text-sm text-red-400">{errors.email}</p>}
{errors.password && <p className="mt-1 text-sm text-red-400">{errors.password}</p>} <p className="mt-1 text-xs text-cyan-400/70">
<p className="mt-1 text-xs text-amber-400/70"> After creating the user, use the mail icon in the table to send them a link to set their password.
The user will be required to change this password on first login.
</p> </p>
</div> </div>
@@ -372,6 +391,9 @@ function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserM
} }
// ── Edit User Modal ────────────────────────────────────────────── // ── Edit User Modal ──────────────────────────────────────────────
//
// No password field here either — password changes go through the
// "Send Email" action on the table instead.
interface EditUserModalProps { interface EditUserModalProps {
user: UserOut; user: UserOut;
@@ -383,28 +405,25 @@ interface EditUserModalProps {
function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUserModalProps) { function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUserModalProps) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
full_name: user.full_name || "",
email: user.email || "", email: user.email || "",
role: user.role, role: user.role,
password: "",
}); });
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const payload: Parameters<typeof updateUser>[1] = { onSubmit({
full_name: formData.full_name || undefined,
email: formData.email || undefined, email: formData.email || undefined,
role: formData.role, role: formData.role,
}; });
if (formData.password) {
payload.password = formData.password;
}
onSubmit(payload);
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-gray-800 bg-gray-900 p-6"> <div className="w-full max-w-md rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Edit User: {user.username}</h3> <h3 className="text-lg font-semibold text-white">Edit User: {user.full_name || user.username}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white"> <button onClick={onClose} className="text-gray-400 hover:text-white">
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
@@ -417,6 +436,16 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300">Full Name</label>
<input
type="text"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
/>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-300">Email</label> <label className="block text-sm font-medium text-gray-300">Email</label>
<input <input
@@ -442,22 +471,6 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse
</select> </select>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-300">
New Password <span className="text-gray-500">(leave blank to keep current)</span>
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
placeholder="••••••••"
/>
{formData.password.length > 0 && (
<PasswordPolicyChecklist password={formData.password} />
)}
</div>
<div className="flex justify-end gap-3 pt-4"> <div className="flex justify-end gap-3 pt-4">
<button <button
type="button" type="button"
+1
View File
@@ -5,6 +5,7 @@
export interface User { export interface User {
id: string; id: string;
username: string; username: string;
full_name?: string | null;
role: string; role: string;
must_change_password?: boolean; must_change_password?: boolean;
} }