Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions aegis/core/migration_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]
Expand All @@ -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(
[
Expand All @@ -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,
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ 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):
"""User database model."""

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)
)
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions tests/core/test_migration_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
Loading