From f4f936f624afd8e3b9fcd551dfaaf02f821e6b2a Mon Sep 17 00:00:00 2001 From: zurdi Date: Wed, 23 Aug 2023 14:16:31 +0200 Subject: [PATCH] user avatar and enable/disable user support added --- backend/alembic/versions/1.9.2.py | 3 +- backend/config/__init__.py | 1 + backend/endpoints/identity.py | 44 +++--- backend/exceptions/credentials_exceptions.py | 5 + backend/handler/tests/test_db_handler.py | 2 +- backend/models/user.py | 3 +- backend/utils/auth.py | 2 +- backend/utils/fs.py | 9 ++ backend/utils/tests/test_auth.py | 2 +- backend/utils/tests/test_oauth.py | 2 +- .../{default_user.png => default_avatar.png} | Bin .../src/components/Dialog/User/EditUser.vue | 136 ++++++++++++------ frontend/src/components/Drawer/Footer.vue | 17 ++- frontend/src/components/Notification.vue | 2 +- frontend/src/services/api.js | 22 ++- frontend/src/utils/utils.js | 2 + frontend/src/views/Home.vue | 7 +- frontend/src/views/Library/Scan.vue | 1 + frontend/src/views/Settings/Users/Users.vue | 48 +++++-- 19 files changed, 221 insertions(+), 87 deletions(-) rename frontend/assets/{default_user.png => default_avatar.png} (100%) diff --git a/backend/alembic/versions/1.9.2.py b/backend/alembic/versions/1.9.2.py index 78f1611f1..08e8fa6fc 100644 --- a/backend/alembic/versions/1.9.2.py +++ b/backend/alembic/versions/1.9.2.py @@ -23,10 +23,11 @@ def upgrade() -> None: sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("username", sa.String(length=255), nullable=True), sa.Column("hashed_password", sa.String(length=255), nullable=True), - sa.Column("disabled", sa.Boolean(), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=True), sa.Column( "role", sa.Enum("VIEWER", "EDITOR", "ADMIN", name="role"), nullable=True ), + sa.Column("avatar_path", sa.String(length=255), nullable=True), sa.PrimaryKeyConstraint("id"), ) with op.batch_alter_table("users", schema=None) as batch_op: diff --git a/backend/config/__init__.py b/backend/config/__init__.py index e4df5c406..ae17f7b81 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -24,6 +24,7 @@ DEFAULT_URL_COVER_L: Final = "https://images.igdb.com/igdb/image/upload/t_cover_ DEFAULT_PATH_COVER_L: Final = "default/default/cover/big.png" DEFAULT_URL_COVER_S: Final = "https://images.igdb.com/igdb/image/upload/t_cover_small/nocover.png" DEFAULT_PATH_COVER_S: Final = "default/default/cover/small.png" +DEFAULT_PATH_USER_AVATAR: Final = f"users" # MARIADB DB_HOST: Final = os.environ.get("DB_HOST", "127.0.0.1") diff --git a/backend/endpoints/identity.py b/backend/endpoints/identity.py index 101a3897a..fe3ec711d 100644 --- a/backend/endpoints/identity.py +++ b/backend/endpoints/identity.py @@ -1,6 +1,6 @@ import secrets from typing import Optional, Annotated -from fastapi import APIRouter, HTTPException, status, Request, Depends +from fastapi import APIRouter, HTTPException, status, Request, Depends, File, UploadFile from fastapi.security.http import HTTPBasic from pydantic import BaseModel, BaseConfig @@ -9,8 +9,9 @@ from models.user import User, Role from utils.cache import cache from utils.auth import authenticate_user, get_password_hash, clear_session from utils.oauth import protected_route -from config import ROMM_AUTH_ENABLED -from exceptions.credentials_exceptions import credentials_exception +from utils.fs import build_avatar_path +from config import ROMM_AUTH_ENABLED, RESOURCES_BASE_PATH, DEFAULT_PATH_USER_AVATAR +from exceptions.credentials_exceptions import credentials_exception, disabled_exception router = APIRouter() @@ -18,9 +19,10 @@ router = APIRouter() class UserSchema(BaseModel): id: int username: str - disabled: bool + enabled: bool role: Role oauth_scopes: list[str] + avatar_path: str class Config(BaseConfig): orm_mode = True @@ -31,6 +33,9 @@ def login(request: Request, credentials=Depends(HTTPBasic())): user = authenticate_user(credentials.username, credentials.password) if not user: raise credentials_exception + + if not user.enabled: + raise disabled_exception # Generate unique session key and store in cache request.session["session_id"] = secrets.token_hex(16) @@ -103,12 +108,14 @@ class UserUpdateForm: username: Optional[str] = None, password: Optional[str] = None, role: Optional[str] = None, - disabled: Optional[bool] = None, + enabled: Optional[bool] = None, + avatar: Optional[UploadFile] = File(None) ): self.username = username self.password = password self.role = role - self.disabled = disabled + self.enabled = enabled + self.avatar = avatar @protected_route(router.put, "/users/{user_id}", ["users.write"]) @@ -143,20 +150,23 @@ def update_user( if form_data.role and request.user.id != user_id: cleaned_data["role"] = Role[form_data.role.upper()] - if form_data.disabled is not None: - cleaned_data["disabled"] = form_data.disabled + # You can't disable yourself + if form_data.enabled is not None and request.user.id != user_id: + cleaned_data["enabled"] = form_data.enabled - if not cleaned_data: - raise HTTPException( - status_code=400, detail="No valid fields to update were provided" - ) + if form_data.avatar is not None: + cleaned_data["avatar_path"], avatar_user_path = build_avatar_path(form_data.avatar.filename, form_data.username) + file_location = f"{avatar_user_path}/{form_data.avatar.filename}" + with open(file_location, "wb+") as file_object: + file_object.write(form_data.avatar.file.read()) - dbh.update_user(user_id, cleaned_data) + if cleaned_data: + dbh.update_user(user_id, cleaned_data) - # Log out the current user if username or password changed - creds_updated = cleaned_data.get("username") or cleaned_data.get("hashed_password") - if request.user.id == user_id and creds_updated: - clear_session(request) + # Log out the current user if username or password changed + creds_updated = cleaned_data.get("username") or cleaned_data.get("hashed_password") + if request.user.id == user_id and creds_updated: + clear_session(request) return dbh.get_user(user_id) diff --git a/backend/exceptions/credentials_exceptions.py b/backend/exceptions/credentials_exceptions.py index 7cc738e1c..08e3d1bf3 100644 --- a/backend/exceptions/credentials_exceptions.py +++ b/backend/exceptions/credentials_exceptions.py @@ -10,3 +10,8 @@ authentication_scheme_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication scheme", ) + +disabled_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Disabled user", +) diff --git a/backend/handler/tests/test_db_handler.py b/backend/handler/tests/test_db_handler.py index 870aa1e2f..9f210cc9c 100644 --- a/backend/handler/tests/test_db_handler.py +++ b/backend/handler/tests/test_db_handler.py @@ -81,7 +81,7 @@ def test_users(admin_user): new_user = dbh.get_user_by_username("new_user") assert new_user.username == "new_user" assert new_user.role == Role.VIEWER - assert not new_user.disabled + assert new_user.enabled dbh.update_user(new_user.id, {"role": Role.EDITOR}) diff --git a/backend/models/user.py b/backend/models/user.py index d97f731bb..3d3cdcd55 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -17,8 +17,9 @@ class User(BaseModel, SimpleUser): id = Column(Integer(), primary_key=True, autoincrement=True) username: str = Column(String(length=255), unique=True, index=True) hashed_password: str = Column(String(length=255)) - disabled: bool = Column(Boolean(), default=False) + enabled: bool = Column(Boolean(), default=True) role: Role = Column(Enum(Role), default=Role.VIEWER) + avatar_path: str = Column(String(length=255), default="") @property def oauth_scopes(self): diff --git a/backend/utils/auth.py b/backend/utils/auth.py index c62984c5a..5955c54cb 100644 --- a/backend/utils/auth.py +++ b/backend/utils/auth.py @@ -73,7 +73,7 @@ async def get_current_active_user_from_session(conn: HTTPConnection): detail="User not found", ) - if user.disabled: + if not user.enabled: clear_session(conn) raise HTTPException( diff --git a/backend/utils/fs.py b/backend/utils/fs.py index b711c7dfd..c301aa85a 100644 --- a/backend/utils/fs.py +++ b/backend/utils/fs.py @@ -12,6 +12,8 @@ from config import ( DEFAULT_PATH_COVER_L, DEFAULT_URL_COVER_S, DEFAULT_PATH_COVER_S, + RESOURCES_BASE_PATH, + DEFAULT_PATH_USER_AVATAR ) from config.config_loader import config from exceptions.fs_exceptions import ( @@ -300,3 +302,10 @@ def remove_rom(p_slug: str, file_name: str): shutil.rmtree(f"{LIBRARY_BASE_PATH}/{rom_path}/{file_name}") except FileNotFoundError as exc: raise RomNotFoundError(file_name, p_slug) from exc + + +# ========= Users utils ========= +def build_avatar_path(avatar_path, username): + avatar_user_path = f"{RESOURCES_BASE_PATH}/{DEFAULT_PATH_USER_AVATAR}/{username}" + Path(avatar_user_path).mkdir(parents=True, exist_ok=True) + return f"{DEFAULT_PATH_USER_AVATAR}/{username}/{avatar_path}", avatar_user_path diff --git a/backend/utils/tests/test_auth.py b/backend/utils/tests/test_auth.py index 4d5ed036b..6bd940cea 100644 --- a/backend/utils/tests/test_auth.py +++ b/backend/utils/tests/test_auth.py @@ -85,7 +85,7 @@ async def test_get_current_active_user_from_session_disabled_user(editor_user): conn = MockConnection() - dbh.update_user(editor_user.id, {"disabled": True}) + dbh.update_user(editor_user.id, {"enabled": False}) try: await get_current_active_user_from_session(conn) diff --git a/backend/utils/tests/test_oauth.py b/backend/utils/tests/test_oauth.py index 3c108f124..92b25c268 100644 --- a/backend/utils/tests/test_oauth.py +++ b/backend/utils/tests/test_oauth.py @@ -53,7 +53,7 @@ async def test_get_current_active_user_from_token_disabled_user(admin_user): }, ) - dbh.update_user(admin_user.id, {"disabled": True}) + dbh.update_user(admin_user.id, {"enabled": False}) try: await get_current_active_user_from_token(token) diff --git a/frontend/assets/default_user.png b/frontend/assets/default_avatar.png similarity index 100% rename from frontend/assets/default_user.png rename to frontend/assets/default_avatar.png diff --git a/frontend/src/components/Dialog/User/EditUser.vue b/frontend/src/components/Dialog/User/EditUser.vue index fa778e365..619cbb058 100644 --- a/frontend/src/components/Dialog/User/EditUser.vue +++ b/frontend/src/components/Dialog/User/EditUser.vue @@ -1,10 +1,11 @@