[ROMM-618] Add last logged in and last active for users

This commit is contained in:
Georges-Antoine Assi
2024-04-06 15:29:40 -04:00
parent 2d66d4a454
commit c3e1f4f44c
11 changed files with 107 additions and 8 deletions

View File

@@ -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 ###

View File

@@ -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"}

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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()})

View File

@@ -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';

View File

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type TinfoilFeedSchema = {
files: Array<Record<string, any>>;
directories: Array<string>;
success: string;
};

View File

@@ -12,5 +12,7 @@ export type UserSchema = {
role: Role;
oauth_scopes: Array<string>;
avatar_path: string;
last_login: (string | null);
last_active: (string | null);
};

View File

@@ -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":

View File

@@ -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(() => {
/>
</v-avatar>
</template>
<template v-slot:item.last_active="{ item }">
{{ formatTimestamp(item.raw.last_active) }}
</template>
<template v-slot:item.enabled="{ item }">
<v-switch
color="romm-accent-1"