From 504dfc52f54b38119e1e8234a974115e978a57db Mon Sep 17 00:00:00 2001
From: kitos
Date: Thu, 23 Jul 2026 14:39:36 +0200
Subject: [PATCH] feat(auth): make email the unique login identifier
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Login is now by email, not username. username still exists internally
(JWT sub claim, audit logs, Jira actor attribution, SSO provisioning all
still key off it) but is now always kept equal to email everywhere a user
is created or their email changes — never a separately-chosen value.
- User.email is now unique + NOT NULL (migration b067 backfills any
missing/blank email from username first, so existing rows — notably
the seeded admin, which historically had none — never violate it).
- /auth/login and the (unused but updated for consistency)
authenticate_user() now query by email.
- create_user (legacy, unreferenced but kept) and
create_user_without_password both derive username from email.
- update_user keeps username in sync when email changes, and rejects
duplicate emails.
- seed.py reads ADMIN_EMAIL (new env var, wired through install.sh and
docker-compose.prod.yml) for the initial admin; falls back to an
email-shaped ADMIN_USERNAME or a placeholder that's flagged for the
operator to fix.
- admin_config.py's import bundle now matches/creates users by email,
skipping (not crashing on) entries with no email.
- sso_service.py always sets username = email for SSO-provisioned users.
- LoginPage/auth.ts updated to email input/copy (wire field name stays
'username' — that's the OAuth2PasswordRequestForm spec, not the value).
---
.env.example | 3 ++
.../versions/b067_email_unique_identifier.py | 33 +++++++++++++++++++
backend/app/models/user.py | 11 +++++--
backend/app/routers/admin_config.py | 16 +++++----
backend/app/routers/auth.py | 12 ++++---
backend/app/seed.py | 27 +++++++++++----
backend/app/services/auth_service.py | 8 ++---
backend/app/services/sso_service.py | 9 ++---
backend/app/services/user_service.py | 29 ++++++++++------
backend/tests/conftest.py | 12 +++----
.../tests/test_admin_config_export_import.py | 17 ++++++++--
backend/tests/test_auth.py | 9 ++---
backend/tests/test_auth_login_audit.py | 6 ++--
backend/tests/test_data_classification.py | 2 +-
backend/tests/test_review_gates_router.py | 4 +--
backend/tests/test_tests.py | 2 +-
backend/tests/test_workflow.py | 2 +-
docker-compose.prod.yml | 2 ++
frontend/src/api/auth.ts | 11 ++++---
frontend/src/pages/LoginPage.tsx | 28 ++++++++--------
scripts/install.sh | 22 ++++++++++---
21 files changed, 184 insertions(+), 81 deletions(-)
create mode 100644 backend/alembic/versions/b067_email_unique_identifier.py
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 {
const params = new URLSearchParams();
- params.append("username", username);
+ params.append("username", email);
params.append("password", password);
await client.post("/auth/login", params, {
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index 6b2515d..cd94b20 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -9,7 +9,7 @@ export default function LoginPage() {
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
- const [username, setUsername] = useState("");
+ const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
@@ -36,10 +36,10 @@ export default function LoginPage() {
setError(null);
setLoading(true);
try {
- await login(username, password);
+ await login(email, password);
navigate("/dashboard", { replace: true });
} catch {
- setError("Invalid username or password");
+ setError("Invalid email or password");
} finally {
setLoading(false);
}
@@ -103,12 +103,12 @@ export default function LoginPage() {