diff --git a/backend/alembic/versions/0016_user_last_login_active.py b/backend/alembic/versions/0016_user_last_login_active.py new file mode 100644 index 000000000..24ba3d9d3 --- /dev/null +++ b/backend/alembic/versions/0016_user_last_login_active.py @@ -0,0 +1,39 @@ +"""empty message + +Revision ID: 0016_user_last_login_active +Revises: 0015_mobygames_data +Create Date: 2024-04-06 15:16:50.539968 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0016_user_last_login_active" +down_revision = "0015_mobygames_data" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column( + sa.Column("last_login", sa.DateTime(timezone=True), nullable=True) + ) + batch_op.add_column( + sa.Column("last_active", sa.DateTime(timezone=True), nullable=True) + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_column("last_active") + batch_op.drop_column("last_login") + + # ### end Alembic commands ### diff --git a/backend/endpoints/auth.py b/backend/endpoints/auth.py index ed6448569..ac4bd8768 100644 --- a/backend/endpoints/auth.py +++ b/backend/endpoints/auth.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import timedelta, datetime from typing import Annotated, Final from endpoints.forms.identity import OAuth2RequestForm @@ -7,7 +7,7 @@ from endpoints.responses.oauth import TokenResponse from exceptions.auth_exceptions import AuthCredentialsException, DisabledException from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security.http import HTTPBasic -from handler import auth_handler, oauth_handler +from handler import auth_handler, oauth_handler, db_user_handler ACCESS_TOKEN_EXPIRE_MINUTES: Final = 30 REFRESH_TOKEN_EXPIRE_DAYS: Final = 7 @@ -156,6 +156,11 @@ def login(request: Request, credentials=Depends(HTTPBasic())) -> MessageResponse request.session.update({"iss": "romm:auth", "sub": user.username}) + # Update last login and active times + db_user_handler.update_user( + user.id, {"last_login": datetime.now(), "last_active": datetime.now()} + ) + return {"msg": "Successfully logged in"} diff --git a/backend/endpoints/responses/identity.py b/backend/endpoints/responses/identity.py index 09b4d48b7..7efe0ef4d 100644 --- a/backend/endpoints/responses/identity.py +++ b/backend/endpoints/responses/identity.py @@ -1,3 +1,4 @@ +from datetime import datetime from models.user import Role from pydantic import BaseModel @@ -9,6 +10,8 @@ class UserSchema(BaseModel): role: Role oauth_scopes: list[str] avatar_path: str + last_login: datetime | None + last_active: datetime | None class Config: from_attributes = True diff --git a/backend/handler/auth_handler/hybrid_auth.py b/backend/handler/auth_handler/hybrid_auth.py index 4783a74c6..4c54a2ef9 100644 --- a/backend/handler/auth_handler/hybrid_auth.py +++ b/backend/handler/auth_handler/hybrid_auth.py @@ -10,6 +10,7 @@ class HybridAuthBackend(AuthenticationBackend): # Check if session key already stored in cache user = await auth_handler.get_current_active_user_from_session(conn) if user: + user.set_last_active() return (AuthCredentials(user.oauth_scopes), user) # Check if Authorization header exists @@ -28,11 +29,14 @@ class HybridAuthBackend(AuthenticationBackend): if user is None: return (AuthCredentials([]), None) + user.set_last_active() return (AuthCredentials(user.oauth_scopes), user) # Check if bearer auth header is valid if scheme.lower() == "bearer": user, payload = await oauth_handler.get_current_active_user_from_bearer_token(token) + if user is None: + return (AuthCredentials([]), None) # Only access tokens can request resources if payload.get("type") != "access": @@ -42,6 +46,7 @@ class HybridAuthBackend(AuthenticationBackend): token_scopes = set(list(payload.get("scopes").split(" "))) overlapping_scopes = list(token_scopes & set(user.oauth_scopes)) + user.set_last_active() return (AuthCredentials(overlapping_scopes), user) return (AuthCredentials([]), None) diff --git a/backend/models/assets.py b/backend/models/assets.py index 677c9fd84..24e47215d 100644 --- a/backend/models/assets.py +++ b/backend/models/assets.py @@ -9,9 +9,11 @@ class BaseAsset(BaseModel): __abstract__ = True id = Column(Integer(), primary_key=True, autoincrement=True) - created_at = Column(DateTime, server_default=func.now(), nullable=False) + created_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) updated_at = Column( - DateTime, + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False, diff --git a/backend/models/user.py b/backend/models/user.py index f05d5a2cd..74a4354d3 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -1,8 +1,9 @@ import enum +import datetime from models.base import BaseModel from models.assets import Save, Screenshot, State -from sqlalchemy import Boolean, Column, Enum, Integer, String +from sqlalchemy import Boolean, Column, Enum, Integer, String, DateTime from starlette.authentication import SimpleUser from sqlalchemy.orm import Mapped, relationship @@ -24,6 +25,8 @@ class User(BaseModel, SimpleUser): enabled: bool = Column(Boolean(), default=True) role: Role = Column(Enum(Role), default=Role.VIEWER) avatar_path: str = Column(String(length=255), default="") + last_login: datetime = Column(DateTime(timezone=True), nullable=True) + last_active: datetime = Column(DateTime(timezone=True), nullable=True) saves: Mapped[list[Save]] = relationship( "Save", @@ -48,8 +51,13 @@ class User(BaseModel, SimpleUser): return WRITE_SCOPES return DEFAULT_SCOPES - + @property def fs_safe_folder_name(self): # Uses the ID to avoid issues with username changes - return f'User:{self.id}'.encode("utf-8").hex() + return f"User:{self.id}".encode("utf-8").hex() + + def set_last_active(self): + from handler import db_user_handler + + db_user_handler.update_user(self.id, {"last_active": datetime.datetime.now()}) diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index b03863248..dae98fd15 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -32,6 +32,7 @@ export type { SearchRomSchema } from './models/SearchRomSchema'; export type { StateSchema } from './models/StateSchema'; export type { StatsReturn } from './models/StatsReturn'; export type { TaskDict } from './models/TaskDict'; +export type { TinfoilFeedSchema } from './models/TinfoilFeedSchema'; export type { TokenResponse } from './models/TokenResponse'; export type { UploadedSavesResponse } from './models/UploadedSavesResponse'; export type { UploadedScreenshotsResponse } from './models/UploadedScreenshotsResponse'; diff --git a/frontend/src/__generated__/models/TinfoilFeedSchema.ts b/frontend/src/__generated__/models/TinfoilFeedSchema.ts new file mode 100644 index 000000000..905a6cc0f --- /dev/null +++ b/frontend/src/__generated__/models/TinfoilFeedSchema.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type TinfoilFeedSchema = { + files: Array>; + directories: Array; + success: string; +}; + diff --git a/frontend/src/__generated__/models/UserSchema.ts b/frontend/src/__generated__/models/UserSchema.ts index 27c6c7eaf..8542f19f5 100644 --- a/frontend/src/__generated__/models/UserSchema.ts +++ b/frontend/src/__generated__/models/UserSchema.ts @@ -12,5 +12,7 @@ export type UserSchema = { role: Role; oauth_scopes: Array; avatar_path: string; + last_login: (string | null); + last_active: (string | null); }; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 1bd5e20db..994efee85 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -87,6 +87,20 @@ export function formatBytes(bytes: number, decimals = 2) { return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; } +/** + * + * Format timestamp to human-readable text + * + * @param string timestamp + * @returns string Formatted timestamp + */ +export function formatTimestamp(timestamp: string | null) { + if (!timestamp) return "-"; + + const date = new Date(timestamp); + return date.toLocaleString(); +} + export function regionToEmoji(region: string) { switch (region.toLowerCase()) { case "as": diff --git a/frontend/src/views/Settings/ControlPanel/Users/Base.vue b/frontend/src/views/Settings/ControlPanel/Users/Base.vue index c3e8fa43c..31e57e05a 100644 --- a/frontend/src/views/Settings/ControlPanel/Users/Base.vue +++ b/frontend/src/views/Settings/ControlPanel/Users/Base.vue @@ -11,7 +11,7 @@ import userApi from "@/services/api/user"; import storeAuth from "@/stores/auth"; import storeUsers from "@/stores/users"; import type { UserItem } from "@/types/emitter"; -import { defaultAvatarPath } from "@/utils"; +import { defaultAvatarPath, formatTimestamp } from "@/utils"; const HEADERS = [ { @@ -33,6 +33,12 @@ const HEADERS = [ sortable: true, key: "role", }, + { + title: "Last active", + align: "start", + sortable: true, + key: "last_active", + }, { title: "Enabled", align: "start", @@ -129,6 +135,9 @@ onMounted(() => { /> +