From ff2a8e9a6eb8fcc586c26efcdf20ceb409d917bb Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 21 Nov 2024 20:20:58 +0100 Subject: [PATCH 1/7] refactor(account): rename kebab-case dirs to camelCase (#5291) --- jsapp/js/account/{add-ons => addOns}/addOnList.component.tsx | 2 +- jsapp/js/account/{add-ons => addOns}/addOnList.module.scss | 0 jsapp/js/account/{add-ons => addOns}/addOns.component.tsx | 0 jsapp/js/account/{add-ons => addOns}/addOns.module.scss | 0 .../account/{add-ons => addOns}/oneTimeAddOnRow.component.tsx | 2 +- .../updateBadge.component.tsx} | 0 jsapp/js/account/plans/plan.component.tsx | 2 +- jsapp/js/account/routes.constants.ts | 2 +- .../oneTimeAddOnList}/oneTimeAddOnList.component.tsx | 0 .../oneTimeAddOnList}/oneTimeAddOnList.module.scss | 0 .../oneTimeAddOnUsageModal.component.tsx | 2 +- .../oneTimeAddOnUsageModal.module.scss | 0 jsapp/js/account/usage/usageContainer.tsx | 2 +- 13 files changed, 6 insertions(+), 6 deletions(-) rename jsapp/js/account/{add-ons => addOns}/addOnList.component.tsx (99%) rename jsapp/js/account/{add-ons => addOns}/addOnList.module.scss (100%) rename jsapp/js/account/{add-ons => addOns}/addOns.component.tsx (100%) rename jsapp/js/account/{add-ons => addOns}/addOns.module.scss (100%) rename jsapp/js/account/{add-ons => addOns}/oneTimeAddOnRow.component.tsx (98%) rename jsapp/js/account/{add-ons/updateBadge.component..tsx => addOns/updateBadge.component.tsx} (100%) rename jsapp/js/account/usage/{one-time-add-on-usage-modal/one-time-add-on-list => oneTimeAddOnUsageModal/oneTimeAddOnList}/oneTimeAddOnList.component.tsx (100%) rename jsapp/js/account/usage/{one-time-add-on-usage-modal/one-time-add-on-list => oneTimeAddOnUsageModal/oneTimeAddOnList}/oneTimeAddOnList.module.scss (100%) rename jsapp/js/account/usage/{one-time-add-on-usage-modal => oneTimeAddOnUsageModal}/oneTimeAddOnUsageModal.component.tsx (97%) rename jsapp/js/account/usage/{one-time-add-on-usage-modal => oneTimeAddOnUsageModal}/oneTimeAddOnUsageModal.module.scss (100%) diff --git a/jsapp/js/account/add-ons/addOnList.component.tsx b/jsapp/js/account/addOns/addOnList.component.tsx similarity index 99% rename from jsapp/js/account/add-ons/addOnList.component.tsx rename to jsapp/js/account/addOns/addOnList.component.tsx index 7ff128c6a8..270fbfc6c1 100644 --- a/jsapp/js/account/add-ons/addOnList.component.tsx +++ b/jsapp/js/account/addOns/addOnList.component.tsx @@ -9,7 +9,7 @@ import type { } from 'js/account/stripe.types'; import {isAddonProduct} from 'js/account/stripe.utils'; import styles from './addOnList.module.scss'; -import {OneTimeAddOnRow} from 'js/account/add-ons/oneTimeAddOnRow.component'; +import {OneTimeAddOnRow} from 'jsapp/js/account/addOns/oneTimeAddOnRow.component'; import type {BadgeColor} from 'jsapp/js/components/common/badge'; import Badge from 'jsapp/js/components/common/badge'; import {formatDate} from 'js/utils'; diff --git a/jsapp/js/account/add-ons/addOnList.module.scss b/jsapp/js/account/addOns/addOnList.module.scss similarity index 100% rename from jsapp/js/account/add-ons/addOnList.module.scss rename to jsapp/js/account/addOns/addOnList.module.scss diff --git a/jsapp/js/account/add-ons/addOns.component.tsx b/jsapp/js/account/addOns/addOns.component.tsx similarity index 100% rename from jsapp/js/account/add-ons/addOns.component.tsx rename to jsapp/js/account/addOns/addOns.component.tsx diff --git a/jsapp/js/account/add-ons/addOns.module.scss b/jsapp/js/account/addOns/addOns.module.scss similarity index 100% rename from jsapp/js/account/add-ons/addOns.module.scss rename to jsapp/js/account/addOns/addOns.module.scss diff --git a/jsapp/js/account/add-ons/oneTimeAddOnRow.component.tsx b/jsapp/js/account/addOns/oneTimeAddOnRow.component.tsx similarity index 98% rename from jsapp/js/account/add-ons/oneTimeAddOnRow.component.tsx rename to jsapp/js/account/addOns/oneTimeAddOnRow.component.tsx index fc25ebe296..05c19d9735 100644 --- a/jsapp/js/account/add-ons/oneTimeAddOnRow.component.tsx +++ b/jsapp/js/account/addOns/oneTimeAddOnRow.component.tsx @@ -1,4 +1,4 @@ -import styles from 'js/account/add-ons/addOnList.module.scss'; +import styles from 'js/account/addOns/addOnList.module.scss'; import React, {useMemo, useState} from 'react'; import type { Product, diff --git a/jsapp/js/account/add-ons/updateBadge.component..tsx b/jsapp/js/account/addOns/updateBadge.component.tsx similarity index 100% rename from jsapp/js/account/add-ons/updateBadge.component..tsx rename to jsapp/js/account/addOns/updateBadge.component.tsx diff --git a/jsapp/js/account/plans/plan.component.tsx b/jsapp/js/account/plans/plan.component.tsx index 3627fd2437..7031d599de 100644 --- a/jsapp/js/account/plans/plan.component.tsx +++ b/jsapp/js/account/plans/plan.component.tsx @@ -18,7 +18,7 @@ import {ACTIVE_STRIPE_STATUSES} from 'js/constants'; import type {FreeTierThresholds} from 'js/envStore'; import envStore from 'js/envStore'; import useWhen from 'js/hooks/useWhen.hook'; -import AddOnList from 'js/account/add-ons/addOnList.component'; +import AddOnList from 'jsapp/js/account/addOns/addOnList.component'; import subscriptionStore from 'js/account/subscriptionStore'; import {when} from 'mobx'; import { diff --git a/jsapp/js/account/routes.constants.ts b/jsapp/js/account/routes.constants.ts index 11ee1897e3..9d29ccebed 100644 --- a/jsapp/js/account/routes.constants.ts +++ b/jsapp/js/account/routes.constants.ts @@ -11,7 +11,7 @@ export const PlansRoute = React.lazy( () => import(/* webpackPrefetch: true */ './plans/plan.component') ); export const AddOnsRoute = React.lazy( - () => import(/* webpackPrefetch: true */ './add-ons/addOns.component') + () => import(/* webpackPrefetch: true */ './addOns/addOns.component') ); export const AccountSettings = React.lazy( () => import(/* webpackPrefetch: true */ './accountSettingsRoute') diff --git a/jsapp/js/account/usage/one-time-add-on-usage-modal/one-time-add-on-list/oneTimeAddOnList.component.tsx b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnList/oneTimeAddOnList.component.tsx similarity index 100% rename from jsapp/js/account/usage/one-time-add-on-usage-modal/one-time-add-on-list/oneTimeAddOnList.component.tsx rename to jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnList/oneTimeAddOnList.component.tsx diff --git a/jsapp/js/account/usage/one-time-add-on-usage-modal/one-time-add-on-list/oneTimeAddOnList.module.scss b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnList/oneTimeAddOnList.module.scss similarity index 100% rename from jsapp/js/account/usage/one-time-add-on-usage-modal/one-time-add-on-list/oneTimeAddOnList.module.scss rename to jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnList/oneTimeAddOnList.module.scss diff --git a/jsapp/js/account/usage/one-time-add-on-usage-modal/oneTimeAddOnUsageModal.component.tsx b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.component.tsx similarity index 97% rename from jsapp/js/account/usage/one-time-add-on-usage-modal/oneTimeAddOnUsageModal.component.tsx rename to jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.component.tsx index a347149937..c1e32895e6 100644 --- a/jsapp/js/account/usage/one-time-add-on-usage-modal/oneTimeAddOnUsageModal.component.tsx +++ b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.component.tsx @@ -4,7 +4,7 @@ import KoboModalHeader from 'js/components/modals/koboModalHeader'; import styles from './oneTimeAddOnUsageModal.module.scss'; import {OneTimeAddOn, RecurringInterval, USAGE_TYPE} from '../../stripe.types'; import {useLimitDisplay} from '../../stripe.utils'; -import OneTimeAddOnList from './one-time-add-on-list/oneTimeAddOnList.component'; +import OneTimeAddOnList from './oneTimeAddOnList/oneTimeAddOnList.component'; interface OneTimeAddOnUsageModalProps { type: USAGE_TYPE; diff --git a/jsapp/js/account/usage/one-time-add-on-usage-modal/oneTimeAddOnUsageModal.module.scss b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.module.scss similarity index 100% rename from jsapp/js/account/usage/one-time-add-on-usage-modal/oneTimeAddOnUsageModal.module.scss rename to jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.module.scss diff --git a/jsapp/js/account/usage/usageContainer.tsx b/jsapp/js/account/usage/usageContainer.tsx index 6768dfaf30..40bca8edc5 100644 --- a/jsapp/js/account/usage/usageContainer.tsx +++ b/jsapp/js/account/usage/usageContainer.tsx @@ -13,7 +13,7 @@ import cx from 'classnames'; import subscriptionStore from 'js/account/subscriptionStore'; import Badge from 'js/components/common/badge'; import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; -import OneTimeAddOnUsageModal from './one-time-add-on-usage-modal/oneTimeAddOnUsageModal.component'; +import OneTimeAddOnUsageModal from './oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.component'; interface UsageContainerProps { usage: number; From e626391dc5ae1ff53e5497e63535f737add859f8 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 21 Nov 2024 20:30:18 +0100 Subject: [PATCH 2/7] build(tsconfig): set lib to allow ES2021 functionalities (#5292) Added for `String.prototype.replaceAll()` to be available --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index b9f6f06ade..01e7baeb20 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ }, "target": "es2017", "module": "es2020", + "lib": ["ES2021"], "esModuleInterop": true, "moduleResolution": "node", "strict": true, From f5b205b3e2c17028f59f701ac7b2c46daf0abbb1 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 21 Nov 2024 20:38:16 +0100 Subject: [PATCH 3/7] style(subscriptionStore): linter cleanup (#5293) --- jsapp/js/account/subscriptionStore.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/jsapp/js/account/subscriptionStore.ts b/jsapp/js/account/subscriptionStore.ts index be7469003f..ac9de4411c 100644 --- a/jsapp/js/account/subscriptionStore.ts +++ b/jsapp/js/account/subscriptionStore.ts @@ -1,9 +1,8 @@ import {makeAutoObservable} from 'mobx'; -import {handleApiFail} from 'js/api'; +import {handleApiFail, fetchGet} from 'js/api'; import {ACTIVE_STRIPE_STATUSES, ROOT_URL} from 'js/constants'; -import {fetchGet} from 'jsapp/js/api'; import type {PaginatedResponse} from 'js/dataInterface'; -import {Product, SubscriptionInfo} from 'js/account/stripe.types'; +import type {Product, SubscriptionInfo} from 'js/account/stripe.types'; const PRODUCTS_URL = '/api/v2/stripe/products/'; @@ -53,16 +52,16 @@ class SubscriptionStore { ); this.canceledPlans = response.results.filter( (sub) => - sub.items[0]?.price.product.metadata?.product_type == 'plan' && + sub.items[0]?.price.product.metadata?.product_type === 'plan' && sub.status === 'canceled' ); // get any active plan subscriptions for the user this.planResponse = this.activeSubscriptions.filter( - (sub) => sub.items[0]?.price.product.metadata?.product_type == 'plan' + (sub) => sub.items[0]?.price.product.metadata?.product_type === 'plan' ); // get any active recurring add-on subscriptions for the user this.addOnsResponse = this.activeSubscriptions.filter( - (sub) => sub.items[0]?.price.product.metadata?.product_type == 'addon' + (sub) => sub.items[0]?.price.product.metadata?.product_type === 'addon' ); this.isPending = false; From 9bea2b26e851f683f60169e87d26df107b71c43a Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 21 Nov 2024 20:40:44 +0100 Subject: [PATCH 4/7] refactor(organizations): simplify shouldUseTeamLabel (#5294) --- jsapp/js/account/organization/organization.utils.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/jsapp/js/account/organization/organization.utils.tsx b/jsapp/js/account/organization/organization.utils.tsx index 4db566acda..3dbeeb4d62 100644 --- a/jsapp/js/account/organization/organization.utils.tsx +++ b/jsapp/js/account/organization/organization.utils.tsx @@ -1,7 +1,7 @@ import type {SubscriptionInfo} from 'jsapp/js/account/stripe.types'; import type {EnvStoreData} from 'jsapp/js/envStore'; -/** Only use this directly for complex cases/strings (for example, possessive case). +/** Only use this directly for complex cases/strings (for example, possessive case). * Otherwise, use getSimpleMMOLabel. * @param {EnvStoreData} envStoreData * @param {SubscriptionInfo} subscription @@ -11,13 +11,9 @@ export function shouldUseTeamLabel( envStoreData: EnvStoreData, subscription: SubscriptionInfo | null ) { - if (subscription) { - return ( - subscription.items[0].price.product.metadata?.use_team_label === 'true' - ); - } - - return envStoreData.use_team_label; + return subscription + ? subscription.items[0].price.product.metadata?.use_team_label === 'true' + : envStoreData.use_team_label; } /** From 6729e75d24ed71fcf6b05d50b25e6a0ebb5dc8ea Mon Sep 17 00:00:00 2001 From: Anji Tong Date: Thu, 21 Nov 2024 20:23:33 +0000 Subject: [PATCH 5/7] feat(organizations): disable email updates for regular organization members TASK-997 (#5233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🗒ī¸ Checklist 1. [x] run linter locally 2. [x] update all related docs (API, README, inline, etc.), if any 3. [x] draft PR with a title `(): TASK-1234` 4. [x] tag PR: at least `frontend` or `backend` unless it's global 5. [x] fill in the template below and delete template comments 6. [x] review thyself: read the diff and repro the preview as written 7. [ ] open PR & confirm that CI passes 8. [x] request reviewers, if needed 9. [ ] delete this section before merging ### đŸ“Ŗ Summary <!-- Delete this section if changes are internal only. --> <!-- One sentence summary for the public changelog, worded for non-technical seasoned Kobo users. --> UI for changing emails is disabled for `Members` of a multi-member organization. ### 👀 Preview steps 1. Make sure your user's email is correct in both the user section of the django admin as well as the email address section. If there is a difference it would only show the former here. See https://www.notion.so/kobotoolbox/task-1236 2. Make an MMO that has an owner, admin, and a member, as well as a regular user 3. Navigate to the account settings page 4. Visit the security section 5. If the user is a regular user, or an MMO owner or admin, they should be able to change their email address as normal 6. If the user is a member of an MMO the text box and button should be gone ### 📖 Description <!-- Delete this section if summary already said everything. --> <!-- Full description for the public changelog, worded for non-technical seasoned Kobo users. --> Remove the text box and button entirely if the user is a `member`. Replaced with just text of the email returned from `/me`. ### 💭 Notes <!-- Delete this section if empty. --> <!-- Anything else useful that's not said above,worded for reviewers, testers, and future git archaeologist collegues. Examples: - screenshots, copy-pasted logs, etc. - what was tried but didn't work, - conscious short-term vs long-term tradeoffs, - proactively answer likely questions, --> Make sure the user either has the proper email reflected in the user section of the django admin or was created through an email confirmation link. See https://www.notion.so/kobotoolbox/task-1236 --------- Co-authored-by: James Kiger <james.kiger@gmail.com> --- .../security/email/emailSection.component.tsx | 159 ++++++++++-------- .../security/email/emailSection.module.scss | 10 ++ 2 files changed, 95 insertions(+), 74 deletions(-) diff --git a/jsapp/js/account/security/email/emailSection.component.tsx b/jsapp/js/account/security/email/emailSection.component.tsx index e350affae5..4877dcc8a8 100644 --- a/jsapp/js/account/security/email/emailSection.component.tsx +++ b/jsapp/js/account/security/email/emailSection.component.tsx @@ -10,6 +10,7 @@ import { deleteUnverifiedUserEmails, } from './emailSection.api'; import type {EmailResponse} from './emailSection.api'; +import {useOrganizationQuery} from '../../organization/organizationQuery'; // Partial components import Button from 'jsapp/js/components/common/button'; @@ -33,6 +34,8 @@ interface EmailState { export default function EmailSection() { const [session] = useState(() => sessionStore); + const orgQuery = useOrganizationQuery(); + let initialEmail = ''; if ('email' in session.currentAccount) { initialEmail = session.currentAccount.email; @@ -116,6 +119,10 @@ export default function EmailSection() { const unverifiedEmail = email.emails.find( (userEmail) => !userEmail.verified && !userEmail.primary ); + const isReady = session.isInitialLoadComplete && 'email' in currentAccount; + const userCanChangeEmail = orgQuery.data?.is_mmo + ? orgQuery.data.request_user_role !== 'member' + : true; return ( <section className={securityStyles.securitySection}> @@ -123,83 +130,87 @@ export default function EmailSection() { <h2 className={securityStyles.securitySectionTitleText}>{t('Email address')}</h2> </div> - <div className={cx(securityStyles.securitySectionBody, styles.body)}> - {!session.isPending && - session.isInitialLoadComplete && - 'email' in currentAccount && ( - <TextBox - value={email.newEmail} - placeholder={t('Type new email address')} - onChange={onTextFieldChange.bind(onTextFieldChange)} - type='email' - /> - )} - - {unverifiedEmail?.email && - !session.isPending && - session.isInitialLoadComplete && - 'email' in currentAccount && ( - <> - <div className={styles.unverifiedEmail}> - <Icon name='alert' /> - <p className={styles.blurb}> - <strong> - {t('Check your email ##UNVERIFIED_EMAIL##. ').replace( - '##UNVERIFIED_EMAIL##', - unverifiedEmail.email - )} - </strong> - - {t( - 'A verification link has been sent to confirm your ownership. Once confirmed, this address will replace ##UNVERIFIED_EMAIL##' - ).replace('##UNVERIFIED_EMAIL##', currentAccount.email)} - </p> - </div> - - <div className={styles.unverifiedEmailButtons}> - <Button - label='Resend' - size='m' - type='secondary' - onClick={resendNewUserEmail.bind( - resendNewUserEmail, + <div + className={cx([ + securityStyles.securitySectionBody, + userCanChangeEmail ? styles.body : styles.emailUpdateDisabled, + ])} + > + {isReady && userCanChangeEmail ? ( + <TextBox + value={email.newEmail} + placeholder={t('Type new email address')} + onChange={onTextFieldChange.bind(onTextFieldChange)} + type='email' + /> + ) : ( + <div className={styles.emailText}>{email.newEmail}</div> + )} + + {unverifiedEmail?.email && isReady && ( + <> + <div className={styles.unverifiedEmail}> + <Icon name='alert' /> + <p className={styles.blurb}> + <strong> + {t('Check your email ##UNVERIFIED_EMAIL##. ').replace( + '##UNVERIFIED_EMAIL##', unverifiedEmail.email )} - /> - <Button - label='Remove' - size='m' - type='secondary-danger' - onClick={deleteNewUserEmail} - /> - </div> - - {email.refreshedEmail && ( - <label> - {t('Email was sent again: ##TIMESTAMP##').replace( - '##TIMESTAMP##', - email.refreshedEmailDate - )} - </label> - )} - </> - )} + </strong> + + {t( + 'A verification link has been sent to confirm your ownership. Once confirmed, this address will replace ##UNVERIFIED_EMAIL##' + ).replace('##UNVERIFIED_EMAIL##', currentAccount.email)} + </p> + </div> + + <div className={styles.unverifiedEmailButtons}> + <Button + label='Resend' + size='m' + type='secondary' + onClick={resendNewUserEmail.bind( + resendNewUserEmail, + unverifiedEmail.email + )} + /> + <Button + label='Remove' + size='m' + type='secondary-danger' + onClick={deleteNewUserEmail} + /> + </div> + + {email.refreshedEmail && ( + <label> + {t('Email was sent again: ##TIMESTAMP##').replace( + '##TIMESTAMP##', + email.refreshedEmailDate + )} + </label> + )} + </> + )} </div> - - <form - className={styles.options} - onSubmit={(e) => { - e.preventDefault(); - handleSubmit(); - }} - > - <Button - label='Change' - size='m' - type='primary' - onClick={handleSubmit} - /> - </form> + {userCanChangeEmail && ( + <div className={styles.options}> + <form + onSubmit={(e) => { + e.preventDefault(); + handleSubmit(); + }} + > + <Button + label='Change' + size='m' + type='primary' + onClick={handleSubmit} + /> + </form> + </div> + )} </section> ); } diff --git a/jsapp/js/account/security/email/emailSection.module.scss b/jsapp/js/account/security/email/emailSection.module.scss index 7675c0999f..3350c95c9d 100644 --- a/jsapp/js/account/security/email/emailSection.module.scss +++ b/jsapp/js/account/security/email/emailSection.module.scss @@ -34,3 +34,13 @@ display: flex; gap: 10px; } + +.emailText { + font-weight: 600; +} + +.emailUpdateDisabled { + flex: 5; + // To compensate for the `options` class not displaying when there is no email + margin-right: calc(30% + 8px); +} From 79e33e8651f5ab85804053276cb3e0f7d30800a8 Mon Sep 17 00:00:00 2001 From: Raj Patel <51355159+rajpatel24@users.noreply.github.com> Date: Fri, 22 Nov 2024 12:20:15 +0530 Subject: [PATCH 6/7] feat(organizations): add endpoints to handle organization members TASK-963 (#5235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### đŸ“Ŗ Summary - Enhance the organization member management API to support role updates and member removal via PATCH and DELETE requests, excluding member creation. ### 📖 Description - This update introduces endpoints for managing organization members: - GET `/api/v2/organizations/<org-id>/members/` to list all members in an organization. - PATCH `/api/v2/organizations/<org-id>/members/<username>/` to update a member's role (e.g., promote to admin). - DELETE `/api/v2/organizations/<org-id>/members/<username>/` to remove a member from the organization. Note: Creating members is not supported via this endpoint. Roles are restricted to 'admin' or 'member'. Including the details of invited members (those who have not yet joined the organization) is not covered in this update. ### 👷 Description for instance maintainers - This update provides new functionality to manage organization members: - Role updates for members (promotion/demotion between 'admin' and 'member'). - Member removal from an organization. - The endpoints are optimized to use database joins to fetch member roles efficiently without excessive database queries, ensuring minimal load. ### 👀 Preview steps 1. ℹī¸ Have an account and an organization with multiple members. 2. Use the GET method to list the members of the organization. 3. Use the PATCH method to update a member's role (e.g., promote a user to admin). 4. Use the DELETE method to remove a member from the organization. 5. đŸŸĸ Notice the endpoints behave as expected, with valid role updates and member removal. ### 💭 Notes - The code uses database joins to optimize role determination for members, avoiding inefficient role lookups. - Role validation restricts role assignments to 'admin' or 'member' only. - Including invited members' details is not covered in this update. This will be implemented in a future update. --- kobo/apps/organizations/permissions.py | 35 +-- kobo/apps/organizations/serializers.py | 57 ++++- .../tests/test_organization_members_api.py | 131 +++++++++++ .../tests/test_organizations_api.py | 9 +- kobo/apps/organizations/views.py | 209 +++++++++++++++++- kpi/urls/router_api_v2.py | 8 +- 6 files changed, 421 insertions(+), 28 deletions(-) create mode 100644 kobo/apps/organizations/tests/test_organization_members_api.py diff --git a/kobo/apps/organizations/permissions.py b/kobo/apps/organizations/permissions.py index 30d8988331..f37dc36757 100644 --- a/kobo/apps/organizations/permissions.py +++ b/kobo/apps/organizations/permissions.py @@ -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. """ @@ -26,18 +29,22 @@ 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_permission(self, request, view): + if not super().has_permission(request, view): + return False + + organization = Organization.objects.filter( + id=view.kwargs.get('organization_id') + ).first() + if organization and not self.has_object_permission( + request, view, organization + ): + return False + return True 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 diff --git a/kobo/apps/organizations/serializers.py b/kobo/apps/organizations/serializers.py index 031545f220..ae13041394 100644 --- a/kobo/apps/organizations/serializers.py +++ b/kobo/apps/organizations/serializers.py @@ -1,4 +1,7 @@ +from django.contrib.auth import get_user_model +from django.utils.translation import gettext as t from rest_framework import serializers +from rest_framework.reverse import reverse from kobo.apps.organizations.models import ( Organization, @@ -12,10 +15,62 @@ class OrganizationUserSerializer(serializers.ModelSerializer): + user = serializers.HyperlinkedRelatedField( + queryset=get_user_model().objects.all(), + lookup_field='username', + view_name='user-kpi-detail', + ) + role = serializers.CharField() + user__has_mfa_enabled = serializers.BooleanField( + source='has_mfa_enabled', read_only=True + ) + url = serializers.SerializerMethodField() + date_joined = serializers.DateTimeField( + source='user.date_joined', format='%Y-%m-%dT%H:%M:%SZ' + ) + user__username = serializers.ReadOnlyField(source='user.username') + user__extra_details__name = serializers.ReadOnlyField( + source='user.extra_details.data.name' + ) + user__email = serializers.ReadOnlyField(source='user.email') + user__is_active = serializers.ReadOnlyField(source='user.is_active') class Meta: model = OrganizationUser - fields = ['user', 'organization'] + fields = [ + 'url', + 'user', + 'user__username', + 'user__email', + 'user__extra_details__name', + 'role', + 'user__has_mfa_enabled', + 'date_joined', + 'user__is_active' + ] + + def get_url(self, obj): + request = self.context.get('request') + return reverse( + 'organization-members-detail', + kwargs={ + 'organization_id': obj.organization.id, + 'user__username': obj.user.username + }, + request=request + ) + + def update(self, instance, validated_data): + if role := validated_data.get('role', None): + validated_data['is_admin'] = role == 'admin' + return super().update(instance, validated_data) + + def validate_role(self, role): + if role not in ['admin', 'member']: + raise serializers.ValidationError( + {'role': t("Invalid role. Only 'admin' or 'member' are allowed")} + ) + return role class OrganizationOwnerSerializer(serializers.ModelSerializer): diff --git a/kobo/apps/organizations/tests/test_organization_members_api.py b/kobo/apps/organizations/tests/test_organization_members_api.py new file mode 100644 index 0000000000..eb9e74b33b --- /dev/null +++ b/kobo/apps/organizations/tests/test_organization_members_api.py @@ -0,0 +1,131 @@ +from ddt import ddt, data, unpack +from django.urls import reverse +from rest_framework import status + +from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.organizations.tests.test_organizations_api import ( + BaseOrganizationAssetApiTestCase +) +from kpi.urls.router_api_v2 import URL_NAMESPACE + + +@ddt +class OrganizationMemberAPITestCase(BaseOrganizationAssetApiTestCase): + fixtures = ['test_data'] + URL_NAMESPACE = URL_NAMESPACE + + def setUp(self): + super().setUp() + self.organization = self.someuser.organization + self.owner_user = self.someuser + self.member_user = self.alice + self.admin_user = self.anotheruser + self.external_user = self.bob + + self.list_url = reverse( + self._get_endpoint('organization-members-list'), + kwargs={'organization_id': self.organization.id}, + ) + self.detail_url = lambda username: reverse( + self._get_endpoint('organization-members-detail'), + kwargs={ + 'organization_id': self.organization.id, + 'user__username': username + }, + ) + + @data( + ('owner', status.HTTP_200_OK), + ('admin', status.HTTP_200_OK), + ('member', status.HTTP_200_OK), + ('external', status.HTTP_404_NOT_FOUND), + ('anonymous', status.HTTP_401_UNAUTHORIZED), + ) + @unpack + def test_list_members_with_different_roles(self, user_role, expected_status): + if user_role == 'anonymous': + self.client.logout() + else: + user = getattr(self, f'{user_role}_user') + self.client.force_login(user) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, expected_status) + + @data( + ('owner', status.HTTP_200_OK), + ('admin', status.HTTP_200_OK), + ('member', status.HTTP_200_OK), + ('external', status.HTTP_404_NOT_FOUND), + ('anonymous', status.HTTP_401_UNAUTHORIZED), + ) + @unpack + def test_retrieve_member_details_with_different_roles( + self, user_role, expected_status + ): + if user_role == 'anonymous': + self.client.logout() + else: + user = getattr(self, f'{user_role}_user') + self.client.force_login(user) + response = self.client.get(self.detail_url(self.member_user)) + self.assertEqual(response.status_code, expected_status) + + @data( + ('owner', status.HTTP_200_OK), + ('admin', status.HTTP_200_OK), + ('member', status.HTTP_403_FORBIDDEN), + ('external', status.HTTP_404_NOT_FOUND), + ('anonymous', status.HTTP_401_UNAUTHORIZED), + ) + @unpack + def test_update_member_role_with_different_roles(self, user_role, expected_status): + if user_role == 'anonymous': + self.client.logout() + else: + user = getattr(self, f'{user_role}_user') + self.client.force_login(user) + data = {'role': 'admin'} + response = self.client.patch(self.detail_url(self.member_user), data) + self.assertEqual(response.status_code, expected_status) + + @data( + ('owner', status.HTTP_204_NO_CONTENT), + ('admin', status.HTTP_204_NO_CONTENT), + ('member', status.HTTP_403_FORBIDDEN), + ('external', status.HTTP_404_NOT_FOUND), + ('anonymous', status.HTTP_401_UNAUTHORIZED), + ) + @unpack + def test_delete_member_with_different_roles(self, user_role, expected_status): + if user_role == 'anonymous': + self.client.logout() + else: + user = getattr(self, f'{user_role}_user') + self.client.force_login(user) + response = self.client.delete(self.detail_url(self.member_user)) + self.assertEqual(response.status_code, expected_status) + if expected_status == status.HTTP_204_NO_CONTENT: + # Confirm deletion + response = self.client.get(self.detail_url(self.member_user)) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertFalse( + User.objects.filter(username=f'{user_role}_user').exists() + ) + + @data( + ('owner', status.HTTP_405_METHOD_NOT_ALLOWED), + ('admin', status.HTTP_405_METHOD_NOT_ALLOWED), + ('member', status.HTTP_403_FORBIDDEN), + ('external', status.HTTP_404_NOT_FOUND), + ('anonymous', status.HTTP_401_UNAUTHORIZED), + ) + @unpack + def test_post_request_is_not_allowed(self, user_role, expected_status): + if user_role == 'anonymous': + self.client.logout() + else: + user = getattr(self, f'{user_role}_user') + self.client.force_login(user) + data = {'role': 'admin'} + response = self.client.post(self.list_url, data) + self.assertEqual(response.status_code, expected_status) diff --git a/kobo/apps/organizations/tests/test_organizations_api.py b/kobo/apps/organizations/tests/test_organizations_api.py index c97ebef66e..a3fa635543 100644 --- a/kobo/apps/organizations/tests/test_organizations_api.py +++ b/kobo/apps/organizations/tests/test_organizations_api.py @@ -61,7 +61,12 @@ def test_anonymous_user(self): def test_create(self): data = {'name': 'my org'} res = self.client.post(self.url_list, data) - self.assertContains(res, data['name'], status_code=201) + self.assertEqual(res.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_delete(self): + self._insert_data() + res = self.client.delete(self.url_detail) + self.assertEqual(res.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) def test_list(self): self._insert_data() @@ -349,7 +354,7 @@ def test_can_list(self, username, expected_status_code): def test_list_not_found_as_anonymous(self): self.client.logout() response = self.client.get(self.org_assets_list_url) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_list_only_organization_assets(self): # The organization's assets endpoint only returns assets where the `owner` diff --git a/kobo/apps/organizations/views.py b/kobo/apps/organizations/views.py index 9be7689a27..7fd0e59470 100644 --- a/kobo/apps/organizations/views.py +++ b/kobo/apps/organizations/views.py @@ -1,6 +1,14 @@ from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg -from django.db.models import QuerySet +from django.db.models import ( + QuerySet, + Case, + When, + Value, + CharField, + OuterRef, +) +from django.db.models.expressions import Exists from django.utils.decorators import method_decorator from django.utils.http import http_date from django.views.decorators.cache import cache_page @@ -14,19 +22,17 @@ from kpi.constants import ASSET_TYPE_SURVEY from kpi.filters import AssetOrderingFilter, SearchFilter from kpi.models.asset import Asset -from kpi.paginators import AssetUsagePagination -from kpi.permissions import IsAuthenticated from kpi.serializers.v2.service_usage import ( CustomAssetUsageSerializer, ServiceUsageSerializer, ) from kpi.utils.object_permission import get_database_user from kpi.views.v2.asset import AssetViewSet - +from .models import Organization, OrganizationOwner, OrganizationUser +from .permissions import HasOrgRolePermission, IsOrgAdminPermission +from .serializers import OrganizationSerializer, OrganizationUserSerializer +from ..accounts.mfa.models import MfaMethod from ..stripe.constants import ACTIVE_STRIPE_STATUSES -from .models import Organization -from .permissions import IsOrgAdmin, IsOrgAdminOrReadOnly -from .serializers import OrganizationSerializer class OrganizationAssetViewSet(AssetViewSet): @@ -79,10 +85,12 @@ class OrganizationViewSet(viewsets.ModelViewSet): queryset = Organization.objects.all() serializer_class = OrganizationSerializer lookup_field = 'id' - permission_classes = (IsAuthenticated, IsOrgAdminOrReadOnly) - pagination_class = AssetUsagePagination + permission_classes = [HasOrgRolePermission] + http_method_names = ['get', 'patch'] - @action(detail=True, methods=['GET'], permission_classes=[IsOrgAdmin]) + @action( + detail=True, methods=['GET'], permission_classes=[IsOrgAdminPermission] + ) def assets(self, request: Request, *args, **kwargs): """ ### Retrieve Organization Assets @@ -263,3 +271,184 @@ def asset_usage(self, request, pk=None, *args, **kwargs): page, many=True, context=context ) return self.get_paginated_response(serializer.data) + + +class OrganizationMemberViewSet(viewsets.ModelViewSet): + """ + The API uses `ModelViewSet` instead of `NestedViewSetMixin` to maintain + explicit control over the queryset. + + ## Organization Members API + + This API allows authorized users to view and manage organization members and + their roles, including promoting or demoting members (eg. to admin). + + * Manage members and their roles within an organization. + * Update member roles (promote/demote). + + ### List Members + + Retrieves all members in the specified organization. + + <pre class="prettyprint"> + <b>GET</b> /api/v2/organizations/{organization_id}/members/ + </pre> + + > Example + > + > curl -X GET https://[kpi]/api/v2/organizations/org_12345/members/ + + > Response 200 + + > { + > "count": 2, + > "next": null, + > "previous": null, + > "results": [ + > { + > "url": "http://[kpi]/api/v2/organizations/org_12345/ \ + > members/foo_bar/", + > "user": "http://[kpi]/api/v2/users/foo_bar/", + > "user__username": "foo_bar", + > "user__email": "foo_bar@example.com", + > "user__name": "Foo Bar", + > "role": "owner", + > "user__has_mfa_enabled": true, + > "date_joined": "2024-08-11T12:36:32Z", + > "user__is_active": true + > }, + > { + > "url": "http://[kpi]/api/v2/organizations/org_12345/ \ + > members/john_doe/", + > "user": "http://[kpi]/api/v2/users/john_doe/", + > "user__username": "john_doe", + > "user__email": "john_doe@example.com", + > "user__name": "John Doe", + > "role": "admin", + > "user__has_mfa_enabled": false, + > "date_joined": "2024-10-21T06:38:45Z", + > "user__is_active": true + > } + > ] + > } + + + ### Retrieve Member Details + + Retrieves the details of a specific member within an organization by username. + + <pre class="prettyprint"> + <b>GET</b> /api/v2/organizations/{organization_id}/members/{username}/ + </pre> + + > Example + > + > curl -X GET https://[kpi]/api/v2/organizations/org_12345/members/foo_bar/ + + > Response 200 + + > { + > "url": "http://[kpi]/api/v2/organizations/org_12345/members/foo_bar/", + > "user": "http://[kpi]/api/v2/users/foo_bar/", + > "user__username": "foo_bar", + > "user__email": "foo_bar@example.com", + > "user__name": "Foo Bar", + > "role": "owner", + > "user__has_mfa_enabled": true, + > "date_joined": "2024-08-11T12:36:32Z", + > "user__is_active": true + > } + + ### Update Member Role + + Updates the role of a member within the organization to `admin` or + `member`. + + - **admin**: Grants the member admin privileges within the organization + - **member**: Revokes admin privileges, setting the member as a regular user + + <pre class="prettyprint"> + <b>PATCH</b> /api/v2/organizations/{organization_id}/members/{username}/ + </pre> + + > Example + > + > curl -X PATCH https://[kpi]/api/v2/organizations/org_12345/members/foo_bar/ + + > Payload + + > { + > "role": "admin" + > } + + > Response 200 + + > { + > "url": "http://[kpi]/api/v2/organizations/org_12345/members/foo_bar/", + > "user": "http://[kpi]/api/v2/users/foo_bar/", + > "user__username": "foo_bar", + > "user__email": "foo_bar@example.com", + > "user__name": "Foo Bar", + > "role": "admin", + > "user__has_mfa_enabled": true, + > "date_joined": "2024-08-11T12:36:32Z", + > "user__is_active": true + > } + + + ### Remove Member + + Delete an organization member. + + <pre class="prettyprint"> + <b>DELETE</b> /api/v2/organizations/{organization_id}/members/{username}/ + </pre> + + > Example + > + > curl -X DELETE https://[kpi]/api/v2/organizations/org_12345/members/foo_bar/ + + ## Permissions + + - The user must be authenticated to perform these actions. + - Owners and admins can manage members and roles. + - Members can view the list but cannot update roles or delete members. + + ## Notes + + - **Role Validation**: Only valid roles ('admin', 'member') are accepted + in updates. + """ + serializer_class = OrganizationUserSerializer + permission_classes = [HasOrgRolePermission] + http_method_names = ['get', 'patch', 'delete'] + lookup_field = 'user__username' + + def get_queryset(self): + organization_id = self.kwargs['organization_id'] + + # Subquery to check if the user has an active MFA method + mfa_subquery = MfaMethod.objects.filter( + user=OuterRef('user_id'), + is_active=True + ).values('pk') + + # Subquery to check if the user is the owner + owner_subquery = OrganizationOwner.objects.filter( + organization_id=organization_id, + organization_user=OuterRef('pk') + ).values('pk') + + # Annotate with role based on organization ownership and admin status + queryset = OrganizationUser.objects.filter( + organization_id=organization_id + ).select_related('user__extra_details').annotate( + role=Case( + When(Exists(owner_subquery), then=Value('owner')), + When(is_admin=True, then=Value('admin')), + default=Value('member'), + output_field=CharField() + ), + has_mfa_enabled=Exists(mfa_subquery) + ) + return queryset diff --git a/kpi/urls/router_api_v2.py b/kpi/urls/router_api_v2.py index 7c7dbd2575..411d77de2b 100644 --- a/kpi/urls/router_api_v2.py +++ b/kpi/urls/router_api_v2.py @@ -6,7 +6,7 @@ from kobo.apps.hook.views.v2.hook import HookViewSet from kobo.apps.hook.views.v2.hook_log import HookLogViewSet from kobo.apps.languages.urls import router as language_router -from kobo.apps.organizations.views import OrganizationViewSet +from kobo.apps.organizations.views import OrganizationViewSet, OrganizationMemberViewSet from kobo.apps.project_ownership.urls import router as project_ownership_router from kobo.apps.project_views.views import ProjectViewViewSet from kpi.views.v2.asset import AssetViewSet @@ -140,6 +140,12 @@ def get_urls(self, *args, **kwargs): router_api_v2.register(r'imports', ImportTaskViewSet) router_api_v2.register(r'organizations', OrganizationViewSet, basename='organizations',) +router_api_v2.register( + r'organizations/(?P<organization_id>[^/.]+)/members', + OrganizationMemberViewSet, + basename='organization-members', +) + router_api_v2.register(r'permissions', PermissionViewSet) router_api_v2.register(r'project-views', ProjectViewViewSet) router_api_v2.register(r'service_usage', From 1386a155f48e6677308b023ab47235b24b781ed2 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak <leszek@magicznyleszek.xyz> Date: Mon, 25 Nov 2024 17:37:09 +0100 Subject: [PATCH 7/7] feat(organizations): members table TASK-980 (#5261) Add MembersRoute component that renders things at `#/account/organization/members` (a header and a table of organization members). Add `membersQuery` that defines `OrganizationMember` TS interface and adds way for getting organization members. Change minimum column size in UniversalTable props to 60 (smaller then previously). --- .../js/account/organization/MembersRoute.tsx | 102 ++++++++++++++++++ jsapp/js/account/organization/membersQuery.ts | 85 +++++++++++++++ .../organization/membersRoute.module.scss | 26 +++++ jsapp/js/account/routes.constants.ts | 3 + jsapp/js/account/routes.tsx | 3 +- jsapp/js/api.endpoints.ts | 1 + jsapp/js/query/queryKeys.ts | 1 + .../universalTable.component.tsx | 2 +- 8 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 jsapp/js/account/organization/MembersRoute.tsx create mode 100644 jsapp/js/account/organization/membersQuery.ts create mode 100644 jsapp/js/account/organization/membersRoute.module.scss diff --git a/jsapp/js/account/organization/MembersRoute.tsx b/jsapp/js/account/organization/MembersRoute.tsx new file mode 100644 index 0000000000..dc7708e35f --- /dev/null +++ b/jsapp/js/account/organization/MembersRoute.tsx @@ -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 './organizationQuery'; +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__extra_details__name', + 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__extra_details__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> + ); +} diff --git a/jsapp/js/account/organization/membersQuery.ts b/jsapp/js/account/organization/membersQuery.ts new file mode 100644 index 0000000000..1bec128e80 --- /dev/null +++ b/jsapp/js/account/organization/membersQuery.ts @@ -0,0 +1,85 @@ +import {keepPreviousData, useQuery} from '@tanstack/react-query'; +import {endpoints} from 'js/api.endpoints'; +import type {PaginatedResponse} from 'js/dataInterface'; +import {fetchGet} from 'js/api'; +import {QueryKeys} from 'js/query/queryKeys'; +import {useOrganizationQuery, type OrganizationUserRole} from './organizationQuery'; + +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 an empty string in some edge cases */ + user__email: string | ''; + /** can be an empty string in some edge cases */ + user__extra_details__name: string | ''; + role: OrganizationUserRole; + 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'; + }; +} + +/** + * 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(), + }); + + const apiUrl = endpoints.ORGANIZATION_MEMBERS_URL.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, + }); +} diff --git a/jsapp/js/account/organization/membersRoute.module.scss b/jsapp/js/account/organization/membersRoute.module.scss new file mode 100644 index 0000000000..d100d36428 --- /dev/null +++ b/jsapp/js/account/organization/membersRoute.module.scss @@ -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; + } +} diff --git a/jsapp/js/account/routes.constants.ts b/jsapp/js/account/routes.constants.ts index 9d29ccebed..4a4e49b6ea 100644 --- a/jsapp/js/account/routes.constants.ts +++ b/jsapp/js/account/routes.constants.ts @@ -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 */ './organization/MembersRoute') +); export const OrganizationSettingsRoute = React.lazy( () => import(/* webpackPrefetch: true */ './organization/OrganizationSettingsRoute') ); diff --git a/jsapp/js/account/routes.tsx b/jsapp/js/account/routes.tsx index 57044c76ec..b6646f9360 100644 --- a/jsapp/js/account/routes.tsx +++ b/jsapp/js/account/routes.tsx @@ -11,6 +11,7 @@ import { DataStorage, PlansRoute, SecurityRoute, + MembersRoute, OrganizationSettingsRoute, } from 'js/account/routes.constants'; import {useFeatureFlag, FeatureFlag} from 'js/featureFlags'; @@ -121,7 +122,7 @@ export default function routes() { mmoOnly redirectRoute={ACCOUNT_ROUTES.ACCOUNT_SETTINGS} > - <div>Organization members view to be implemented</div> + <MembersRoute /> </RequireOrgPermissions> </RequireAuth> } diff --git a/jsapp/js/api.endpoints.ts b/jsapp/js/api.endpoints.ts index 3a4c1a1485..a6096989f1 100644 --- a/jsapp/js/api.endpoints.ts +++ b/jsapp/js/api.endpoints.ts @@ -5,6 +5,7 @@ export const endpoints = { PRODUCTS_URL: '/api/v2/stripe/products/', SUBSCRIPTION_URL: '/api/v2/stripe/subscriptions/', ADD_ONS_URL: '/api/v2/stripe/addons/', + ORGANIZATION_MEMBERS_URL: '/api/v2/organizations/:organization_id/members/', /** Expected parameters: price_id and organization_id **/ CHECKOUT_URL: '/api/v2/stripe/checkout-link', /** Expected parameter: organization_id **/ diff --git a/jsapp/js/query/queryKeys.ts b/jsapp/js/query/queryKeys.ts index 3cd3dc26e4..1ded3e7116 100644 --- a/jsapp/js/query/queryKeys.ts +++ b/jsapp/js/query/queryKeys.ts @@ -11,4 +11,5 @@ export enum QueryKeys { activityLogs = 'activityLogs', activityLogsFilter = 'activityLogsFilter', organization = 'organization', + organizationMembers = 'organizationMembers', } diff --git a/jsapp/js/universalTable/universalTable.component.tsx b/jsapp/js/universalTable/universalTable.component.tsx index eaa29bacb2..8b3dc480da 100644 --- a/jsapp/js/universalTable/universalTable.component.tsx +++ b/jsapp/js/universalTable/universalTable.component.tsx @@ -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 };