diff --git a/aegis/core/migration_generator.py b/aegis/core/migration_generator.py index b221eca2..800de266 100644 --- a/aegis/core/migration_generator.py +++ b/aegis/core/migration_generator.py @@ -87,7 +87,11 @@ class ServiceMigrationSpec: ColumnSpec("email", "sa.String()", nullable=False), ColumnSpec("full_name", "sa.String()", nullable=True), ColumnSpec("is_active", "sa.Boolean()", nullable=False, default="True"), + ColumnSpec( + "is_verified", "sa.Boolean()", nullable=False, default="False" + ), ColumnSpec("hashed_password", "sa.String()", nullable=False), + ColumnSpec("last_login", "sa.DateTime()", nullable=True), ColumnSpec("created_at", "sa.DateTime()", nullable=False), ColumnSpec("updated_at", "sa.DateTime()", nullable=True), ], diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py index fca51ecb..097c0c35 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py @@ -1,5 +1,7 @@ """Authentication API routes.""" +from datetime import UTC, datetime + from app.components.backend.api.deps import get_async_db from app.core.security import create_access_token, verify_password from app.models.user import UserCreate, UserResponse @@ -49,6 +51,11 @@ async def login( headers={"WWW-Authenticate": "Bearer"}, ) + # Update last_login timestamp + await user_service.update_user( + user.id, last_login=datetime.now(UTC).replace(tzinfo=None) + ) + # Create access token access_token = create_access_token(data={"sub": user.email}) return {"access_token": access_token, "token_type": "bearer"} diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/activity_feed.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/activity_feed.py index 394658fc..181a534f 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/activity_feed.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/activity_feed.py @@ -351,7 +351,8 @@ def __init__(self, max_events: int = 40) -> None: animate_opacity=_anim, ) - # Colored indicator dot (visible when a non-"All" filter is active and pills collapsed) + # Colored indicator dot + # (visible when a non-"All" filter is active and pills collapsed) self._filter_dot = ft.Container( width=6, height=6, diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/modals/auth_users_tab.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/modals/auth_users_tab.py index 2e3afad2..027176ec 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/modals/auth_users_tab.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/modals/auth_users_tab.py @@ -69,6 +69,7 @@ def __init__( DataTableColumn("Email", style="primary"), DataTableColumn("Name", width=140, style="secondary"), DataTableColumn("Status", width=45, style=None), + DataTableColumn("Verified", width=55, style=None), DataTableColumn("Created", width=80, style="secondary"), DataTableColumn("Actions", width=80, alignment="right", style=None), ] @@ -81,6 +82,12 @@ def __init__( color=Theme.Colors.SUCCESS if is_active else Theme.Colors.WARNING, ) + is_verified = user.get("is_verified", False) + verified_tag = Tag( + text="Yes" if is_verified else "No", + color=Theme.Colors.SUCCESS if is_verified else Theme.Colors.WARNING, + ) + # Action buttons action_buttons = ft.Row( [ @@ -107,6 +114,7 @@ def __init__( user.get("email", ""), user.get("full_name") or "-", status_tag, + verified_tag, _format_relative_time(user.get("created_at", "")), action_buttons, ] diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/main.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/main.py.jinja index 8d495e10..86964cd5 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/main.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/main.py.jinja @@ -888,7 +888,7 @@ def create_frontend_app() -> Callable[[ft.Page], Awaitable[None]]: # Consecutive disconnect checks before declaring page truly dead. # Flet sessions reconnect within a few seconds — this grace period # prevents background tasks from exiting during transient blips. - _DISCONNECT_GRACE_CHECKS = 30 # ~30s at 1s per check + _disconnect_grace_checks = 30 # ~30s at 1s per check _consecutive_disconnects = 0 def _is_alive() -> bool: @@ -902,7 +902,7 @@ def create_frontend_app() -> Callable[[ft.Page], Awaitable[None]]: _consecutive_disconnects = 0 return True _consecutive_disconnects += 1 - if _consecutive_disconnects >= _DISCONNECT_GRACE_CHECKS: + if _consecutive_disconnects >= _disconnect_grace_checks: logger.debug("Page permanently disconnected after grace period") return False return True # Still within grace period diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/user.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/user.py index 3f5d6ed2..7da73876 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/user.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/user.py @@ -12,6 +12,7 @@ class UserBase(SQLModel): email: EmailStr = Field(unique=True, index=True) full_name: str | None = None is_active: bool = Field(default=True) + is_verified: bool = Field(default=False) class User(UserBase, table=True): @@ -19,6 +20,7 @@ class User(UserBase, table=True): id: int | None = Field(default=None, primary_key=True) hashed_password: str + last_login: datetime | None = None created_at: datetime = Field( default_factory=lambda: datetime.now(UTC).replace(tzinfo=None) ) @@ -42,5 +44,6 @@ class UserResponse(UserBase): """User response model (excludes sensitive data).""" id: int + last_login: datetime | None = None created_at: datetime updated_at: datetime | None = None diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/health.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/health.py index 51f20da6..ce6e9a18 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/health.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/health.py @@ -73,18 +73,27 @@ async def check_auth_service_health() -> ComponentStatus: # Get user count for display user_count = 0 user_count_display = "0" + verified_user_count = 0 if database_available: try: + import sqlalchemy as sa from app.core.db import db_session from app.models.user import User from sqlalchemy import func from sqlmodel import select with db_session() as session: - # Single COUNT query instead of loading up to 101 User objects - user_count = session.exec( - select(func.count()).select_from(User) + # Single query for total and verified counts + row = session.execute( + select( + func.count().label("total"), + func.sum( + sa.case((User.is_verified == True, 1), else_=0) # noqa: E712 + ).label("verified"), + ).select_from(User) ).one() + user_count = row.total + verified_user_count = row.verified or 0 user_count_display = "100+" if user_count > 100 else str(user_count) except Exception: @@ -126,6 +135,7 @@ async def check_auth_service_health() -> ComponentStatus: else 0, "user_count": user_count, "user_count_display": user_count_display, + "verified_user_count": verified_user_count, "security_level": security_level, } diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/api/test_auth_endpoints.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/api/test_auth_endpoints.py.jinja index 26a5e19e..3bbbb3c3 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/api/test_auth_endpoints.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/api/test_auth_endpoints.py.jinja @@ -36,6 +36,7 @@ class TestAuthEndpoints: assert data["email"] == user_data["email"] assert data["full_name"] == user_data["full_name"] assert data["is_active"] is True + assert data["is_verified"] is False assert "id" in data assert "created_at" in data # Ensure password is not returned diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/services/test_auth_integration.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/services/test_auth_integration.py.jinja index 3159e00f..3780263b 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/services/test_auth_integration.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/services/test_auth_integration.py.jinja @@ -38,6 +38,8 @@ class TestAuthServiceIntegration: assert created_user.email == user_data.email assert created_user.full_name == user_data.full_name assert created_user.is_active is True + assert created_user.is_verified is False + assert created_user.last_login is None # Password should be hashed assert created_user.hashed_password != user_data.password assert verify_password(user_data.password, created_user.hashed_password) diff --git a/tests/core/test_migration_generator.py b/tests/core/test_migration_generator.py index 7a74bcfa..65963ab9 100644 --- a/tests/core/test_migration_generator.py +++ b/tests/core/test_migration_generator.py @@ -203,6 +203,8 @@ def test_generates_auth_migration(self, tmp_path: Path) -> None: assert "op.create_table" in content assert "'user'" in content assert "'email'" in content + assert "'is_verified'" in content + assert "'last_login'" in content def test_generates_ai_migration(self, tmp_path: Path) -> None: """Test generates AI migration file.""" @@ -280,6 +282,9 @@ def test_auth_spec_exists(self) -> None: assert AUTH_MIGRATION.service_name == "auth" assert len(AUTH_MIGRATION.tables) == 1 assert AUTH_MIGRATION.tables[0].name == "user" + column_names = [col.name for col in AUTH_MIGRATION.tables[0].columns] + assert "is_verified" in column_names + assert "last_login" in column_names def test_ai_spec_exists(self) -> None: """Test AI migration spec is defined."""