mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 06:46:00 +00:00
[ROMM-618] Add last logged in and last active for users
This commit is contained in:
39
backend/alembic/versions/0016_user_last_login_active.py
Normal file
39
backend/alembic/versions/0016_user_last_login_active.py
Normal 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 ###
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()})
|
||||
|
||||
1
frontend/src/__generated__/index.ts
generated
1
frontend/src/__generated__/index.ts
generated
@@ -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';
|
||||
|
||||
11
frontend/src/__generated__/models/TinfoilFeedSchema.ts
generated
Normal file
11
frontend/src/__generated__/models/TinfoilFeedSchema.ts
generated
Normal 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;
|
||||
};
|
||||
|
||||
2
frontend/src/__generated__/models/UserSchema.ts
generated
2
frontend/src/__generated__/models/UserSchema.ts
generated
@@ -12,5 +12,7 @@ export type UserSchema = {
|
||||
role: Role;
|
||||
oauth_scopes: Array<string>;
|
||||
avatar_path: string;
|
||||
last_login: (string | null);
|
||||
last_active: (string | null);
|
||||
};
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user