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:
@@ -57,7 +57,7 @@ from app.models.user import User
|
||||
from app.schemas.auth import TokenResponse, UserOut
|
||||
|
||||
# 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
|
||||
from app.services.audit_service import log_action
|
||||
@@ -72,6 +72,12 @@ from app.services.auth_service import (
|
||||
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"])
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -381,3 +387,37 @@ def change_password(
|
||||
|
||||
# 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")
|
||||
def test_jira_connection(
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -1814,6 +1814,7 @@ def request_discussion(
|
||||
if rejector_id else None
|
||||
)
|
||||
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
|
||||
|
||||
# Notify the rejecting lead
|
||||
@@ -1852,6 +1853,7 @@ def request_discussion(
|
||||
"status": "notification_sent",
|
||||
"message": f"Discussion request sent to {rejector_name}",
|
||||
"rejector_username": rejector_name,
|
||||
"rejector_full_name": rejector_full_name,
|
||||
"rejector_email": rejector_email,
|
||||
"rejector_role": rejector_label,
|
||||
}
|
||||
|
||||
@@ -21,16 +21,24 @@ from app.domain.unit_of_work import UnitOfWork
|
||||
# Import User from app.models.user
|
||||
from app.models.user import 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
|
||||
|
||||
# Import from app.services.user_service
|
||||
from app.services.user_service import (
|
||||
create_user,
|
||||
create_user_without_password,
|
||||
get_user_or_raise,
|
||||
list_users,
|
||||
update_user,
|
||||
)
|
||||
from app.services.password_setup_service import send_password_setup_email
|
||||
|
||||
# Assign router = APIRouter(prefix="/users", tags=["users"])
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
@@ -110,19 +118,17 @@ def create_user_route(
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
) -> 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
|
||||
with UnitOfWork(db) as uow:
|
||||
# Assign user = create_user(
|
||||
user = create_user(
|
||||
user = create_user_without_password(
|
||||
db,
|
||||
# Keyword argument: username
|
||||
username=payload.username,
|
||||
# Keyword argument: email
|
||||
full_name=payload.full_name,
|
||||
email=payload.email,
|
||||
# Keyword argument: password
|
||||
password=payload.password,
|
||||
# Keyword argument: role
|
||||
role=payload.role,
|
||||
)
|
||||
# Call log_action()
|
||||
@@ -137,7 +143,7 @@ def create_user_route(
|
||||
# Keyword argument: entity_id
|
||||
entity_id=user.id,
|
||||
# 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()
|
||||
uow.commit()
|
||||
@@ -241,3 +247,34 @@ def update_user_route(
|
||||
|
||||
# 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}")
|
||||
|
||||
Reference in New Issue
Block a user