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() {

setUsername(e.target.value)} - placeholder="Username" + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder="Email" className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500" />
setUsername(e.target.value)} + value={email} + onChange={(e) => setEmail(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" - placeholder="admin" + placeholder="you@company.com" />
diff --git a/scripts/install.sh b/scripts/install.sh index 9d1e486..48e0715 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -195,6 +195,17 @@ if [ "$SKIP_CONFIG" = false ]; then read -r INPUT_ADMIN_USER ADMIN_USERNAME="${INPUT_ADMIN_USER:-admin}" + echo "" + echo -e " ${DIM}Email is what you'll actually log in with (the unique identifier).${NC}" + print_prompt "Admin email: " + read -r INPUT_ADMIN_EMAIL + while [ -z "$INPUT_ADMIN_EMAIL" ]; do + print_warn "An admin email is required." + print_prompt "Admin email: " + read -r INPUT_ADMIN_EMAIL + done + ADMIN_EMAIL="$INPUT_ADMIN_EMAIL" + echo "" echo -e " ${DIM}Leave empty to auto-generate a secure password." echo -e " The password will be shown in the installation summary.${NC}" @@ -270,6 +281,7 @@ if [ "$SKIP_CONFIG" = false ]; then echo -e "${BOLD} ├──────────────────────────────────────────────────────┤${NC}" echo -e " │ URL: ${CYAN}${ORIGIN_URL}${NC}" echo -e " │ Admin user: ${CYAN}${ADMIN_USERNAME}${NC}" + echo -e " │ Admin email: ${CYAN}${ADMIN_EMAIL}${NC}" if [ "$ADMIN_PW_GENERATED" = true ]; then echo -e " │ Admin pass: ${CYAN}(auto-generated)${NC}" else @@ -315,6 +327,7 @@ TOKEN_EXPIRE_MINUTES=${TOKEN_EXPIRE_MINUTES} # ── Initial Admin Account ──────────────────────────────────────────────────── ADMIN_USERNAME=${ADMIN_USERNAME} +ADMIN_EMAIL=${ADMIN_EMAIL} ADMIN_PASSWORD=${ADMIN_PASSWORD} # ── MinIO Object Storage ───────────────────────────────────────────────────── @@ -422,6 +435,8 @@ echo -e " ${GREEN}ready${NC}" print_ok "All services are running" # ── Extract admin credentials from backend logs ────────────────────── +# Login is by email (the unique identifier) — ADMIN_CREDS_USER holds that +# email despite the variable name, kept for minimal diff below. ADMIN_CREDS_USER="" ADMIN_CREDS_PASS="" @@ -430,14 +445,13 @@ ADMIN_CREDS_PASS="" LOG_OUTPUT=$(docker logs aegis-backend 2>&1 | tail -20) if echo "$LOG_OUTPUT" | grep -q "Initial Admin User Created"; then - ADMIN_CREDS_USER=$(echo "$LOG_OUTPUT" | grep "Username :" | sed 's/.*Username : //') + ADMIN_CREDS_USER=$(echo "$LOG_OUTPUT" | grep "Login (email) :" | sed 's/.*Login (email) : //') ADMIN_CREDS_PASS=$(echo "$LOG_OUTPUT" | grep "Password :" | sed 's/.*Password : //') fi # Fallback: if we set it via env, use those values if [ -z "$ADMIN_CREDS_USER" ]; then - ADMIN_CREDS_USER=$(grep ADMIN_USERNAME "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "admin") - ADMIN_CREDS_USER="${ADMIN_CREDS_USER:-admin}" + ADMIN_CREDS_USER=$(grep '^ADMIN_EMAIL=' "$ENV_FILE" 2>/dev/null | cut -d= -f2-) fi if [ -z "$ADMIN_CREDS_PASS" ] || [ "$ADMIN_CREDS_PASS" = "(set via ADMIN_PASSWORD env var)" ]; then @@ -531,7 +545,7 @@ echo -e "${BOLD}║${NC} echo -e "${BOLD}╠══════════════════════════════════════════════════════════════╣${NC}" echo -e "${BOLD}║${NC} ${BOLD}║${NC}" echo -e "${BOLD}║${NC} ${CYAN}Admin Login${NC} ${BOLD}║${NC}" -echo -e "${BOLD}║${NC} Username: ${GREEN}${ADMIN_CREDS_USER}${NC}" +echo -e "${BOLD}║${NC} Email: ${GREEN}${ADMIN_CREDS_USER}${NC}" if [ -n "$ADMIN_CREDS_PASS" ]; then echo -e "${BOLD}║${NC} Password: ${GREEN}${ADMIN_CREDS_PASS}${NC}" else