forked from open-webui/open-webui
Merge pull request #400 from ollama-webui/edit-user
feat: edit user support
This commit is contained in:
commit
e9753b87a8
9 changed files with 371 additions and 43 deletions
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
package-lock.json
generated
11
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
src/lib/components/admin/EditUserModal.svelte
Normal file
172
src/lib/components/admin/EditUserModal.svelte
Normal 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>
|
|
@ -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();
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
Loading…
Reference in a new issue