Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alper/eng 4673 create a UI to manage permissions #3

Merged
merged 8 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion label_studio/organizations/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from django.utils.decorators import method_decorator
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from khan.rbac.models import UserRole
from khan.rbac.roles import Role
from organizations.models import Organization, OrganizationMember
from organizations.serializers import (
OrganizationIdSerializer,
Expand Down Expand Up @@ -184,6 +186,71 @@ def delete(self, request, pk=None, user_pk=None):
member.soft_delete()
return Response(status=204) # 204 No Content is a common HTTP status for successful delete requests

@method_decorator(
name='patch',
decorator=swagger_auto_schema(
tags=['Organizations'],
x_fern_sdk_group_name=['organizations', 'permissions'],
x_fern_sdk_method_name='update',
operation_summary='Update organization member permission',
operation_description='Update permissions of an organization member',
manual_parameters=[
openapi.Parameter(
name='user_pk',
type=openapi.TYPE_INTEGER,
in_=openapi.IN_PATH,
description='A unique integer value identifying the user whose permissions should be updated.',
),
],
responses={
204: 'Permission updated successfully.',
405: 'User cannot update self permissions.',
405: 'Cannot update organization creator permissions',
404: 'Member not found',
400: 'New role must be provided to update permissions.',
},
),
)
class OrganizationMemberPermissionUpdateAPI(GetParentObjectMixin, generics.UpdateAPIView):
permission_required = all_permissions.organizations_change
parser_classes = (JSONParser, FormParser, MultiPartParser)
parent_queryset = Organization.objects.all()

def patch(self, request, pk=None, user_pk=None):
org = self.get_parent_object()
if org != request.user.active_organization:
raise PermissionDenied('You can only update permissions for your current active organization')

user = get_object_or_404(User, pk=user_pk)
member = get_object_or_404(OrganizationMember, user=user, organization=org)
if member is None:
raise NotFound('Member not found')

if org.created_by.id == member.user.id:
return Response({'detail': 'Cannot update organization creator permissions'}, status=status.HTTP_405_METHOD_NOT_ALLOWED)

if member.user_id == request.user.id:
return Response({'detail': 'User cannot update self permissions'}, status=status.HTTP_405_METHOD_NOT_ALLOWED)

user_role = UserRole.objects.filter(user=user).first()

new_role = self.request.data.get('role')

if new_role is None:
return Response({'detail': 'New role must be provided to update permissions.'}, status=status.HTTP_400_BAD_REQUEST)

try:
new_role = int(new_role)
if new_role not in [role.value for role in Role]:
raise ValueError
except (ValueError, TypeError):
return Response({'detail': 'Invalid role provided.'}, status=status.HTTP_400_BAD_REQUEST)


user_role.role = new_role
user_role.save()

return Response(status=200)

@method_decorator(
name='get',
Expand All @@ -209,7 +276,8 @@ class OrganizationAPI(generics.RetrieveUpdateAPIView):

parser_classes = (JSONParser, FormParser, MultiPartParser)
queryset = Organization.objects.all()
permission_required = all_permissions.organizations_change
permission_required = ViewClassPermission(GET=all_permissions.organizations_view,
PATCH=all_permissions.organizations_change)
serializer_class = OrganizationSerializer

redirect_route = 'organizations-dashboard'
Expand Down
6 changes: 6 additions & 0 deletions label_studio/organizations/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
api.OrganizationMemberDetailAPI.as_view(),
name='organization-membership-detail',
),
# organization permission viewset
path(
'<int:pk>/memberships/<int:user_pk>/permission',
api.OrganizationMemberPermissionUpdateAPI.as_view(),
name='organization-membership-permission',
),
]
# TODO: these urlpatterns should be moved in core/urls with include('organizations.urls')
urlpatterns = [
Expand Down
8 changes: 8 additions & 0 deletions label_studio/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from core.utils.common import load_func
from core.utils.db import fast_first
from django.conf import settings
from khan.rbac.models import UserRole
from organizations.models import OrganizationMember
from rest_flex_fields import FlexFieldsModelSerializer
from rest_framework import serializers
Expand All @@ -15,6 +16,7 @@ class BaseUserSerializer(FlexFieldsModelSerializer):
# short form for user presentation
initials = serializers.SerializerMethodField(default='?', read_only=True)
avatar = serializers.SerializerMethodField(read_only=True)
user_role = serializers.SerializerMethodField()

def get_avatar(self, instance):
return instance.avatar_url
Expand Down Expand Up @@ -54,6 +56,11 @@ def _is_deleted(self, instance):
return True
return bool(organization_member_for_user.deleted_at)

def get_user_role(self, instance):
# Fetch the user role and return its ID
user_role = UserRole.objects.filter(user=instance).first()
return user_role.role if user_role else None

def to_representation(self, instance):
"""Returns user with cache, this helps to avoid multiple s3/gcs links resolving for avatars"""

Expand Down Expand Up @@ -85,6 +92,7 @@ class Meta:
'phone',
'active_organization',
'allow_newsletters',
'user_role'
)


Expand Down
18 changes: 13 additions & 5 deletions web/apps/labelstudio/src/app/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@ import { LibraryProvider } from "../providers/LibraryProvider";
import { MultiProvider } from "../providers/MultiProvider";
import { ProjectProvider } from "../providers/ProjectProvider";
import { RoutesProvider } from "../providers/RoutesProvider";
import { DRAFT_GUARD_KEY, DraftGuard, draftGuardCallback } from "../components/DraftGuard/DraftGuard";
import { CurrentUserProvider } from "../providers/CurrentUser";
import {
DRAFT_GUARD_KEY,
DraftGuard,
draftGuardCallback
} from "../components/DraftGuard/DraftGuard";
import "./App.styl";
import { AsyncPage } from "./AsyncPage/AsyncPage";
import ErrorBoundary from "./ErrorBoundary";
import { RootPage } from "./RootPage";
import { FF_OPTIC_2, isFF } from "../utils/feature-flags";
import { ToastProvider, ToastViewport } from "../components/Toast/Toast";
import { OrganizationProvider } from "../providers/OrganizationProvider";

const baseURL = new URL(APP_SETTINGS.hostname || location.origin);

Expand All @@ -30,7 +36,7 @@ const browserHistory = createBrowserHistory({
} else {
callback(window.confirm(message));
}
},
}
});

window.LSH = browserHistory;
Expand All @@ -42,13 +48,13 @@ const App = ({ content }) => {
lsf: {
scriptSrc: window.EDITOR_JS,
cssSrc: window.EDITOR_CSS,
checkAvailability: () => !!window.LabelStudio,
checkAvailability: () => !!window.LabelStudio
},
dm: {
scriptSrc: window.DM_JS,
cssSrc: window.DM_CSS,
checkAvailability: () => !!window.DataManager,
},
checkAvailability: () => !!window.DataManager
}
};

return (
Expand All @@ -61,8 +67,10 @@ const App = ({ content }) => {
<ConfigProvider key="config" />,
<LibraryProvider key="lsf" libraries={libraries} />,
<RoutesProvider key="rotes" />,
<OrganizationProvider key="organization" />,
<ProjectProvider key="project" />,
<ToastProvider key="toast" />,
<CurrentUserProvider key="current-user" />
]}
>
<AsyncPage>
Expand Down
4 changes: 4 additions & 0 deletions web/apps/labelstudio/src/config/ApiConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ export const API_CONFIG = {
me: "/current-user/whoami",

// Organization
detail: "/organizations/:pk",
memberships: "/organizations/:pk/memberships",
inviteLink: "/invite",
resetInviteLink: "POST:/invite/reset-token",

// Permissions
updatePermission: "PATCH:/organizations/:pk/memberships/:user_pk/permission",

// Project
projects: "/projects",
project: "/projects/:pk",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Block, Elem } from "../../../utils/bem";
import { Description } from "../../../components/Description/Description";
import { useConfig } from "../../../providers/ConfigProvider";
import { LsPlus } from "../../../assets/icons";
import { Space } from "../../../components/Space/Space";
import { Button } from "../../../components";
import { Input } from "../../../components/Form";
import { useAPI } from "../../../providers/ApiProvider";
import { modal } from "../../../components/Modal/Modal";
import { copyText } from "../../../utils/helpers";

const InvitationModal = ({ link }) => {
return (
<Block name="invite">
<Input value={link} style={{ width: "100%" }} readOnly />

<Description style={{ marginTop: 16 }}>
Invite people to join your Label Studio instance. People that you invite
have full access to all of your projects.{" "}
<a
href="https://labelstud.io/guide/signup.html"
target="_blank"
rel="noreferrer"
>
Learn more
</a>
.
</Description>
</Block>
);
};

const AddPeopleButton = () => {
const api = useAPI();
const inviteModal = useRef();
const config = useConfig();
const [link, setLink] = useState();

const setInviteLink = useCallback(
link => {
const hostname = config.hostname || location.origin;

setLink(`${hostname}${link}`);
},
[config, setLink]
);

const updateLink = useCallback(() => {
api.callApi("resetInviteLink").then(({ invite_url }) => {
setInviteLink(invite_url);
});
}, [setInviteLink]);

const inviteModalProps = useCallback(
link => ({
title: "Invite people",
style: { width: 640, height: 472 },
body: () => <InvitationModal link={link} />,
footer: () => {
const [copied, setCopied] = useState(false);

const copyLink = useCallback(() => {
setCopied(true);
copyText(link);
setTimeout(() => setCopied(false), 1500);
}, []);

return (
<Space spread>
<Space>
<Button style={{ width: 170 }} onClick={() => updateLink()}>
Reset Link
</Button>
</Space>
<Space>
<Button primary style={{ width: 170 }} onClick={copyLink}>
{copied ? "Copied!" : "Copy link"}
</Button>
</Space>
</Space>
);
},
bareFooter: true
}),
[]
);

const showInvitationModal = useCallback(() => {
inviteModal.current = modal(inviteModalProps(link));
}, [inviteModalProps, link]);

useEffect(() => {
api.callApi("inviteLink").then(({ invite_url }) => {
setInviteLink(invite_url);
});
}, []);

useEffect(() => {
inviteModal.current?.update(inviteModalProps(link));
}, [link]);

return (
<Button icon={<LsPlus />} primary onClick={showInvitationModal}>
Add People
</Button>
);
};

export default AddPeopleButton;
Loading
Loading