mirror of
https://github.com/StanGirard/quivr.git
synced 2024-09-11 13:15:41 +03:00
Feat/brain access rights (#705)
* refactor(BrainUsers) * feat: give brain share access to EDITORs * feat(RBAC): add role enum and supports multiple roles check * feat: make owner right read only for other permissions
This commit is contained in:
parent
6a7bda392c
commit
87458d8de1
@ -1,4 +1,5 @@
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from auth.auth_bearer import get_current_user
|
||||
@ -7,16 +8,27 @@ from models.brains import Brain
|
||||
from models.users import User
|
||||
|
||||
|
||||
def has_brain_authorization(required_role: Optional[str] = "Owner"):
|
||||
class RoleEnum(str, Enum):
|
||||
Viewer = "Viewer"
|
||||
Editor = "Editor"
|
||||
Owner = "Owner"
|
||||
|
||||
|
||||
def has_brain_authorization(
|
||||
required_roles: Optional[Union[RoleEnum, List[RoleEnum]]] = RoleEnum.Owner
|
||||
):
|
||||
"""
|
||||
Decorator to check if the user has the required role for the brain
|
||||
param: required_role: The role required to access the brain
|
||||
Decorator to check if the user has the required role(s) for the brain
|
||||
param: required_roles: The role(s) required to access the brain
|
||||
return: A wrapper function that checks the authorization
|
||||
"""
|
||||
|
||||
async def wrapper(brain_id: UUID, current_user: User = Depends(get_current_user)):
|
||||
nonlocal required_roles
|
||||
if isinstance(required_roles, str):
|
||||
required_roles = [required_roles] # Convert single role to a list
|
||||
validate_brain_authorization(
|
||||
brain_id=brain_id, user_id=current_user.id, required_role=required_role
|
||||
brain_id=brain_id, user_id=current_user.id, required_roles=required_roles
|
||||
)
|
||||
|
||||
return wrapper
|
||||
@ -25,17 +37,17 @@ def has_brain_authorization(required_role: Optional[str] = "Owner"):
|
||||
def validate_brain_authorization(
|
||||
brain_id: UUID,
|
||||
user_id: UUID,
|
||||
required_role: Optional[str] = "Owner",
|
||||
required_roles: Optional[Union[RoleEnum, List[RoleEnum]]] = RoleEnum.Owner,
|
||||
):
|
||||
"""
|
||||
Function to check if the user has the required role for the brain
|
||||
Function to check if the user has the required role(s) for the brain
|
||||
param: brain_id: The id of the brain
|
||||
param: user_id: The id of the user
|
||||
param: required_role: The role required to access the brain
|
||||
param: required_roles: The role(s) required to access the brain
|
||||
return: None
|
||||
"""
|
||||
|
||||
if required_role is None:
|
||||
if required_roles is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing required role",
|
||||
@ -49,10 +61,13 @@ def validate_brain_authorization(
|
||||
detail="You don't have permission for this brain",
|
||||
)
|
||||
|
||||
# TODO: Update this logic when we have more roles
|
||||
# Eg: Owner > Admin > User ... this should be taken into account
|
||||
if user_brain.get("rights") != required_role:
|
||||
# Convert single role to a list to handle both cases
|
||||
if isinstance(required_roles, str):
|
||||
required_roles = [required_roles]
|
||||
|
||||
# Check if the user has at least one of the required roles
|
||||
if user_brain.get("rights") not in required_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have the required role for this brain",
|
||||
detail="You don't have the required role(s) for this brain",
|
||||
)
|
||||
|
@ -18,6 +18,7 @@ from repository.user.get_user_email_by_user_id import get_user_email_by_user_id
|
||||
from repository.user.get_user_id_by_user_email import get_user_id_by_user_email
|
||||
|
||||
from routes.authorizations.brain_authorization import (
|
||||
RoleEnum,
|
||||
has_brain_authorization,
|
||||
validate_brain_authorization,
|
||||
)
|
||||
@ -33,7 +34,7 @@ subscription_service = SubscriptionInvitationService()
|
||||
Depends(
|
||||
AuthBearer(),
|
||||
),
|
||||
Depends(has_brain_authorization),
|
||||
Depends(has_brain_authorization([RoleEnum.Owner, RoleEnum.Editor])),
|
||||
Depends(get_origin_header),
|
||||
],
|
||||
tags=["BrainSubscription"],
|
||||
@ -49,7 +50,6 @@ def invite_users_to_brain(
|
||||
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"]
|
||||
@ -68,7 +68,10 @@ def invite_users_to_brain(
|
||||
|
||||
@subscription_router.get(
|
||||
"/brains/{brain_id}/users",
|
||||
dependencies=[Depends(AuthBearer()), Depends(has_brain_authorization())],
|
||||
dependencies=[
|
||||
Depends(AuthBearer()),
|
||||
Depends(has_brain_authorization([RoleEnum.Owner, RoleEnum.Editor])),
|
||||
],
|
||||
)
|
||||
def get_brain_users(
|
||||
brain_id: UUID,
|
||||
@ -244,15 +247,19 @@ class BrainSubscriptionUpdatableProperties(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
@subscription_router.put("/brains/{brain_id}/subscription")
|
||||
@subscription_router.put(
|
||||
"/brains/{brain_id}/subscription",
|
||||
dependencies=[
|
||||
Depends(AuthBearer()),
|
||||
Depends(has_brain_authorization([RoleEnum.Owner, RoleEnum.Editor])),
|
||||
],
|
||||
)
|
||||
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,
|
||||
@ -260,11 +267,40 @@ def update_brain_subscription(
|
||||
)
|
||||
|
||||
user_id = get_user_id_by_user_email(user_email)
|
||||
brain = Brain(
|
||||
id=brain_id,
|
||||
)
|
||||
|
||||
# check if user is an editor but trying to give high level permissions
|
||||
if subscription.rights == "Owner":
|
||||
try:
|
||||
validate_brain_authorization(
|
||||
brain_id,
|
||||
current_user.id,
|
||||
RoleEnum.Owner,
|
||||
)
|
||||
except HTTPException:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You don't have the rights to give owner permissions",
|
||||
)
|
||||
|
||||
# check if user is not an editor trying to update an owner right which is not allowed
|
||||
current_invitation = brain.get_brain_for_user(user_id)
|
||||
if current_invitation is not None and current_invitation.get("rights") == "Owner":
|
||||
try:
|
||||
validate_brain_authorization(
|
||||
brain_id,
|
||||
current_user.id,
|
||||
RoleEnum.Owner,
|
||||
)
|
||||
except HTTPException:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You can't change the permissions of an owner",
|
||||
)
|
||||
|
||||
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)
|
||||
|
@ -1,15 +1,20 @@
|
||||
import { MinimalBrainForUser } from "@/lib/context/BrainProvider/types";
|
||||
|
||||
import { DeleteBrain, ShareBrain } from "./components";
|
||||
import { BrainRoleType } from "./types";
|
||||
|
||||
type BrainActionsProps = {
|
||||
brain: MinimalBrainForUser;
|
||||
};
|
||||
|
||||
const requiredAccessToShareBrain: BrainRoleType[] = ["Owner", "Editor"];
|
||||
|
||||
export const BrainActions = ({ brain }: BrainActionsProps): JSX.Element => {
|
||||
return (
|
||||
<div className="absolute right-0 flex flex-row">
|
||||
{brain.rights === "Owner" && <ShareBrain brainId={brain.id} />}
|
||||
{requiredAccessToShareBrain.includes(brain.rights) && (
|
||||
<ShareBrain brainId={brain.id} name={brain.name} />
|
||||
)}
|
||||
<DeleteBrain brainId={brain.id} />
|
||||
</div>
|
||||
);
|
||||
|
@ -8,15 +8,19 @@ import { MdContentPaste, MdShare } from "react-icons/md";
|
||||
import Button from "@/lib/components/ui/Button";
|
||||
import { Modal } from "@/lib/components/ui/Modal";
|
||||
|
||||
import { BrainUsers } from "./components/BrainUsers";
|
||||
import { BrainUsers } from "./components/BrainUsers/BrainUsers";
|
||||
import { UserToInvite } from "./components/UserToInvite";
|
||||
import { useShareBrain } from "./hooks/useShareBrain";
|
||||
|
||||
type ShareBrainModalProps = {
|
||||
brainId: UUID;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
|
||||
export const ShareBrain = ({
|
||||
brainId,
|
||||
name,
|
||||
}: ShareBrainModalProps): JSX.Element => {
|
||||
const {
|
||||
roleAssignations,
|
||||
brainShareLink,
|
||||
@ -28,9 +32,6 @@ export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
|
||||
sendingInvitation,
|
||||
setIsShareModalOpen,
|
||||
isShareModalOpen,
|
||||
brainUsers,
|
||||
fetchBrainUsers,
|
||||
isFetchingBrainUsers,
|
||||
} = useShareBrain(brainId);
|
||||
|
||||
const canAddNewRow =
|
||||
@ -51,7 +52,7 @@ export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
|
||||
</Button>
|
||||
}
|
||||
CloseTrigger={<div />}
|
||||
title="Share brain"
|
||||
title={`Share brain ${name}`}
|
||||
isOpen={isShareModalOpen}
|
||||
setOpen={setIsShareModalOpen}
|
||||
>
|
||||
@ -108,12 +109,7 @@ export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
|
||||
</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
|
||||
isFetchingBrainUsers={isFetchingBrainUsers}
|
||||
brainId={brainId}
|
||||
fetchBrainUsers={fetchBrainUsers}
|
||||
users={brainUsers}
|
||||
/>
|
||||
<BrainUsers brainId={brainId} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@ -43,7 +44,10 @@ describe("ShareBrain", () => {
|
||||
const { getByTestId } = render(
|
||||
<SupabaseProviderMock>
|
||||
<BrainConfigProviderMock>
|
||||
<ShareBrain brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318" />
|
||||
<ShareBrain
|
||||
name="test"
|
||||
brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318"
|
||||
/>
|
||||
</BrainConfigProviderMock>
|
||||
</SupabaseProviderMock>
|
||||
);
|
||||
@ -56,20 +60,26 @@ describe("ShareBrain", () => {
|
||||
// Todo: add a custom render function that wraps the component with the providers
|
||||
<SupabaseProviderMock>
|
||||
<BrainConfigProviderMock>
|
||||
<ShareBrain brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318" />
|
||||
<ShareBrain
|
||||
name="test"
|
||||
brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318"
|
||||
/>
|
||||
</BrainConfigProviderMock>
|
||||
</SupabaseProviderMock>
|
||||
);
|
||||
const shareButton = getByTestId("share-brain-button");
|
||||
fireEvent.click(shareButton);
|
||||
expect(getByText("Share brain")).toBeDefined();
|
||||
expect(getByText("Share brain test")).toBeDefined();
|
||||
});
|
||||
|
||||
it('shoud add new user row when "Add new user" button is clicked and only where there is no empty field', async () => {
|
||||
const { getByTestId, findAllByTestId } = render(
|
||||
<SupabaseProviderMock>
|
||||
<BrainConfigProviderMock>
|
||||
<ShareBrain brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318" />
|
||||
<ShareBrain
|
||||
name="test"
|
||||
brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318"
|
||||
/>
|
||||
</BrainConfigProviderMock>
|
||||
</SupabaseProviderMock>
|
||||
);
|
||||
|
@ -1,13 +1,16 @@
|
||||
/* eslint-disable max-lines */
|
||||
import axios from "axios";
|
||||
import { useState } from "react";
|
||||
import { MdOutlineRemoveCircle, MdOutlineTimelapse } 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 { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
|
||||
import { useToast } from "@/lib/hooks";
|
||||
|
||||
import { BrainRoleType } from "../../../types";
|
||||
import { availableRoles } from "../types";
|
||||
import { BrainRoleType } from "../../../../types";
|
||||
import { availableRoles } from "../../types";
|
||||
|
||||
type BrainUserProps = {
|
||||
email: string;
|
||||
@ -18,22 +21,42 @@ type BrainUserProps = {
|
||||
|
||||
export const BrainUser = ({
|
||||
email,
|
||||
rights: role,
|
||||
rights,
|
||||
brainId,
|
||||
fetchBrainUsers,
|
||||
}: BrainUserProps): JSX.Element => {
|
||||
const { updateBrainAccess } = useBrainApi();
|
||||
const { publish } = useToast();
|
||||
const [selectedRole, setSelectedRole] = useState<BrainRoleType>(role);
|
||||
const [selectedRole, setSelectedRole] = useState<BrainRoleType>(rights);
|
||||
const [isRemovingAccess, setIsRemovingAccess] = useState(false);
|
||||
|
||||
const { currentBrain } = useBrainContext();
|
||||
const updateSelectedRole = async (newRole: BrainRoleType) => {
|
||||
setSelectedRole(newRole);
|
||||
await updateBrainAccess(brainId, email, {
|
||||
rights: newRole,
|
||||
});
|
||||
publish({ variant: "success", text: `Updated ${email} to ${newRole}` });
|
||||
void fetchBrainUsers();
|
||||
try {
|
||||
await updateBrainAccess(brainId, email, {
|
||||
rights: newRole,
|
||||
});
|
||||
publish({ variant: "success", text: `Updated ${email} to ${newRole}` });
|
||||
void fetchBrainUsers();
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
publish({
|
||||
variant: "danger",
|
||||
text: `${JSON.stringify(
|
||||
(
|
||||
e.response as {
|
||||
data: { detail: string };
|
||||
}
|
||||
).data.detail
|
||||
)}`,
|
||||
});
|
||||
} else {
|
||||
publish({
|
||||
variant: "danger",
|
||||
text: `Failed to update ${email} to ${newRole}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeUserAccess = async () => {
|
||||
@ -83,6 +106,7 @@ export const BrainUser = ({
|
||||
onChange={(newRole) => void updateSelectedRole(newRole)}
|
||||
value={selectedRole}
|
||||
options={availableRoles}
|
||||
readOnly={currentBrain?.rights !== "Owner" && selectedRole === "Owner"}
|
||||
/>
|
||||
</div>
|
||||
);
|
@ -1,32 +1,25 @@
|
||||
import { UUID } from "crypto";
|
||||
|
||||
import { Subscription } from "@/lib/api/brain/brain";
|
||||
|
||||
import { BrainUser } from "./BrainUser";
|
||||
import { useBrainUsers } from "./hooks/useBrainUsers";
|
||||
|
||||
type BrainUsersProps = {
|
||||
users: Subscription[];
|
||||
brainId: UUID;
|
||||
fetchBrainUsers: () => Promise<void>;
|
||||
isFetchingBrainUsers: boolean;
|
||||
};
|
||||
export const BrainUsers = ({
|
||||
users,
|
||||
brainId,
|
||||
fetchBrainUsers,
|
||||
isFetchingBrainUsers,
|
||||
}: BrainUsersProps): JSX.Element => {
|
||||
export const BrainUsers = ({ brainId }: BrainUsersProps): JSX.Element => {
|
||||
const { brainUsers, fetchBrainUsers, isFetchingBrainUsers } =
|
||||
useBrainUsers(brainId);
|
||||
if (isFetchingBrainUsers) {
|
||||
return <p className="text-gray-500">Loading...</p>;
|
||||
}
|
||||
|
||||
if (users.length === 0) {
|
||||
return <p className="text-gray-500">No users</p>;
|
||||
if (brainUsers.length === 0) {
|
||||
return <p className="text-gray-500">No brainUsers</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{users.map((subscription) => (
|
||||
{brainUsers.map((subscription) => (
|
||||
<BrainUser
|
||||
key={subscription.email}
|
||||
email={subscription.email}
|
@ -0,0 +1,43 @@
|
||||
/* eslint-disable max-lines */
|
||||
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";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useBrainUsers = (brainId: string) => {
|
||||
const [brainUsers, setBrainUsers] = useState<Subscription[]>([]);
|
||||
const [isFetchingBrainUsers, setFetchingBrainUsers] = useState(false);
|
||||
|
||||
const { publish } = useToast();
|
||||
const { getBrainUsers } = useBrainApi();
|
||||
const { session } = useSupabase();
|
||||
|
||||
const fetchBrainUsers = async () => {
|
||||
// Optimistic update
|
||||
setFetchingBrainUsers(brainUsers.length === 0);
|
||||
try {
|
||||
const users = await getBrainUsers(brainId);
|
||||
setBrainUsers(users.filter(({ email }) => email !== session?.user.email));
|
||||
} catch {
|
||||
publish({
|
||||
variant: "danger",
|
||||
text: "An error occurred while fetching brain users",
|
||||
});
|
||||
} finally {
|
||||
setFetchingBrainUsers(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchBrainUsers();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
brainUsers,
|
||||
fetchBrainUsers,
|
||||
isFetchingBrainUsers,
|
||||
};
|
||||
};
|
@ -1,2 +1,2 @@
|
||||
export * from "./BrainUser";
|
||||
export * from "./BrainUsers/BrainUser";
|
||||
export * from "./UserToInvite";
|
||||
|
@ -1,9 +1,7 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { useEffect, useState } from "react";
|
||||
import { 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";
|
||||
@ -11,8 +9,6 @@ import { generateBrainAssignation } from "../utils/generateBrainAssignation";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useShareBrain = (brainId: string) => {
|
||||
const [brainUsers, setBrainUsers] = useState<Subscription[]>([]);
|
||||
const [isFetchingBrainUsers, setFetchingBrainUsers] = useState(false);
|
||||
const [sendingInvitation, setSendingInvitation] = useState(false);
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||
const [roleAssignations, setRoleAssignation] = useState<
|
||||
@ -23,8 +19,7 @@ export const useShareBrain = (brainId: string) => {
|
||||
const brainShareLink = `${baseUrl}/invitation/${brainId}`;
|
||||
|
||||
const { publish } = useToast();
|
||||
const { addBrainSubscriptions, getBrainUsers } = useBrainApi();
|
||||
const { session } = useSupabase();
|
||||
const { addBrainSubscriptions } = useBrainApi();
|
||||
|
||||
const handleCopyInvitationLink = async () => {
|
||||
await navigator.clipboard.writeText(brainShareLink);
|
||||
@ -95,26 +90,6 @@ export const useShareBrain = (brainId: string) => {
|
||||
setRoleAssignation([...roleAssignations, generateBrainAssignation()]);
|
||||
};
|
||||
|
||||
const fetchBrainUsers = async () => {
|
||||
// Optimistic update
|
||||
setFetchingBrainUsers(brainUsers.length === 0);
|
||||
try {
|
||||
const users = await getBrainUsers(brainId);
|
||||
setBrainUsers(users.filter(({ email }) => email !== session?.user.email));
|
||||
} catch {
|
||||
publish({
|
||||
variant: "danger",
|
||||
text: "An error occurred while fetching brain users",
|
||||
});
|
||||
} finally {
|
||||
setFetchingBrainUsers(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchBrainUsers();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
roleAssignations,
|
||||
brainShareLink,
|
||||
@ -126,8 +101,5 @@ export const useShareBrain = (brainId: string) => {
|
||||
sendingInvitation,
|
||||
setIsShareModalOpen,
|
||||
isShareModalOpen,
|
||||
brainUsers,
|
||||
fetchBrainUsers,
|
||||
isFetchingBrainUsers,
|
||||
};
|
||||
};
|
||||
|
@ -13,6 +13,7 @@ type SelectProps<T> = {
|
||||
value?: T;
|
||||
onChange: (option: T) => void;
|
||||
label?: string;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
const selectedStyle = "rounded-lg bg-black text-white";
|
||||
@ -22,11 +23,41 @@ export const Select = <T extends string | number>({
|
||||
options,
|
||||
value,
|
||||
label,
|
||||
readOnly = false,
|
||||
}: SelectProps<T>): JSX.Element => {
|
||||
const selectedValueLabel = options.find(
|
||||
(option) => option.value === value
|
||||
)?.label;
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div>
|
||||
{label !== undefined && (
|
||||
<label
|
||||
id="listbox-label"
|
||||
className="block text-sm font-medium leading-6 text-gray-900 mb-2"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="relative w-full cursor-default rounded-md bg-white py-1.5 px-3 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 sm:text-sm sm:leading-6"
|
||||
aria-haspopup="listbox"
|
||||
disabled
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<span className="mx-4 block truncate">
|
||||
{selectedValueLabel ?? label ?? "Select"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label !== undefined && (
|
||||
|
Loading…
Reference in New Issue
Block a user