user avatar and enable/disable user support added

This commit is contained in:
zurdi
2023-08-23 14:16:31 +02:00
parent f323c38ecb
commit f4f936f624
19 changed files with 221 additions and 87 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,10 +1,11 @@
<script setup>
import { ref, inject } from "vue";
import { updateUserApi } from "@/services/api";
import { defaultAvatarPath } from "@/utils/utils"
const user = ref();
const show = ref(false);
const avatarFile = ref();
const emitter = inject("emitter");
emitter.on("showEditUserDialog", (userToEdit) => {
@@ -13,22 +14,32 @@ emitter.on("showEditUserDialog", (userToEdit) => {
});
function editUser() {
updateUserApi(user.value).catch(({ response, message }) => {
emitter.emit("snackbarShow", {
msg: `Unable to edit user: ${
response?.data?.detail || response?.statusText || message
}`,
icon: "mdi-close-circle",
color: "red",
updateUserApi(user.value)
.then((response) => {
emitter.emit("snackbarShow", {
msg: `User ${response.data.username} updated successfully`,
icon: "mdi-check-bold",
color: "green",
timeout: 5000
});
})
.catch(({ response, message }) => {
emitter.emit("snackbarShow", {
msg: `Unable to edit user: ${
response?.data?.detail || response?.statusText || message
}`,
icon: "mdi-close-circle",
color: "red",
timeout: 5000
});
});
});
show.value = false;
emitter.emit("refreshView");
emitter.emit("refreshDrawer");
}
</script>
<template>
<v-dialog v-model="show" max-width="500px" :scrim="false">
<v-dialog v-model="show" max-width="700px" :scrim="false">
<v-card>
<v-toolbar density="compact" class="bg-terciary">
<v-row class="align-center" no-gutters>
@@ -50,43 +61,74 @@ function editUser() {
<v-divider class="border-opacity-25" :thickness="1" />
<v-card-text>
<v-row class="pa-2" no-gutters>
<v-col>
<v-text-field
rounded="0"
variant="outlined"
v-model="user.username"
label="username"
required
hide-details
clearable
></v-text-field>
<v-row no-gutters>
<v-col cols="12" lg="9">
<v-row class="pa-2" no-gutters>
<v-col>
<v-text-field
rounded="0"
variant="outlined"
v-model="user.username"
label="username"
required
hide-details
clearable
></v-text-field>
</v-col>
</v-row>
<v-row class="pa-2" no-gutters>
<v-col>
<v-text-field
rounded="0"
variant="outlined"
v-model="user.password"
label="Password"
required
hide-details
clearable
></v-text-field>
</v-col>
</v-row>
<v-row class="pa-2" no-gutters>
<v-col>
<v-select
v-model="user.role"
rounded="0"
variant="outlined"
:items="['viewer', 'editor', 'admin']"
label="Role"
required
hide-details
></v-select>
</v-col>
</v-row>
</v-col>
</v-row>
<v-row class="pa-2" no-gutters>
<v-col>
<v-text-field
rounded="0"
variant="outlined"
v-model="user.password"
label="Password"
required
hide-details
clearable
></v-text-field>
</v-col>
</v-row>
<v-row class="pa-2" no-gutters>
<v-col>
<v-select
v-model="user.role"
rounded="0"
variant="outlined"
:items="['viewer', 'editor', 'admin']"
label="Role"
required
hide-details
></v-select>
<v-col cols="12" lg="3">
<v-row class="pa-2 justify-center" no-gutters>
<v-avatar size="128" class="">
<v-img
:src="
user.avatar_path
? `/assets/romm/resources/${user.avatar_path}`
: defaultAvatarPath
"
/>
</v-avatar>
</v-row>
<v-row class="pa-2" no-gutters>
<v-col>
<v-file-input
class="text-truncate"
@keyup.enter="updateRom()"
v-model="user.avatar"
label="Avatar"
prepend-inner-icon="mdi-image"
prepend-icon=""
variant="outlined"
hide-details
/>
</v-col>
</v-row>
</v-col>
</v-row>
<v-row class="justify-center pa-2" no-gutters>

View File

@@ -2,14 +2,16 @@
import { inject } from "vue";
import { useRouter } from "vue-router";
import axios from "axios";
import storeAuth from "@/stores/auth";
import { defaultAvatarPath } from "@/utils/utils"
// Props
const props = defineProps(["rail"]);
const router = useRouter();
const emitter = inject("emitter");
const auth = storeAuth();
// Functions
async function logout() {
axios
.post("/api/logout", {})
@@ -23,7 +25,8 @@ async function logout() {
})
.catch(() => {
router.push("/login");
}).finally(() => {
})
.finally(() => {
auth.setUser(null);
});
}
@@ -38,8 +41,14 @@ async function logout() {
{{ rail ? "" : auth.user?.role }}
</div>
<template v-slot:prepend>
<v-avatar :class="{ 'ml-4': rail, 'my-2': rail }" >
<v-img src="/assets/default_user.png" />
<v-avatar :class="{ 'ml-4': rail, 'my-2': rail }">
<v-img
:src="
auth.user?.avatar_path
? `/assets/romm/resources/${auth.user?.avatar_path}`
: defaultAvatarPath
"
/>
</v-avatar>
</template>
<template v-slot:append>

View File

@@ -16,7 +16,7 @@ emitter.on("snackbarShow", (snackbar) => {
<template>
<v-snackbar
v-model="snackbarShow"
:timeout="2000"
:timeout="snackbarStatus.timeout ? snackbarStatus.timeout : 2000"
location="top"
color="tooltip"
>

View File

@@ -115,8 +115,26 @@ export async function createUserApi({ username, password, role }) {
return api.post("/users", {}, { params: { username, password, role } });
}
export async function updateUserApi({ id, username, password, role }) {
return api.put(`/users/${id}`, {}, { params: { username, password, role } });
export async function updateUserApi({
id,
username,
password,
role,
enabled,
avatar,
}) {
return api.put(
`/users/${id}`,
{
avatar: avatar ? avatar[0] : null,
},
{
headers: {
"Content-Type": avatar ? "multipart/form-data" : "text/text",
},
params: { username, password, role, enabled },
}
);
}
export async function deleteUserApi(user) {

View File

@@ -34,3 +34,5 @@ export function normalizeString(s) {
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
export const defaultAvatarPath = "/assets/default_avatar.png";

View File

@@ -14,23 +14,26 @@ const { mdAndDown } = useDisplay();
const platforms = storePlatforms();
const scanning = storeScanning();
const auth = storeAuth();
const refreshDrawer = ref(false);
const refreshView = ref(false);
const refreshDrawer = ref(false);
// Event listeners bus
const emitter = inject("emitter");
emitter.on("refreshView", () => {
refreshView.value = !refreshView.value;
});
emitter.on("refreshDrawer", () => {
refreshDrawer.value = !refreshDrawer.value;
});
// Functions
onMounted(async () => {
try {
const { data: platformData } = await fetchPlatformsApi();
platforms.set(platformData);
const { data: userData } = await fetchCurrentUserApi();
if (userData) auth.setUser(userData);
emitter.emit("refreshDrawer");
} catch (error) {
console.error(error);
}

View File

@@ -46,6 +46,7 @@ socket.on("scan:done", () => {
scanning.set(false);
emitter.emit("refreshDrawer");
emitter.emit("refreshView");
emitter.emit("snackbarShow", {
msg: "Scan completed successfully!",
icon: "mdi-check-bold",

View File

@@ -1,11 +1,14 @@
<script setup>
import { ref, inject, onMounted } from "vue";
import { VDataTable } from "vuetify/labs/VDataTable";
import { fetchUsersApi } from "@/services/api";
import { fetchUsersApi, updateUserApi } from "@/services/api";
import storeAuth from "@/stores/auth";
import CreateUserDialog from "@/components/Dialog/User/CreateUser.vue";
import EditUserDialog from "@/components/Dialog/User/EditUser.vue";
import DeleteUserDialog from "@/components/Dialog/User/DeleteUser.vue";
const auth = storeAuth();
const HEADERS = [
{
title: "Username",
@@ -19,6 +22,12 @@ const HEADERS = [
sortable: true,
key: "role",
},
{
title: "Enabled",
align: "start",
sortable: true,
key: "enabled",
},
{ align: "end", key: "actions", sortable: false },
];
@@ -35,12 +44,27 @@ const users = ref([]);
const usersPerPage = ref(5);
const userSearch = ref("");
onMounted(() => {
fetchUsersApi().then(({ data }) => {
users.value = data;
}).catch((error) => {
console.log(error);
function disableUser(user) {
updateUserApi(user).catch(({ response, message }) => {
emitter.emit("snackbarShow", {
msg: `Unable to disable/enable user: ${
response?.data?.detail || response?.statusText || message
}`,
icon: "mdi-close-circle",
color: "red",
timeout: 5000,
});
});
}
onMounted(() => {
fetchUsersApi()
.then(({ data }) => {
users.value = data;
})
.catch((error) => {
console.log(error);
});
});
</script>
<template>
@@ -88,6 +112,14 @@ onMounted(() => {
:items="users"
:sort-by="[{ key: 'username', order: 'asc' }]"
>
<template v-slot:item.enabled="{ item }">
<v-switch
:disabled="item.selectable.id == auth.user?.id"
v-model="item.selectable.enabled"
:update:modelValue="disableUser(item.selectable)"
hide-details
/>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
class="mr-2 bg-terciary"
@@ -98,8 +130,8 @@ onMounted(() => {
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn
size="small"
rounded="0"
size="small"
rounded="0"
class="bg-terciary text-rommRed"
@click="emitter.emit('showDeleteUserDialog', item.raw)"
><v-icon>mdi-delete</v-icon></v-btn