diff --git a/client/src/api/index.js b/client/src/api/index.js index 9774f10dd..7f0a8aaf6 100644 --- a/client/src/api/index.js +++ b/client/src/api/index.js @@ -593,8 +593,9 @@ export function organisationInvitationDelete(organisationInvitationId, showError return fetchDelete(`/api/organisation_invitations/${organisationInvitationId}`, showErrorDialog); } -export function organisationInvitationExists(email, organisationId) { - return fetchJson(`/api/organisation_invitations/exists_email?email=${email}&organisation_id=${organisationId}`); +export function organisationInvitationExists(emails, organisationId) { + const body = {emails: emails, organisation_id: organisationId} + return postPutJson("/api/organisation_invitations/exists_email", body, "POST"); } //Invitations @@ -623,8 +624,9 @@ export function invitationDelete(invitationId, showErrorDialog = true) { return fetchDelete(`/api/invitations/${invitationId}`, showErrorDialog); } -export function invitationExists(email, collaborationId) { - return fetchJson(`/api/invitations/exists_email?email=${email}&collaboration_id=${collaborationId}`); +export function invitationExists(emails, collaborationId) { + const body = {emails: emails, collaboration_id: collaborationId} + return postPutJson("/api/organisation_invitations/exists_email", body, "POST"); } @@ -988,8 +990,9 @@ export function serviceInvitationDelete(serviceInvitationId, showErrorDialog = t return fetchDelete(`/api/service_invitations/${serviceInvitationId}`, showErrorDialog); } -export function serviceInvitationExists(email, serviceId) { - return fetchJson(`/api/service_invitations/exists_email?email=${email}&service_id=${serviceId}`); +export function serviceInvitationExists(emails, serviceId) { + const body = {emails: emails, service_id: serviceId} + return postPutJson("/api/service_invitations/exists_email", body, "POST"); } //ServiceAups diff --git a/client/src/pages/NewInvitation.jsx b/client/src/pages/NewInvitation.jsx index 7a8098348..84b576caf 100644 --- a/client/src/pages/NewInvitation.jsx +++ b/client/src/pages/NewInvitation.jsx @@ -29,8 +29,7 @@ class NewInvitation extends React.Component { constructor(props, context) { super(props, context); this.intendedRolesOptions = collaborationRoles.map(role => ({ - value: role, - label: I18n.t(`collaboration.${role}`) + value: role, label: I18n.t(`collaboration.${role}`) })); const email = getParameterByName("email", window.location.search); const administrators = !isEmpty(email) && validEmailRegExp.test(email.trim()) ? [email.trim()] : []; @@ -51,8 +50,7 @@ class NewInvitation extends React.Component { initial: true, confirmationDialogOpen: false, confirmationDialogAction: () => this.setState({confirmationDialogOpen: false}), - cancelDialogAction: () => this.setState({confirmationDialogOpen: false}, - () => this.props.history.push(`/collaborations/${this.props.match.params.collaboration_id}`)), + cancelDialogAction: () => this.setState({confirmationDialogOpen: false}, () => this.props.history.push(`/collaborations/${this.props.match.params.collaboration_id}`)), leavePage: true, htmlPreview: "", activeTab: "invitation_form", @@ -86,25 +84,16 @@ class NewInvitation extends React.Component { updateAppStore = (collaboration, user) => { const orgManager = isUserAllowed(ROLES.ORG_MANAGER, user, collaboration.organisation_id, null); AppStore.update(s => { - s.breadcrumb.paths = orgManager ? [ - {path: "/", value: I18n.t("breadcrumb.home")}, - { - path: `/organisations/${collaboration.organisation_id}`, - value: I18n.t("breadcrumb.organisation", {name: collaboration.organisation.name}) - }, - { - path: `/collaborations/${collaboration.id}`, - value: I18n.t("breadcrumb.collaboration", {name: collaboration.name}) - }, - {value: I18n.t("breadcrumb.invite")} - ] : [ - {path: "/", value: I18n.t("breadcrumb.home")}, - { - path: `/collaborations/${collaboration.id}`, - value: I18n.t("breadcrumb.collaboration", {name: collaboration.name}) - }, - {value: I18n.t("breadcrumb.invite")} - ]; + s.breadcrumb.paths = orgManager ? [{path: "/", value: I18n.t("breadcrumb.home")}, { + path: `/organisations/${collaboration.organisation_id}`, + value: I18n.t("breadcrumb.organisation", {name: collaboration.organisation.name}) + }, { + path: `/collaborations/${collaboration.id}`, + value: I18n.t("breadcrumb.collaboration", {name: collaboration.name}) + }, {value: I18n.t("breadcrumb.invite")}] : [{path: "/", value: I18n.t("breadcrumb.home")}, { + path: `/collaborations/${collaboration.id}`, + value: I18n.t("breadcrumb.collaboration", {name: collaboration.name}) + }, {value: I18n.t("breadcrumb.invite")}]; }); } @@ -119,8 +108,15 @@ class NewInvitation extends React.Component { doSubmit = () => { const { - administrators, message, collaboration, expiry_date, fileEmails, intended_role, - selectedGroup, membership_expiry_date, isAdminView + administrators, + message, + collaboration, + expiry_date, + fileEmails, + intended_role, + selectedGroup, + membership_expiry_date, + isAdminView } = this.state; if (this.isValid()) { this.setState({loading: true}); @@ -160,14 +156,11 @@ class NewInvitation extends React.Component { validateDuplicates(newAdministrators) { const collaborationId = this.props.match.params.collaboration_id; - const promises = newAdministrators.map(email => invitationExists(email, collaborationId)); - Promise.all(promises) - .then(res => { - const existingInvitations = res - .filter(email => email.exists) - .map(email => email.email); - this.setState({existingInvitations: existingInvitations, initial: isEmpty(existingInvitations)}) - }) + this.setState({loading: true}); + invitationExists(newAdministrators, collaborationId) + .then(existingInvitations => this.setState({ + existingInvitations: existingInvitations, initial: isEmpty(existingInvitations), loading: false + })) } addEmails = emails => { @@ -214,12 +207,10 @@ class NewInvitation extends React.Component { } }; - preview = disabledSubmit => ( -
+ preview = disabledSubmit => (
{this.renderActions(disabledSubmit)} -
- ); +
); selectedGroupsChanged = selectedOptions => { if (selectedOptions === null) { @@ -230,8 +221,7 @@ class NewInvitation extends React.Component { } } - invitationForm = (fileInputKey, fileName, fileTypeError, fileEmails, initial, administrators, - intended_role, message, expiry_date, disabledSubmit, groups, selectedGroup, membership_expiry_date, existingInvitations) => + invitationForm = (fileInputKey, fileName, fileTypeError, fileEmails, initial, administrators, intended_role, message, expiry_date, disabledSubmit, groups, selectedGroup, membership_expiry_date, existingInvitations) =>
- {(!initial && isEmpty(expiry_date)) && - } + {(!initial && isEmpty(expiry_date)) && } {this.renderActions(disabledSubmit)}
; - renderActions = disabledSubmit => ( -
+ renderActions = disabledSubmit => (
- ); +
); render() { const { @@ -326,23 +313,20 @@ class NewInvitation extends React.Component { return } const disabledSubmit = (!initial && !this.isValid()); - return ( - <> - - -
-

{I18n.t("tabs.invitation_form")}

-
- {this.invitationForm(fileInputKey, fileName, fileTypeError, fileEmails, initial, - administrators, intended_role, message, expiry_date, disabledSubmit, groups, - selectedGroup, membership_expiry_date, existingInvitations)} -
+ return (<> + + +
+

{I18n.t("tabs.invitation_form")}

+
+ {this.invitationForm(fileInputKey, fileName, fileTypeError, fileEmails, initial, administrators, intended_role, message, expiry_date, disabledSubmit, groups, selectedGroup, membership_expiry_date, existingInvitations)}
- ); +
+ ); } } diff --git a/client/src/pages/NewOrganisationInvitation.jsx b/client/src/pages/NewOrganisationInvitation.jsx index 2ae5ad415..dd82c5b84 100644 --- a/client/src/pages/NewOrganisationInvitation.jsx +++ b/client/src/pages/NewOrganisationInvitation.jsx @@ -139,14 +139,15 @@ class NewOrganisationInvitation extends React.Component { validateDuplicates(newAdministrators) { const organisationId = this.props.match.params.organisation_id; - const promises = newAdministrators.map(email => organisationInvitationExists(email, organisationId)); - Promise.all(promises) - .then(res => { - const existingInvitations = res - .filter(email => email.exists) - .map(email => email.email); - this.setState({existingInvitations: existingInvitations, initial: isEmpty(existingInvitations)}) - }) + this.setState({loading: true}); + organisationInvitationExists(newAdministrators, organisationId) + .then(existingInvitations => + this.setState({ + existingInvitations: existingInvitations, + initial: isEmpty(existingInvitations), + loading: false + }) + ); } tabChanged = activeTab => { diff --git a/client/src/pages/NewServiceInvitation.jsx b/client/src/pages/NewServiceInvitation.jsx index 2b34d338e..30bc1f372 100644 --- a/client/src/pages/NewServiceInvitation.jsx +++ b/client/src/pages/NewServiceInvitation.jsx @@ -3,7 +3,7 @@ import moment from "moment"; import "react-datepicker/dist/react-datepicker.css"; -import {serviceById, serviceInvitations} from "../api"; +import {serviceById, serviceInvitationExists, serviceInvitations} from "../api"; import I18n from "../locale/I18n"; import InputField from "../components/InputField"; import Button from "../components/Button"; @@ -50,7 +50,8 @@ class NewServiceInvitation extends React.Component { leavePage: true, activeTab: "invitation_form", htmlPreview: "", - loading: true + loading: true, + existingInvitations: [] }; } @@ -81,8 +82,8 @@ class NewServiceInvitation extends React.Component { }; isValid = () => { - const {administrators, fileEmails} = this.state; - return !isEmpty(administrators) || !isEmpty(fileEmails); + const {administrators, fileEmails, existingInvitations} = this.state; + return (!isEmpty(administrators) || !isEmpty(fileEmails)) && isEmpty(existingInvitations); }; doSubmit = () => { @@ -113,16 +114,31 @@ class NewServiceInvitation extends React.Component { } }; + validateDuplicates(newAdministrators) { + const collaborationId = this.props.match.params.collaboration_id; + this.setState({loading: true}); + serviceInvitationExists(newAdministrators, collaborationId) + .then(existingInvitations => + this.setState({ + existingInvitations: existingInvitations, + initial: isEmpty(existingInvitations), + loading: false + }) + ) + } + removeMail = email => e => { stopEvent(e); const {administrators} = this.state; const newAdministrators = administrators.filter(currentMail => currentMail !== email); + this.validateDuplicates(newAdministrators); this.setState({administrators: newAdministrators}); }; addEmails = emails => { const {administrators} = this.state; const uniqueEmails = [...new Set(administrators.concat(emails))]; + this.validateDuplicates(uniqueEmails); this.setState({administrators: uniqueEmails}); }; diff --git a/server/api/invitation.py b/server/api/invitation.py index 45eeee9a7..41d677162 100644 --- a/server/api/invitation.py +++ b/server/api/invitation.py @@ -6,7 +6,7 @@ from flasgger import swag_from from flask import Blueprint, request as current_request, current_app, g as request_context, jsonify from sqlalchemy import or_, func -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, load_only from werkzeug.exceptions import Conflict, Forbidden, BadRequest from server.api.base import json_endpoint, query_param, emit_socket @@ -136,17 +136,17 @@ def invitations_by_hash(): return {"invitation": invitation_json, "service_emails": service_emails, "admin_emails": admin_emails}, 200 -@invitations_api.route("/exists_email", methods=["GET"], strict_slashes=False) +@invitations_api.route("/exists_email", methods=["POST"], strict_slashes=False) @json_endpoint def invitation_exists_by_email(): - email = query_param("email") - collaboration_id = int(query_param("collaboration_id")) - invitation_first = Invitation.query \ - .filter(func.lower(Invitation.invitee_email) == email.lower()) \ + data = current_request.get_json() + collaboration_id = int(data["collaboration_id"]) + invitations = Invitation.query.options(load_only(Invitation.invitee_email)) \ + .filter(func.lower(Invitation.invitee_email).in_([e.lower() for e in data["emails"]])) \ .filter(Invitation.collaboration_id == collaboration_id) \ .filter(Invitation.status == STATUS_OPEN) \ - .count() - return {"exists": invitation_first > 0, "email": email}, 200 + .all() + return [i.invitee_email for i in invitations], 200 @invitations_api.route("/v1/collaboration_invites", methods=["PUT"], strict_slashes=False) diff --git a/server/api/organisation_invitation.py b/server/api/organisation_invitation.py index 625bde0cd..0f24a35db 100644 --- a/server/api/organisation_invitation.py +++ b/server/api/organisation_invitation.py @@ -1,9 +1,6 @@ -from sqlalchemy import func - -from server.tools import dt_now - from flask import Blueprint, request as current_request, current_app -from sqlalchemy.orm import joinedload +from sqlalchemy import func +from sqlalchemy.orm import joinedload, load_only from werkzeug.exceptions import Conflict from server.api.base import json_endpoint, query_param, emit_socket @@ -12,6 +9,7 @@ from server.db.domain import OrganisationInvitation, Organisation, OrganisationMembership, db from server.db.models import delete from server.mail import mail_organisation_invitation +from server.tools import dt_now organisation_invitations_api = Blueprint("organisation_invitations_api", __name__, url_prefix="/api/organisation_invitations") @@ -132,13 +130,13 @@ def delete_organisation_invitation(id): return delete(OrganisationInvitation, id) -@organisation_invitations_api.route("/exists_email", methods=["GET"], strict_slashes=False) +@organisation_invitations_api.route("/exists_email", methods=["POST"], strict_slashes=False) @json_endpoint def invitation_exists_by_email(): - email = query_param("email") - organisation_id = int(query_param("organisation_id")) - invitation_first = OrganisationInvitation.query \ - .filter(func.lower(OrganisationInvitation.invitee_email) == email.lower()) \ + data = current_request.get_json() + organisation_id = int(data["organisation_id"]) + invitations = OrganisationInvitation.query.options(load_only(OrganisationInvitation.invitee_email)) \ + .filter(func.lower(OrganisationInvitation.invitee_email).in_([e.lower() for e in data["emails"]])) \ .filter(OrganisationInvitation.organisation_id == organisation_id) \ - .count() - return {"exists": invitation_first > 0, "email": email}, 200 + .all() + return [i.invitee_email for i in invitations], 200 diff --git a/server/api/service_invitation.py b/server/api/service_invitation.py index 85b637eea..c5f2b4274 100644 --- a/server/api/service_invitation.py +++ b/server/api/service_invitation.py @@ -1,6 +1,6 @@ from flask import Blueprint, request as current_request, current_app from sqlalchemy import func -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, load_only from werkzeug.exceptions import Conflict from server.api.base import json_endpoint, query_param, emit_socket @@ -129,11 +129,13 @@ def delete_service_invitation(id): return delete(ServiceInvitation, id) -@service_invitations_api.route("/exists_email", methods=["GET"], strict_slashes=False) +@service_invitations_api.route("/exists_email", methods=["POST"], strict_slashes=False) @json_endpoint def invitation_exists_by_email(): - email = query_param("email") - invitation_first = ServiceInvitation.query \ - .filter(func.lower(ServiceInvitation.invitee_email) == email.lower()) \ - .count() - return {"exists": invitation_first > 0, "email": email}, 200 + data = current_request.get_json() + service_id = int(data["service_id"]) + invitations = ServiceInvitation.query.options(load_only(ServiceInvitation.invitee_email)) \ + .filter(func.lower(ServiceInvitation.invitee_email).in_([e.lower() for e in data["emails"]])) \ + .filter(ServiceInvitation.service_id == service_id) \ + .all() + return [i.invitee_email for i in invitations], 200 diff --git a/server/test/api/test_invitation.py b/server/test/api/test_invitation.py index 9c1333f37..e6c4768b8 100644 --- a/server/test/api/test_invitation.py +++ b/server/test/api/test_invitation.py @@ -318,9 +318,11 @@ def test_delete_external_invitation(self): def test_invitation_exists_by_email(self): invitation = Invitation.query.filter(Invitation.invitee_email == "curious@ex.org").one() collaboration_id = invitation.collaboration_id - res = self.get("/api/invitations/exists_email", - query_data={"email": "CURIOUS@ex.org", "collaboration_id": collaboration_id}) - self.assertEqual(True, res["exists"]) - res = self.get("/api/invitations/exists_email", - query_data={"email": "nope@ex.org", "collaboration_id": collaboration_id}) - self.assertEqual(False, res["exists"]) + res = self.post("/api/invitations/exists_email", + body={"emails": ["CURIOUS@ex.org"], "collaboration_id": collaboration_id}, + response_status_code=200) + self.assertEqual(["curious@ex.org"], res) + res = self.post("/api/invitations/exists_email", + body={"emails": ["nope@ex.org"], "collaboration_id": collaboration_id}, + response_status_code=200) + self.assertEqual(0, len(res)) diff --git a/server/test/api/test_organisation_invitation.py b/server/test/api/test_organisation_invitation.py index 6ee52ccc6..231b4688b 100644 --- a/server/test/api/test_organisation_invitation.py +++ b/server/test/api/test_organisation_invitation.py @@ -94,9 +94,15 @@ def test_delete_not_found(self): def test_invitation_exists_by_email(self): inv = OrganisationInvitation.query.filter(OrganisationInvitation.invitee_email == "roger@example.org").one() organisation_id = inv.organisation_id - res = self.get("/api/organisation_invitations/exists_email", - query_data={"email": "roger@EXAMPLE.ORG", "organisation_id": organisation_id}) - self.assertEqual(True, res["exists"]) - res = self.get("/api/organisation_invitations/exists_email", - query_data={"email": "nope@ex.org", "organisation_id": organisation_id}) - self.assertEqual(False, res["exists"]) + res = self.post("/api/organisation_invitations/exists_email", + body={"emails": ["roger@EXAMPLE.ORG"], "organisation_id": organisation_id}, + response_status_code=200) + self.assertEqual(["roger@example.org"], res) + res = self.post("/api/organisation_invitations/exists_email", + body={"emails": ["nope@ex.org"], "organisation_id": organisation_id}, + response_status_code=200) + self.assertEqual(0, len(res)) + res = self.post("/api/organisation_invitations/exists_email", + body={"emails": ["roger@example.org"], "organisation_id": "9999"}, + response_status_code=200) + self.assertEqual(0, len(res)) diff --git a/server/test/api/test_service_invitation.py b/server/test/api/test_service_invitation.py index c4fcb7884..90911b579 100644 --- a/server/test/api/test_service_invitation.py +++ b/server/test/api/test_service_invitation.py @@ -83,9 +83,12 @@ def test_delete_not_found(self): self.delete("/api/service_invitations", primary_key="nope", response_status_code=404) def test_invitation_exists_by_email(self): - res = self.get("/api/service_invitations/exists_email", - query_data={"email": "ADMIN@CLOUD.ORG"}) - self.assertEqual(True, res["exists"]) - res = self.get("/api/service_invitations/exists_email", - query_data={"email": "nope@ex.org"}) - self.assertEqual(False, res["exists"]) + inv = ServiceInvitation.query.filter(ServiceInvitation.invitee_email == "admin@cloud.org").one() + service_id = inv.service_id + res = self.post("/api/service_invitations/exists_email", + body={"emails": ["ADMIN@CLOUD.ORG", "nice@nope.com"], "service_id": service_id}, + response_status_code=200) + self.assertEqual(["admin@cloud.org"], res) + res = self.post("/api/service_invitations/exists_email", + body={"emails": ["nope@ex.org"], "service_id": service_id}, response_status_code=200) + self.assertEqual(0, len(res))