From d7e3b634fdb7d53675aa59a5c07d2ea1ba740b07 Mon Sep 17 00:00:00 2001 From: yazzylazy Date: Thu, 3 Sep 2026 17:48:39 -0400 Subject: [PATCH] Add Organizations' user invitation UI Adds the frontend for issue #6088: a My Organizations landing page (pending-invitations banner + org card grid), an organization edit page with Details and Sharing tabs, an invite-by-email form, and a unified members/pending-invites table with role change, remove, resend, and revoke actions. Reuses StudioImmersiveModal for the edit page so it matches the channel edit/details page pattern, including its focus trap and dark banner header. Also supports creating a new organization through the same page. All new state is Composition API composables (no new Vuex), and every new component uses Kolibri Design System components exclusively (no Vuetify). Backend: adds a send_organization_invitation_email endpoint, a revoke action and organization support on InvitationViewSet, and a role annotation on top of #6080's OrganizationViewSet so the frontend can show the current user's role in an organization without an extra request per organization. Co-Authored-By: Claude Sonnet 5 --- .../composables/useOrganization.js | 50 ++++ .../composables/useOrganizationInvitations.js | 60 +++++ .../composables/useOrganizationList.js | 27 ++ .../composables/useOrganizationMembers.js | 50 ++++ .../frontend/channelList/constants.js | 20 ++ .../frontend/channelList/router.js | 20 +- .../channelList/views/ChannelListIndex.vue | 17 +- .../InviteOrganizationUserForm.vue | 131 +++++++++ .../views/Organization/OrganizationCard.vue | 128 +++++++++ .../Organization/OrganizationDetailsTab.vue | 200 ++++++++++++++ .../Organization/OrganizationEditPage.vue | 194 ++++++++++++++ .../Organization/OrganizationInvitation.vue | 136 ++++++++++ .../Organization/OrganizationSharingTab.vue | 119 +++++++++ .../Organization/OrganizationUsersTable.vue | 250 ++++++++++++++++++ .../Organization/StudioMyOrganizations.vue | 180 +++++++++++++ .../InviteOrganizationUserForm.spec.js | 62 +++++ .../__tests__/OrganizationCard.spec.js | 55 ++++ .../__tests__/OrganizationDetailsTab.spec.js | 168 ++++++++++++ .../__tests__/OrganizationEditPage.spec.js | 143 ++++++++++ .../__tests__/OrganizationSharingTab.spec.js | 51 ++++ .../__tests__/OrganizationUsersTable.spec.js | 85 ++++++ .../__tests__/StudioMyOrganizations.spec.js | 98 +++++++ .../frontend/shared/__tests__/app.spec.js | 31 +++ .../contentcuration/frontend/shared/app.js | 7 +- .../frontend/shared/data/resources.js | 50 ++++ .../tests/test_organization_invitation.py | 80 ++++++ .../tests/viewsets/test_invitation.py | 67 ++++- .../test_organization_role_annotation.py | 84 ++++++ contentcuration/contentcuration/urls.py | 5 + .../contentcuration/views/users.py | 54 +++- .../contentcuration/viewsets/invitation.py | 35 +++ .../contentcuration/viewsets/organization.py | 11 + 32 files changed, 2662 insertions(+), 6 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelList/composables/useOrganization.js create mode 100644 contentcuration/contentcuration/frontend/channelList/composables/useOrganizationInvitations.js create mode 100644 contentcuration/contentcuration/frontend/channelList/composables/useOrganizationList.js create mode 100644 contentcuration/contentcuration/frontend/channelList/composables/useOrganizationMembers.js create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/InviteOrganizationUserForm.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationCard.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationDetailsTab.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationEditPage.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationInvitation.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationSharingTab.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationUsersTable.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/StudioMyOrganizations.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/InviteOrganizationUserForm.spec.js create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationCard.spec.js create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationDetailsTab.spec.js create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationEditPage.spec.js create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationSharingTab.spec.js create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationUsersTable.spec.js create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/StudioMyOrganizations.spec.js create mode 100644 contentcuration/contentcuration/tests/test_organization_invitation.py create mode 100644 contentcuration/contentcuration/tests/viewsets/test_organization_role_annotation.py diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useOrganization.js b/contentcuration/contentcuration/frontend/channelList/composables/useOrganization.js new file mode 100644 index 0000000000..2df54b304e --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/composables/useOrganization.js @@ -0,0 +1,50 @@ +import { ref, onMounted } from 'vue'; +import { OrganizationRoles } from '../constants'; +import { Organization } from 'shared/data/resources'; + +/** + * Composable for fetching, creating, and updating a single organization. + * Pass a falsy organizationId to use this in "create a new organization" mode: + * the fetch is skipped and `create` becomes usable instead of `update`. + */ +export function useOrganization(organizationId) { + const loading = ref(Boolean(organizationId)); + const organization = ref(null); + + function load() { + return Organization.fetchModel(organizationId).then(data => { + organization.value = data; + }); + } + + onMounted(() => { + if (!organizationId) { + return; + } + load().finally(() => { + loading.value = false; + }); + }); + + function update(data) { + return Organization.update(organizationId, data).then(updated => { + organization.value = updated; + return updated; + }); + } + + function create(data) { + return Organization.create(data).then(created => { + const withAdminRole = { ...created, role: OrganizationRoles.ADMIN }; + organization.value = withAdminRole; + return withAdminRole; + }); + } + + return { + loading, + organization, + update, + create, + }; +} diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationInvitations.js b/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationInvitations.js new file mode 100644 index 0000000000..e311340b5a --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationInvitations.js @@ -0,0 +1,60 @@ +import { ref, onMounted } from 'vue'; +import { Invitation } from 'shared/data/resources'; + +/** + * Composable for fetching and responding to organization invitations. + * + * @param {Object} params - fetchCollection params, e.g. `{ invited: 1 }` for + * "invitations addressed to me" (used by the My Organizations banner), or + * `{ organization: organizationId }` for "pending invites for this org" + * (used by the org Sharing tab). + */ +export function useOrganizationInvitations(params = { invited: 1 }) { + const loading = ref(true); + const invitations = ref([]); + + function loadInvitations() { + return Invitation.fetchCollection(params).then(data => { + invitations.value = data.filter( + invitation => + invitation.organization && + !invitation.accepted && + !invitation.declined && + !invitation.revoked, + ); + }); + } + + onMounted(() => { + loadInvitations().finally(() => { + loading.value = false; + }); + }); + + function accept(invitationId) { + return Invitation.accept(invitationId).then(() => { + invitations.value = invitations.value.filter(i => i.id !== invitationId); + }); + } + + function decline(invitationId) { + return Invitation.decline(invitationId).then(() => { + invitations.value = invitations.value.filter(i => i.id !== invitationId); + }); + } + + function revoke(invitationId) { + return Invitation.revoke(invitationId).then(() => { + invitations.value = invitations.value.filter(i => i.id !== invitationId); + }); + } + + return { + loading, + invitations, + accept, + decline, + revoke, + refresh: loadInvitations, + }; +} diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationList.js b/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationList.js new file mode 100644 index 0000000000..07fb0fd74b --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationList.js @@ -0,0 +1,27 @@ +import { ref, onMounted } from 'vue'; +import { Organization } from 'shared/data/resources'; + +const MAX_PAGE_SIZE = 100; + +/** + * Composable for fetching the organizations the current user belongs to. + */ +export function useOrganizationList() { + const loading = ref(true); + const organizations = ref([]); + + onMounted(() => { + Organization.fetchCollection({ page_size: MAX_PAGE_SIZE }) + .then(data => { + organizations.value = data; + }) + .finally(() => { + loading.value = false; + }); + }); + + return { + loading, + organizations, + }; +} diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationMembers.js b/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationMembers.js new file mode 100644 index 0000000000..c1d2dda0f7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationMembers.js @@ -0,0 +1,50 @@ +import { ref, onMounted } from 'vue'; +import { OrganizationRoleStatuses } from '../constants'; +import { OrganizationRole } from 'shared/data/resources'; + +const MAX_PAGE_SIZE = 100; + +/** + * Composable for fetching and managing an organization's active members. + */ +export function useOrganizationMembers(organizationId) { + const loading = ref(true); + const members = ref([]); + + function loadMembers() { + return OrganizationRole.fetchCollection({ + organization: organizationId, + status: OrganizationRoleStatuses.ACTIVE, + page_size: MAX_PAGE_SIZE, + }).then(data => { + members.value = data; + }); + } + + onMounted(() => { + loadMembers().finally(() => { + loading.value = false; + }); + }); + + function changeRole(roleId, role) { + return OrganizationRole.update(roleId, { role }).then(updated => { + members.value = members.value.map(member => (member.id === roleId ? updated : member)); + return updated; + }); + } + + function close(roleId) { + return OrganizationRole.delete(roleId).then(() => { + members.value = members.value.filter(member => member.id !== roleId); + }); + } + + return { + loading, + members, + changeRole, + close, + refresh: loadMembers, + }; +} diff --git a/contentcuration/contentcuration/frontend/channelList/constants.js b/contentcuration/contentcuration/frontend/channelList/constants.js index b0599932ca..d3aed8a2cc 100644 --- a/contentcuration/contentcuration/frontend/channelList/constants.js +++ b/contentcuration/contentcuration/frontend/channelList/constants.js @@ -5,6 +5,7 @@ import { ChannelListTypes } from 'shared/constants'; export const InvitationShareModes = { EDIT: 'edit', VIEW_ONLY: 'view', + ADMIN: 'admin', }; export const ChannelInvitationMapping = { @@ -14,6 +15,9 @@ export const ChannelInvitationMapping = { export const RouteNames = { CHANNELS_EDITABLE: 'CHANNELS_EDITABLE', + MY_ORGANIZATIONS: 'MY_ORGANIZATIONS', + ORGANIZATION_EDIT: 'ORGANIZATION_EDIT', + NEW_ORGANIZATION: 'NEW_ORGANIZATION', CHANNELS_STARRED: 'CHANNELS_STARRED', CHANNELS_VIEW_ONLY: 'CHANNELS_VIEW_ONLY', CHANNELS_PUBLIC: 'CHANNELS_PUBLIC', @@ -41,3 +45,19 @@ export const ListTypeToRouteMapping = { export const RouteToListTypeMapping = invert(ListTypeToRouteMapping); export const CHANNEL_PAGE_SIZE = 25; + +export const OrganizationEditTabs = { + DETAILS: 'details', + SHARING: 'sharing', +}; + +export const OrganizationRoles = { + ADMIN: 'admin', + EDITOR: 'editor', + VIEWER: 'viewer', +}; + +export const OrganizationRoleStatuses = { + ACTIVE: 'active', + INACTIVE: 'inactive', +}; diff --git a/contentcuration/contentcuration/frontend/channelList/router.js b/contentcuration/contentcuration/frontend/channelList/router.js index dfe37388cd..cd9548b0bf 100644 --- a/contentcuration/contentcuration/frontend/channelList/router.js +++ b/contentcuration/contentcuration/frontend/channelList/router.js @@ -1,6 +1,8 @@ import VueRouter from 'vue-router'; import CommunityChannelDetailsModal from './views/Channel/CommunityLibraryList/CommunityChannelDetailsModal.vue'; import StudioMyChannels from './views/Channel/StudioMyChannels'; +import StudioMyOrganizations from './views/Organization/StudioMyOrganizations.vue'; +import OrganizationEditPage from './views/Organization/OrganizationEditPage.vue'; import StudioStarredChannels from './views/Channel/StudioStarredChannels'; import StudioViewOnlyChannels from './views/Channel/StudioViewOnlyChannels'; import StudioCollectionsTable from './views/ChannelSet/StudioCollectionsTable'; @@ -20,6 +22,23 @@ const router = new VueRouter({ path: '/my-channels', component: StudioMyChannels, }, + { + name: RouteNames.MY_ORGANIZATIONS, + path: '/my-organizations', + component: StudioMyOrganizations, + }, + { + name: RouteNames.NEW_ORGANIZATION, + path: '/organization/new', + component: OrganizationEditPage, + props: true, + }, + { + name: RouteNames.ORGANIZATION_EDIT, + path: '/organization/:organizationId/:tab', + component: OrganizationEditPage, + props: true, + }, { name: RouteNames.CHANNEL_SETS, path: '/collections', @@ -99,7 +118,6 @@ const router = new VueRouter({ component: SubmissionDetailsModal, props: true, }, - // Catch-all for unrecognized URLs { path: '*', redirect: { name: RouteNames.CHANNELS_EDITABLE }, diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelListIndex.vue b/contentcuration/contentcuration/frontend/channelList/views/ChannelListIndex.vue index 83e7976db6..5728b5a363 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelListIndex.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelListIndex.vue @@ -130,6 +130,16 @@ badgeValue: this.invitationsByListCounts[listType] || 0, analyticsLabel: ListTypeToAnalyticsLabel[listType], }); + + if (listType === ChannelListTypes.EDITABLE) { + tabs.push({ + id: 'myOrganizations', + label: this.$tr('myOrganizations'), + to: this.myOrganizationsLink, + badgeValue: 0, + analyticsLabel: 'MY_ORGANIZATIONS', + }); + } }); tabs.push({ @@ -195,6 +205,9 @@ channelSetLink() { return { name: RouteNames.CHANNEL_SETS }; }, + myOrganizationsLink() { + return { name: RouteNames.MY_ORGANIZATIONS }; + }, catalogLink() { return { name: RouteNames.CATALOG_ITEMS }; }, @@ -240,11 +253,12 @@ return { name: ListTypeToRouteMapping[listType] }; }, updateTitleForPage() { - // Updates the tab title every time the top-level route changes let title; const routeName = this.$route.name; if (routeName === RouteNames.CHANNEL_SETS) { title = this.$tr('channelSets'); + } else if (routeName === RouteNames.MY_ORGANIZATIONS) { + title = this.$tr('myOrganizations'); } else if (routeName === RouteNames.CATALOG_ITEMS) { title = this.translateConstant('public'); } else if (routeName === RouteNames.CHANNELS_VIEW_ONLY) { @@ -265,6 +279,7 @@ }, $trs: { channelSets: 'Collections', + myOrganizations: 'My organizations', catalog: 'Kolibri Library', libraryTitle: 'Kolibri Content Library Catalog', frequentlyAskedQuestions: 'Frequently asked questions', diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/InviteOrganizationUserForm.vue b/contentcuration/contentcuration/frontend/channelList/views/Organization/InviteOrganizationUserForm.vue new file mode 100644 index 0000000000..549b807042 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/InviteOrganizationUserForm.vue @@ -0,0 +1,131 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationCard.vue b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationCard.vue new file mode 100644 index 0000000000..17891994ce --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationCard.vue @@ -0,0 +1,128 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationDetailsTab.vue b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationDetailsTab.vue new file mode 100644 index 0000000000..27076bb4b0 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationDetailsTab.vue @@ -0,0 +1,200 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationEditPage.vue b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationEditPage.vue new file mode 100644 index 0000000000..f75d065c51 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationEditPage.vue @@ -0,0 +1,194 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationInvitation.vue b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationInvitation.vue new file mode 100644 index 0000000000..9cb88ffcb4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationInvitation.vue @@ -0,0 +1,136 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationSharingTab.vue b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationSharingTab.vue new file mode 100644 index 0000000000..6f6ce5b786 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationSharingTab.vue @@ -0,0 +1,119 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationUsersTable.vue b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationUsersTable.vue new file mode 100644 index 0000000000..f4a6f8eb29 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/OrganizationUsersTable.vue @@ -0,0 +1,250 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/StudioMyOrganizations.vue b/contentcuration/contentcuration/frontend/channelList/views/Organization/StudioMyOrganizations.vue new file mode 100644 index 0000000000..0eeef87608 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/StudioMyOrganizations.vue @@ -0,0 +1,180 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/InviteOrganizationUserForm.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/InviteOrganizationUserForm.spec.js new file mode 100644 index 0000000000..3a059ce034 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/InviteOrganizationUserForm.spec.js @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import Vuex, { Store } from 'vuex'; +import InviteOrganizationUserForm from '../InviteOrganizationUserForm.vue'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +localVue.use(Vuex); + +const router = new VueRouter(); + +const createStore = () => { + return new Store({ + getters: { + snackbarIsVisible: () => false, + snackbarOptions: () => null, + }, + actions: { + showSnackbar: jest.fn(), + }, + }); +}; + +describe('InviteOrganizationUserForm', () => { + it('does not send an invitation when the email is blank', async () => { + const sendInvitation = jest.fn(); + render(InviteOrganizationUserForm, { + localVue, + router, + store: createStore(), + props: { organizationId: 'org-1', sendInvitation }, + }); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Send invitation' })); + + expect(sendInvitation).not.toHaveBeenCalled(); + expect(await screen.findByText('Email is required')).toBeInTheDocument(); + }); + + it('sends the invitation with the entered email and selected role', async () => { + const sendInvitation = jest.fn().mockResolvedValue({}); + render(InviteOrganizationUserForm, { + localVue, + router, + store: createStore(), + props: { organizationId: 'org-1', sendInvitation }, + }); + + const user = userEvent.setup(); + await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); + await user.click(screen.getByRole('button', { name: 'Send invitation' })); + + expect(sendInvitation).toHaveBeenCalledWith({ + organizationId: 'org-1', + email: 'new@example.com', + shareMode: 'view', + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationCard.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationCard.spec.js new file mode 100644 index 0000000000..d483f502bb --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationCard.spec.js @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import OrganizationCard from '../OrganizationCard.vue'; +import { RouteNames } from '../../../constants'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); + +const baseProps = () => ({ + organization: { + id: 'org-1', + name: 'Acme', + description: 'A learning organization', + role: 'admin', + }, + headingLevel: 2, +}); + +describe('OrganizationCard', () => { + it('emits "click" when the card title is clicked', async () => { + const router = new VueRouter(); + const { container, emitted } = render(OrganizationCard, { + localVue, + router, + props: baseProps(), + }); + + const user = userEvent.setup(); + await user.click(container.querySelector('[data-focus="true"]')); + + expect(emitted().click).toBeTruthy(); + }); + + it('navigates to the organization edit page from the options menu', async () => { + const router = new VueRouter({ + routes: [ + { + name: RouteNames.ORGANIZATION_EDIT, + path: '/organization/:organizationId/:tab', + component: { template: '
Edit
' }, + }, + ], + }); + render(OrganizationCard, { localVue, router, props: baseProps() }); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'More options for Acme' })); + await user.click(screen.getByText('Edit organization')); + + expect(router.currentRoute.name).toBe(RouteNames.ORGANIZATION_EDIT); + expect(router.currentRoute.params).toMatchObject({ organizationId: 'org-1', tab: 'details' }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationDetailsTab.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationDetailsTab.spec.js new file mode 100644 index 0000000000..22b64c41da --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationDetailsTab.spec.js @@ -0,0 +1,168 @@ +import { render, screen } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import Vuex, { Store } from 'vuex'; +import OrganizationDetailsTab from '../OrganizationDetailsTab.vue'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +localVue.use(Vuex); + +const router = new VueRouter(); + +const createStore = () => { + return new Store({ + getters: { + snackbarIsVisible: () => false, + snackbarOptions: () => null, + }, + actions: { + showSnackbar: jest.fn(), + }, + }); +}; + +describe('OrganizationDetailsTab', () => { + it('shows a loader while loading', () => { + render(OrganizationDetailsTab, { + localVue, + router, + store: createStore(), + props: { organization: null, loading: true, save: jest.fn() }, + }); + + expect(screen.queryByRole('textbox', { name: 'Organization name' })).not.toBeInTheDocument(); + }); + + it('pre-fills the form from the organization prop', () => { + render(OrganizationDetailsTab, { + localVue, + router, + store: createStore(), + props: { + organization: { id: 'org-1', name: 'Acme', description: 'Learning org', public: true }, + loading: false, + save: jest.fn(), + isAdmin: true, + }, + }); + + expect(screen.getByDisplayValue('Acme')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Learning org')).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /Public/ })).toBeChecked(); + }); + + it('saves the trimmed field values when Save changes is clicked', async () => { + const save = jest.fn().mockResolvedValue({}); + render(OrganizationDetailsTab, { + localVue, + router, + store: createStore(), + props: { + organization: { id: 'org-1', name: 'Acme', description: '', public: false }, + loading: false, + save, + isAdmin: true, + }, + }); + + const user = userEvent.setup(); + const nameInput = screen.getByRole('textbox', { name: 'Organization name' }); + await user.clear(nameInput); + await user.type(nameInput, ' Renamed Org '); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(save).toHaveBeenCalledWith({ + name: 'Renamed Org', + description: '', + public: false, + }); + }); + + it('does not save when the name is blank', async () => { + const save = jest.fn(); + render(OrganizationDetailsTab, { + localVue, + router, + store: createStore(), + props: { + organization: { id: 'org-1', name: 'Acme', description: '', public: false }, + loading: false, + save, + isAdmin: true, + }, + }); + + const user = userEvent.setup(); + const nameInput = screen.getByRole('textbox', { name: 'Organization name' }); + await user.clear(nameInput); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(save).not.toHaveBeenCalled(); + expect(await screen.findByText('Organization name is required')).toBeInTheDocument(); + }); + + it('shows a read-only view with no Save button for non-admins', () => { + render(OrganizationDetailsTab, { + localVue, + router, + store: createStore(), + props: { + organization: { id: 'org-1', name: 'Acme', description: '', public: false }, + loading: false, + save: jest.fn(), + isAdmin: false, + }, + }); + + expect(screen.getByRole('textbox', { name: 'Organization name' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument(); + expect( + screen.getByText('Only organization admins can edit these details.'), + ).toBeInTheDocument(); + }); + + it('shows a "Create organization" button and starts with blank fields in create mode', () => { + render(OrganizationDetailsTab, { + localVue, + router, + store: createStore(), + props: { + organization: null, + loading: false, + save: jest.fn(), + isNew: true, + isAdmin: true, + }, + }); + + expect(screen.getByRole('textbox', { name: 'Organization name' })).toHaveValue(''); + expect(screen.getByRole('checkbox', { name: /Public/ })).not.toBeChecked(); + expect(screen.getByRole('button', { name: 'Create organization' })).toBeInTheDocument(); + }); + + it('emits "created" with the new id after a successful create', async () => { + const save = jest.fn().mockResolvedValue({ id: 'org-2', name: 'New Org' }); + const { emitted } = render(OrganizationDetailsTab, { + localVue, + router, + store: createStore(), + props: { + organization: null, + loading: false, + save, + isNew: true, + isAdmin: true, + }, + }); + + const user = userEvent.setup(); + await user.type(screen.getByRole('textbox', { name: 'Organization name' }), 'New Org'); + await user.click(screen.getByRole('button', { name: 'Create organization' })); + + expect(save).toHaveBeenCalledWith({ name: 'New Org', description: '', public: false }); + await new Promise(resolve => setTimeout(resolve)); + expect(emitted().created[0]).toEqual(['org-2']); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationEditPage.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationEditPage.spec.js new file mode 100644 index 0000000000..162cdae38b --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationEditPage.spec.js @@ -0,0 +1,143 @@ +import { render, screen } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import Vuex, { Store } from 'vuex'; +import OrganizationEditPage from '../OrganizationEditPage.vue'; +import { RouteNames } from '../../../constants'; +import { Organization } from 'shared/data/resources'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +localVue.use(Vuex); + +const createStore = () => { + return new Store({ + state: { + connection: { online: true }, + }, + getters: { + snackbarIsVisible: () => false, + snackbarOptions: () => null, + }, + actions: { + showSnackbar: jest.fn(), + }, + }); +}; + +const createRouter = initialPath => { + const router = new VueRouter({ + routes: [ + { + name: RouteNames.NEW_ORGANIZATION, + path: '/organization/new', + component: OrganizationEditPage, + props: true, + }, + { + name: RouteNames.ORGANIZATION_EDIT, + path: '/organization/:organizationId/:tab', + component: OrganizationEditPage, + props: true, + }, + { + name: RouteNames.MY_ORGANIZATIONS, + path: '/my-organizations', + component: { template: '
My organizations
' }, + }, + ], + }); + router.push(initialPath); + return router; +}; + +describe('OrganizationEditPage', () => { + beforeEach(() => { + jest + .spyOn(Organization, 'fetchModel') + .mockResolvedValue({ id: 'org-1', name: 'Acme', description: '', public: false }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders the organization name and the details tab by default', async () => { + const router = createRouter('/organization/org-1/details'); + render(OrganizationEditPage, { + localVue, + router, + store: createStore(), + props: { organizationId: 'org-1', tab: 'details' }, + }); + + expect(await screen.findByText('Acme')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Organization name' })).toBeInTheDocument(); + }); + + it('shows nothing on the sharing tab', async () => { + const router = createRouter('/organization/org-1/sharing'); + render(OrganizationEditPage, { + localVue, + router, + store: createStore(), + props: { organizationId: 'org-1', tab: 'sharing' }, + }); + + await screen.findByText('Acme'); + expect(screen.queryByRole('textbox', { name: 'Organization name' })).not.toBeInTheDocument(); + }); + + it('navigates to the "last" route when the close button is clicked', async () => { + const router = createRouter('/organization/org-1/details?last=' + RouteNames.MY_ORGANIZATIONS); + render(OrganizationEditPage, { + localVue, + router, + store: createStore(), + props: { organizationId: 'org-1', tab: 'details' }, + }); + + await screen.findByText('Acme'); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(router.currentRoute.name).toBe(RouteNames.MY_ORGANIZATIONS); + }); + + it('shows a blank creation form with no tabs on the "new" route', () => { + const router = createRouter('/organization/new'); + render(OrganizationEditPage, { + localVue, + router, + store: createStore(), + props: {}, + }); + + expect(screen.getByText('New organization')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Organization name' })).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Create organization' })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Sharing' })).not.toBeInTheDocument(); + }); + + it("navigates to the new organization's edit page after creating it", async () => { + jest + .spyOn(Organization, 'create') + .mockResolvedValue({ id: 'org-2', name: 'New Org', description: '', public: false }); + const router = createRouter('/organization/new'); + render(OrganizationEditPage, { + localVue, + router, + store: createStore(), + props: {}, + }); + + const user = userEvent.setup(); + await user.type(screen.getByRole('textbox', { name: 'Organization name' }), 'New Org'); + await user.click(screen.getByRole('button', { name: 'Create organization' })); + + await screen.findByRole('textbox', { name: 'Organization name' }); + expect(router.currentRoute.name).toBe(RouteNames.ORGANIZATION_EDIT); + expect(router.currentRoute.params).toMatchObject({ organizationId: 'org-2', tab: 'details' }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationSharingTab.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationSharingTab.spec.js new file mode 100644 index 0000000000..cefd84f439 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationSharingTab.spec.js @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/vue'; +import { createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import Vuex, { Store } from 'vuex'; +import OrganizationSharingTab from '../OrganizationSharingTab.vue'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +localVue.use(Vuex); + +const router = new VueRouter(); + +const createStore = () => { + return new Store({ + getters: { + snackbarIsVisible: () => false, + snackbarOptions: () => null, + }, + actions: { + showSnackbar: jest.fn(), + }, + }); +}; + +describe('OrganizationSharingTab', () => { + it('shows the invite form and users table to an admin', async () => { + render(OrganizationSharingTab, { + localVue, + router, + store: createStore(), + props: { organizationId: 'org-1', isAdmin: true }, + }); + + expect(await screen.findByText('Invite users')).toBeInTheDocument(); + expect(screen.getByText('Users')).toBeInTheDocument(); + }); + + it('shows a message instead of the form to a non-admin', () => { + render(OrganizationSharingTab, { + localVue, + router, + store: createStore(), + props: { organizationId: 'org-1', isAdmin: false }, + }); + + expect( + screen.getByText('Only organization admins can manage sharing settings.'), + ).toBeInTheDocument(); + expect(screen.queryByText('Invite users')).not.toBeInTheDocument(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationUsersTable.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationUsersTable.spec.js new file mode 100644 index 0000000000..6b82d182f9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/OrganizationUsersTable.spec.js @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import Vuex, { Store } from 'vuex'; +import OrganizationUsersTable from '../OrganizationUsersTable.vue'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +localVue.use(Vuex); + +const router = new VueRouter(); + +const createStore = () => { + return new Store({ + getters: { + snackbarIsVisible: () => false, + snackbarOptions: () => null, + }, + actions: { + showSnackbar: jest.fn(), + }, + }); +}; + +const baseProps = () => ({ + members: [ + { + id: 'role-1', + user_first_name: 'Ann', + user_last_name: 'Admin', + user_name: 'Ann Admin', + user_email: 'ann@example.com', + role: 'admin', + }, + ], + invitations: [ + { + id: 'invite-1', + first_name: '', + last_name: '', + email: 'pending@example.com', + share_mode: 'edit', + }, + ], + loading: false, + changeRole: jest.fn().mockResolvedValue({}), + closeMemberRole: jest.fn().mockResolvedValue({}), + resendInvitation: jest.fn().mockResolvedValue({}), + revokeInvitation: jest.fn().mockResolvedValue({}), +}); + +describe('OrganizationUsersTable', () => { + it('renders a row for each active member and each pending invitation', () => { + render(OrganizationUsersTable, { localVue, router, store: createStore(), props: baseProps() }); + + expect(screen.getByText('Ann Admin')).toBeInTheDocument(); + expect(screen.getByText('ann@example.com')).toBeInTheDocument(); + expect(screen.getAllByText('pending@example.com').length).toBeGreaterThan(0); + expect(screen.getByText('Pending Editor')).toBeInTheDocument(); + }); + + it('resends the invitation when "Resend invitation" is selected', async () => { + const props = baseProps(); + render(OrganizationUsersTable, { localVue, router, store: createStore(), props }); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Options for pending@example.com' })); + await user.click(screen.getByText('Resend invitation')); + + expect(props.resendInvitation).toHaveBeenCalledWith('invite-1'); + }); + + it('closes a member role after confirming the modal', async () => { + const props = baseProps(); + render(OrganizationUsersTable, { localVue, router, store: createStore(), props }); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Options for ann@example.com' })); + await user.click(screen.getByText('Remove from organization')); + await user.click(screen.getByRole('button', { name: 'Remove' })); + + expect(props.closeMemberRole).toHaveBeenCalledWith('role-1'); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/StudioMyOrganizations.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/StudioMyOrganizations.spec.js new file mode 100644 index 0000000000..ddabd51412 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Organization/__tests__/StudioMyOrganizations.spec.js @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import Vuex, { Store } from 'vuex'; +import StudioMyOrganizations from '../StudioMyOrganizations.vue'; +import { Organization, Invitation } from 'shared/data/resources'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +localVue.use(Vuex); + +const createStore = () => { + return new Store({ + getters: { + snackbarIsVisible: () => false, + snackbarOptions: () => null, + }, + actions: { + showSnackbar: jest.fn(), + }, + }); +}; + +describe('StudioMyOrganizations', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders the empty state when the user has no organizations', async () => { + const router = new VueRouter({ + routes: [{ path: '/my-organizations', component: StudioMyOrganizations }], + }); + + render(StudioMyOrganizations, { localVue, router, store: createStore() }); + + expect(screen.getByRole('heading', { name: 'Organizations' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'New organization' })).toBeInTheDocument(); + expect( + await screen.findByText('You are not a member of any organizations yet.'), + ).toBeInTheDocument(); + }); + + it('renders a card for each organization the user belongs to', async () => { + jest.spyOn(Organization, 'fetchCollection').mockResolvedValue([ + { id: 'org-1', name: 'Org One', description: 'First org', role: 'admin' }, + { id: 'org-2', name: 'Org Two', description: 'Second org', role: 'viewer' }, + ]); + const router = new VueRouter({ + routes: [{ path: '/my-organizations', component: StudioMyOrganizations }], + }); + + render(StudioMyOrganizations, { localVue, router, store: createStore() }); + + expect((await screen.findAllByText('Org One')).length).toBeGreaterThan(0); + expect(screen.getAllByText('Org Two').length).toBeGreaterThan(0); + expect( + screen.queryByText('You are not a member of any organizations yet.'), + ).not.toBeInTheDocument(); + }); + + it('renders pending organization invitations and lets the user accept them', async () => { + jest.spyOn(Invitation, 'fetchCollection').mockResolvedValue([ + { + id: 'invite-1', + organization: 'org-1', + organization_name: 'Org One', + sender_name: 'Admin User', + share_mode: 'edit', + accepted: false, + declined: false, + revoked: false, + }, + ]); + const accept = jest.spyOn(Invitation, 'accept').mockResolvedValue(); + const router = new VueRouter({ + routes: [{ path: '/my-organizations', component: StudioMyOrganizations }], + }); + + const { container } = render(StudioMyOrganizations, { + localVue, + router, + store: createStore(), + }); + + expect( + await screen.findByText('Admin User has invited you to edit Org One'), + ).toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(container.querySelector('[data-test="accept"]')); + + expect(accept).toHaveBeenCalledWith('invite-1'); + expect( + screen.queryByText('Admin User has invited you to edit Org One'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/__tests__/app.spec.js b/contentcuration/contentcuration/frontend/shared/__tests__/app.spec.js index 2a95b653f1..70d779cf13 100644 --- a/contentcuration/contentcuration/frontend/shared/__tests__/app.spec.js +++ b/contentcuration/contentcuration/frontend/shared/__tests__/app.spec.js @@ -1,4 +1,5 @@ import VueRouter from 'vue-router'; +import { Workbox } from 'workbox-window'; import startApp from '../app'; import { CURRENT_USER } from 'shared/data/constants'; @@ -7,6 +8,7 @@ import { resetDB } from 'shared/data'; import storeFactory from 'shared/vuex/baseStore'; jest.mock('shared/data'); +jest.mock('workbox-window'); const router = new VueRouter(); @@ -108,4 +110,33 @@ describe('startApp', () => { }); }); }); + + describe('when service worker registration fails', () => { + let originalServiceWorker; + + beforeEach(() => { + global.user = USER_1; + originalServiceWorker = navigator.serviceWorker; + Object.defineProperty(navigator, 'serviceWorker', { + value: {}, + configurable: true, + }); + Workbox.mockImplementation(() => ({ + register: () => Promise.reject(new Error('registration failed')), + addEventListener: jest.fn(), + })); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'serviceWorker', { + value: originalServiceWorker, + configurable: true, + }); + }); + + it('still finishes starting the app instead of hanging', async () => { + cleanup = await startApp({ router, store }); + expect(cleanup).toEqual(expect.any(Function)); + }); + }); }); diff --git a/contentcuration/contentcuration/frontend/shared/app.js b/contentcuration/contentcuration/frontend/shared/app.js index eefc9d12b0..9bed1da69c 100644 --- a/contentcuration/contentcuration/frontend/shared/app.js +++ b/contentcuration/contentcuration/frontend/shared/app.js @@ -109,6 +109,7 @@ import { Workbox, messageSW } from 'workbox-window'; import KThemePlugin from 'kolibri-design-system/lib/KThemePlugin'; import trackInputModality from 'kolibri-design-system/lib/styles/trackInputModality'; +import logging from './logging'; import AnalyticsPlugin from './analytics/plugin'; import { theme, icons } from 'shared/vuetify'; @@ -309,7 +310,11 @@ export let rootVue; export default async function startApp({ store, router, index }) { trackInputModality(); - await initiateServiceWorker(); + try { + await initiateServiceWorker(); + } catch (error) { + logging.error(error); + } await initializeDB(); await i18nSetup(); diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 2a458cd2a3..eefe2512a2 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -2065,6 +2065,10 @@ export const Invitation = new Resource({ const changes = { declined: true }; return this._handleInvitation(id, window.Urls.invitationDecline(id), changes); }, + revoke(id) { + const changes = { revoked: true }; + return this._handleInvitation(id, window.Urls.invitationRevoke(id), changes); + }, _handleInvitation(id, url, changes) { return client.post(url).then(() => { return this.transaction({ mode: 'rw' }, () => { @@ -2072,6 +2076,19 @@ export const Invitation = new Resource({ }); }); }, + sendOrganizationInvitation({ organizationId, email, shareMode }) { + return client + .post(window.Urls.send_organization_invitation_email(), { + user_email: email, + organization_id: organizationId, + share_mode: shareMode, + }) + .then(response => { + return this.transaction({ mode: 'rw' }, () => { + return this.table.put(response.data); + }).then(() => response.data); + }); + }, getChannelId(obj) { return obj.channel; }, @@ -2426,6 +2443,39 @@ export const CommunityLibrarySubmission = new APIResource({ }, }); +export const Organization = new APIResource({ + urlName: 'organization', + fetchCollection(params) { + return client.get(this.collectionUrl(), { params }).then(response => { + return (response.data && response.data.results) || []; + }); + }, + fetchModel(id) { + return client.get(this.modelUrl(id)).then(response => response.data); + }, + create(data) { + return client.post(this.collectionUrl(), data).then(response => response.data); + }, + update(id, data) { + return client.patch(this.modelUrl(id), data).then(response => response.data); + }, +}); + +export const OrganizationRole = new APIResource({ + urlName: 'organization_members', + fetchCollection(params) { + return client.get(this.collectionUrl(), { params }).then(response => { + return (response.data && response.data.results) || []; + }); + }, + update(id, data) { + return client.patch(this.modelUrl(id), data).then(response => response.data); + }, + delete(id) { + return client.delete(this.modelUrl(id)); + }, +}); + export const AdminCommunityLibrarySubmission = new APIResource({ urlName: 'admin_community_library_submission', fetchCollection(params) { diff --git a/contentcuration/contentcuration/tests/test_organization_invitation.py b/contentcuration/contentcuration/tests/test_organization_invitation.py new file mode 100644 index 0000000000..c536ca639a --- /dev/null +++ b/contentcuration/contentcuration/tests/test_organization_invitation.py @@ -0,0 +1,80 @@ +import json + +from rest_framework.test import force_authenticate + +from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN +from contentcuration.constants.organization_roles import ORGANIZATION_ROLE_STATUS_ACTIVE +from contentcuration.models import Invitation +from contentcuration.models import User +from contentcuration.tests import testdata +from contentcuration.tests.base import BaseAPITestCase +from contentcuration.views.users import send_organization_invitation_email + + +class OrganizationInvitationTestCase(BaseAPITestCase): + def setUp(self): + super(OrganizationInvitationTestCase, self).setUp() + self.org = testdata.organization() + testdata.organization_role( + self.user, + self.org, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + + def send_invitation(self, email, share_mode, user=None): + user = user or self.user + request = self.create_post_request( + "/api/send_organization_invitation_email/", + data=json.dumps( + { + "user_email": email, + "organization_id": self.org.id, + "share_mode": share_mode, + } + ), + content_type="application/json", + ) + request.user = user + force_authenticate(request, user=user) + return send_organization_invitation_email(request) + + def test_admin_can_send_invitation(self): + response = self.send_invitation("invitee@example.com", "edit") + self.assertEqual(response.status_code, 200) + invitation = Invitation.objects.get( + organization=self.org, email="invitee@example.com" + ) + self.assertEqual(invitation.share_mode, "edit") + self.assertEqual(invitation.sender, self.user) + + def test_non_admin_cannot_send_invitation(self): + non_admin = testdata.user(email="not-an-admin@example.com") + + response = self.send_invitation("invitee@example.com", "edit", user=non_admin) + self.assertEqual(response.status_code, 403) + self.assertFalse( + Invitation.objects.filter( + organization=self.org, email="invitee@example.com" + ).exists() + ) + + def test_reinviting_the_same_email_updates_the_existing_invitation(self): + self.send_invitation("invitee@example.com", "view") + self.send_invitation("invitee@example.com", "edit") + + invitations = Invitation.objects.filter( + organization=self.org, email="invitee@example.com" + ) + self.assertEqual(invitations.count(), 1) + self.assertEqual(invitations.first().share_mode, "edit") + + def test_invitation_matches_an_existing_user_by_email(self): + User.objects.create(email="existing@example.com", first_name="Existing") + + response = self.send_invitation("existing@example.com", "view") + self.assertEqual(response.status_code, 200) + invitation = Invitation.objects.get( + organization=self.org, email="existing@example.com" + ) + self.assertEqual(invitation.first_name, "Existing") diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py index be0105106e..685061bfea 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py +++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py @@ -799,4 +799,69 @@ def test_accept_revoked_invitation_returns_400(self): self.assertEqual(response.status_code, 400, response.content) invitation.refresh_from_db() self.assertFalse(invitation.accepted) - self.assertFalse(self.channel.editors.filter(pk=self.invited_user.id).exists()) + + +class OrganizationInvitationRevokeActionTestCase(StudioAPITestCase): + def setUp(self): + super(OrganizationInvitationRevokeActionTestCase, self).setUp() + self.organization = testdata.organization() + self.admin = testdata.user("org-admin@example.com") + testdata.organization_role( + self.admin, self.organization, role=ORGANIZATION_ADMIN + ) + self.invited_user = testdata.user("invitee@example.com") + + def _invitation(self, sender=None): + return models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=self.organization, + email=self.invited_user.email, + sender=sender or self.admin, + ) + + def test_admin_can_revoke(self): + invitation = self._invitation() + self.client.force_authenticate(user=self.admin) + response = self.client.post( + reverse("invitation-revoke", kwargs={"pk": invitation.id}) + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.revoked) + + def test_sender_can_revoke_even_if_no_longer_admin(self): + sender = testdata.user("former-admin@example.com") + testdata.organization_role(sender, self.organization, role=ORGANIZATION_ADMIN) + invitation = self._invitation(sender=sender) + + self.client.force_authenticate(user=sender) + response = self.client.post( + reverse("invitation-revoke", kwargs={"pk": invitation.id}) + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.revoked) + + def test_unrelated_org_member_cannot_revoke(self): + editor = testdata.user("editor@example.com") + testdata.organization_role(editor, self.organization, role=ORGANIZATION_EDITOR) + invitation = self._invitation() + + self.client.force_authenticate(user=editor) + response = self.client.post( + reverse("invitation-revoke", kwargs={"pk": invitation.id}) + ) + self.assertEqual(response.status_code, 404, response.content) + invitation.refresh_from_db() + self.assertFalse(invitation.revoked) + + def test_invitee_cannot_revoke_their_own_invitation(self): + invitation = self._invitation() + + self.client.force_authenticate(user=self.invited_user) + response = self.client.post( + reverse("invitation-revoke", kwargs={"pk": invitation.id}) + ) + self.assertEqual(response.status_code, 403, response.content) + invitation.refresh_from_db() + self.assertFalse(invitation.revoked) diff --git a/contentcuration/contentcuration/tests/viewsets/test_organization_role_annotation.py b/contentcuration/contentcuration/tests/viewsets/test_organization_role_annotation.py new file mode 100644 index 0000000000..76de482dbe --- /dev/null +++ b/contentcuration/contentcuration/tests/viewsets/test_organization_role_annotation.py @@ -0,0 +1,84 @@ +from django.urls import reverse +from rest_framework import status + +from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN +from contentcuration.constants.organization_roles import ORGANIZATION_EDITOR +from contentcuration.constants.organization_roles import ORGANIZATION_VIEWER +from contentcuration.tests import testdata +from contentcuration.tests.viewsets.test_organization import OrganizationAPITestCase + + +class OrganizationRoleAnnotationTestCase(OrganizationAPITestCase): + def test_list_includes_the_users_own_role(self): + self.authenticate_as(self.viewer_user) + + response = self.client.get(self.organization_list_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + [organization] = response.data["results"] + self.assertEqual(organization["role"], ORGANIZATION_VIEWER) + + def test_retrieve_includes_the_users_own_role(self): + self.authenticate_as(self.editor_user) + + response = self.client.get(self.organization_detail_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["role"], ORGANIZATION_EDITOR) + + def test_role_is_null_for_a_public_org_viewed_by_a_nonmember(self): + self.organization.public = True + self.organization.save(update_fields=["public"]) + self.authenticate_as(self.other_user) + + response = self.client.get(self.organization_detail_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.data["role"]) + + def test_role_ignores_an_inactive_membership(self): + self.organization.public = True + self.organization.save(update_fields=["public"]) + self.authenticate_as(self.inactive_user) + + response = self.client.get(self.organization_detail_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.data["role"]) + + def test_create_response_includes_the_new_admin_role(self): + creator = testdata.user(email="role-annotation-creator@test.com") + self.authenticate_as(creator) + + response = self.client.post( + self.organization_list_url, {"name": "New Org"}, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["role"], ORGANIZATION_ADMIN) + + def test_update_response_reflects_the_admins_role(self): + self.authenticate_as(self.organization_admin) + + response = self.client.patch( + self.organization_detail_url(), + {"name": "Renamed Org"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["role"], ORGANIZATION_ADMIN) + + def test_site_admin_sees_null_role_for_an_org_they_do_not_belong_to(self): + site_admin = testdata.user(email="site-admin@test.com") + site_admin.is_admin = True + site_admin.save(update_fields=["is_admin"]) + self.authenticate_as(site_admin) + + response = self.client.get(reverse("organization-list")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + [organization] = [ + o for o in response.data["results"] if o["id"] == str(self.organization.id) + ] + self.assertIsNone(organization["role"]) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index d013650272..5c97e43d50 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -394,6 +394,11 @@ def get_redirect_url(self, *args, **kwargs): registration_views.send_invitation_email, name="send_invitation_email", ), + re_path( + r"^api/send_organization_invitation_email/$", + registration_views.send_organization_invitation_email, + name="send_organization_invitation_email", + ), re_path( r"^new/accept_invitation/(?P[^/]+)/", registration_views.new_user_redirect, diff --git a/contentcuration/contentcuration/views/users.py b/contentcuration/contentcuration/views/users.py index 2d5b1236b5..f5195fe4e0 100644 --- a/contentcuration/contentcuration/views/users.py +++ b/contentcuration/contentcuration/views/users.py @@ -37,6 +37,7 @@ from contentcuration.forms import RegistrationForm from contentcuration.models import Channel from contentcuration.models import Invitation +from contentcuration.models import Organization from contentcuration.models import User from contentcuration.viewsets.invitation import InvitationSerializer @@ -71,7 +72,6 @@ def send_invitation_email(request): "last_name": recipient.last_name if recipient else "", } - # Need to break into two steps to avoid MultipleObjectsReturned error invitation = Invitation.objects.filter( channel_id=channel_id, email=user_email, @@ -83,7 +83,6 @@ def send_invitation_email(request): if not invitation: invitation = Invitation.objects.create(**fields) - # Handle these values separately as different users might invite the same user again invitation.share_mode = share_mode invitation.sender = invitation.sender or request.user invitation.save() @@ -115,6 +114,57 @@ def send_invitation_email(request): return Response(InvitationSerializer(invitation).data) +@api_view(["POST"]) +@authentication_classes( + (SessionAuthentication, BasicAuthentication, TokenAuthentication) +) +@permission_classes((IsAuthenticated,)) +def send_organization_invitation_email(request): + try: + user_email = request.data["user_email"].lower() + organization_id = request.data["organization_id"] + share_mode = request.data["share_mode"] + + if not Organization.filter_edit_queryset( + Organization.objects.filter(id=organization_id), request.user + ).exists(): + raise PermissionDenied() + + recipient = User.get_for_email(user_email) + + fields = { + "invited": recipient, + "email": user_email, + "organization_id": organization_id, + "first_name": recipient.first_name if recipient else "", + "last_name": recipient.last_name if recipient else "", + } + + invitation = Invitation.objects.filter( + organization_id=organization_id, + email=user_email, + revoked=False, + accepted=False, + declined=False, + ).first() + + if not invitation: + invitation = Invitation.objects.create(**fields) + + invitation.share_mode = share_mode + invitation.sender = invitation.sender or request.user + invitation.save() + except KeyError: + logger.warning( + "send_organization_invitation_email missing required field", exc_info=True + ) + return HttpResponseBadRequest( + "Missing attribute from data", content_type="text/plain" + ) + + return Response(InvitationSerializer(invitation).data) + + @api_view(["GET"]) @authentication_classes((SessionAuthentication,)) @permission_classes((IsAuthenticated,)) diff --git a/contentcuration/contentcuration/viewsets/invitation.py b/contentcuration/contentcuration/viewsets/invitation.py index bf62298b91..51eab01f72 100644 --- a/contentcuration/contentcuration/viewsets/invitation.py +++ b/contentcuration/contentcuration/viewsets/invitation.py @@ -173,12 +173,14 @@ class InvitationViewSet(ValuesViewset): "organization_id", "share_mode", "channel__name", + "organization__name", ) field_map = { "first_name": "invited__first_name", "last_name": "invited__last_name", "sender_name": get_sender_name, "channel_name": "channel__name", + "organization_name": "organization__name", "channel": "channel_id", "organization": "organization_id", } @@ -236,3 +238,36 @@ def decline(self, request, pk=None): created_by_id=request.user.id, ) return Response({"status": "success"}) + + @action(detail=True, methods=["post"]) + def revoke(self, request, pk=None): + invitation = self.get_edit_object() + is_org_admin = ( + invitation.organization_id + and Organization.filter_edit_queryset( + Organization.objects.filter(id=invitation.organization_id), + request.user, + ).exists() + ) + if ( + invitation.sender_id != request.user.id + and not is_org_admin + and not request.user.is_admin + ): + raise PermissionDenied( + "Only the sender or an organization admin may revoke this invitation." + ) + invitation.revoked = True + invitation.save() + Change.create_change( + generate_update_event( + invitation.id, + INVITATION, + {"revoked": True}, + channel_id=invitation.channel_id, + user_id=request.user.id, + ), + applied=True, + created_by_id=request.user.id, + ) + return Response({"status": "success"}) diff --git a/contentcuration/contentcuration/viewsets/organization.py b/contentcuration/contentcuration/viewsets/organization.py index cf898638fd..b9a636302e 100644 --- a/contentcuration/contentcuration/viewsets/organization.py +++ b/contentcuration/contentcuration/viewsets/organization.py @@ -1,4 +1,6 @@ from django.db import transaction +from django.db.models import OuterRef +from django.db.models import Subquery from django_filters.rest_framework import CharFilter from django_filters.rest_framework import FilterSet from django_filters.rest_framework import NumberFilter @@ -150,6 +152,7 @@ class OrganizationViewSet( "public", "created_at", "updated_at", + "role", ) def get_queryset(self): @@ -158,6 +161,14 @@ def get_queryset(self): self.request.user, ) + def annotate_queryset(self, queryset): + role = OrganizationRole.objects.filter( + organization=OuterRef("id"), + user=self.request.user, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + return queryset.annotate(role=Subquery(role.values("role")[:1])) + def perform_create(self, serializer, change=None): """Create the organization and its initial administrator atomically.""" with transaction.atomic():