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

feat(organizations): add functions for managing members TASK-985 #5281

Open
wants to merge 55 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 47 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
e17f6a7
Add endpoints to handle organization members
rajpatel24 Nov 6, 2024
024f911
Merge branch 'main' of github.com:kobotoolbox/kpi into task-963-creat…
rajpatel24 Nov 7, 2024
56cd7cf
add initial files for organization members route (WIP)
magicznyleszek Nov 12, 2024
95e48c4
pass org id to hook (WIP)
magicznyleszek Nov 12, 2024
0401fc4
Refactor organization member API to eliminate redundancy and optimize…
rajpatel24 Nov 13, 2024
1dec6eb
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
rajpatel24 Nov 13, 2024
99eea0c
Add `queryHookOptions` to `PaginatedQueryUniversalTable` to make the …
magicznyleszek Nov 13, 2024
9527848
Merge branch 'main' into leszek/task-980-members-table
magicznyleszek Nov 13, 2024
a75ca3b
add initial files for organization members route (WIP)
magicznyleszek Nov 12, 2024
f2301b7
pass org id to hook (WIP)
magicznyleszek Nov 12, 2024
2066a3b
Add `queryHookOptions` to `PaginatedQueryUniversalTable` to make the …
magicznyleszek Nov 13, 2024
864e244
style(universalTable) use Array<T> for columns prop (#5260)
magicznyleszek Nov 13, 2024
47f6cf1
Merge branch 'leszek/task-980-members-table' of github.com:kobotoolbo…
magicznyleszek Nov 13, 2024
1f1cc12
small fixes (WIP)
magicznyleszek Nov 13, 2024
3c174df
Add role-based validation tests for organization member permissions
rajpatel24 Nov 14, 2024
f432404
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
rajpatel24 Nov 14, 2024
d876fbc
Revert unintended change and fix linting issue
rajpatel24 Nov 14, 2024
89721fe
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 15, 2024
953b716
Refactor organization members API with updated permission logic and c…
rajpatel24 Nov 15, 2024
f8cb95b
Refactor organization members API with updated permission logic and c…
rajpatel24 Nov 15, 2024
3d0372a
Resolve merge conflicts
rajpatel24 Nov 15, 2024
906be3d
Fix failing tests
rajpatel24 Nov 15, 2024
d122576
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
rajpatel24 Nov 18, 2024
837c1ac
adjust MembersRoute table column sizes
magicznyleszek Nov 19, 2024
1222115
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 19, 2024
08bc12e
display badges in MembersRoute table and remove some WIP code
magicznyleszek Nov 19, 2024
78a4b51
Merge branch 'leszek/task-980-members-table' of github.com:kobotoolbo…
magicznyleszek Nov 19, 2024
94b47cb
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
adf5cad
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 19, 2024
696bef0
Merge branch 'leszek/task-980-members-table' of github.com:kobotoolbo…
magicznyleszek Nov 19, 2024
01c189c
Update delete logic to remove user from user table along with organiz…
rajpatel24 Nov 19, 2024
89db1d1
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
42d4f23
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 19, 2024
b5ccac7
add some comments
magicznyleszek Nov 19, 2024
3ea9420
add missing semicolon
magicznyleszek Nov 19, 2024
988b10b
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
4951a1c
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 19, 2024
ac80999
use latest Avatar version in the table
magicznyleszek Nov 19, 2024
e5688a3
add functions for updating and removing organization member
magicznyleszek Nov 19, 2024
e5abfa0
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
19d1b6a
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 19, 2024
7672143
Use useOrganizationQuery directly in useOrganizationMembersQuery
jamesrkiger Nov 19, 2024
c20c0f4
remove queryHookOptions from PaginatedQueryUniversalTable
magicznyleszek Nov 19, 2024
f250c27
add helpful comment
magicznyleszek Nov 20, 2024
7afa649
Merge branch 'leszek/task-980-members-table' into leszek/task-985-mem…
magicznyleszek Nov 20, 2024
4f0f55a
update comments
magicznyleszek Nov 20, 2024
1389d6e
Merge branch 'leszek/task-980-members-table' into leszek/task-985-mem…
magicznyleszek Nov 20, 2024
def1a7a
Refactor permissions to block external users from listing organizatio…
rajpatel24 Nov 20, 2024
c2cf803
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 20, 2024
8c7ed27
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 20, 2024
40ca814
use OrganizationUserRole in membersQuery
magicznyleszek Nov 20, 2024
500df96
Merge branch 'leszek/task-980-members-table' into leszek/task-985-mem…
magicznyleszek Nov 21, 2024
3847958
Merge branch 'main' into leszek/task-985-member-mutation-api
magicznyleszek Nov 25, 2024
3e9048e
post merge conflict fixes
magicznyleszek Nov 25, 2024
5c8d255
Merge branch 'main' into leszek/task-985-member-mutation-api
magicznyleszek Nov 29, 2024
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
102 changes: 102 additions & 0 deletions jsapp/js/account/organizations/MembersRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Libraries
import React from 'react';

// Partial components
import PaginatedQueryUniversalTable from 'js/universalTable/paginatedQueryUniversalTable.component';
import LoadingSpinner from 'js/components/common/loadingSpinner';
import Avatar from 'js/components/common/avatar';
import Badge from 'jsapp/js/components/common/badge';

// Stores, hooks and utilities
import {formatTime} from 'js/utils';
import {useOrganizationQuery} from 'js/account/stripe.api';
import useOrganizationMembersQuery from './membersQuery';

// Constants and types
import type {OrganizationMember} from './membersQuery';

// Styles
import styles from './membersRoute.module.scss';

export default function MembersRoute() {
const orgQuery = useOrganizationQuery();

if (!orgQuery.data?.id) {
return (
<LoadingSpinner />
);
}

return (
<div className={styles.membersRouteRoot}>
<header className={styles.header}>
<h2 className={styles.headerText}>{t('Members')}</h2>
</header>

<PaginatedQueryUniversalTable<OrganizationMember>
queryHook={useOrganizationMembersQuery}
columns={[
{
key: 'user__username',
label: t('Name'),
cellFormatter: (member: OrganizationMember) => (
<Avatar
size='m'
username={member.user__username}
isUsernameVisible
email={member.user__email}
// We pass `undefined` for the case it's an empty string
fullName={member.user__name || undefined}
/>
),
size: 360,
},
{
key: 'invite',
label: t('Status'),
size: 120,
cellFormatter: (member: OrganizationMember) => {
if (member.invite?.status) {
return member.invite.status;
} else {
return <Badge color='light-green' size='s' label={t('Active')} />;
}
return null;
},
},
{
key: 'date_joined',
label: t('Date added'),
size: 140,
cellFormatter: (member: OrganizationMember) => formatTime(member.date_joined),
},
{
key: 'role',
label: t('Role'),
size: 120,
},
{
key: 'user__has_mfa_enabled',
label: t('2FA'),
size: 90,
cellFormatter: (member: OrganizationMember) => {
if (member.user__has_mfa_enabled) {
return <Badge size='s' color='light-blue' icon='check' />;
}
return <Badge size='s' color='light-storm' icon='minus' />;
},
},
{
// We use `url` here, but the cell would contain interactive UI
// element
key: 'url',
label: '',
size: 64,
// TODO: this will be added soon
cellFormatter: () => (' '),
},
]}
/>
</div>
);
}
125 changes: 125 additions & 0 deletions jsapp/js/account/organizations/membersQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {keepPreviousData, useQuery} from '@tanstack/react-query';
import {endpoints} from 'js/api.endpoints';
import type {PaginatedResponse} from 'js/dataInterface';
import {fetchGet, fetchPatch, fetchDelete} from 'js/api';
import {QueryKeys} from 'js/query/queryKeys';
import {useOrganizationQuery} from '../stripe.api';

/**
* Note that it's only possible to update the role via API to either `admin` or
* `member`.
*/
export enum OrganizationMemberRole {
magicznyleszek marked this conversation as resolved.
Show resolved Hide resolved
admin = 'admin',
member = 'member',
owner = 'owner',
external = 'external',
}

export interface OrganizationMember {
/**
* The url to the member within the organization
* `/api/v2/organizations/<organization_uid>/members/<username>/`
*/
url: string;
/** `/api/v2/users/<username>/` */
user: string;
user__username: string;
/** can be empty an string in some edge cases */
user__email: string | '';
/** can be empty an string in some edge cases */
user__name: string | '';
role: OrganizationMemberRole;
user__has_mfa_enabled: boolean;
user__is_active: boolean;
/** yyyy-mm-dd HH:MM:SS */
date_joined: string;
invite?: {
/** '/api/v2/organizations/<organization_uid>/invites/<invite_uid>/' */
url: string;
/** yyyy-mm-dd HH:MM:SS */
date_created: string;
/** yyyy-mm-dd HH:MM:SS */
date_modified: string;
status: 'sent' | 'accepted' | 'expired' | 'declined';
};
}

/**
* For updating member within given organization. Accepts partial properties
* of `OrganizationMember`.
*/
export async function patchOrganizationMember(
organizationId: string,
username: string,
newMemberData: Partial<OrganizationMember>
) {
const apiUrl = endpoints.ORGANIZATION_MEMBER_URL
.replace(':organization_id', organizationId)
.replace(':username', username);
return fetchPatch<OrganizationMember>(apiUrl, newMemberData);
}

/**
* For removing member from given organization.
*/
export async function removeOrganizationMember(
organizationId: string,
username: string
) {
const apiUrl = endpoints.ORGANIZATION_MEMBER_URL
.replace(':organization_id', organizationId)
.replace(':username', username);
return fetchDelete(apiUrl);
}

/**
* Fetches paginated list of members for given organization.
* This is mainly needed for `useOrganizationMembersQuery`, so you most probably
* would use it through that hook rather than directly.
*/
async function getOrganizationMembers(
limit: number,
offset: number,
orgId: string
) {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
});

let apiUrl = endpoints.ORGANIZATION_MEMBERS_URL;
apiUrl = apiUrl.replace(':organization_id', orgId);

return fetchGet<PaginatedResponse<OrganizationMember>>(
apiUrl + '?' + params,
{
errorMessageDisplay: t('There was an error getting the list.'),
}
);
}

/**
* A hook that gives you paginated list of organization members. Uses
* `useOrganizationQuery` to get the id.
*/
export default function useOrganizationMembersQuery(
itemLimit: number,
pageOffset: number
) {
const orgQuery = useOrganizationQuery();
const orgId = orgQuery.data?.id;

return useQuery({
queryKey: [QueryKeys.organizationMembers, itemLimit, pageOffset, orgId],
// `orgId!` because it's ensured to be there in `enabled` property :ok:
queryFn: () => getOrganizationMembers(itemLimit, pageOffset, orgId!),
placeholderData: keepPreviousData,
enabled: !!orgId,
// We might want to improve this in future, for now let's not retry
retry: false,
// The `refetchOnWindowFocus` option is `true` by default, I'm setting it
// here so we don't forget about it.
refetchOnWindowFocus: true,
});
}
26 changes: 26 additions & 0 deletions jsapp/js/account/organizations/membersRoute.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@use 'scss/colors';
@use 'scss/breakpoints';

.membersRouteRoot {
padding: 20px;
overflow-y: auto;
height: 100%;
}

.header {
margin-bottom: 20px;
}

h2.headerText {
color: colors.$kobo-storm;
text-transform: uppercase;
font-size: 18px;
font-weight: 700;
margin: 0;
}

@include breakpoints.breakpoint(mediumAndUp) {
.membersRouteRoot {
padding: 50px;
}
}
3 changes: 3 additions & 0 deletions jsapp/js/account/routes.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const AccountSettings = React.lazy(
export const DataStorage = React.lazy(
() => import(/* webpackPrefetch: true */ './usage/usageTopTabs')
);
export const MembersRoute = React.lazy(
() => import(/* webpackPrefetch: true */ './organizations/MembersRoute')
);
export const ACCOUNT_ROUTES: {readonly [key: string]: string} = {
ACCOUNT_SETTINGS: ROUTES.ACCOUNT_ROOT + '/settings',
USAGE: ROUTES.ACCOUNT_ROOT + '/usage',
Expand Down
3 changes: 2 additions & 1 deletion jsapp/js/account/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DataStorage,
PlansRoute,
SecurityRoute,
MembersRoute,
} from 'js/account/routes.constants';
import {useFeatureFlag, FeatureFlag} from 'js/featureFlags';

Expand Down Expand Up @@ -120,7 +121,7 @@ export default function routes() {
mmoOnly
redirectRoute={ACCOUNT_ROUTES.ACCOUNT_SETTINGS}
>
<div>Organization members view to be implemented</div>
<MembersRoute />
</ValidateOrgPermissions>
</RequireAuth>
}
Expand Down
2 changes: 2 additions & 0 deletions jsapp/js/api.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const endpoints = {
SUBSCRIPTION_URL: '/api/v2/stripe/subscriptions/',
ADD_ONS_URL: '/api/v2/stripe/addons/',
ORGANIZATION_URL: '/api/v2/organizations/',
ORGANIZATION_MEMBERS_URL: '/api/v2/organizations/:organization_id/members/',
ORGANIZATION_MEMBER_URL: '/api/v2/organizations/:organization_id/members/:username/',
/** Expected parameters: price_id and organization_id **/
CHECKOUT_URL: '/api/v2/stripe/checkout-link',
/** Expected parameter: organization_id **/
Expand Down
1 change: 1 addition & 0 deletions jsapp/js/query/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export enum QueryKeys {
activityLogs = 'activityLogs',
activityLogsFilter = 'activityLogsFilter',
organization = 'organization',
organizationMembers = 'organizationMembers',
}
2 changes: 1 addition & 1 deletion jsapp/js/universalTable/universalTable.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ interface UniversalTableProps<DataItem> {

const DEFAULT_COLUMN_SIZE = {
size: 200, // starting column size
minSize: 100, // enforced during column resizing
minSize: 60, // enforced during column resizing
maxSize: 600, // enforced during column resizing
};

Expand Down
24 changes: 9 additions & 15 deletions kobo/apps/organizations/permissions.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from django.http import Http404
from rest_framework import permissions
from rest_framework.permissions import IsAuthenticated

from kobo.apps.organizations.constants import ORG_EXTERNAL_ROLE
from kobo.apps.organizations.models import Organization
from kpi.mixins.validation_password_permission import ValidationPasswordPermissionMixin
from kpi.utils.object_permission import get_database_user


class IsOrgAdmin(ValidationPasswordPermissionMixin, permissions.BasePermission):
class IsOrgAdminPermission(ValidationPasswordPermissionMixin, IsAuthenticated):
"""
Object-level permission to only allow admin members of an object to access it.
Object-level permission to only allow admin (and owner) members of an object
to access it.
Assumes the model instance has an `is_admin` attribute.
"""

Expand All @@ -26,18 +29,9 @@ def has_object_permission(self, request, view, obj):
return obj.is_admin(user)


class IsOrgAdminOrReadOnly(IsOrgAdmin):
"""
Object-level permission to only allow admin members of an object to edit it.
Assumes the model instance has an `is_admin` attribute.
"""

class HasOrgRolePermission(IsOrgAdminPermission):
def has_object_permission(self, request, view, obj):

# Read permissions are allowed to any request,
# so we'll always allow GET, HEAD or OPTIONS requests.
if request.method in permissions.SAFE_METHODS:
obj = obj if isinstance(obj, Organization) else obj.organization
if super().has_object_permission(request, view, obj):
return True

# Instance must have an attribute named `is_admin`
return obj.is_admin(request.user)
return request.method in permissions.SAFE_METHODS
Loading
Loading