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(),
});