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/organization/MembersRoute.tsx b/jsapp/js/account/organization/MembersRoute.tsx index 5d9024696e..dc7708e35f 100644 --- a/jsapp/js/account/organization/MembersRoute.tsx +++ b/jsapp/js/account/organization/MembersRoute.tsx @@ -37,7 +37,7 @@ export default function MembersRoute() { queryHook={useOrganizationMembersQuery} columns={[ { - key: 'user__username', + key: 'user__extra_details__name', label: t('Name'), cellFormatter: (member: OrganizationMember) => ( ), size: 360, diff --git a/jsapp/js/account/organization/membersQuery.ts b/jsapp/js/account/organization/membersQuery.ts index 61c7849ab7..9414c796bd 100644 --- a/jsapp/js/account/organization/membersQuery.ts +++ b/jsapp/js/account/organization/membersQuery.ts @@ -14,10 +14,10 @@ export interface OrganizationMember { /** `/api/v2/users//` */ user: string; user__username: string; - /** can be empty an string in some edge cases */ + /** can be an empty string in some edge cases */ user__email: string | ''; - /** can be empty an string in some edge cases */ - user__name: 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; 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; } /** 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 7d632344f5..4a4e49b6ea 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/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 (
@@ -123,83 +130,87 @@ export default function EmailSection() {

{t('Email address')}

-
- {!session.isPending && - session.isInitialLoadComplete && - 'email' in currentAccount && ( - - )} - - {unverifiedEmail?.email && - !session.isPending && - session.isInitialLoadComplete && - 'email' in currentAccount && ( - <> -
- -

- - {t('Check your email ##UNVERIFIED_EMAIL##. ').replace( - '##UNVERIFIED_EMAIL##', - unverifiedEmail.email - )} - - - {t( - 'A verification link has been sent to confirm your ownership. Once confirmed, this address will replace ##UNVERIFIED_EMAIL##' - ).replace('##UNVERIFIED_EMAIL##', currentAccount.email)} -

-
- -
-
- - {email.refreshedEmail && ( - - )} - - )} + + + {t( + 'A verification link has been sent to confirm your ownership. Once confirmed, this address will replace ##UNVERIFIED_EMAIL##' + ).replace('##UNVERIFIED_EMAIL##', currentAccount.email)} +

+
+ +
+
+ + {email.refreshedEmail && ( + + )} + + )} - -
{ - e.preventDefault(); - handleSubmit(); - }} - > -
); } 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); +} 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; 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; diff --git a/kobo/apps/organizations/serializers.py b/kobo/apps/organizations/serializers.py index df855b011a..ae13041394 100644 --- a/kobo/apps/organizations/serializers.py +++ b/kobo/apps/organizations/serializers.py @@ -29,7 +29,9 @@ class OrganizationUserSerializer(serializers.ModelSerializer): source='user.date_joined', format='%Y-%m-%dT%H:%M:%SZ' ) user__username = serializers.ReadOnlyField(source='user.username') - user__name = serializers.ReadOnlyField(source='user.get_full_name') + 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') @@ -40,7 +42,7 @@ class Meta: 'user', 'user__username', 'user__email', - 'user__name', + 'user__extra_details__name', 'role', 'user__has_mfa_enabled', 'date_joined', diff --git a/kobo/apps/organizations/tests/test_organizations_api.py b/kobo/apps/organizations/tests/test_organizations_api.py index b1e467d5e9..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() diff --git a/kobo/apps/organizations/views.py b/kobo/apps/organizations/views.py index 343682f39a..7fd0e59470 100644 --- a/kobo/apps/organizations/views.py +++ b/kobo/apps/organizations/views.py @@ -22,7 +22,6 @@ from kpi.constants import ASSET_TYPE_SURVEY from kpi.filters import AssetOrderingFilter, SearchFilter from kpi.models.asset import Asset -from kpi.paginators import AssetUsagePagination, OrganizationMembersPagination from kpi.serializers.v2.service_usage import ( CustomAssetUsageSerializer, ServiceUsageSerializer, @@ -87,7 +86,7 @@ class OrganizationViewSet(viewsets.ModelViewSet): serializer_class = OrganizationSerializer lookup_field = 'id' permission_classes = [HasOrgRolePermission] - pagination_class = AssetUsagePagination + http_method_names = ['get', 'patch'] @action( detail=True, methods=['GET'], permission_classes=[IsOrgAdminPermission] @@ -399,7 +398,7 @@ class OrganizationMemberViewSet(viewsets.ModelViewSet): ### Remove Member - Delete an organization member and their associated user account. + Delete an organization member.
     DELETE /api/v2/organizations/{organization_id}/members/{username}/
@@ -422,7 +421,6 @@ class OrganizationMemberViewSet(viewsets.ModelViewSet):
     """
     serializer_class = OrganizationUserSerializer
     permission_classes = [HasOrgRolePermission]
-    pagination_class = OrganizationMembersPagination
     http_method_names = ['get', 'patch', 'delete']
     lookup_field = 'user__username'
 
@@ -444,7 +442,7 @@ def get_queryset(self):
         # Annotate with role based on organization ownership and admin status
         queryset = OrganizationUser.objects.filter(
             organization_id=organization_id
-        ).annotate(
+        ).select_related('user__extra_details').annotate(
             role=Case(
                 When(Exists(owner_subquery), then=Value('owner')),
                 When(is_admin=True, then=Value('admin')),
@@ -454,13 +452,3 @@ def get_queryset(self):
             has_mfa_enabled=Exists(mfa_subquery)
         )
         return queryset
-
-    def destroy(self, request, *args, **kwargs):
-        """
-        Delete an organization member and their associated user account
-        """
-        instance = self.get_object()
-        user = instance.user
-        instance.delete()
-        user.delete()
-        return Response(status=status.HTTP_204_NO_CONTENT)
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,