diff --git a/backend/core/models/brains.py b/backend/core/models/brains.py index f56e97eae..109ce0d77 100644 --- a/backend/core/models/brains.py +++ b/backend/core/models/brains.py @@ -3,11 +3,12 @@ from typing import Any, List, Optional from uuid import UUID from logger import get_logger -from models.settings import CommonsDep, common_dependencies -from models.users import User from pydantic import BaseModel from utils.vectors import get_unique_files_from_vector_ids +from models.settings import CommonsDep, common_dependencies +from models.users import User + logger = get_logger(__name__) @@ -79,11 +80,15 @@ class Brain(BaseModel): response = ( self.commons["supabase"] .from_("brains_users") - .select("id:brain_id, brains (id: brain_id, name)") + .select("id:brain_id, rights, brains (id: brain_id, name)") .filter("user_id", "eq", user_id) .execute() ) - return [item["brains"] for item in response.data] + user_brains = [] + for item in response.data: + user_brains.append(item["brains"]) + user_brains[-1]["rights"] = item["rights"] + return user_brains def get_brain_for_user(self, user_id): response = ( diff --git a/backend/core/repository/brain/update_user_rights.py b/backend/core/repository/brain/update_user_rights.py new file mode 100644 index 000000000..778232ce9 --- /dev/null +++ b/backend/core/repository/brain/update_user_rights.py @@ -0,0 +1,12 @@ +from uuid import UUID + +from models.settings import common_dependencies + + +def update_brain_user_rights(brain_id: UUID, user_id: UUID, rights: str) -> None: + commons = common_dependencies() + + commons["supabase"].table("brains_users").update({"rights": rights}).eq( + "brain_id", + brain_id, + ).eq("user_id", user_id).execute() diff --git a/backend/core/repository/user/get_user_email_by_user_id.py b/backend/core/repository/user/get_user_email_by_user_id.py index 060c03aff..40f744e3f 100644 --- a/backend/core/repository/user/get_user_email_by_user_id.py +++ b/backend/core/repository/user/get_user_email_by_user_id.py @@ -1,7 +1,9 @@ +from uuid import UUID + from models.settings import common_dependencies -def get_user_email_by_user_id(user_id: int) -> str: +def get_user_email_by_user_id(user_id: UUID) -> str: commons = common_dependencies() response = ( commons["supabase"] diff --git a/backend/core/repository/user/get_user_id_by_user_email.py b/backend/core/repository/user/get_user_id_by_user_email.py new file mode 100644 index 000000000..fb636358e --- /dev/null +++ b/backend/core/repository/user/get_user_id_by_user_email.py @@ -0,0 +1,13 @@ +from uuid import UUID + +from models.settings import common_dependencies + + +def get_user_id_by_user_email(email: str) -> UUID: + commons = common_dependencies() + response = ( + commons["supabase"] + .rpc("get_user_id_by_user_email", {"user_email": email}) + .execute() + ) + return response.data[0]["user_id"] diff --git a/backend/core/routes/brain_routes.py b/backend/core/routes/brain_routes.py index 787708ac0..f88c337dc 100644 --- a/backend/core/routes/brain_routes.py +++ b/backend/core/routes/brain_routes.py @@ -3,10 +3,14 @@ from uuid import UUID from auth import AuthBearer, get_current_user from fastapi import APIRouter, Depends, HTTPException from logger import get_logger -from models.brains import (Brain, get_default_user_brain, - get_default_user_brain_or_create_new) +from models.brains import ( + Brain, + get_default_user_brain, + get_default_user_brain_or_create_new, +) from models.settings import common_dependencies from models.users import User + from routes.authorizations.brain_authorization import has_brain_authorization logger = get_logger(__name__) @@ -47,10 +51,7 @@ async def get_default_brain_endpoint(current_user: User = Depends(get_current_us """ brain = get_default_user_brain_or_create_new(current_user) - return { - "id": brain.id, - "name": brain.name, - } + return {"id": brain.id, "name": brain.name, "rights": "Owner"} # get one brain - Currently not used in FE @@ -77,7 +78,6 @@ async def get_brain_endpoint( return { "id": brain_id, "name": brains[0]["name"], - "status": brains[0]["status"], } else: return HTTPException( @@ -122,6 +122,7 @@ async def create_brain_endpoint( return { "id": brain.id, # pyright: ignore reportPrivateUsage=none "name": brain.name, + "rights": "Owner", } diff --git a/backend/core/routes/subscription_routes.py b/backend/core/routes/subscription_routes.py index 2044d4189..9673d0736 100644 --- a/backend/core/routes/subscription_routes.py +++ b/backend/core/routes/subscription_routes.py @@ -1,4 +1,3 @@ -import os from typing import List from uuid import UUID @@ -7,12 +6,21 @@ from fastapi import APIRouter, Depends, HTTPException from models.brains import Brain from models.brains_subscription_invitations import BrainSubscription from models.users import User -from repository.brain_subscription.resend_invitation_email import \ - resend_invitation_email -from repository.brain_subscription.subscription_invitation_service import \ - SubscriptionInvitationService +from pydantic import BaseModel +from repository.brain.update_user_rights import update_brain_user_rights +from repository.brain_subscription.resend_invitation_email import ( + resend_invitation_email, +) +from repository.brain_subscription.subscription_invitation_service import ( + SubscriptionInvitationService, +) from repository.user.get_user_email_by_user_id import get_user_email_by_user_id -from routes.authorizations.brain_authorization import has_brain_authorization +from repository.user.get_user_id_by_user_email import get_user_id_by_user_email + +from routes.authorizations.brain_authorization import ( + has_brain_authorization, + validate_brain_authorization, +) from routes.headers.get_origin_header import get_origin_header subscription_router = APIRouter() @@ -23,14 +31,19 @@ subscription_service = SubscriptionInvitationService() "/brains/{brain_id}/subscription", dependencies=[ Depends( - AuthBearer(), + AuthBearer(), ), Depends(has_brain_authorization), Depends(get_origin_header), ], tags=["BrainSubscription"], ) -def invite_users_to_brain(brain_id: UUID, users: List[dict], origin: str = Depends(get_origin_header), current_user: User = Depends(get_current_user)): +def invite_users_to_brain( + brain_id: UUID, + users: List[dict], + origin: str = Depends(get_origin_header), + current_user: User = Depends(get_current_user), +): """ Invite multiple users to a brain by their emails. This function creates or updates a brain subscription invitation for each user and sends an @@ -38,11 +51,15 @@ def invite_users_to_brain(brain_id: UUID, users: List[dict], origin: str = Depen """ for user in users: - subscription = BrainSubscription(brain_id=brain_id, email=user['email'], rights=user['rights']) - + subscription = BrainSubscription( + brain_id=brain_id, email=user["email"], rights=user["rights"] + ) + try: subscription_service.create_or_update_subscription_invitation(subscription) - resend_invitation_email(subscription, inviter_email=current_user.email or "Quivr", origin=origin) + resend_invitation_email( + subscription, inviter_email=current_user.email or "Quivr", origin=origin + ) except Exception as e: raise HTTPException(status_code=400, detail=f"Error inviting user: {e}") @@ -137,7 +154,9 @@ def get_user_invitation(brain_id: UUID, current_user: User = Depends(get_current "/brains/{brain_id}/subscription/accept", tags=["Brain"], ) -async def accept_invitation(brain_id: UUID, current_user: User = Depends(get_current_user)): +async def accept_invitation( + brain_id: UUID, current_user: User = Depends(get_current_user) +): """ Accept an invitation to a brain for a user. This function removes the invitation from the subscription invitations and adds the user to the @@ -159,7 +178,7 @@ async def accept_invitation(brain_id: UUID, current_user: User = Depends(get_cur try: brain = Brain(id=brain_id) brain.create_brain_user( - user_id=current_user.id, rights=invitation['rights'], default_brain=False + user_id=current_user.id, rights=invitation["rights"], default_brain=False ) except Exception as e: raise HTTPException(status_code=400, detail=f"Error adding user to brain: {e}") @@ -176,7 +195,9 @@ async def accept_invitation(brain_id: UUID, current_user: User = Depends(get_cur "/brains/{brain_id}/subscription/decline", tags=["Brain"], ) -async def decline_invitation(brain_id: UUID, current_user: User = Depends(get_current_user)): +async def decline_invitation( + brain_id: UUID, current_user: User = Depends(get_current_user) +): """ Decline an invitation to a brain for a user. This function removes the invitation from the subscription invitations. @@ -190,7 +211,7 @@ async def decline_invitation(brain_id: UUID, current_user: User = Depends(get_cu invitation = subscription_service.fetch_invitation(subscription) except Exception as e: raise HTTPException(status_code=400, detail=f"Error fetching invitation: {e}") - + if not invitation: raise HTTPException(status_code=404, detail="Invitation not found") @@ -200,3 +221,36 @@ async def decline_invitation(brain_id: UUID, current_user: User = Depends(get_cu raise HTTPException(status_code=400, detail=f"Error removing invitation: {e}") return {"message": "Invitation declined successfully"} + + +class BrainSubscriptionUpdatableProperties(BaseModel): + rights: str | None + email: str + + +@subscription_router.put("/brains/{brain_id}/subscription") +def update_brain_subscription( + brain_id: UUID, + subscription: BrainSubscriptionUpdatableProperties, + current_user: User = Depends(get_current_user), +): + validate_brain_authorization(brain_id, current_user.id, "Owner") + user_email = subscription.email + + if user_email == current_user.email: + raise HTTPException( + status_code=403, + detail="You can't change your own permissions", + ) + + user_id = get_user_id_by_user_email(user_email) + + if subscription.rights is None: + brain = Brain( + id=brain_id, + ) + brain.delete_user_from_brain(user_id) + else: + update_brain_user_rights(brain_id, user_id, subscription.rights) + + return {"message": "Brain subscription updated successfully"} diff --git a/backend/core/tests/test_brains.py b/backend/core/tests/test_brains.py index 6873ee48a..2c3f0a0d8 100644 --- a/backend/core/tests/test_brains.py +++ b/backend/core/tests/test_brains.py @@ -97,7 +97,6 @@ def test_retrieve_one_brain(client, api_key): brain = response.json() assert "id" in brain assert "name" in brain - assert "status" in brain def test_delete_all_brains(client, api_key): diff --git a/frontend/app/invitation/[brainId]/hooks/useInvitation.ts b/frontend/app/invitation/[brainId]/hooks/useInvitation.ts index 1c8e03774..8a9347ebc 100644 --- a/frontend/app/invitation/[brainId]/hooks/useInvitation.ts +++ b/frontend/app/invitation/[brainId]/hooks/useInvitation.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useState } from "react"; import { useSubscriptionApi } from "@/lib/api/subscription/useSubscriptionApi"; import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext"; import { useToast } from "@/lib/hooks"; +import { useEventTracking } from "@/services/analytics/useEventTracking"; interface UseInvitationReturn { brainId: UUID | undefined; @@ -20,6 +21,7 @@ export const useInvitation = (): UseInvitationReturn => { const params = useParams(); const brainId = params?.brainId as UUID | undefined; const { publish } = useToast(); + const { track } = useEventTracking(); if (brainId === undefined) { throw new Error("Brain ID is undefined"); } @@ -32,7 +34,7 @@ export const useInvitation = (): UseInvitationReturn => { [] ); - const { addBrain, setActiveBrain } = useBrainContext(); + const { fetchAllBrains, setActiveBrain } = useBrainContext(); const router = useRouter(); // Check invitation on component mount useEffect(() => { @@ -67,9 +69,9 @@ export const useInvitation = (): UseInvitationReturn => { // After success, redirect user to a specific page -> chat page try { const response = await acceptInvitation(brainId); - console.log(response.message); + void track("BRAIN_ADDED"); - await addBrain(brainId); + await fetchAllBrains(); setActiveBrain({ id: brainId, name: "BrainName" }); //set brainId as active brain diff --git a/frontend/lib/api/brain/__tests__/useBrainApi.test.ts b/frontend/lib/api/brain/__tests__/useBrainApi.test.ts index fdfc938c3..05297d09c 100644 --- a/frontend/lib/api/brain/__tests__/useBrainApi.test.ts +++ b/frontend/lib/api/brain/__tests__/useBrainApi.test.ts @@ -18,6 +18,7 @@ const axiosPostMock = vi.fn(() => ({ })); const axiosDeleteMock = vi.fn(() => ({})); +const axiosPutMock = vi.fn(() => ({})); vi.mock("@/lib/hooks", () => ({ useAxios: vi.fn(() => ({ @@ -25,6 +26,7 @@ vi.mock("@/lib/hooks", () => ({ get: axiosGetMock, post: axiosPostMock, delete: axiosDeleteMock, + put: axiosPutMock, }, })), })); @@ -144,4 +146,22 @@ describe("useBrainApi", () => { expect(axiosGetMock).toHaveBeenCalledTimes(1); expect(axiosGetMock).toHaveBeenCalledWith(`/brains/${id}/users`); }); + it("should call updateBrainAccess with the correct parameters", async () => { + const { + result: { + current: { updateBrainAccess }, + }, + } = renderHook(() => useBrainApi()); + const brainId = "123"; + const email = "456"; + const subscription = { + rights: "viewer", + }; + await updateBrainAccess(brainId, email, subscription); + expect(axiosPutMock).toHaveBeenCalledTimes(1); + expect(axiosPutMock).toHaveBeenCalledWith( + `/brains/${brainId}/subscription`, + { ...subscription, email } + ); + }); }); diff --git a/frontend/lib/api/brain/brain.ts b/frontend/lib/api/brain/brain.ts index b61b54478..310eaafd8 100644 --- a/frontend/lib/api/brain/brain.ts +++ b/frontend/lib/api/brain/brain.ts @@ -1,7 +1,7 @@ import { AxiosInstance } from "axios"; import { BrainRoleType } from "@/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/types"; -import { Brain } from "@/lib/context/BrainProvider/types"; +import { Brain, MinimalBrainForUser } from "@/lib/context/BrainProvider/types"; import { Document } from "@/lib/types/Document"; export const getBrainDocuments = async ( @@ -18,9 +18,10 @@ export const getBrainDocuments = async ( export const createBrain = async ( name: string, axiosInstance: AxiosInstance -): Promise => { - const createdBrain = (await axiosInstance.post(`/brains/`, { name })) - .data; +): Promise => { + const createdBrain = ( + await axiosInstance.post(`/brains/`, { name }) + ).data; return createdBrain; }; @@ -45,18 +46,17 @@ export const deleteBrain = async ( export const getDefaultBrain = async ( axiosInstance: AxiosInstance -): Promise => { - const defaultBrain = (await axiosInstance.get(`/brains/default/`)) +): Promise => { + return (await axiosInstance.get(`/brains/default/`)) .data; - - return { id: defaultBrain.id, name: defaultBrain.name }; }; export const getBrains = async ( axiosInstance: AxiosInstance -): Promise => { - const brains = (await axiosInstance.get<{ brains: Brain[] }>(`/brains/`)) - .data; +): Promise => { + const brains = ( + await axiosInstance.get<{ brains: MinimalBrainForUser[] }>(`/brains/`) + ).data; return brains.brains; }; @@ -78,3 +78,19 @@ export const getBrainUsers = async ( return (await axiosInstance.get(`/brains/${brainId}/users`)) .data; }; + +export type SubscriptionUpdatableProperties = { + rights: BrainRoleType | null; +}; + +export const updateBrainAccess = async ( + brainId: string, + userEmail: string, + subscription: SubscriptionUpdatableProperties, + axiosInstance: AxiosInstance +): Promise => { + await axiosInstance.put(`/brains/${brainId}/subscription`, { + ...subscription, + email: userEmail, + }); +}; diff --git a/frontend/lib/api/brain/useBrainApi.ts b/frontend/lib/api/brain/useBrainApi.ts index 054f2cbfb..84848f2f6 100644 --- a/frontend/lib/api/brain/useBrainApi.ts +++ b/frontend/lib/api/brain/useBrainApi.ts @@ -10,6 +10,8 @@ import { getBrainUsers, getDefaultBrain, Subscription, + SubscriptionUpdatableProperties, + updateBrainAccess, } from "./brain"; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types @@ -30,5 +32,10 @@ export const useBrainApi = () => { ) => addBrainSubscriptions(brainId, subscriptions, axiosInstance), getBrainUsers: async (brainId: string) => getBrainUsers(brainId, axiosInstance), + updateBrainAccess: async ( + brainId: string, + userEmail: string, + subscription: SubscriptionUpdatableProperties + ) => updateBrainAccess(brainId, userEmail, subscription, axiosInstance), }; }; diff --git a/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/BrainsDropDown.tsx b/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/BrainsDropDown.tsx index bdbbdbe66..4d36df704 100644 --- a/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/BrainsDropDown.tsx +++ b/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/BrainsDropDown.tsx @@ -73,7 +73,7 @@ export const BrainsDropDown = (): JSX.Element => { {brain.name} - + ); } diff --git a/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/BrainActions.tsx b/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/BrainActions.tsx index 85b15a1c8..58d6190dd 100644 --- a/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/BrainActions.tsx +++ b/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/BrainActions.tsx @@ -1,16 +1,16 @@ -import { UUID } from "crypto"; +import { MinimalBrainForUser } from "@/lib/context/BrainProvider/types"; import { DeleteBrain, ShareBrain } from "./components"; type BrainActionsProps = { - brainId: UUID; + brain: MinimalBrainForUser; }; -export const BrainActions = ({ brainId }: BrainActionsProps): JSX.Element => { +export const BrainActions = ({ brain }: BrainActionsProps): JSX.Element => { return (
- - + {brain.rights === "Owner" && } +
); }; diff --git a/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/components/ShareBrain/ShareBrain.tsx b/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/components/ShareBrain/ShareBrain.tsx index 67f0452c6..0fd5e0400 100644 --- a/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/components/ShareBrain/ShareBrain.tsx +++ b/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/components/ShareBrain/ShareBrain.tsx @@ -29,6 +29,8 @@ export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => { setIsShareModalOpen, isShareModalOpen, brainUsers, + fetchBrainUsers, + isFetchingBrainUsers, } = useShareBrain(brainId); const canAddNewRow = @@ -103,13 +105,19 @@ export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {

Users with access

- {brainUsers.map((subscription) => ( - - ))} + {isFetchingBrainUsers ? ( +

Loading...

+ ) : ( + brainUsers.map((subscription) => ( + + )) + )} ); }; diff --git a/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/components/ShareBrain/components/BrainUser.tsx b/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/components/ShareBrain/components/BrainUser.tsx index 46df87767..2ed8fe583 100644 --- a/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/components/ShareBrain/components/BrainUser.tsx +++ b/frontend/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/components/ShareBrain/components/BrainUser.tsx @@ -1,8 +1,10 @@ import { useState } from "react"; import { MdOutlineRemoveCircle } from "react-icons/md"; +import { useBrainApi } from "@/lib/api/brain/useBrainApi"; import Field from "@/lib/components/ui/Field"; import { Select } from "@/lib/components/ui/Select"; +import { useToast } from "@/lib/hooks"; import { BrainRoleType } from "../../../types"; import { availableRoles } from "../types"; @@ -10,20 +12,35 @@ import { availableRoles } from "../types"; type BrainUserProps = { email: string; rights: BrainRoleType; + brainId: string; + fetchBrainUsers: () => Promise; }; export const BrainUser = ({ email, rights: role, + brainId, + fetchBrainUsers, }: BrainUserProps): JSX.Element => { + const { updateBrainAccess } = useBrainApi(); + const { publish } = useToast(); const [selectedRole, setSelectedRole] = useState(role); - const updateSelectedRole = (newRole: BrainRoleType) => { + const updateSelectedRole = async (newRole: BrainRoleType) => { setSelectedRole(newRole); + await updateBrainAccess(brainId, email, { + rights: newRole, + }); + publish({ variant: "success", text: `Updated ${email} to ${newRole}` }); + void fetchBrainUsers(); }; - const removeCurrentInvitation = () => { - alert("soon"); + const removeCurrentInvitation = async () => { + await updateBrainAccess(brainId, email, { + rights: null, + }); + publish({ variant: "success", text: `Removed ${email} from brain` }); + void fetchBrainUsers(); }; return ( @@ -31,7 +48,10 @@ export const BrainUser = ({ data-testid="assignation-row" className="flex flex-row align-center my-2 gap-3 items-center" > -
+
void removeCurrentInvitation()} + >
@@ -46,7 +66,7 @@ export const BrainUser = ({ />