Skip to content

Commit

Permalink
Merge pull request #797 from rommapp/romm-618
Browse files Browse the repository at this point in the history
[ROMM-618] Track last login and active times for users
  • Loading branch information
zurdi15 authored Apr 7, 2024
2 parents 2d66d4a + c3e1f4f commit 4add86f
Show file tree
Hide file tree
Showing 11 changed files with 107 additions and 8 deletions.
39 changes: 39 additions & 0 deletions backend/alembic/versions/0016_user_last_login_active.py
Original file line number Diff line number Diff line change
@@ -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 ###
9 changes: 7 additions & 2 deletions backend/endpoints/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import timedelta
from datetime import timedelta, datetime
from typing import Annotated, Final

from endpoints.forms.identity import OAuth2RequestForm
Expand All @@ -7,7 +7,7 @@
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
Expand Down Expand Up @@ -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"}


Expand Down
3 changes: 3 additions & 0 deletions backend/endpoints/responses/identity.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from models.user import Role
from pydantic import BaseModel

Expand All @@ -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
5 changes: 5 additions & 0 deletions backend/handler/auth_handler/hybrid_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ async def authenticate(self, conn: HTTPConnection):
# 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
Expand All @@ -28,11 +29,14 @@ async def authenticate(self, conn: HTTPConnection):
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":
Expand All @@ -42,6 +46,7 @@ async def authenticate(self, conn: HTTPConnection):
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)
6 changes: 4 additions & 2 deletions backend/models/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions backend/models/user.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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",
Expand All @@ -48,8 +51,13 @@ def oauth_scopes(self):
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 change: 1 addition & 0 deletions frontend/src/__generated__/index.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions frontend/src/__generated__/models/TinfoilFeedSchema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/src/__generated__/models/UserSchema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions frontend/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/views/Settings/ControlPanel/Users/Base.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand All @@ -33,6 +33,12 @@ const HEADERS = [
sortable: true,
key: "role",
},
{
title: "Last active",
align: "start",
sortable: true,
key: "last_active",
},
{
title: "Enabled",
align: "start",
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 4add86f

Please sign in to comment.