Feat/shareable brains open link authenticated (#676)

* ♻️ use Single Responsibility Principle on brains_subscription

*  new brain subscription endpoints for invited user

* 📝 add documentation to endpoints

* 🎨 add base_frontend_url to send custom url for brain share

* ✏️ brains instead of brain in url

*  use origin in header for frontend url in subscription email

* 🚚 move and remove unused code

*  new subscription API for BE endpoints in frontend

*  new addBrain to  add a shared brain in frontend

* 🥚 new hook for brain invitations

*  new page for brain invitation

*  change frontend url to copy for brain subscription

* ✏️ call RBAC with wrapper function

* ✏️ last typos
This commit is contained in:
Zineb El Bachiri 2023-07-18 09:47:59 +02:00 committed by GitHub
parent f8fce33191
commit 0b091bd8c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 548 additions and 75 deletions

View File

@ -10,10 +10,9 @@ logger = get_logger(__name__)
class BrainSubscription(BaseModel):
brain_id: Optional[UUID] = None
inviter_email: Optional[str]
email: Optional[str]
rights: Optional[str]
brain_id: UUID
email: str
rights: str = "Viewer"
class Config:
arbitrary_types_allowed = True
@ -65,36 +64,4 @@ class BrainSubscription(BaseModel):
else:
response = self.create_subscription_invitation()
return response
def get_brain_url(self) -> str:
"""Generates the brain URL based on the brain_id."""
base_url = "https://www.quivr.app/chat"
return f"{base_url}?brain_subscription_invitation={self.brain_id}"
def resend_invitation_email(self):
brains_settings = BrainSettings() # pyright: ignore reportPrivateUsage=none
resend.api_key = brains_settings.resend_api_key
brain_url = self.get_brain_url()
html_body = f"""
<p>This brain has been shared with you by {self.inviter_email}.</p>
<p><a href='{brain_url}'>Click here</a> to access your brain.</p>
"""
try:
r = resend.Emails.send(
{
"from": brains_settings.resend_email_address,
"to": self.email,
"subject": "Quivr - Brain Shared With You",
"html": html_body,
}
)
print("Resend response", r)
except Exception as e:
logger.error(f"Error sending email: {e}")
return
return r
return response

View File

@ -0,0 +1,8 @@
import os
from uuid import UUID
def get_brain_url(origin: str, brain_id: UUID) -> str:
"""Generates the brain URL based on the brain_id."""
return f"{origin}/invitation/{brain_id}"

View File

@ -0,0 +1,37 @@
import os
from typing import Optional
from uuid import UUID
import resend
from logger import get_logger
from models.brains_subscription_invitations import BrainSubscription
from models.settings import BrainSettings
from repository.brain_subscription.get_brain_url import get_brain_url
logger = get_logger(__name__)
def resend_invitation_email(brain_subscription: BrainSubscription, inviter_email: str, origin: str = "https://www.quivr.app"):
brains_settings = BrainSettings() # pyright: ignore reportPrivateUsage=none
resend.api_key = brains_settings.resend_api_key
brain_url = get_brain_url(origin, brain_subscription.brain_id)
html_body = f"""
<p>This brain has been shared with you by {inviter_email}.</p>
<p><a href='{brain_url}'>Click here</a> to access your brain.</p>
"""
try:
r = resend.Emails.send({
"from": "onboarding@resend.dev",
"to": brain_subscription.email,
"subject": "Quivr - Brain Shared With You",
"html": html_body
})
logger.info('Resend response', r)
except Exception as e:
logger.error(f"Error sending email: {e}")
return
return r

View File

@ -0,0 +1,78 @@
from typing import Optional
from uuid import UUID
from logger import get_logger
from models.brains_subscription_invitations import BrainSubscription
from models.settings import CommonsDep, common_dependencies
logger = get_logger(__name__)
class SubscriptionInvitationService:
def __init__(self, commons: Optional[CommonsDep] = None):
self.commons = common_dependencies()
def create_subscription_invitation(self, brain_subscription: BrainSubscription):
logger.info("Creating subscription invitation")
response = (
self.commons["supabase"]
.table("brain_subscription_invitations")
.insert({"brain_id": str(brain_subscription.brain_id), "email": brain_subscription.email, "rights": brain_subscription.rights})
.execute()
)
return response.data
def update_subscription_invitation(self, brain_subscription: BrainSubscription):
logger.info('Updating subscription invitation')
response = (
self.commons["supabase"]
.table("brain_subscription_invitations")
.update({"rights": brain_subscription.rights})
.eq("brain_id", str(brain_subscription.brain_id))
.eq("email", brain_subscription.email)
.execute()
)
return response.data
def create_or_update_subscription_invitation(self, brain_subscription: BrainSubscription):
response = self.commons["supabase"].table("brain_subscription_invitations").select("*").eq("brain_id", str(brain_subscription.brain_id)).eq("email", brain_subscription.email).execute()
if response.data:
response = self.update_subscription_invitation(brain_subscription)
else:
response = self.create_subscription_invitation(brain_subscription)
return response
def check_invitation(self, brain_subscription: BrainSubscription):
response = self.commons["supabase"].table("brain_subscription_invitations").select("*").eq("brain_id", str(brain_subscription.brain_id)).eq("email", brain_subscription.email).execute()
return response.data != []
def fetch_invitation(self, subscription: BrainSubscription):
logger.info("Fetching subscription invitation")
response = (
self.commons["supabase"]
.table("brain_subscription_invitations")
.select("*")
.eq("brain_id", str(subscription.brain_id))
.eq("email", subscription.email)
.execute()
)
if response.data:
return response.data[0] # return the first matching invitation
else:
return None
def remove_invitation(self, subscription: BrainSubscription):
logger.info(f"Removing subscription invitation for email {subscription.email} and brain {subscription.brain_id}")
response = (
self.commons["supabase"]
.table("brain_subscription_invitations")
.delete()
.eq("brain_id", str(subscription.brain_id))
.eq("email", subscription.email)
.execute()
)
logger.info(f"Removed subscription invitation for email {subscription.email} and brain {subscription.brain_id}")
logger.info(response)
return response.data

View File

@ -3,14 +3,10 @@ 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__)

View File

@ -6,9 +6,7 @@ from models.brains import Brain
from models.settings import common_dependencies
from models.users import User
from routes.authorizations.brain_authorization import (
has_brain_authorization,
validate_brain_authorization,
)
has_brain_authorization, validate_brain_authorization)
explore_router = APIRouter()

View File

View File

@ -0,0 +1,7 @@
from typing import Optional
from fastapi import Header
def get_origin_header(origin: Optional[str] = Header(None)):
return origin

View File

@ -1,3 +1,4 @@
import os
from typing import List
from uuid import UUID
@ -6,32 +7,42 @@ 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 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 routes.authorizations.brain_authorization import has_brain_authorization
from routes.headers.get_origin_header import get_origin_header
subscription_router = APIRouter()
subscription_service = SubscriptionInvitationService()
@subscription_router.post("/brain/{brain_id}/subscription")
async def invite_user_to_brain(
brain_id: UUID, users: List[dict], current_user: User = Depends(get_current_user)
):
# TODO: Ensure the current user has permissions to invite users to this brain
@subscription_router.post(
"/brains/{brain_id}/subscription",
dependencies=[
Depends(
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)):
"""
Invite multiple users to a brain by their emails. This function creates
or updates a brain subscription invitation for each user and sends an
invitation email to each user.
"""
for user in users:
subscription = BrainSubscription(
brain_id=brain_id,
email=user["email"],
rights=user["rights"],
inviter_email=current_user.email or "Quivr",
)
subscription = BrainSubscription(brain_id=brain_id, email=user['email'], rights=user['rights'])
try:
subscription.create_or_update_subscription_invitation()
subscription.resend_invitation_email()
subscription_service.create_or_update_subscription_invitation(subscription)
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}")
@ -39,7 +50,7 @@ async def invite_user_to_brain(
@subscription_router.get(
"/brain/{brain_id}/users",
"/brains/{brain_id}/users",
dependencies=[Depends(AuthBearer()), Depends(has_brain_authorization())],
)
def get_brain_users(
@ -66,7 +77,7 @@ def get_brain_users(
@subscription_router.delete(
"/brain/{brain_id}/subscription",
"/brains/{brain_id}/subscription",
)
async def remove_user_subscription(
brain_id: UUID, current_user: User = Depends(get_current_user)
@ -101,3 +112,91 @@ async def remove_user_subscription(
brain.delete_user_from_brain(current_user.id)
return {"message": f"Subscription removed successfully from brain {brain_id}"}
@subscription_router.get(
"/brains/{brain_id}/subscription",
dependencies=[Depends(AuthBearer())],
tags=["BrainSubscription"],
)
def get_user_invitation(brain_id: UUID, current_user: User = Depends(get_current_user)):
"""
Get an invitation to a brain for a user. This function checks if the user
has been invited to the brain and returns the invitation status.
"""
if not current_user.email:
raise HTTPException(status_code=400, detail="User email is not defined")
subscription = BrainSubscription(brain_id=brain_id, email=current_user.email)
has_invitation = subscription_service.check_invitation(subscription)
return {"hasInvitation": has_invitation}
@subscription_router.post(
"/brains/{brain_id}/subscription/accept",
tags=["Brain"],
)
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
brain users.
"""
if not current_user.email:
raise HTTPException(status_code=400, detail="User email is not defined")
subscription = BrainSubscription(brain_id=brain_id, email=current_user.email)
try:
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")
try:
brain = Brain(id=brain_id)
brain.create_brain_user(
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}")
try:
subscription_service.remove_invitation(subscription)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error removing invitation: {e}")
return {"message": "Invitation accepted successfully"}
@subscription_router.post(
"/brains/{brain_id}/subscription/decline",
tags=["Brain"],
)
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.
"""
if not current_user.email:
raise HTTPException(status_code=400, detail="User email is not defined")
subscription = BrainSubscription(brain_id=brain_id, email=current_user.email)
try:
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")
try:
subscription_service.remove_invitation(subscription)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error removing invitation: {e}")
return {"message": "Invitation declined successfully"}

View File

@ -118,7 +118,7 @@ def test_delete_all_brains(client, api_key):
# Send a DELETE request to delete the specific brain
delete_response = client.delete(
f"/brain/{brain_id}/subscription",
f"/brains/{brain_id}/subscription",
headers={"Authorization": "Bearer " + api_key},
)

View File

@ -0,0 +1,142 @@
/* eslint-disable max-lines */
"use client";
import { UUID } from "crypto";
import { useParams, useRouter } from "next/navigation";
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";
interface UseInvitationReturn {
brainId: UUID | undefined;
handleAccept: () => Promise<void>;
handleDecline: () => Promise<void>;
isValidInvitation: boolean;
isLoading: boolean;
}
export const useInvitation = (): UseInvitationReturn => {
const params = useParams();
const brainId = params?.brainId as UUID | undefined;
const { publish } = useToast();
if (brainId === undefined) {
throw new Error("Brain ID is undefined");
}
const [isLoading, setIsLoading] = useState(false);
const [isValidInvitation, setIsValidInvitation] = useState(false);
const { acceptInvitation, declineInvitation } = useSubscriptionApi();
const checkValidInvitation = useCallback(
useSubscriptionApi().checkValidInvitation,
[]
);
const { addBrain, setActiveBrain } = useBrainContext();
const router = useRouter();
// Check invitation on component mount
useEffect(() => {
setIsLoading(true);
const checkInvitationValidity = async () => {
try {
console.log("Checking invitation validity...");
const validInvitation = await checkValidInvitation(brainId);
setIsValidInvitation(validInvitation);
console.log("validInvitation", validInvitation);
if (!validInvitation) {
publish({
variant: "warning",
text: "This invitation is not valid.",
});
router.push("/");
}
} catch (error) {
console.error("Error checking invitation validity:", error);
} finally {
setIsLoading(false);
}
};
checkInvitationValidity().catch((error) => {
console.error("Error checking invitation validity:", error);
});
}, [brainId, checkValidInvitation]);
const handleAccept = async () => {
// API call to accept the invitation
// After success, redirect user to a specific page -> chat page
try {
const response = await acceptInvitation(brainId);
console.log(response.message);
await addBrain(brainId);
setActiveBrain({ id: brainId, name: "BrainName" });
//set brainId as active brain
publish({
variant: "success",
text: JSON.stringify(response.message),
});
} catch (error) {
// @ts-ignore Error is of unknown type
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (error.response.data.detail !== undefined) {
publish({
variant: "danger",
// @ts-ignore Error is of unknown type
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
text: error.response.data.detail,
});
} else {
console.error("Error calling the API:", error);
publish({
variant: "danger",
text: "An unknown error occurred while accepting the invitaiton",
});
}
} finally {
void router.push("/chat");
}
};
const handleDecline = async () => {
// API call to accept the invitation
// After success, redirect user to a specific page -> home page
try {
const response = await declineInvitation(brainId);
console.log(response.message);
publish({
variant: "success",
text: JSON.stringify(response.message),
});
} catch (error) {
// @ts-ignore Error is of unknown type
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (error.response.data.detail !== undefined) {
publish({
variant: "danger",
// @ts-ignore Error is of unknown type
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
text: error.response.data.detail,
});
} else {
console.error("Error calling the API:", error);
publish({
variant: "danger",
text: "An unknown error occurred while declining the invitaiton",
});
}
} finally {
void router.push("/upload");
}
};
return {
brainId,
handleAccept,
handleDecline,
isValidInvitation,
isLoading,
};
};

View File

@ -0,0 +1,52 @@
"use client";
import Button from "@/lib/components/ui/Button";
import PageHeading from "@/lib/components/ui/PageHeading";
import Spinner from "@/lib/components/ui/Spinner";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { redirectToLogin } from "@/lib/router/redirectToLogin";
import { useInvitation } from "./hooks/useInvitation";
const InvitationPage = (): JSX.Element => {
const { handleAccept, handleDecline, isLoading } = useInvitation();
const { session } = useSupabase();
// Show the loader while invitation validity is being checked
if (isLoading) {
return <Spinner />;
}
// TODO: Modify this to fetch the brain name from the database
const brain = { name: "TestBrain" };
if (session?.user === undefined) {
redirectToLogin();
}
return (
<main className="pt-10">
<PageHeading
title={`Welcome to ${brain.name}!`}
subtitle="You have been exclusively invited to join this brain and start exploring. Do you accept this exciting journey?"
/>
<div className="flex flex-col items-center justify-center gap-5 mt-5">
<Button
onClick={() => void handleAccept()}
variant={"secondary"}
className="py-3"
>
Yes, count me in!
</Button>
<Button
onClick={() => void handleDecline()}
variant={"danger"}
className="py-3"
>
No, thank you.
</Button>
</div>
</main>
);
};
export default InvitationPage;

View File

@ -70,7 +70,7 @@ describe("useBrainApi", () => {
await deleteBrain(id);
expect(axiosDeleteMock).toHaveBeenCalledTimes(1);
expect(axiosDeleteMock).toHaveBeenCalledWith(`/brain/${id}/subscription`);
expect(axiosDeleteMock).toHaveBeenCalledWith(`/brains/${id}/subscription`);
});
it("should call getDefaultBrain with the correct parameters", async () => {
@ -127,7 +127,7 @@ describe("useBrainApi", () => {
expect(axiosPostMock).toHaveBeenCalledTimes(1);
expect(axiosPostMock).toHaveBeenCalledWith(
`/brain/${id}/subscription`,
`/brains/${id}/subscription`,
subscriptions
);
});
@ -142,6 +142,6 @@ describe("useBrainApi", () => {
await getBrainUsers(id);
expect(axiosGetMock).toHaveBeenCalledTimes(1);
expect(axiosGetMock).toHaveBeenCalledWith(`/brain/${id}/users`);
expect(axiosGetMock).toHaveBeenCalledWith(`/brains/${id}/users`);
});
});

View File

@ -40,7 +40,7 @@ export const deleteBrain = async (
brainId: string,
axiosInstance: AxiosInstance
): Promise<void> => {
await axiosInstance.delete(`/brain/${brainId}/subscription`);
await axiosInstance.delete(`/brains/${brainId}/subscription`);
};
export const getDefaultBrain = async (
@ -68,13 +68,13 @@ export const addBrainSubscriptions = async (
subscriptions: Subscription[],
axiosInstance: AxiosInstance
): Promise<void> => {
await axiosInstance.post(`/brain/${brainId}/subscription`, subscriptions);
await axiosInstance.post(`/brains/${brainId}/subscription`, subscriptions);
};
export const getBrainUsers = async (
brainId: string,
axiosInstance: AxiosInstance
): Promise<Subscription[]> => {
return (await axiosInstance.get<Subscription[]>(`/brain/${brainId}/users`))
return (await axiosInstance.get<Subscription[]>(`/brains/${brainId}/users`))
.data;
};

View File

@ -0,0 +1,43 @@
import { AxiosInstance } from "axios";
import { UUID } from "crypto";
export const acceptInvitation = async (
brainId: UUID,
axiosInstance: AxiosInstance
): Promise<{ message: string }> => {
const acceptedInvitation = (
await axiosInstance.post<{ message: string }>(
`/brains/${brainId}/subscription/accept`
)
).data;
console.log("acceptedInvitation", acceptedInvitation);
return acceptedInvitation;
};
export const declineInvitation = async (
brainId: UUID,
axiosInstance: AxiosInstance
): Promise<{ message: string }> => {
const deletedInvitation = (
await axiosInstance.post<{ message: string }>(
`/brains/${brainId}/subscription/decline`
)
).data;
return deletedInvitation;
};
export const checkValidInvitation = async (
brainId: UUID,
axiosInstance: AxiosInstance
): Promise<boolean> => {
const answer = await axiosInstance.get<{ hasInvitation: boolean }>(
`/brains/${brainId}/subscription`
);
const toto = answer.data["hasInvitation"];
console.log(answer);
return toto;
};

View File

@ -0,0 +1,23 @@
import { UUID } from "crypto";
import { useAxios } from "@/lib/hooks";
import {
acceptInvitation,
checkValidInvitation,
declineInvitation,
} from "./subscription";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useSubscriptionApi = () => {
const { axiosInstance } = useAxios();
return {
acceptInvitation: async (brainId: UUID) =>
acceptInvitation(brainId, axiosInstance),
declineInvitation: async (brainId: UUID) =>
declineInvitation(brainId, axiosInstance),
checkValidInvitation: async (brainId: UUID) =>
checkValidInvitation(brainId, axiosInstance),
};
};

View File

@ -19,7 +19,7 @@ export const useShareBrain = (brainId: string) => {
>([generateBrainAssignation()]);
const baseUrl = window.location.origin;
const brainShareLink = `${baseUrl}/brain_subscription_invitation=${brainId}`;
const brainShareLink = `${baseUrl}/invitation/${brainId}`;
const { publish } = useToast();
const { addBrainSubscriptions, getBrainUsers } = useBrainApi();

View File

@ -18,7 +18,7 @@ import { Brain } from "../types";
export const useBrainProvider = () => {
const { publish } = useToast();
const { track } = useEventTracking();
const { createBrain, deleteBrain, getBrains, getDefaultBrain } =
const { createBrain, deleteBrain, getBrains, getDefaultBrain, getBrain } =
useBrainApi();
const [allBrains, setAllBrains] = useState<Brain[]>([]);
@ -45,6 +45,28 @@ export const useBrainProvider = () => {
}
};
const addBrain = async (id: UUID): Promise<void> => {
const brain = await getBrain(id);
if (brain === undefined) {
publish({
variant: "danger",
text: "Error occurred while adding a brain",
});
return;
}
try {
setAllBrains((prevBrains) => [...prevBrains, brain]);
saveBrainInLocalStorage(brain);
void track("BRAIN_ADDED");
} catch {
publish({
variant: "danger",
text: "Error occurred while adding a brain",
});
}
};
const deleteBrainHandler = async (id: UUID) => {
await deleteBrain(id);
setAllBrains((prevBrains) => prevBrains.filter((brain) => brain.id !== id));
@ -102,6 +124,7 @@ export const useBrainProvider = () => {
allBrains,
createBrain: createBrainHandler,
deleteBrain: deleteBrainHandler,
addBrain,
setActiveBrain,
fetchAllBrains,
setDefaultBrain,