Browse Source

enh: notes user group permission

Timothy Jaeryang Baek 1 month ago
parent
commit
84a05bec7b

+ 5 - 0
backend/open_webui/config.py

@@ -1137,6 +1137,10 @@ USER_PERMISSIONS_FEATURES_CODE_INTERPRETER = (
     == "true"
 )
 
+USER_PERMISSIONS_FEATURES_NOTES = (
+    os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true"
+)
+
 
 DEFAULT_USER_PERMISSIONS = {
     "workspace": {
@@ -1170,6 +1174,7 @@ DEFAULT_USER_PERMISSIONS = {
         "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
         "image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
         "code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
+        "notes": USER_PERMISSIONS_FEATURES_NOTES,
     },
 }
 

+ 60 - 7
backend/open_webui/routers/notes.py

@@ -15,7 +15,7 @@ from open_webui.env import SRC_LOG_LEVELS
 
 
 from open_webui.utils.auth import get_admin_user, get_verified_user
-from open_webui.utils.access_control import has_access
+from open_webui.utils.access_control import has_permission
 
 log = logging.getLogger(__name__)
 log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -28,7 +28,16 @@ router = APIRouter()
 
 
 @router.get("/", response_model=list[NoteUserResponse])
-async def get_notes(user=Depends(get_verified_user)):
+async def get_notes(request: Request, user=Depends(get_verified_user)):
+
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
     notes = [
         NoteUserResponse(
             **{
@@ -43,7 +52,16 @@ async def get_notes(user=Depends(get_verified_user)):
 
 
 @router.get("/list", response_model=list[NoteUserResponse])
-async def get_note_list(user=Depends(get_verified_user)):
+async def get_note_list(request: Request, user=Depends(get_verified_user)):
+
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
     notes = [
         NoteUserResponse(
             **{
@@ -63,7 +81,18 @@ async def get_note_list(user=Depends(get_verified_user)):
 
 
 @router.post("/create", response_model=Optional[NoteModel])
-async def create_new_note(form_data: NoteForm, user=Depends(get_admin_user)):
+async def create_new_note(
+    request: Request, form_data: NoteForm, user=Depends(get_verified_user)
+):
+
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
     try:
         note = Notes.insert_new_note(form_data, user.id)
         return note
@@ -80,7 +109,15 @@ async def create_new_note(form_data: NoteForm, user=Depends(get_admin_user)):
 
 
 @router.get("/{id}", response_model=Optional[NoteModel])
-async def get_note_by_id(id: str, user=Depends(get_verified_user)):
+async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
     note = Notes.get_note_by_id(id)
     if not note:
         raise HTTPException(
@@ -104,8 +141,16 @@ async def get_note_by_id(id: str, user=Depends(get_verified_user)):
 
 @router.post("/{id}/update", response_model=Optional[NoteModel])
 async def update_note_by_id(
-    id: str, form_data: NoteForm, user=Depends(get_verified_user)
+    request: Request, id: str, form_data: NoteForm, user=Depends(get_verified_user)
 ):
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
     note = Notes.get_note_by_id(id)
     if not note:
         raise HTTPException(
@@ -135,7 +180,15 @@ async def update_note_by_id(
 
 
 @router.delete("/{id}/delete", response_model=bool)
-async def delete_note_by_id(id: str, user=Depends(get_verified_user)):
+async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
     note = Notes.get_note_by_id(id)
     if not note:
         raise HTTPException(

+ 1 - 0
backend/open_webui/routers/users.py

@@ -129,6 +129,7 @@ class FeaturesPermissions(BaseModel):
     web_search: bool = True
     image_generation: bool = True
     code_interpreter: bool = True
+    notes: bool = True
 
 
 class UserPermissions(BaseModel):

+ 2 - 1
src/lib/components/admin/Users/Groups.svelte

@@ -81,7 +81,8 @@
 			direct_tool_servers: false,
 			web_search: true,
 			image_generation: true,
-			code_interpreter: true
+			code_interpreter: true,
+			notes: true
 		}
 	};
 

+ 10 - 1
src/lib/components/admin/Users/Groups/Permissions.svelte

@@ -37,7 +37,8 @@
 			direct_tool_servers: false,
 			web_search: true,
 			image_generation: true,
-			code_interpreter: true
+			code_interpreter: true,
+			notes: true
 		}
 	};
 
@@ -380,5 +381,13 @@
 
 			<Switch bind:state={permissions.features.code_interpreter} />
 		</div>
+
+		<div class="  flex w-full justify-between my-2 pr-2">
+			<div class=" self-center text-xs font-medium">
+				{$i18n.t('Notes')}
+			</div>
+
+			<Switch bind:state={permissions.features.notes} />
+		</div>
 	</div>
 </div>

+ 1 - 1
src/lib/components/layout/Sidebar.svelte

@@ -570,7 +570,7 @@
 			</div>
 		{/if} -->
 
-		{#if $config?.features?.enable_notes ?? false}
+		{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
 			<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
 				<a
 					class="grow flex items-center space-x-3 rounded-lg px-2 py-[7px] hover:bg-gray-100 dark:hover:bg-gray-900 transition"

+ 10 - 3
src/lib/components/notes/NoteEditor.svelte

@@ -53,7 +53,9 @@
 
 	export let id: null | string = null;
 
-	let note = {
+	let note = null;
+
+	const newNote = {
 		title: '',
 		data: {
 			content: {
@@ -85,8 +87,7 @@
 			note = res;
 			files = res.data.files || [];
 		} else {
-			toast.error($i18n.t('Note not found'));
-			goto('/notes');
+			goto('/');
 			return;
 		}
 
@@ -101,6 +102,12 @@
 		}
 
 		debounceTimeout = setTimeout(async () => {
+			if (!note) {
+				return;
+			}
+
+			console.log('Saving note:', note);
+
 			const res = await updateNoteById(localStorage.token, id, {
 				...note,
 				title: note.title === '' ? $i18n.t('Untitled') : note.title

+ 73 - 61
src/routes/(app)/notes/+layout.svelte

@@ -1,17 +1,27 @@
 <script lang="ts">
 	import { onMount, getContext } from 'svelte';
 	import { WEBUI_NAME, showSidebar, functions, config, user, showArchivedChats } from '$lib/stores';
+	import { goto } from '$app/navigation';
+
 	import MenuLines from '$lib/components/icons/MenuLines.svelte';
-	import { page } from '$app/stores';
 	import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
 
 	const i18n = getContext('i18n');
 
+	let loaded = false;
+
 	onMount(async () => {
-		if (!$config?.features?.enable_notes) {
+		if (
+			!(
+				($config?.features?.enable_notes ?? false) &&
+				($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))
+			)
+		) {
 			// If the feature is not enabled, redirect to the home page
 			goto('/');
 		}
+
+		loaded = true;
 	});
 </script>
 
@@ -21,71 +31,73 @@
 	</title>
 </svelte:head>
 
-<div
-	class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
-		? 'md:max-w-[calc(100%-260px)]'
-		: ''} max-w-full"
->
-	<nav class="   px-2 pt-1 backdrop-blur-xl w-full drag-region">
-		<div class=" flex items-center">
-			<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center">
-				<button
-					id="sidebar-toggle-button"
-					class="cursor-pointer p-1.5 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition"
-					on:click={() => {
-						showSidebar.set(!$showSidebar);
-					}}
-					aria-label="Toggle Sidebar"
-				>
-					<div class=" m-auto self-center">
-						<MenuLines />
-					</div>
-				</button>
-			</div>
-
-			<div class="ml-2 py-0.5 self-center flex items-center justify-between w-full">
-				<div class="">
-					<div
-						class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium bg-transparent py-1 touch-auto pointer-events-auto"
+{#if loaded}
+	<div
+		class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
+			? 'md:max-w-[calc(100%-260px)]'
+			: ''} max-w-full"
+	>
+		<nav class="   px-2 pt-1 backdrop-blur-xl w-full drag-region">
+			<div class=" flex items-center">
+				<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center">
+					<button
+						id="sidebar-toggle-button"
+						class="cursor-pointer p-1.5 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition"
+						on:click={() => {
+							showSidebar.set(!$showSidebar);
+						}}
+						aria-label="Toggle Sidebar"
 					>
-						<a class="min-w-fit transition" href="/notes">
-							{$i18n.t('Notes')}
-						</a>
-					</div>
+						<div class=" m-auto self-center">
+							<MenuLines />
+						</div>
+					</button>
 				</div>
 
-				<div class=" self-center flex items-center gap-1">
-					{#if $user !== undefined && $user !== null}
-						<UserMenu
-							className="max-w-[200px]"
-							role={$user?.role}
-							on:show={(e) => {
-								if (e.detail === 'archived-chat') {
-									showArchivedChats.set(true);
-								}
-							}}
+				<div class="ml-2 py-0.5 self-center flex items-center justify-between w-full">
+					<div class="">
+						<div
+							class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium bg-transparent py-1 touch-auto pointer-events-auto"
 						>
-							<button
-								class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
-								aria-label="User Menu"
+							<a class="min-w-fit transition" href="/notes">
+								{$i18n.t('Notes')}
+							</a>
+						</div>
+					</div>
+
+					<div class=" self-center flex items-center gap-1">
+						{#if $user !== undefined && $user !== null}
+							<UserMenu
+								className="max-w-[200px]"
+								role={$user?.role}
+								on:show={(e) => {
+									if (e.detail === 'archived-chat') {
+										showArchivedChats.set(true);
+									}
+								}}
 							>
-								<div class=" self-center">
-									<img
-										src={$user?.profile_image_url}
-										class="size-6 object-cover rounded-full"
-										alt="User profile"
-										draggable="false"
-									/>
-								</div>
-							</button>
-						</UserMenu>
-					{/if}
+								<button
+									class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
+									aria-label="User Menu"
+								>
+									<div class=" self-center">
+										<img
+											src={$user?.profile_image_url}
+											class="size-6 object-cover rounded-full"
+											alt="User profile"
+											draggable="false"
+										/>
+									</div>
+								</button>
+							</UserMenu>
+						{/if}
+					</div>
 				</div>
 			</div>
-		</div>
-	</nav>
+		</nav>
 
-	<div class=" pb-1 flex-1 max-h-full overflow-y-auto @container">
-		<slot />
+		<div class=" pb-1 flex-1 max-h-full overflow-y-auto @container">
+			<slot />
+		</div>
 	</div>
-</div>
+{/if}