diff --git a/.env.example b/.env.example
index b259c64..b2912af 100644
--- a/.env.example
+++ b/.env.example
@@ -23,7 +23,10 @@ TOKEN_EXPIRE_MINUTES=60
# ── Initial Admin Account ────────────────────────────────────────────────────
# If ADMIN_PASSWORD is empty, a random password is auto-generated and
# printed to the backend container logs on first startup.
+# ADMIN_EMAIL is what you actually log in with (email is the unique
+# identifier) — set it to a real address you control.
ADMIN_USERNAME=admin
+ADMIN_EMAIL=
ADMIN_PASSWORD=
# ── MinIO Object Storage ─────────────────────────────────────────────────────
diff --git a/backend/alembic/versions/b067_email_unique_identifier.py b/backend/alembic/versions/b067_email_unique_identifier.py
new file mode 100644
index 0000000..23d893c
--- /dev/null
+++ b/backend/alembic/versions/b067_email_unique_identifier.py
@@ -0,0 +1,33 @@
+"""Make users.email the unique login identifier (NOT NULL + unique index).
+
+Backfills any missing/blank email from username first, so existing rows
+(e.g. the seeded admin, which historically had no email) never violate
+the new constraint.
+
+Revision ID: b067
+Revises: b066
+Create Date: 2026-07-23
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "b067"
+down_revision = "b066"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute(
+ "UPDATE users SET email = username WHERE email IS NULL OR email = ''"
+ )
+ op.alter_column("users", "email", existing_type=sa.String(), nullable=False)
+ op.create_unique_constraint("uq_users_email", "users", ["email"])
+ op.create_index("ix_users_email", "users", ["email"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_users_email", table_name="users")
+ op.drop_constraint("uq_users_email", "users", type_="unique")
+ op.alter_column("users", "email", existing_type=sa.String(), nullable=True)
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 5a47bb2..d0816dd 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -26,10 +26,15 @@ class User(Base):
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
- # Assign username = Column(String, unique=True, nullable=False)
+ # Internal login identifier — always kept equal to `email` (see below).
+ # Kept as a separate column (rather than removed) since the JWT `sub`
+ # claim, audit logs, Jira actor attribution, and SSO provisioning all
+ # still key off it; changing all of those to read `email` directly
+ # would be a much larger, riskier refactor for no behavioral gain now
+ # that the two are always identical.
username = Column(String, unique=True, nullable=False)
- # Assign email = Column(String, nullable=True)
- email = Column(String, nullable=True)
+ # The unique identifier a user logs in with. Every user must have one.
+ email = Column(String, unique=True, nullable=False, index=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)
diff --git a/backend/app/routers/admin_config.py b/backend/app/routers/admin_config.py
index a623642..23c0e83 100644
--- a/backend/app/routers/admin_config.py
+++ b/backend/app/routers/admin_config.py
@@ -222,6 +222,7 @@ async def import_config(
"custom_templates": 0,
"users_created": 0,
"users_updated": 0,
+ "users_skipped_no_email": 0,
}
# ── 1. system_configs ────────────────────────────────────────────
@@ -309,12 +310,16 @@ async def import_config(
summary["custom_templates"] += 1
# ── 6. Users ─────────────────────────────────────────────────────
+ # Email is the unique login identifier — a bundle entry with no email
+ # (e.g. exported from an older instance, before this requirement) can't
+ # be created; it's skipped rather than crashing the whole import.
import secrets as _secrets
for item in bundle.get("users", []):
- username = item.get("username")
- if not username:
+ email = item.get("email")
+ if not email:
+ summary["users_skipped_no_email"] += 1
continue
- existing = db.query(User).filter(User.username == username).first()
+ existing = db.query(User).filter(User.email == email).first()
if existing:
existing.role = item.get("role", existing.role)
existing.is_active = item.get("is_active", existing.is_active)
@@ -323,14 +328,13 @@ async def import_config(
# Create with random temp password — user must reset on login
temp_pw = _secrets.token_urlsafe(16) + "Aa1!"
new_user = User(
- username=username,
+ username=email,
+ email=email,
hashed_password=hash_password(temp_pw),
role=item.get("role", "viewer"),
is_active=item.get("is_active", True),
must_change_password=True,
)
- if item.get("email") and hasattr(User, "email"):
- new_user.email = item["email"]
db.add(new_user)
summary["users_created"] += 1
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 34d4e65..ba74802 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -114,8 +114,10 @@ def login(
Rate-limited to **5 attempts per minute per IP**. Failed and successful
logins are recorded in the audit log (SEC-009).
"""
- # Assign user = db.query(User).filter(User.username == form_data.username).first()
- user = db.query(User).filter(User.username == form_data.username).first()
+ # OAuth2PasswordRequestForm's field is spec-named "username" but the
+ # value a user actually types in is their email — email is the unique
+ # login identifier (username is an internal id, always kept == email).
+ user = db.query(User).filter(User.email == form_data.username).first()
# Assign target_hash = user.hashed_password if user else _DUMMY_HASH
target_hash = user.hashed_password if user else _DUMMY_HASH
# Assign password_valid = verify_password(form_data.password, target_hash)
@@ -140,7 +142,7 @@ def login(
# Keyword argument: details
details={
# Literal argument value
- "username": form_data.username,
+ "email": form_data.username,
# Literal argument value
"ip": ip,
# Literal argument value
@@ -152,7 +154,7 @@ def login(
# Call uow.commit()
uow.commit()
# Raise BusinessRuleViolation
- raise BusinessRuleViolation("Incorrect username or password")
+ raise BusinessRuleViolation("Incorrect email or password")
# Check: not user.is_active
if not user.is_active:
@@ -174,7 +176,7 @@ def login(
"auth",
str(user.id),
# Keyword argument: details
- details={"username": user.username, "ip": ip},
+ details={"email": user.email, "ip": ip},
# Keyword argument: ip_address
ip_address=ip,
)
diff --git a/backend/app/seed.py b/backend/app/seed.py
index 7c5b34d..dbebc11 100644
--- a/backend/app/seed.py
+++ b/backend/app/seed.py
@@ -1,7 +1,10 @@
"""Seed script — creates the initial admin user if it does not already exist.
On first run the admin credentials are generated securely:
-- Username is read from ``ADMIN_USERNAME`` env var (default: ``admin``).
+- Email (the unique login identifier) is read from ``ADMIN_EMAIL`` env var.
+ Falls back to ``ADMIN_USERNAME`` if it's already email-shaped, otherwise
+ to a placeholder that MUST be changed before this account can receive
+ any webhook email (password reset, notifications, etc).
- Password is read from ``ADMIN_PASSWORD`` env var. When the variable is
**not set**, a cryptographically random 16-character password is generated
automatically and printed to the startup logs so the operator can copy it.
@@ -54,12 +57,19 @@ def seed_admin() -> None:
# Assign admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
- # Assign existing = db.query(User).filter(User.username == admin_username).first()
- existing = db.query(User).filter(User.username == admin_username).first()
+ # Email is the unique login identifier — prefer ADMIN_EMAIL, then an
+ # already email-shaped ADMIN_USERNAME, then a placeholder that needs
+ # fixing later (Users page) before this account can receive email.
+ admin_email = os.environ.get("ADMIN_EMAIL", "").strip()
+ if not admin_email:
+ admin_email = admin_username if "@" in admin_username else f"{admin_username}@localhost"
+
+ # Assign existing = db.query(User).filter(User.email == admin_email).first()
+ existing = db.query(User).filter(User.email == admin_email).first()
# Check: existing
if existing:
# Call print()
- print(f"Admin user '{admin_username}' already exists — skipping.")
+ print(f"Admin user '{admin_email}' already exists — skipping.")
# Return control to caller
return
@@ -78,7 +88,9 @@ def seed_admin() -> None:
# Assign admin = User(
admin = User(
# Keyword argument: username
- username=admin_username,
+ username=admin_email,
+ # Keyword argument: email
+ email=admin_email,
# Keyword argument: hashed_password
hashed_password=hash_password(admin_password),
# Keyword argument: role
@@ -98,7 +110,10 @@ def seed_admin() -> None:
# Call print()
print("=" * 60)
# Call print()
- print(f" Username : {admin_username}")
+ print(f" Login (email) : {admin_email}")
+ if "@localhost" in admin_email:
+ print(" ** No ADMIN_EMAIL was set — using a placeholder. **")
+ print(" ** Update it in Users before relying on emailed links. **")
# Check: password_was_generated
if password_was_generated:
# Call print()
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
index a34f6d5..6027110 100644
--- a/backend/app/services/auth_service.py
+++ b/backend/app/services/auth_service.py
@@ -19,15 +19,15 @@ _DUMMY_HASH = "$2b$12$LJ3m4ys3Lg3dMO/NpNmOaeVwFpWJMxlB2FLmEAo9fZr.S8H1vC4Wy"
# Define function authenticate_user
-def authenticate_user(db: Session, *, username: str, password: str) -> User:
+def authenticate_user(db: Session, *, email: str, password: str) -> User:
"""Validate credentials and return the User.
Raises BusinessRuleViolation for invalid credentials.
Raises PermissionViolation for disabled account.
Uses constant-time comparison to prevent timing attacks.
"""
- # Assign user = db.query(User).filter(User.username == username).first()
- user = db.query(User).filter(User.username == username).first()
+ # Assign user = db.query(User).filter(User.email == email).first()
+ user = db.query(User).filter(User.email == email).first()
# Assign hashed = user.hashed_password if user else _DUMMY_HASH
hashed = user.hashed_password if user else _DUMMY_HASH
# Assign password_valid = verify_password(password, hashed)
@@ -36,7 +36,7 @@ def authenticate_user(db: Session, *, username: str, password: str) -> User:
# Check: user is None or not password_valid
if user is None or not password_valid:
# Raise BusinessRuleViolation
- raise BusinessRuleViolation("Incorrect username or password")
+ raise BusinessRuleViolation("Incorrect email or password")
# Check: not user.is_active
if not user.is_active:
# Raise PermissionViolation
diff --git a/backend/app/services/sso_service.py b/backend/app/services/sso_service.py
index 7829050..0793502 100644
--- a/backend/app/services/sso_service.py
+++ b/backend/app/services/sso_service.py
@@ -177,8 +177,9 @@ def process_callback(db: Session, request_data: dict) -> User:
name_id = auth.get_nameid()
# Attribute claim URIs — defaults support both plain names and Azure AD full URIs
+ # (attr_username is no longer read: email is the sole login identifier
+ # platform-wide, username always mirrors it — see below.)
email_attr = cfg.attr_email or "email"
- username_attr = cfg.attr_username or "email" # Azure AD: use email as username
role_attr = cfg.attr_role or "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
# Resolve email: try configured attr → Azure email claim URI → NameID
@@ -189,9 +190,9 @@ def process_callback(db: Session, request_data: dict) -> User:
or ""
)
- # Resolve username: keep full email (e.g. user@company.com) to avoid collisions with local accounts
- raw_username = _first_attr(attrs, username_attr) or email or name_id or ""
- username = raw_username.strip() or email.split("@")[0] or name_id
+ # Email is the unique login identifier platform-wide — username always
+ # mirrors it (see User model), never a separately-provisioned IdP value.
+ username = email
# Resolve role: try configured attr → Azure role claim URI → default
role = (
diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py
index 8804ffb..d817709 100644
--- a/backend/app/services/user_service.py
+++ b/backend/app/services/user_service.py
@@ -45,27 +45,28 @@ def create_user(
# Entry: db
db: Session,
*,
- # Entry: username
- username: str,
# Entry: email
- email: str | None,
+ email: str,
# Entry: password
password: str,
# Entry: role
role: str,
) -> User:
- """Create a new user.
+ """Create a new user. Email is the unique login identifier.
- Raises DuplicateEntityError if username already exists.
+ ``username`` is derived from ``email`` — it's kept internally (JWT,
+ audit logs) but always mirrors email, never a separate value.
+
+ Raises DuplicateEntityError if a user with this email already exists.
Raises BusinessRuleViolation if role is invalid.
Does not commit; the router handles that.
"""
- # Assign existing = db.query(User).filter(User.username == username).first()
- existing = db.query(User).filter(User.username == username).first()
+ # Assign existing = db.query(User).filter(User.email == email).first()
+ existing = db.query(User).filter(User.email == email).first()
# Check: existing
if existing:
# Raise DuplicateEntityError
- raise DuplicateEntityError("User", "username", username)
+ raise DuplicateEntityError("User", "email", email)
# Check: role not in VALID_ROLES
if role not in VALID_ROLES:
@@ -77,7 +78,7 @@ def create_user(
# Assign user = User(
user = User(
# Keyword argument: username
- username=username,
+ username=email,
# Keyword argument: email
email=email,
# Keyword argument: hashed_password
@@ -102,7 +103,7 @@ def create_user_without_password(db: Session, *, full_name: str, email: str, rol
exists. Raises BusinessRuleViolation if role is invalid. Does not
commit; the router handles that.
"""
- existing = db.query(User).filter(User.username == email).first()
+ existing = db.query(User).filter(User.email == email).first()
if existing:
raise DuplicateEntityError("User", "email", email)
@@ -171,6 +172,14 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User:
# Assign update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
+ # Email is the unique login identifier — enforce uniqueness on change,
+ # and keep username (the internal id) mirrored to it.
+ if update_data.get("email") is not None and update_data["email"] != user.email:
+ existing = db.query(User).filter(User.email == update_data["email"], User.id != user_id).first()
+ if existing:
+ raise DuplicateEntityError("User", "email", update_data["email"])
+ update_data["username"] = update_data["email"]
+
# Iterate over update_data.items()
for field, value in update_data.items():
# Call setattr()
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index f3952ca..042b219 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -239,7 +239,7 @@ def admin_token(client, admin_user):
"""Get an auth token for the admin user."""
response = client.post(
"/api/v1/auth/login",
- data={"username": "admin", "password": "admin123"},
+ data={"username": "admin@test.com", "password": "admin123"},
)
return response.json()["access_token"]
@@ -249,7 +249,7 @@ def red_tech_token(client, red_tech_user):
"""Get an auth token for the red_tech user."""
response = client.post(
"/api/v1/auth/login",
- data={"username": "redtech", "password": "redtech123"},
+ data={"username": "redtech@test.com", "password": "redtech123"},
)
return response.json()["access_token"]
@@ -271,7 +271,7 @@ def blue_tech_token(client, blue_tech_user):
"""Get an auth token for the blue_tech user."""
response = client.post(
"/api/v1/auth/login",
- data={"username": "bluetech", "password": "bluetech123"},
+ data={"username": "bluetech@test.com", "password": "bluetech123"},
)
return response.json()["access_token"]
@@ -287,7 +287,7 @@ def red_lead_token(client, red_lead_user):
"""Get an auth token for the red_lead user."""
response = client.post(
"/api/v1/auth/login",
- data={"username": "redlead", "password": "redlead123"},
+ data={"username": "redlead@test.com", "password": "redlead123"},
)
return response.json()["access_token"]
@@ -303,7 +303,7 @@ def blue_lead_token(client, blue_lead_user):
"""Get an auth token for the blue_lead user."""
response = client.post(
"/api/v1/auth/login",
- data={"username": "bluelead", "password": "bluelead123"},
+ data={"username": "bluelead@test.com", "password": "bluelead123"},
)
return response.json()["access_token"]
@@ -319,7 +319,7 @@ def manager_token(client, manager_user):
"""Get an auth token for the manager user."""
response = client.post(
"/api/v1/auth/login",
- data={"username": "manager", "password": "manager123"},
+ data={"username": "manager@test.com", "password": "manager123"},
)
return response.json()["access_token"]
diff --git a/backend/tests/test_admin_config_export_import.py b/backend/tests/test_admin_config_export_import.py
index 9a800a4..15e66b6 100644
--- a/backend/tests/test_admin_config_export_import.py
+++ b/backend/tests/test_admin_config_export_import.py
@@ -105,24 +105,35 @@ def test_import_config_rejects_invalid_json(client, auth_headers):
def test_import_config_creates_new_user_with_forced_reset(client, db, auth_headers):
resp = client.post(
"/api/v1/admin/import-config",
- json={"users": [{"username": "imported_user", "role": "red_tech", "is_active": True}]},
+ json={"users": [{"username": "imported_user", "email": "imported_user@test.com", "role": "red_tech", "is_active": True}]},
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.json()["summary"]["users_created"] == 1
- user = db.query(User).filter(User.username == "imported_user").first()
+ user = db.query(User).filter(User.email == "imported_user@test.com").first()
assert user is not None
assert user.must_change_password is True
assert user.role == "red_tech"
+def test_import_config_skips_user_with_no_email(client, db, auth_headers):
+ resp = client.post(
+ "/api/v1/admin/import-config",
+ json={"users": [{"username": "no_email_user", "role": "red_tech", "is_active": True}]},
+ headers=auth_headers,
+ )
+ assert resp.status_code == 200
+ assert resp.json()["summary"]["users_skipped_no_email"] == 1
+ assert db.query(User).filter(User.username == "no_email_user").first() is None
+
+
def test_import_config_updates_existing_user_role_only(client, db, auth_headers, red_tech_user):
original_hash = red_tech_user.hashed_password
resp = client.post(
"/api/v1/admin/import-config",
- json={"users": [{"username": red_tech_user.username, "role": "red_lead", "is_active": True}]},
+ json={"users": [{"username": red_tech_user.username, "email": red_tech_user.email, "role": "red_lead", "is_active": True}]},
headers=auth_headers,
)
assert resp.status_code == 200
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index 5c440cd..e5937d8 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -23,7 +23,7 @@ def test_login_success(client, admin_user):
"""Test successful login returns a token."""
response = client.post(
"/api/v1/auth/login",
- data={"username": "admin", "password": "admin123"},
+ data={"username": "admin@test.com", "password": "admin123"},
)
assert response.status_code == 200
data = response.json()
@@ -35,7 +35,7 @@ def test_login_wrong_password(client, admin_user):
"""Test login with wrong password returns 400."""
response = client.post(
"/api/v1/auth/login",
- data={"username": "admin", "password": "wrongpassword"},
+ data={"username": "admin@test.com", "password": "wrongpassword"},
)
assert response.status_code == 400
@@ -56,6 +56,7 @@ def test_login_inactive_user(client, db):
user = User(
username="inactive",
+ email="inactive@test.com",
hashed_password=hash_password("password"),
role="viewer",
is_active=False,
@@ -65,7 +66,7 @@ def test_login_inactive_user(client, db):
response = client.post(
"/api/v1/auth/login",
- data={"username": "inactive", "password": "password"},
+ data={"username": "inactive@test.com", "password": "password"},
)
assert response.status_code == 403
@@ -106,7 +107,7 @@ def test_logout_revokes_token(client, admin_user):
"""
login = client.post(
"/api/v1/auth/login",
- data={"username": "admin", "password": "admin123"},
+ data={"username": "admin@test.com", "password": "admin123"},
)
assert login.status_code == 200
token = login.json()["access_token"]
diff --git a/backend/tests/test_auth_login_audit.py b/backend/tests/test_auth_login_audit.py
index efafd9f..ecf2b80 100644
--- a/backend/tests/test_auth_login_audit.py
+++ b/backend/tests/test_auth_login_audit.py
@@ -6,7 +6,7 @@ from app.models.audit import AuditLog
def test_login_failed_creates_audit_entry(client, admin_user, db):
response = client.post(
"/api/v1/auth/login",
- data={"username": "admin", "password": "wrong"},
+ data={"username": "admin@test.com", "password": "wrong"},
headers={"X-Forwarded-For": "198.51.100.10", "User-Agent": "LoginAuditTest/1.0"},
)
assert response.status_code == 400
@@ -19,7 +19,7 @@ def test_login_failed_creates_audit_entry(client, admin_user, db):
)
assert log is not None
assert log.entity_type == "auth"
- assert log.details["username"] == "admin"
+ assert log.details["email"] == "admin@test.com"
assert log.details["reason"] == "invalid_credentials"
assert log.ip_address == "198.51.100.10"
assert log.user_agent == "LoginAuditTest/1.0"
@@ -30,7 +30,7 @@ def test_login_success_creates_audit_entry(client, admin_user, db):
client.cookies.clear()
response = client.post(
"/api/v1/auth/login",
- data={"username": "admin", "password": "admin123"},
+ data={"username": "admin@test.com", "password": "admin123"},
headers={"X-Forwarded-For": "198.51.100.20"},
)
assert response.status_code == 200
diff --git a/backend/tests/test_data_classification.py b/backend/tests/test_data_classification.py
index 0300759..7301b04 100644
--- a/backend/tests/test_data_classification.py
+++ b/backend/tests/test_data_classification.py
@@ -137,7 +137,7 @@ def test_viewer_cannot_update_classification(client, db, admin_user, red_lead_us
)
db.add(viewer)
db.commit()
- login = client.post("/api/v1/auth/login", data={"username": "viewer_classif", "password": "x"})
+ login = client.post("/api/v1/auth/login", data={"username": "viewer_classif@test.com", "password": "x"})
viewer_token = login.json()["access_token"]
technique = _seed_technique(db)
diff --git a/backend/tests/test_review_gates_router.py b/backend/tests/test_review_gates_router.py
index b981f2e..93cc342 100644
--- a/backend/tests/test_review_gates_router.py
+++ b/backend/tests/test_review_gates_router.py
@@ -101,7 +101,7 @@ def test_review_red_forbidden_for_non_assigned_lead(
db.add(other_lead)
db.commit()
- login = client.post("/api/v1/auth/login", data={"username": "otherredlead", "password": "x"})
+ login = client.post("/api/v1/auth/login", data={"username": "otherredlead@test.com", "password": "x"})
assert login.status_code == 200
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
@@ -176,7 +176,7 @@ def test_review_blue_forbidden_for_non_assigned_lead(
db.add(other_lead)
db.commit()
- login = client.post("/api/v1/auth/login", data={"username": "otherbluelead", "password": "x"})
+ login = client.post("/api/v1/auth/login", data={"username": "otherbluelead@test.com", "password": "x"})
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", other_headers, json={"decision": "approve"})
diff --git a/backend/tests/test_tests.py b/backend/tests/test_tests.py
index e2c11bd..3b2216e 100644
--- a/backend/tests/test_tests.py
+++ b/backend/tests/test_tests.py
@@ -102,7 +102,7 @@ def test_start_execution_twice_returns_invalid_transition(
rl = client.post(
"/api/v1/auth/login",
- data={"username": "redtech", "password": "redtech123"},
+ data={"username": "redtech@test.com", "password": "redtech123"},
)
assert rl.status_code == 200
red_headers = {"Authorization": f"Bearer {rl.json()['access_token']}"}
diff --git a/backend/tests/test_workflow.py b/backend/tests/test_workflow.py
index a755274..4591c30 100644
--- a/backend/tests/test_workflow.py
+++ b/backend/tests/test_workflow.py
@@ -953,7 +953,7 @@ class TestReviewerSelection:
def _make_lead(self, db, username, role="red_lead"):
from app.models.user import User
- u = User(username=username, role=role, hashed_password="x", is_active=True)
+ u = User(username=username, email=f"{username}@test.com", role=role, hashed_password="x", is_active=True)
db.add(u)
db.flush()
return u
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 24dc8db..784ff7e 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -97,6 +97,8 @@ services:
AEGIS_ENV: ${AEGIS_ENV:-production}
SECURE_COOKIES: ${SECURE_COOKIES:-false}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
+ # Email is the admin account's unique login identifier.
+ ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
# ── Tempo time-tracking (optional) ────────────────────────────────────
TEMPO_ENABLED: ${TEMPO_ENABLED:-false}
diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts
index 1dded7c..fefae89 100644
--- a/frontend/src/api/auth.ts
+++ b/frontend/src/api/auth.ts
@@ -2,17 +2,20 @@ import client from "./client";
import type { User } from "../types/models";
/**
- * Authenticate the user.
+ * Authenticate the user with their email (the unique login identifier)
+ * and password.
*
* The backend sets an HttpOnly cookie with the JWT — no token is stored
- * in JavaScript memory or localStorage.
+ * in JavaScript memory or localStorage. The form field is still named
+ * "username" on the wire — that's the OAuth2PasswordRequestForm spec's
+ * fixed field name, not a statement about what value it holds.
*/
export async function login(
- username: string,
+ email: string,
password: string,
): Promise