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
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:
@@ -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")
|
||||||
@@ -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])
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user