Shareable brain 8 (#674)

* feat(ShareableBrain): add get brain users endpoints

* feat(sdk): add getBrainUsers

* feat(ShareableBrain): display users with access

* feat: rename role to rights

* fix(Brain): fecth brains on auth status change
This commit is contained in:
Mamadou DICKO 2023-07-17 15:45:18 +02:00 committed by GitHub
parent 4d00a1ec92
commit 430ab54479
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 202 additions and 35 deletions

View File

@ -0,0 +1,11 @@
from models.settings import common_dependencies
def get_user_email_by_user_id(user_id: int) -> str:
commons = common_dependencies()
response = (
commons["supabase"]
.rpc("get_user_email_by_user_id", {"user_id": user_id})
.execute()
)
return response.data[0]["email"]

View File

@ -1,11 +1,16 @@
from typing import List
from uuid import UUID
from auth.auth_bearer import get_current_user
from auth.auth_bearer import AuthBearer, get_current_user
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.user.get_user_email_by_user_id import get_user_email_by_user_id
from routes.authorizations.brain_authorization import (
has_brain_authorization,
)
subscription_router = APIRouter()
@ -33,6 +38,33 @@ async def invite_user_to_brain(
return {"message": "Invitations sent successfully"}
@subscription_router.get(
"/brain/{brain_id}/users",
dependencies=[Depends(AuthBearer()), Depends(has_brain_authorization())],
)
def get_brain_users(
brain_id: UUID,
):
"""
Get all users for a brain
"""
brain = Brain(
id=brain_id,
)
brain_users = brain.get_brain_users()
brain_access_list = []
for brain_user in brain_users:
brain_access = {}
# TODO: find a way to fetch user email concurrently
brain_access["email"] = get_user_email_by_user_id(brain_user["user_id"])
brain_access["rights"] = brain_user["rights"]
brain_access_list.append(brain_access)
return brain_access_list
@subscription_router.delete(
"/brain/{brain_id}/subscription",
)
@ -55,10 +87,10 @@ async def remove_user_subscription(
if user_brain.get("rights") != "Owner":
brain.delete_user_from_brain(current_user.id)
else:
brain_other_users = brain.get_brain_users()
brain_users = brain.get_brain_users()
brain_other_owners = [
brain
for brain in brain_other_users
for brain in brain_users
if brain["rights"] == "Owner"
and str(brain["user_id"]) != str(current_user.id)
]

View File

@ -6,15 +6,17 @@ import Footer from "@/lib/components/Footer";
import { NavBar } from "@/lib/components/NavBar";
import { TrackingWrapper } from "@/lib/components/TrackingWrapper";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useSupabase } from "@/lib/context/SupabaseProvider";
// This wrapper is used to make effect calls at a high level in app rendering.
export const App = ({ children }: PropsWithChildren): JSX.Element => {
const { fetchAllBrains, fetchAndSetActiveBrain } = useBrainContext();
const { session } = useSupabase();
useEffect(() => {
void fetchAllBrains();
void fetchAndSetActiveBrain();
}, []);
}, [session?.user]);
return (
<>

View File

@ -131,4 +131,17 @@ describe("useBrainApi", () => {
subscriptions
);
});
it("should call getBrainUsers with the correct parameters", async () => {
const {
result: {
current: { getBrainUsers },
},
} = renderHook(() => useBrainApi());
const id = "123";
await getBrainUsers(id);
expect(axiosGetMock).toHaveBeenCalledTimes(1);
expect(axiosGetMock).toHaveBeenCalledWith(`/brain/${id}/users`);
});
});

View File

@ -61,12 +61,20 @@ export const getBrains = async (
return brains.brains;
};
export type Subscription = { email: string; rights: BrainRoleType }[];
export type Subscription = { email: string; rights: BrainRoleType };
export const addBrainSubscriptions = async (
brainId: string,
subscriptions: Subscription,
subscriptions: Subscription[],
axiosInstance: AxiosInstance
): Promise<void> => {
await axiosInstance.post(`/brain/${brainId}/subscription`, subscriptions);
};
export const getBrainUsers = async (
brainId: string,
axiosInstance: AxiosInstance
): Promise<Subscription[]> => {
return (await axiosInstance.get<Subscription[]>(`/brain/${brainId}/users`))
.data;
};

View File

@ -7,6 +7,7 @@ import {
getBrain,
getBrainDocuments,
getBrains,
getBrainUsers,
getDefaultBrain,
Subscription,
} from "./brain";
@ -25,7 +26,9 @@ export const useBrainApi = () => {
getBrain: async (id: string) => getBrain(id, axiosInstance),
addBrainSubscriptions: async (
brainId: string,
subscriptions: Subscription
subscriptions: Subscription[]
) => addBrainSubscriptions(brainId, subscriptions, axiosInstance),
getBrainUsers: async (brainId: string) =>
getBrainUsers(brainId, axiosInstance),
};
};

View File

@ -8,7 +8,8 @@ import { MdContentPaste, MdShare } from "react-icons/md";
import Button from "@/lib/components/ui/Button";
import { Modal } from "@/lib/components/ui/Modal";
import { InvitedUserRow } from "./components/InvitedUserRow";
import { BrainUser } from "./components";
import { UserToInvite } from "./components/UserToInvite";
import { useShareBrain } from "./hooks/useShareBrain";
type ShareBrainModalProps = {
@ -27,6 +28,7 @@ export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
sendingInvitation,
setIsShareModalOpen,
isShareModalOpen,
brainUsers,
} = useShareBrain(brainId);
const canAddNewRow =
@ -75,7 +77,7 @@ export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
<div className="bg-gray-100 h-0.5 mb-5 border-gray-200 dark:border-gray-700" />
{roleAssignations.map((roleAssignation, index) => (
<InvitedUserRow
<UserToInvite
key={roleAssignation.id}
onChange={updateRoleAssignation(index)}
removeCurrentInvitation={removeRoleAssignation(index)}
@ -99,6 +101,15 @@ export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
</Button>
</div>
</form>
<div className="bg-gray-100 h-0.5 mb-5 border-gray-200 dark:border-gray-700" />
<p className="text-lg font-bold">Users with access</p>
{brainUsers.map((subscription) => (
<BrainUser
key={subscription.email}
email={subscription.email}
rights={subscription.rights}
/>
))}
</Modal>
);
};

View File

@ -20,6 +20,20 @@ vi.mock("@/lib/context/BrainConfigProvider/brain-config-provider", () => ({
BrainConfigContext: BrainConfigContextMock,
}));
vi.mock("@/lib/api/brain/useBrainApi", async () => {
const actual = await vi.importActual<
typeof import("@/lib/api/brain/useBrainApi")
>("@/lib/api/brain/useBrainApi");
return {
...actual,
useBrainApi: () => ({
...actual.useBrainApi(),
getBrainUsers: () => Promise.resolve([]),
}),
};
});
describe("ShareBrain", () => {
afterEach(() => {
vi.restoreAllMocks();

View File

@ -0,0 +1,55 @@
import { useState } from "react";
import { MdOutlineRemoveCircle } from "react-icons/md";
import Field from "@/lib/components/ui/Field";
import { Select } from "@/lib/components/ui/Select";
import { BrainRoleType } from "../../../types";
import { availableRoles } from "../types";
type BrainUserProps = {
email: string;
rights: BrainRoleType;
};
export const BrainUser = ({
email,
rights: role,
}: BrainUserProps): JSX.Element => {
const [selectedRole, setSelectedRole] = useState<BrainRoleType>(role);
const updateSelectedRole = (newRole: BrainRoleType) => {
setSelectedRole(newRole);
};
const removeCurrentInvitation = () => {
alert("soon");
};
return (
<div
data-testid="assignation-row"
className="flex flex-row align-center my-2 gap-3 items-center"
>
<div className="cursor-pointer" onClick={removeCurrentInvitation}>
<MdOutlineRemoveCircle />
</div>
<div className="flex flex-1">
<Field
name="email"
required
type="email"
placeholder="Email"
value={email}
data-testid="role-assignation-email-input"
readOnly
/>
</div>
<Select
onChange={updateSelectedRole}
value={selectedRole}
options={availableRoles}
/>
</div>
);
};

View File

@ -5,29 +5,21 @@ import Field from "@/lib/components/ui/Field";
import { Select } from "@/lib/components/ui/Select";
import { BrainRoleAssignation, BrainRoleType } from "../../../types";
import { availableRoles } from "../types";
type AddUserRowProps = {
type UserToInviteProps = {
onChange: (newRole: BrainRoleAssignation) => void;
removeCurrentInvitation?: () => void;
roleAssignation: BrainRoleAssignation;
};
type SelectOptionsProps = {
label: string;
value: BrainRoleType;
};
const SelectOptions: SelectOptionsProps[] = [
{ label: "Viewer", value: "viewer" },
{ label: "Editor", value: "editor" },
];
export const InvitedUserRow = ({
export const UserToInvite = ({
onChange,
removeCurrentInvitation,
roleAssignation,
}: AddUserRowProps): JSX.Element => {
}: UserToInviteProps): JSX.Element => {
const [selectedRole, setSelectedRole] = useState<BrainRoleType>(
roleAssignation.role
roleAssignation.rights
);
const [email, setEmail] = useState(roleAssignation.email);
@ -35,7 +27,7 @@ export const InvitedUserRow = ({
onChange({
...roleAssignation,
email,
role: selectedRole,
rights: selectedRole,
});
}, [email, selectedRole]);
@ -62,7 +54,7 @@ export const InvitedUserRow = ({
<Select
onChange={setSelectedRole}
value={selectedRole}
options={SelectOptions}
options={availableRoles}
/>
</div>
);

View File

@ -1 +1,2 @@
export * from "./InvitedUserRow";
export * from "./BrainUser";
export * from "./UserToInvite";

View File

@ -1,7 +1,9 @@
/* eslint-disable max-lines */
import { useState } from "react";
import { useEffect, useState } from "react";
import { Subscription } from "@/lib/api/brain/brain";
import { useBrainApi } from "@/lib/api/brain/useBrainApi";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { useToast } from "@/lib/hooks";
import { BrainRoleAssignation } from "../../../types";
@ -9,17 +11,20 @@ import { generateBrainAssignation } from "../utils/generateBrainAssignation";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useShareBrain = (brainId: string) => {
const baseUrl = window.location.origin;
const { publish } = useToast();
const { addBrainSubscriptions } = useBrainApi();
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const brainShareLink = `${baseUrl}/brain_subscription_invitation=${brainId}`;
const [brainUsers, setBrainUsers] = useState<Subscription[]>([]);
const [sendingInvitation, setSendingInvitation] = useState(false);
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [roleAssignations, setRoleAssignation] = useState<
BrainRoleAssignation[]
>([generateBrainAssignation()]);
const baseUrl = window.location.origin;
const brainShareLink = `${baseUrl}/brain_subscription_invitation=${brainId}`;
const { publish } = useToast();
const { addBrainSubscriptions, getBrainUsers } = useBrainApi();
const { session } = useSupabase();
const handleCopyInvitationLink = async () => {
await navigator.clipboard.writeText(brainShareLink);
publish({
@ -63,7 +68,7 @@ export const useShareBrain = (brainId: string) => {
.filter(({ email }) => email !== "")
.map((assignation) => ({
email: assignation.email,
rights: assignation.role,
rights: assignation.rights,
}));
await addBrainSubscriptions(brainId, inviteUsersPayload);
@ -88,6 +93,15 @@ export const useShareBrain = (brainId: string) => {
setRoleAssignation([...roleAssignations, generateBrainAssignation()]);
};
useEffect(() => {
const fetchBrainUsers = async () => {
const users = await getBrainUsers(brainId);
setBrainUsers(users.filter(({ email }) => email !== session?.user.email));
};
void fetchBrainUsers();
}, []);
return {
roleAssignations,
brainShareLink,
@ -99,5 +113,6 @@ export const useShareBrain = (brainId: string) => {
sendingInvitation,
setIsShareModalOpen,
isShareModalOpen,
brainUsers,
};
};

View File

@ -0,0 +1,10 @@
import { BrainRoleType } from "../../../types";
export type SelectOptionsProps = {
label: string;
value: BrainRoleType;
};
export const availableRoles: SelectOptionsProps[] = [
{ label: "Viewer", value: "viewer" },
{ label: "Editor", value: "editor" },
];

View File

@ -3,7 +3,7 @@ import { BrainRoleAssignation } from "../../../types";
export const generateBrainAssignation = (): BrainRoleAssignation => {
return {
email: "",
role: "viewer",
rights: "viewer",
id: Math.random().toString(),
};
};

View File

@ -4,6 +4,6 @@ export type BrainRoleType = (typeof roles)[number];
export type BrainRoleAssignation = {
email: string;
role: BrainRoleType;
rights: BrainRoleType;
id: string;
};