Merge pull request #400 from ollama-webui/edit-user

feat: edit user support
This commit is contained in:
Timothy Jaeryang Baek 2024-01-06 00:00:56 -05:00 committed by GitHub
commit e9753b87a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 371 additions and 43 deletions

View file

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

View file

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

View file

@ -8,10 +8,10 @@ from pydantic import BaseModel
import time import time
import uuid 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 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 from constants import ERROR_MESSAGES
router = APIRouter() router = APIRouter()
@ -22,9 +22,7 @@ router = APIRouter()
@router.get("/", response_model=List[UserModel]) @router.get("/", response_model=List[UserModel])
async def get_users(skip: int = 0, async def get_users(skip: int = 0, limit: int = 50, user=Depends(get_current_user)):
limit: int = 50,
user=Depends(get_current_user)):
if user.role != "admin": if user.role != "admin":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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]) @router.post("/{user_id}/update", response_model=Optional[UserModel])
async def update_user_role(form_data: UserRoleUpdateForm, async def update_user_by_id(
user=Depends(get_current_user)): user_id: str, form_data: UserUpdateForm, session_user=Depends(get_current_user)
if user.role != "admin": ):
if session_user.role != "admin":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
if user.id != form_data.id: user = Users.get_user_by_id(user_id)
return Users.update_user_role_by_id(form_data.id, form_data.role)
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: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACTION_PROHIBITED, detail=ERROR_MESSAGES.DEFAULT(),
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
) )

11
package-lock.json generated
View file

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

View file

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

View file

@ -84,3 +84,43 @@ export const deleteUserById = async (token: string, userId: string) => {
return res; 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;
};

View file

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

View file

@ -3,8 +3,18 @@
import { fade, blur } from 'svelte/transition'; import { fade, blur } from 'svelte/transition';
export let show = true; export let show = true;
export let size = 'md';
let mounted = false; let mounted = false;
const sizeToWidth = (size) => {
if (size === 'sm') {
return 'w-[30rem]';
} else {
return 'w-[40rem]';
}
};
onMount(() => { onMount(() => {
mounted = true; mounted = true;
}); });
@ -28,7 +38,9 @@
}} }}
> >
<div <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 }} transition:fade={{ delay: 100, duration: 200 }}
on:click={(e) => { on:click={(e) => {
e.stopPropagation(); e.stopPropagation();

View file

@ -8,11 +8,15 @@
import { updateUserRole, getUsers, deleteUserById } from '$lib/apis/users'; import { updateUserRole, getUsers, deleteUserById } from '$lib/apis/users';
import { getSignUpEnabledStatus, toggleSignUpEnabledStatus } from '$lib/apis/auths'; import { getSignUpEnabledStatus, toggleSignUpEnabledStatus } from '$lib/apis/auths';
import EditUserModal from '$lib/components/admin/EditUserModal.svelte';
let loaded = false; let loaded = false;
let users = []; let users = [];
let selectedUser = null;
let signUpEnabled = true; let signUpEnabled = true;
let showEditUserModal = false;
const updateRoleHandler = async (id, role) => { const updateRoleHandler = async (id, role) => {
const res = await updateUserRole(localStorage.token, id, role).catch((error) => { 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 deleteUserHandler = async (id) => {
const res = await deleteUserById(localStorage.token, id).catch((error) => { const res = await deleteUserById(localStorage.token, id).catch((error) => {
toast.error(error); toast.error(error);
@ -51,6 +66,17 @@
}); });
</script> </script>
{#key selectedUser}
<EditUserModal
bind:show={showEditUserModal}
{selectedUser}
sessionUser={$user}
on:save={async () => {
users = await getUsers(localStorage.token);
}}
/>
{/key}
<div <div
class=" bg-white dark:bg-gray-800 dark:text-gray-100 min-h-screen w-full flex justify-center font-mona" 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 }}>{user.role}</button
> >
</td> </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 <button
class="self-center w-fit text-sm p-1.5 border dark:border-gray-600 rounded-xl flex" class="self-center w-fit text-sm p-1.5 border dark:border-gray-600 rounded-xl flex"
on:click={async () => { on:click={async () => {