diff --git a/client/src/components/InputField.jsx b/client/src/components/InputField.jsx index 320cb790..6a3bc915 100644 --- a/client/src/components/InputField.jsx +++ b/client/src/components/InputField.jsx @@ -31,7 +31,8 @@ export default function InputField({ displayLabel = true, button = null, isInteger = false, - isUrl = false + isUrl = false, + customClassName = "" }) { const navigate = useNavigate(); placeholder = disabled ? "" : placeholder; @@ -40,8 +41,10 @@ export default function InputField({ className += "error "; } const validExternalLink = externalLink && !isEmpty(value) && validUrlRegExp.test(value); + const isError = error ? "sds--text-field--status-error" : ""; + const topClassName = `input-field sds--text-field ${isError} ${customClassName}`; return ( -
+
{(name && displayLabel) && } diff --git a/client/src/locale/en.js b/client/src/locale/en.js index 0eb1c3f4..86e3fd6e 100644 --- a/client/src/locale/en.js +++ b/client/src/locale/en.js @@ -255,6 +255,12 @@ const en = { roleExpiryDate: "Role expiry date", roleExpiryDateQuestion: "Set a custom role expiration period", roleExpiryDateInfo: "This role will be removed from the user {{expiry}}", + customInviterDisplayNameQuestion: "Set a custom inviter name", + inviterDisplayName: "Custom inviter display name for invitations", + inviterDisplayNamePlaceholder: "e.g. working@home.university.nl", + inviterDisplayNameError: "Custom inviter name is required, unless you use the default inviter name", + customInviterDisplayNameInfoDefault: "Invitations for this role will show the name of the inviter as sender", + customInviterDisplayNameInfo: "Invitations for this role will show a custom name / email as sender", expiryDateQuestion: "Set a custom invitation expiration period", expiryDateInfo: "Default an invitation is valid for 1 month", withinThreeMonths: "Within 1 month", @@ -403,6 +409,7 @@ const en = { eduIDOnlyTooltip: "When checked the invitees will be required to log in with eduID", roleExpiryDateTooltip: "The end date of this role. After this date the role is removed from the user.", expiryDateTooltip: "The date on which this invitation expires", + inviterDisplayName: "The functional address which will used in the invitations of the role.

Default the name of the inviter is show.", rolesTooltip: "Select all the roles that the invitee will be granted after accepting the invitation", intendedAuthorityTooltip: "The authority determines the rights the invitee will be granted on accepting the invitation", inviteesTooltip: "Add email addresses separated by comma, space or semi-colon or on seperate lines. You can also paste a csv file with line-separated email addresses.", diff --git a/client/src/locale/nl.js b/client/src/locale/nl.js index 902ea77f..27044807 100644 --- a/client/src/locale/nl.js +++ b/client/src/locale/nl.js @@ -254,6 +254,12 @@ const nl = { roleExpiryDate: "Verloopdatum rol", roleExpiryDateQuestion: "Zet een specifieke verloopdatum voor de rol", roleExpiryDateInfo: "Deze rol wordt verwijderd van de gebruiker {{expiry}}", + customInviterDisplayNameQuestion: "Zet een specifieke naam voor de uitnodiger", + inviterDisplayName: "Specifieke naam uitnodiger voor in de uitnodiging", + inviterDisplayNamePlaceholder: "Bijv. werken@thuis.universiteit.nl", + inviterDisplayNameError: "Specifieke naam uitnodiger is verplicht, tenzij je ervoor kiest om de default uitnodiger naam te gebruiken", + customInviterDisplayNameInfoDefault: "Uitnodigingen voor deze rol zullen de naam van de uitnodiger tonen.", + customInviterDisplayNameInfo: "Uitnodigingen voor deze rol zullen deze specifieke uitnodiger naam tonen.", expiryDateQuestion: "Zet een specifieke verloopdatum voor de uitnodiging", expiryDateInfo: "Standaard verloopt een uitnodiging na 1 maand", withinThreeMonths: "Binnen 1 maand", @@ -402,6 +408,7 @@ const nl = { eduIDOnlyTooltip: "Indien ingeschakeld moeten de genodigden eduID gebruiken om in te loggen bij het accepteren", roleExpiryDateTooltip: "De einddatum van deze rol. Na deze datum wordt de rol verwijderd bij de gebruiker.", expiryDateTooltip: "De datum waarop deze uitnodiging verloopt", + inviterDisplayName: "De specifieke naam van de uitnodiger zal worden getoond in de uitnodiging.

Standaard tonen we de naam van de daadwerkelijke uitnodiger.", rolesTooltip: "Alle rollen die de genodigden verkrijgen na het accepteren van de uitnodiging", intendedAuthorityTooltip: "De autoriteit geeft de rechten aan die de genodigde verwerft na het accepteren van de uitnodiging", inviteesTooltip: "Geef e-mailadressen op, gescheiden door komma, spatie of puntkomma, of op een eigen regel. Je kunt ook een csv-bestand plakken met daarin op elke regel een e-mailadres.", diff --git a/client/src/pages/RoleForm.js b/client/src/pages/RoleForm.js index 0f84dbfa..de0ea262 100644 --- a/client/src/pages/RoleForm.js +++ b/client/src/pages/RoleForm.js @@ -56,6 +56,7 @@ export const RoleForm = () => { const [validOrganizationGUID, setValidOrganizationGUID] = useState(true); const [organizationGUIDIdentityProvider, setOrganizationGUIDIdentityProvider] = useState(null); const [customRoleExpiryDate, setCustomRoleExpiryDate] = useState(false); + const [customInviterDisplayName, setCustomInviterDisplayName] = useState(false); const [applications, setApplications] = useState([]); const [allowedToEditApplication, setAllowedToEditApplication] = useState(true); const [deletedUserRoles, setDeletedUserRoles] = useState(null); @@ -79,6 +80,7 @@ export const RoleForm = () => { if (!newRole) { setRole(res[0]); setCustomRoleExpiryDate(res[0].defaultExpiryDays !== DEFAULT_EXPIRY_DAYS) + setCustomInviterDisplayName(!isEmpty(res[0].inviterDisplayName)) } let providers; if (user.superUser) { @@ -238,7 +240,8 @@ export const RoleForm = () => { && validOrganizationGUID && !isEmpty(applications[0]) && applications.every(app => !app || (!app.invalid && !isEmpty(app.landingPage))) - && role.defaultExpiryDays > 0; + && role.defaultExpiryDays > 0 + && (!isEmpty(role.inviterDisplayName) || !customInviterDisplayName); } const changeApplication = (index, application) => { @@ -424,6 +427,7 @@ export const RoleForm = () => { info={I18n.t("invitations.roleExpiryDateInfo", { expiry: displayExpiryDate(futureDate(role.defaultExpiryDays, new Date())) })} + last={customRoleExpiryDate} /> {customRoleExpiryDate && { {...role, defaultExpiryDays: defaultExpiryDays}) }} toolTip={I18n.t("tooltips.defaultExpiryDays")} + customClassName="inner-switch" />} {(!initial && (isEmpty(role.defaultExpiryDays) || role.defaultExpiryDays < 1)) && @@ -442,6 +447,31 @@ export const RoleForm = () => { attribute: I18n.t("roles.defaultExpiryDays").toLowerCase() })}/>} + { + setCustomInviterDisplayName(!customInviterDisplayName); + setRole( + {...role, inviterDisplayName: null}) + }} + last={customInviterDisplayName} + label={I18n.t("invitations.customInviterDisplayNameQuestion")} + info={I18n.t(`invitations.${customInviterDisplayName ? "customInviterDisplayNameInfo" : "customInviterDisplayNameInfoDefault"}`)} + /> + {customInviterDisplayName && { + setRole( + {...role, inviterDisplayName: e.target.value}) + }} + placeholder={I18n.t("invitations.inviterDisplayNamePlaceholder")} + toolTip={I18n.t("tooltips.inviterDisplayName")} + customClassName="inner-switch" + />} + {(!initial && isEmpty(role.inviterDisplayName) && customInviterDisplayName) && + } + setRole({...role, overrideSettingsAllowed: value})} diff --git a/client/src/styles/mixins.scss b/client/src/styles/mixins.scss index 4cd0c242..ccd65591 100644 --- a/client/src/styles/mixins.scss +++ b/client/src/styles/mixins.scss @@ -35,6 +35,12 @@ .input-field, .select-field, .date-field, .sds--checkbox-container { margin-top: 20px; + + &.inner-switch { + margin-top: 0; + padding-bottom: 15px; + border-bottom: 1px solid var(--sds--color--gray--200); + } } .user-role .sds--checkbox-container { diff --git a/server/src/main/java/access/api/InvitationOperations.java b/server/src/main/java/access/api/InvitationOperations.java index 6c962b3a..66a851e1 100644 --- a/server/src/main/java/access/api/InvitationOperations.java +++ b/server/src/main/java/access/api/InvitationOperations.java @@ -49,7 +49,8 @@ public ResponseEntity sendInvitation(InvitationRequest invit } //We need to assert validations on the roles soo we need to load them List requestedRoles = invitationRequest.getRoleIdentifiers().stream() - .map(id -> invitationResource.getRoleRepository().findById(id).orElseThrow(() -> new NotFoundException("Role not found"))).toList(); + .map(id -> invitationResource.getRoleRepository().findById(id) + .orElseThrow(() -> new NotFoundException("Role not found"))).toList(); if (user != null) { UserPermissions.assertValidInvitation(user, intendedAuthority, requestedRoles); diff --git a/server/src/main/java/access/mail/MailBox.java b/server/src/main/java/access/mail/MailBox.java index 7097df75..ea7737a2 100644 --- a/server/src/main/java/access/mail/MailBox.java +++ b/server/src/main/java/access/mail/MailBox.java @@ -73,7 +73,15 @@ public void sendInviteMail(Provisionable provisionable, Invitation invitation, L } else { variables.put("institutionName", "SURF"); } - variables.put("roles", splitListSemantically(invitation.getRoles().stream().map(invitationRole -> invitationRole.getRole().getName()).toList())); + variables.put("roles", splitListSemantically(invitation.getRoles().stream() + .map(invitationRole -> invitationRole.getRole().getName()).toList())); + if (invitation.getRoles().stream() + .anyMatch(invitationRole -> StringUtils.hasText(invitationRole.getRole().getInviterDisplayName()))) { + variables.put("displaySenderName", splitListSemantically(invitation.getRoles().stream() + .map(invitationRole -> invitationRole.getRole().getInviterDisplayName()).toList())); + } else { + variables.put("displaySenderName", provisionable.getName()); + } if (StringUtils.hasText(invitation.getMessage())) { variables.put("message", invitation.getMessage().replaceAll("\n", "
")); } diff --git a/server/src/main/java/access/model/Role.java b/server/src/main/java/access/model/Role.java index 2f597863..318b78cf 100644 --- a/server/src/main/java/access/model/Role.java +++ b/server/src/main/java/access/model/Role.java @@ -64,6 +64,9 @@ public class Role implements Serializable, Provisionable { @Column(name = "remote_api_user") private String remoteApiUser; + @Column(name = "inviter_display_name") + private String inviterDisplayName; + @Formula(value = "(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id=id)") private Long userRoleCount; diff --git a/server/src/main/java/access/provision/Provisioning.java b/server/src/main/java/access/provision/Provisioning.java index 420f9745..598524ae 100644 --- a/server/src/main/java/access/provision/Provisioning.java +++ b/server/src/main/java/access/provision/Provisioning.java @@ -55,7 +55,6 @@ public Provisioning(Map provider) { List> applicationMaps = (List>) provider.getOrDefault("applications", emptyList()); this.remoteApplications = applicationMaps.stream().map(m -> new ManageIdentifier(m.get("id"), EntityType.valueOf(m.get("type").toUpperCase()))).toList(); this.invariant(); - } private void invariant() { diff --git a/server/src/main/resources/db/mysql/migration/V37_0__role_remote_user.sql b/server/src/main/resources/db/mysql/migration/V37_0__role_remote_user.sql new file mode 100644 index 00000000..3a1508c2 --- /dev/null +++ b/server/src/main/resources/db/mysql/migration/V37_0__role_remote_user.sql @@ -0,0 +1,2 @@ +ALTER TABLE `roles` + add `inviter_display_name` varchar(255) DEFAULT NULL; diff --git a/server/src/main/resources/templates/invitation_en.html b/server/src/main/resources/templates/invitation_en.html index d7345650..d155cfd6 100644 --- a/server/src/main/resources/templates/invitation_en.html +++ b/server/src/main/resources/templates/invitation_en.html @@ -16,7 +16,7 @@

{{/environment}}

Hello,

-

{{user.name}} (from {{institutionName}}) has invited you for one or +

{{displaySenderName}} (from {{institutionName}}) has invited you for one or more applications or systems that they use.

@@ -31,7 +31,7 @@ {{/invitation.anyRoles}} {{#message}} -

Personal message from {{user.name}}

+

Personal message from {{displaySenderName}}

{{{message}}} diff --git a/server/src/main/resources/templates/invitation_nl.html b/server/src/main/resources/templates/invitation_nl.html index a572adb0..f52eb8ac 100644 --- a/server/src/main/resources/templates/invitation_nl.html +++ b/server/src/main/resources/templates/invitation_nl.html @@ -16,7 +16,7 @@

{{/environment}}

Hallo,

-

{{user.name}} (van {{institutionName}}) heeft je uitgenodigd voor één of meer +

{{displaySenderName}} (van {{institutionName}}) heeft je uitgenodigd voor één of meer applicaties en systemen die zij gebruiken.

Je bent uitgenodigd voor:

@@ -30,7 +30,7 @@ {{/invitation.anyRoles}} {{#message}} -

Persoonlijk bericht van {{user.name}}

+

Persoonlijk bericht van {{displaySenderName}}

{{{message}}} diff --git a/server/src/test/java/access/AbstractTest.java b/server/src/test/java/access/AbstractTest.java index 7dd00f43..29a1c361 100644 --- a/server/src/test/java/access/AbstractTest.java +++ b/server/src/test/java/access/AbstractTest.java @@ -578,10 +578,13 @@ public void doSeed() { Role wiki = new Role("Wiki", "Wiki desc", application("1", EntityType.SAML20_SP), 365, false, false); + wiki.setInviterDisplayName("wiki@university.com"); + Role network = new Role("Network", "Network desc", application("2", EntityType.SAML20_SP), 365, false, false); + Set applications = Set.of( new Application("3", EntityType.SAML20_SP), new Application("6", EntityType.OIDC10_RP)) diff --git a/server/src/test/java/access/api/InvitationMailControllerTest.java b/server/src/test/java/access/api/InvitationMailControllerTest.java index 1e6a0c56..53cfb379 100644 --- a/server/src/test/java/access/api/InvitationMailControllerTest.java +++ b/server/src/test/java/access/api/InvitationMailControllerTest.java @@ -2,13 +2,20 @@ import access.AbstractMailTest; import access.AccessCookieFilter; +import access.mail.MimeMessageParser; import access.manage.EntityType; -import access.model.Authority; -import access.model.Invitation; +import access.model.*; +import io.restassured.common.mapper.TypeRef; import io.restassured.http.ContentType; import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; + import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class InvitationMailControllerTest extends AbstractMailTest { @@ -33,4 +40,47 @@ void resendInviteMail() throws Exception { assertTrue(htmlContent.contains("SURF bv")); assertTrue(htmlContent.contains("Mail")); } + + @Test + void newInvitationCustomDisplayName() throws Exception { + //Because the user is changed and provisionings are queried + stubForManageProvisioning(List.of()); + AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", MANAGE_SUB); + + stubForManageProviderById(EntityType.SAML20_SP, "1"); + //Wiki role, see AbstractTest#seed + List roles = roleRepository.findByApplicationUsagesApplicationManageId("1"); + List roleIdentifiers = roles.stream() + .map(Role::getId) + .toList(); + InvitationRequest invitationRequest = new InvitationRequest( + Authority.GUEST, + "Message", + Language.en, + true, + false, + false, + false, + List.of("new@new.nl"), + roleIdentifiers, + Instant.now().plus(365, ChronoUnit.DAYS), + Instant.now().plus(12, ChronoUnit.DAYS)); + + given() + .when() + .filter(accessCookieFilter.cookieFilter()) + .accept(ContentType.JSON) + .header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken()) + .contentType(ContentType.JSON) + .body(invitationRequest) + .post("/api/v1/invitations") + .then() + .statusCode(201); + + List mimeMessageParsers = super.allMailMessages(1); + String htmlContent = mimeMessageParsers.getFirst().getHtmlContent(); + + assertTrue(htmlContent.contains("wiki@university.com")); + } + }