From a11c95e30fccfea2efda32dc92530031af980c2a Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:24:29 +0530 Subject: [PATCH] feat: add last_login column to user table and update user login tracking --- .../versions/103_add_last_login_to_user.py | 39 +++++++++++++++++++ surfsense_backend/app/db.py | 4 ++ surfsense_backend/app/routes/rbac_routes.py | 2 + surfsense_backend/app/schemas/rbac_schemas.py | 1 + surfsense_backend/app/users.py | 19 +++++++++ .../dashboard/[search_space_id]/team/page.tsx | 6 +-- .../atoms/members/members-query.atoms.ts | 1 + .../contracts/types/members.types.ts | 1 + 8 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 surfsense_backend/alembic/versions/103_add_last_login_to_user.py diff --git a/surfsense_backend/alembic/versions/103_add_last_login_to_user.py b/surfsense_backend/alembic/versions/103_add_last_login_to_user.py new file mode 100644 index 000000000..20a061082 --- /dev/null +++ b/surfsense_backend/alembic/versions/103_add_last_login_to_user.py @@ -0,0 +1,39 @@ +"""103_add_last_login_to_user + +Revision ID: 103 +Revises: 102 +Create Date: 2026-03-08 + +Adds last_login timestamp column to the user table so we can track +when each user last authenticated. The column is nullable — existing +rows will have NULL until the user's next login. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "103" +down_revision: str | None = "102" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + existing_columns = [col["name"] for col in sa.inspect(conn).get_columns("user")] + + if "last_login" not in existing_columns: + op.add_column( + "user", + sa.Column("last_login", sa.TIMESTAMP(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("user", "last_login") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 510f64cc3..9f0af4fc5 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1720,6 +1720,8 @@ if config.AUTH_TYPE == "GOOGLE": display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + last_login = Column(TIMESTAMP(timezone=True), nullable=True) + # Refresh tokens for this user refresh_tokens = relationship( "RefreshToken", @@ -1820,6 +1822,8 @@ else: display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + last_login = Column(TIMESTAMP(timezone=True), nullable=True) + # Refresh tokens for this user refresh_tokens = relationship( "RefreshToken", diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index 7d2cc5c77..38ae31269 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -510,6 +510,7 @@ async def list_members( "user_email": member_user.email if member_user else None, "user_display_name": member_user.display_name if member_user else None, "user_avatar_url": member_user.avatar_url if member_user else None, + "user_last_login": member_user.last_login if member_user else None, } response.append(membership_dict) @@ -602,6 +603,7 @@ async def update_member_role( "created_at": db_membership.created_at, "role": db_membership.role, "user_email": member_user.email if member_user else None, + "user_last_login": member_user.last_login if member_user else None, } except HTTPException: diff --git a/surfsense_backend/app/schemas/rbac_schemas.py b/surfsense_backend/app/schemas/rbac_schemas.py index 031eef3d2..8de8426c3 100644 --- a/surfsense_backend/app/schemas/rbac_schemas.py +++ b/surfsense_backend/app/schemas/rbac_schemas.py @@ -77,6 +77,7 @@ class MembershipRead(BaseModel): user_email: str | None = None user_display_name: str | None = None user_avatar_url: str | None = None + user_last_login: datetime | None = None class Config: from_attributes = True diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index 7ec657781..d24a6faf1 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -1,5 +1,6 @@ import logging import uuid +from datetime import UTC, datetime import httpx from fastapi import Depends, Request, Response @@ -12,6 +13,7 @@ from fastapi_users.authentication import ( ) from fastapi_users.db import SQLAlchemyUserDatabase from pydantic import BaseModel +from sqlalchemy import update from app.config import config from app.db import ( @@ -123,6 +125,23 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): return user + async def on_after_login( + self, + user: User, + request: Request | None = None, + response: Response | None = None, + ) -> None: + try: + async with async_session_maker() as session: + await session.execute( + update(User) + .where(User.id == user.id) + .values(last_login=datetime.now(UTC)) + ) + await session.commit() + except Exception as e: + logger.warning(f"Failed to update last_login for user {user.id}: {e}") + async def on_after_register(self, user: User, request: Request | None = None): """ Called after a user registers. Creates a default search space for the user diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index d21e0387e..1d91d32f0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -546,9 +546,9 @@ function MemberRow({ - - {formatRelativeDate(member.joined_at)} - + + {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"} + {showActions ? ( diff --git a/surfsense_web/atoms/members/members-query.atoms.ts b/surfsense_web/atoms/members/members-query.atoms.ts index f486dc02b..c08a7a337 100644 --- a/surfsense_web/atoms/members/members-query.atoms.ts +++ b/surfsense_web/atoms/members/members-query.atoms.ts @@ -10,6 +10,7 @@ export const membersAtom = atomWithQuery((get) => { queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""), enabled: !!searchSpaceId, staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration + refetchInterval: 2 * 60 * 1000, // 2 minutes queryFn: async () => { if (!searchSpaceId) { return []; diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts index 9e0665c65..e458807af 100644 --- a/surfsense_web/contracts/types/members.types.ts +++ b/surfsense_web/contracts/types/members.types.ts @@ -13,6 +13,7 @@ export const membership = z.object({ user_email: z.string().nullable().optional(), user_display_name: z.string().nullable().optional(), user_avatar_url: z.string().nullable().optional(), + user_last_login: z.string().nullable().optional(), user_is_active: z.boolean().nullable().optional(), });