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 @@
+
+
+
+
+
+
+
+
+
+
{{ roleLabel }}
+
+ {{ organization.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $tr('organizationDetails') }}
+
+
+ {{ $tr('viewOnlyNotice') }}
+
+
+
+
+
+
+ (isPublic = value)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ isNew ? $tr('newOrganizationTitle') : organization ? organization.name : '' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ invitationText }}
+
+
+
+
+
+
+ {{ $tr('decliningInvitationMessage') }}
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $tr('notAdmin') }}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
{{ $tr('users') }}
+
+
+
+
+
+
+
+ {{ content }}
+
+
+ handleSelect(option, rows[rowIndex][3])"
+ />
+
+
+
+
+
+
+ {{ $tr('closeRoleText', { email: closeTarget.email }) }}
+
+
+
+ {{ $tr('revokeInvitationText', { email: revokeTarget.email }) }}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ $tr('invitations', { count: invitations.length }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $tr('noOrganizationsFound') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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():