mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
feat: add last_login column to user table and update user login tracking
This commit is contained in:
parent
2ac0e4f931
commit
a11c95e30f
8 changed files with 70 additions and 3 deletions
|
|
@ -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")
|
||||||
|
|
@ -1720,6 +1720,8 @@ if config.AUTH_TYPE == "GOOGLE":
|
||||||
display_name = Column(String, nullable=True)
|
display_name = Column(String, nullable=True)
|
||||||
avatar_url = 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 for this user
|
||||||
refresh_tokens = relationship(
|
refresh_tokens = relationship(
|
||||||
"RefreshToken",
|
"RefreshToken",
|
||||||
|
|
@ -1820,6 +1822,8 @@ else:
|
||||||
display_name = Column(String, nullable=True)
|
display_name = Column(String, nullable=True)
|
||||||
avatar_url = 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 for this user
|
||||||
refresh_tokens = relationship(
|
refresh_tokens = relationship(
|
||||||
"RefreshToken",
|
"RefreshToken",
|
||||||
|
|
|
||||||
|
|
@ -510,6 +510,7 @@ async def list_members(
|
||||||
"user_email": member_user.email if member_user else None,
|
"user_email": member_user.email if member_user else None,
|
||||||
"user_display_name": member_user.display_name 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_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)
|
response.append(membership_dict)
|
||||||
|
|
||||||
|
|
@ -602,6 +603,7 @@ async def update_member_role(
|
||||||
"created_at": db_membership.created_at,
|
"created_at": db_membership.created_at,
|
||||||
"role": db_membership.role,
|
"role": db_membership.role,
|
||||||
"user_email": member_user.email if member_user else None,
|
"user_email": member_user.email if member_user else None,
|
||||||
|
"user_last_login": member_user.last_login if member_user else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ class MembershipRead(BaseModel):
|
||||||
user_email: str | None = None
|
user_email: str | None = None
|
||||||
user_display_name: str | None = None
|
user_display_name: str | None = None
|
||||||
user_avatar_url: str | None = None
|
user_avatar_url: str | None = None
|
||||||
|
user_last_login: datetime | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Depends, Request, Response
|
from fastapi import Depends, Request, Response
|
||||||
|
|
@ -12,6 +13,7 @@ from fastapi_users.authentication import (
|
||||||
)
|
)
|
||||||
from fastapi_users.db import SQLAlchemyUserDatabase
|
from fastapi_users.db import SQLAlchemyUserDatabase
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import (
|
from app.db import (
|
||||||
|
|
@ -123,6 +125,23 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||||
|
|
||||||
return user
|
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):
|
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
|
Called after a user registers. Creates a default search space for the user
|
||||||
|
|
|
||||||
|
|
@ -547,7 +547,7 @@ function MemberRow({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
|
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
|
||||||
{formatRelativeDate(member.joined_at)}
|
{member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
|
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const membersAtom = atomWithQuery((get) => {
|
||||||
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
|
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!searchSpaceId,
|
||||||
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
|
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
|
||||||
|
refetchInterval: 2 * 60 * 1000, // 2 minutes
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!searchSpaceId) {
|
if (!searchSpaceId) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export const membership = z.object({
|
||||||
user_email: z.string().nullable().optional(),
|
user_email: z.string().nullable().optional(),
|
||||||
user_display_name: z.string().nullable().optional(),
|
user_display_name: z.string().nullable().optional(),
|
||||||
user_avatar_url: 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(),
|
user_is_active: z.boolean().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue