Browse Source

feat: edit user support

Timothy J. Baek 1 year ago
parent
commit
fb0c64379d

+ 17 - 16
backend/apps/web/models/auths.py

@@ -75,26 +75,20 @@ class SignupForm(BaseModel):
 
 
 class AuthsTable:
-
     def __init__(self, db):
         self.db = db
         self.db.create_tables([Auth])
 
-    def insert_new_auth(self,
-                        email: str,
-                        password: str,
-                        name: str,
-                        role: str = "pending") -> Optional[UserModel]:
+    def insert_new_auth(
+        self, email: str, password: str, name: str, role: str = "pending"
+    ) -> Optional[UserModel]:
         print("insert_new_auth")
 
         id = str(uuid.uuid4())
 
-        auth = AuthModel(**{
-            "id": id,
-            "email": email,
-            "password": password,
-            "active": True
-        })
+        auth = AuthModel(
+            **{"id": id, "email": email, "password": password, "active": True}
+        )
         result = Auth.create(**auth.model_dump())
 
         user = Users.insert_new_user(id, name, email, role)
@@ -104,8 +98,7 @@ class AuthsTable:
         else:
             return None
 
-    def authenticate_user(self, email: str,
-                          password: str) -> Optional[UserModel]:
+    def authenticate_user(self, email: str, password: str) -> Optional[UserModel]:
         print("authenticate_user", email)
         try:
             auth = Auth.get(Auth.email == email, Auth.active == True)
@@ -129,6 +122,15 @@ class AuthsTable:
         except:
             return False
 
+    def update_email_by_id(self, id: str, email: str) -> bool:
+        try:
+            query = Auth.update(email=email).where(Auth.id == id)
+            result = query.execute()
+
+            return True if result == 1 else False
+        except:
+            return False
+
     def delete_auth_by_id(self, id: str) -> bool:
         try:
             # Delete User
@@ -137,8 +139,7 @@ class AuthsTable:
             if result:
                 # Delete Auth
                 query = Auth.delete().where(Auth.id == id)
-                query.execute(
-                )  # Remove the rows, return number of rows removed.
+                query.execute()  # Remove the rows, return number of rows removed.
 
                 return True
             else:

+ 24 - 11
backend/apps/web/models/users.py

@@ -44,17 +44,21 @@ class UserRoleUpdateForm(BaseModel):
     role: str
 
 
-class UsersTable:
+class UserUpdateForm(BaseModel):
+    name: str
+    email: str
+    profile_image_url: str
+    password: Optional[str] = None
+
 
+class UsersTable:
     def __init__(self, db):
         self.db = db
         self.db.create_tables([User])
 
-    def insert_new_user(self,
-                        id: str,
-                        name: str,
-                        email: str,
-                        role: str = "pending") -> Optional[UserModel]:
+    def insert_new_user(
+        self, id: str, name: str, email: str, role: str = "pending"
+    ) -> Optional[UserModel]:
         user = UserModel(
             **{
                 "id": id,
@@ -63,7 +67,8 @@ class UsersTable:
                 "role": role,
                 "profile_image_url": get_gravatar_url(email),
                 "timestamp": int(time.time()),
-            })
+            }
+        )
         result = User.create(**user.model_dump())
         if result:
             return user
@@ -93,8 +98,7 @@ class UsersTable:
     def get_num_users(self) -> Optional[int]:
         return User.select().count()
 
-    def update_user_role_by_id(self, id: str,
-                               role: str) -> Optional[UserModel]:
+    def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]:
         try:
             query = User.update(role=role).where(User.id == id)
             query.execute()
@@ -104,6 +108,16 @@ class UsersTable:
         except:
             return None
 
+    def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
+        try:
+            query = User.update(**updated).where(User.id == id)
+            query.execute()
+
+            user = User.get(User.id == id)
+            return UserModel(**model_to_dict(user))
+        except:
+            return None
+
     def delete_user_by_id(self, id: str) -> bool:
         try:
             # Delete User Chats
@@ -112,8 +126,7 @@ class UsersTable:
             if result:
                 # Delete User
                 query = User.delete().where(User.id == id)
-                query.execute(
-                )  # Remove the rows, return number of rows removed.
+                query.execute()  # Remove the rows, return number of rows removed.
 
                 return True
             else:

+ 45 - 14
backend/apps/web/routers/users.py

@@ -8,10 +8,10 @@ from pydantic import BaseModel
 import time
 import uuid
 
-from apps.web.models.users import UserModel, UserRoleUpdateForm, Users
+from apps.web.models.users import UserModel, UserUpdateForm, UserRoleUpdateForm, Users
 from apps.web.models.auths import Auths
 
-from utils.utils import get_current_user
+from utils.utils import get_current_user, get_password_hash
 from constants import ERROR_MESSAGES
 
 router = APIRouter()
@@ -22,9 +22,7 @@ router = APIRouter()
 
 
 @router.get("/", response_model=List[UserModel])
-async def get_users(skip: int = 0,
-                    limit: int = 50,
-                    user=Depends(get_current_user)):
+async def get_users(skip: int = 0, limit: int = 50, user=Depends(get_current_user)):
     if user.role != "admin":
         raise HTTPException(
             status_code=status.HTTP_403_FORBIDDEN,
@@ -34,25 +32,58 @@ async def get_users(skip: int = 0,
 
 
 ############################
-# UpdateUserRole
+# UpdateUserById
 ############################
 
 
-@router.post("/update/role", response_model=Optional[UserModel])
-async def update_user_role(form_data: UserRoleUpdateForm,
-                           user=Depends(get_current_user)):
-    if user.role != "admin":
+@router.post("/{user_id}/update", response_model=Optional[UserModel])
+async def update_user_by_id(
+    user_id: str, form_data: UserUpdateForm, session_user=Depends(get_current_user)
+):
+    if session_user.role != "admin":
         raise HTTPException(
             status_code=status.HTTP_403_FORBIDDEN,
             detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
         )
 
-    if user.id != form_data.id:
-        return Users.update_user_role_by_id(form_data.id, form_data.role)
+    user = Users.get_user_by_id(user_id)
+
+    if user:
+        if form_data.email != user.email:
+            email_user = Users.get_user_by_email(form_data.email)
+            if email_user:
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail=ERROR_MESSAGES.EMAIL_TAKEN,
+                )
+
+        if form_data.password:
+            hashed = get_password_hash(form_data.password)
+            print(hashed)
+            Auths.update_user_password_by_id(user_id, hashed)
+
+        Auths.update_email_by_id(user_id, form_data.email)
+        updated_user = Users.update_user_by_id(
+            user_id,
+            {
+                "name": form_data.name,
+                "email": form_data.email,
+                "profile_image_url": form_data.profile_image_url,
+            },
+        )
+
+        if updated_user:
+            return updated_user
+        else:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=ERROR_MESSAGES.DEFAULT(),
+            )
+
     else:
         raise HTTPException(
-            status_code=status.HTTP_403_FORBIDDEN,
-            detail=ERROR_MESSAGES.ACTION_PROHIBITED,
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=ERROR_MESSAGES.USER_NOT_FOUND,
         )
 
 

+ 11 - 0
package-lock.json

@@ -9,6 +9,7 @@
 			"version": "0.0.1",
 			"dependencies": {
 				"@sveltejs/adapter-node": "^1.3.1",
+				"dayjs": "^1.11.10",
 				"file-saver": "^2.0.5",
 				"highlight.js": "^11.9.0",
 				"idb": "^7.1.1",
@@ -1577,6 +1578,11 @@
 				"node": ">=4"
 			}
 		},
+		"node_modules/dayjs": {
+			"version": "1.11.10",
+			"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
+			"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
+		},
 		"node_modules/debug": {
 			"version": "4.3.4",
 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -4940,6 +4946,11 @@
 			"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
 			"dev": true
 		},
+		"dayjs": {
+			"version": "1.11.10",
+			"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
+			"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
+		},
 		"debug": {
 			"version": "4.3.4",
 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",

+ 1 - 0
package.json

@@ -40,6 +40,7 @@
 	"type": "module",
 	"dependencies": {
 		"@sveltejs/adapter-node": "^1.3.1",
+		"dayjs": "^1.11.10",
 		"file-saver": "^2.0.5",
 		"highlight.js": "^11.9.0",
 		"idb": "^7.1.1",

+ 40 - 0
src/lib/apis/users/index.ts

@@ -84,3 +84,43 @@ export const deleteUserById = async (token: string, userId: string) => {
 
 	return res;
 };
+
+type UserUpdateForm = {
+	profile_image_url: string;
+	email: string;
+	name: string;
+	password: string;
+};
+
+export const updateUserById = async (token: string, userId: string, user: UserUpdateForm) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}/update`, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/json',
+			Authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			profile_image_url: user.profile_image_url,
+			email: user.email,
+			name: user.name,
+			password: user.password !== '' ? user.password : undefined
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			error = err.detail;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};

+ 172 - 0
src/lib/components/admin/EditUserModal.svelte

@@ -0,0 +1,172 @@
+<script lang="ts">
+	import toast from 'svelte-french-toast';
+	import dayjs from 'dayjs';
+	import { createEventDispatcher } from 'svelte';
+	import { onMount } from 'svelte';
+
+	import { updateUserById } from '$lib/apis/users';
+	import Modal from '../common/Modal.svelte';
+
+	const dispatch = createEventDispatcher();
+
+	export let show = false;
+	export let selectedUser;
+	export let sessionUser;
+
+	let _user = {
+		profile_image_url: '',
+		name: '',
+		email: '',
+		password: ''
+	};
+
+	const submitHandler = async () => {
+		const res = await updateUserById(localStorage.token, selectedUser.id, _user).catch((error) => {
+			toast.error(error);
+		});
+
+		if (res) {
+			dispatch('save');
+			show = false;
+		}
+	};
+
+	onMount(() => {
+		if (selectedUser) {
+			_user = selectedUser;
+			_user.password = '';
+		}
+	});
+</script>
+
+<Modal size="sm" bind:show>
+	<div>
+		<div class=" flex justify-between dark:text-gray-300 px-5 py-4">
+			<div class=" text-lg font-medium self-center">Edit User</div>
+			<button
+				class="self-center"
+				on:click={() => {
+					show = false;
+				}}
+			>
+				<svg
+					xmlns="http://www.w3.org/2000/svg"
+					viewBox="0 0 20 20"
+					fill="currentColor"
+					class="w-5 h-5"
+				>
+					<path
+						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+					/>
+				</svg>
+			</button>
+		</div>
+		<hr class=" dark:border-gray-800" />
+
+		<div class="flex flex-col md:flex-row w-full p-5 md:space-x-4 dark:text-gray-200">
+			<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
+				<form
+					class="flex flex-col w-full"
+					on:submit|preventDefault={() => {
+						submitHandler();
+					}}
+				>
+					<div class=" flex items-center rounded-md py-2 px-4 w-full">
+						<div class=" self-center mr-5">
+							<img
+								src={selectedUser.profile_image_url}
+								class=" max-w-[55px] object-cover rounded-full"
+								alt="User profile"
+							/>
+						</div>
+
+						<div>
+							<div class=" self-center capitalize font-semibold">{selectedUser.name}</div>
+
+							<div class="text-xs text-gray-500">
+								Created at {dayjs(selectedUser.timestamp * 1000).format('MMMM DD, YYYY')}
+							</div>
+						</div>
+					</div>
+
+					<hr class=" dark:border-gray-800 my-3 w-full" />
+
+					<div class=" flex flex-col space-y-1.5">
+						<div class="flex flex-col w-full">
+							<div class=" mb-1 text-xs text-gray-500">Email</div>
+
+							<div class="flex-1">
+								<input
+									class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
+									type="email"
+									bind:value={_user.email}
+									autocomplete="off"
+									required
+									disabled={_user.id == sessionUser.id}
+								/>
+							</div>
+						</div>
+
+						<div class="flex flex-col w-full">
+							<div class=" mb-1 text-xs text-gray-500">Name</div>
+
+							<div class="flex-1">
+								<input
+									class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+									type="text"
+									bind:value={_user.name}
+									autocomplete="off"
+									required
+								/>
+							</div>
+						</div>
+
+						<div class="flex flex-col w-full">
+							<div class=" mb-1 text-xs text-gray-500">New Password</div>
+
+							<div class="flex-1">
+								<input
+									class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+									type="password"
+									bind:value={_user.password}
+									autocomplete="new-password"
+								/>
+							</div>
+						</div>
+					</div>
+
+					<div class="flex justify-end pt-3 text-sm font-medium">
+						<button
+							class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded"
+							type="submit"
+						>
+							Save
+						</button>
+					</div>
+				</form>
+			</div>
+		</div>
+	</div>
+</Modal>
+
+<style>
+	input::-webkit-outer-spin-button,
+	input::-webkit-inner-spin-button {
+		/* display: none; <- Crashes Chrome on hover */
+		-webkit-appearance: none;
+		margin: 0; /* <-- Apparently some margin are still there even though it's hidden */
+	}
+
+	.tabs::-webkit-scrollbar {
+		display: none; /* for Chrome, Safari and Opera */
+	}
+
+	.tabs {
+		-ms-overflow-style: none; /* IE and Edge */
+		scrollbar-width: none; /* Firefox */
+	}
+
+	input[type='number'] {
+		-moz-appearance: textfield; /* Firefox */
+	}
+</style>

+ 13 - 1
src/lib/components/common/Modal.svelte

@@ -3,8 +3,18 @@
 	import { fade, blur } from 'svelte/transition';
 
 	export let show = true;
+	export let size = 'md';
+
 	let mounted = false;
 
+	const sizeToWidth = (size) => {
+		if (size === 'sm') {
+			return 'w-[30rem]';
+		} else {
+			return 'w-[40rem]';
+		}
+	};
+
 	onMount(() => {
 		mounted = true;
 	});
@@ -28,7 +38,9 @@
 		}}
 	>
 		<div
-			class="m-auto rounded-xl max-w-full w-[40rem] mx-2 bg-gray-50 dark:bg-gray-900 shadow-3xl"
+			class="m-auto rounded-xl max-w-full {sizeToWidth(
+				size
+			)} mx-2 bg-gray-50 dark:bg-gray-900 shadow-3xl"
 			transition:fade={{ delay: 100, duration: 200 }}
 			on:click={(e) => {
 				e.stopPropagation();

+ 48 - 1
src/routes/(app)/admin/+page.svelte

@@ -8,11 +8,15 @@
 
 	import { updateUserRole, getUsers, deleteUserById } from '$lib/apis/users';
 	import { getSignUpEnabledStatus, toggleSignUpEnabledStatus } from '$lib/apis/auths';
+	import EditUserModal from '$lib/components/admin/EditUserModal.svelte';
 
 	let loaded = false;
 	let users = [];
 
+	let selectedUser = null;
+
 	let signUpEnabled = true;
+	let showEditUserModal = false;
 
 	const updateRoleHandler = async (id, role) => {
 		const res = await updateUserRole(localStorage.token, id, role).catch((error) => {
@@ -25,6 +29,17 @@
 		}
 	};
 
+	const editUserPasswordHandler = async (id, password) => {
+		const res = await deleteUserById(localStorage.token, id).catch((error) => {
+			toast.error(error);
+			return null;
+		});
+		if (res) {
+			users = await getUsers(localStorage.token);
+			toast.success('Successfully updated');
+		}
+	};
+
 	const deleteUserHandler = async (id) => {
 		const res = await deleteUserById(localStorage.token, id).catch((error) => {
 			toast.error(error);
@@ -51,6 +66,17 @@
 	});
 </script>
 
+{#key selectedUser}
+	<EditUserModal
+		bind:show={showEditUserModal}
+		{selectedUser}
+		sessionUser={$user}
+		on:save={async () => {
+			users = await getUsers(localStorage.token);
+		}}
+	/>
+{/key}
+
 <div
 	class=" bg-white dark:bg-gray-800 dark:text-gray-100 min-h-screen w-full flex justify-center font-mona"
 >
@@ -154,7 +180,28 @@
 												}}>{user.role}</button
 											>
 										</td>
-										<td class="px-6 py-4 text-center flex justify-center">
+										<td class="px-6 py-4 space-x-1 text-center flex justify-center">
+											<button
+												class="self-center w-fit text-sm p-1.5 border dark:border-gray-600 rounded-xl flex"
+												on:click={async () => {
+													showEditUserModal = !showEditUserModal;
+													selectedUser = user;
+												}}
+											>
+												<svg
+													xmlns="http://www.w3.org/2000/svg"
+													viewBox="0 0 16 16"
+													fill="currentColor"
+													class="w-4 h-4"
+												>
+													<path
+														fill-rule="evenodd"
+														d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
+														clip-rule="evenodd"
+													/>
+												</svg>
+											</button>
+
 											<button
 												class="self-center w-fit text-sm p-1.5 border dark:border-gray-600 rounded-xl flex"
 												on:click={async () => {