mirror of
https://github.com/rommapp/romm.git
synced 2026-02-18 00:27:41 +01:00
user avatar and enable/disable user support added
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -34,3 +34,5 @@ export function normalizeString(s) {
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
|
||||
export const defaultAvatarPath = "/assets/default_avatar.png";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user