Skip to content

Commit a8a3596

Browse files
committed
WIP for #378
1 parent 493b0ab commit a8a3596

File tree

16 files changed

+153
-31
lines changed

16 files changed

+153
-31
lines changed

client/src/locale/en.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ const en = {
247247
inviteesPlaceholder: "Invitee email addresses",
248248
requiredEmail: "At least one email address is required",
249249
requiredRole: "At least one role is required for an invitation",
250+
requiredOrganizationGUID: "A valid Organization GUID is required for an institution admin invitation",
250251
intendedAuthority: "Authority",
251252
roles: "Roles",
252253
inviterRoles: "Select the roles for the new invitation",

client/src/locale/nl.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ const nl = {
247247
inviteesPlaceholder: "E-mailadressen genodigden",
248248
requiredEmail: "Geef minimaal 1 e-mailadres op",
249249
requiredRole: "Minimaal 1 rol is benodigd voor een uitnodiging",
250+
requiredOrganizationGUID: "Een valide Organisatie GUID is verplicht voor een instellingsadmin uitnodiging",
250251
intendedAuthority: "Autoriteit",
251252
roles: "Rollen",
252253
inviterRoles: "Selecteer de rollen voor de nieuwe uitnodiging",
@@ -390,7 +391,7 @@ const nl = {
390391
secretTooltip: "De waarde die je gebruikt in de X-API-TOKEN header",
391392
description: "Omschrijving",
392393
superUserToken: "Super User token",
393-
organizationGUID: "Organization GUID",
394+
organizationGUID: "Organisatie GUID",
394395
descriptionPlaceHolder: "Omschrijving voor dit API-token",
395396
descriptionTooltip: "Een omschrijving die de reden voor dit API-token omschrijft",
396397
deleteFlash: "API-token is verwijderd",

client/src/pages/Home.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ export const Home = () => {
6565
);
6666
}
6767
if (user && user.institutionAdmin && user.organizationGUID && !isEmpty(user.applications)) {
68+
newTabs.push(
69+
<Page key="applicationUsers"
70+
name="applicationUsers"
71+
label={I18n.t("tabs.applicationUsers")}
72+
>
73+
<ApplicationUsers/>
74+
</Page>);
6875
newTabs.push(
6976
<Page key="applications"
7077
name="applications"
@@ -73,13 +80,6 @@ export const Home = () => {
7380
<Applications/>
7481
</Page>
7582
);
76-
newTabs.push(
77-
<Page key="applicationUsers"
78-
name="applicationUsers"
79-
label={I18n.t("tabs.applicationUsers")}
80-
>
81-
<ApplicationUsers/>
82-
</Page>);
8383
}
8484
if (user && (user.superUser || (user.institutionAdmin && user.organizationGUID))) {
8585
newTabs.push(

client/src/pages/InvitationForm.js

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import {ReactComponent as UserIcon} from "@surfnet/sds/icons/functional-icons/id-2.svg";
1414
import {ReactComponent as UpIcon} from "@surfnet/sds/icons/functional-icons/arrow-up-2.svg";
1515
import {ReactComponent as DownIcon} from "@surfnet/sds/icons/functional-icons/arrow-down-2.svg";
16-
import {newInvitation, rolesByApplication} from "../api";
16+
import {newInvitation, organizationGUIDValidation, rolesByApplication} from "../api";
1717
import {Button, ButtonType, Loader, Tooltip} from "@surfnet/sds";
1818
import "./InvitationForm.scss";
1919
import {UnitHeader} from "../components/UnitHeader";
@@ -50,6 +50,10 @@ export const InvitationForm = () => {
5050
invites: [],
5151
intendedAuthority: AUTHORITIES.GUEST
5252
});
53+
54+
const [validOrganizationGUID, setValidOrganizationGUID] = useState(true);
55+
const [organizationGUIDIdentityProvider, setOrganizationGUIDIdentityProvider] = useState(null);
56+
5357
const [displayAdvancedSettings, setDisplayAdvancedSettings] = useState(false);
5458
const [loading, setLoading] = useState(true);
5559
const [customExpiryDate, setCustomExpiryDate] = useState(false);
@@ -152,7 +156,8 @@ export const InvitationForm = () => {
152156

153157
const isValid = () => {
154158
return required.every(attr => !isEmpty(invitation[attr])) &&
155-
(!isEmpty(selectedRoles) || [AUTHORITIES.SUPER_USER, AUTHORITIES.INSTITUTION_ADMIN].includes(invitation.intendedAuthority));
159+
(!isEmpty(selectedRoles) || [AUTHORITIES.SUPER_USER, AUTHORITIES.INSTITUTION_ADMIN].includes(invitation.intendedAuthority))
160+
&& (invitation.intendedAuthority !== AUTHORITIES.INSTITUTION_ADMIN || !user.superUser || validOrganizationGUID);
156161
}
157162

158163
const addEmails = emails => {
@@ -175,6 +180,21 @@ export const InvitationForm = () => {
175180
return invitation.roleExpiryDate;
176181
}
177182

183+
const validateOrganizationGUID = e => {
184+
const organizationGUID = e.target.value;
185+
if (!isEmpty(organizationGUID)) {
186+
organizationGUIDValidation(organizationGUID)
187+
.then(idp => {
188+
setOrganizationGUIDIdentityProvider(idp);
189+
setValidOrganizationGUID(true);
190+
})
191+
.catch(() => {
192+
setOrganizationGUIDIdentityProvider(null);
193+
setValidOrganizationGUID(false);
194+
})
195+
}
196+
}
197+
178198
const rolesChanged = selectedOptions => {
179199
if (selectedOptions === null) {
180200
setSelectedRoles([])
@@ -199,7 +219,6 @@ export const InvitationForm = () => {
199219
intendedAuthority: option.value,
200220
roleExpiryDate: defaultRoleExpiryDate(selectedRoles)
201221
});
202-
203222
}
204223

205224
const renderUserRole = (role, index, invitationSelected, invitationSelectCallback) => {
@@ -216,6 +235,7 @@ export const InvitationForm = () => {
216235
}
217236

218237
const renderFormElements = authorityOptions => {
238+
const skipRoles = [AUTHORITIES.SUPER_USER, AUTHORITIES.INSTITUTION_ADMIN].includes(invitation.intendedAuthority);
219239
return (
220240
<>
221241
<EmailField
@@ -242,7 +262,34 @@ export const InvitationForm = () => {
242262
toolTip={I18n.t("tooltips.intendedAuthorityTooltip")}
243263
clearable={false}
244264
/>}
245-
{!isInviter && <>
265+
266+
{(user.superUser && AUTHORITIES.INSTITUTION_ADMIN === invitation.intendedAuthority) &&
267+
<InputField
268+
name={I18n.t("roles.organizationGUID")}
269+
value={invitation.organizationGUID}
270+
onChange={e => {
271+
setInvitation({
272+
...invitation,
273+
organizationGUID: e.target.value
274+
});
275+
setValidOrganizationGUID(true);
276+
setOrganizationGUIDIdentityProvider(null);
277+
}}
278+
onBlur={validateOrganizationGUID}
279+
toolTip={I18n.t("tooltips.organizationGUID")}
280+
/>}
281+
{!validOrganizationGUID &&
282+
<ErrorIndicator msg={I18n.t("forms.invalid", {
283+
value: invitation.organizationGUID,
284+
attribute: I18n.t("roles.organizationGUID").toLowerCase()
285+
})}/>}
286+
{!isEmpty(organizationGUIDIdentityProvider) &&
287+
<p className="info">{I18n.t("roles.identityProvider", {name: organizationGUIDIdentityProvider["name:en"]})}</p>}
288+
{(!initial && isEmpty(invitation.organizationGUID) &&
289+
invitation.intendedAuthority === AUTHORITIES.INSTITUTION_ADMIN && user.superUser) &&
290+
<ErrorIndicator msg={I18n.t("invitations.requiredOrganizationGUID")}/>}
291+
292+
{(!isInviter && !skipRoles) && <>
246293
<SelectField value={selectedRoles}
247294
options={roles.filter(role => !selectedRoles.find(r => r.value === role.value))}
248295
name={I18n.t("invitations.roles")}
@@ -254,7 +301,7 @@ export const InvitationForm = () => {
254301
placeholder={I18n.t("invitations.rolesPlaceHolder")}
255302
onChange={rolesChanged}/>
256303
{(!initial && isEmpty(selectedRoles) &&
257-
![AUTHORITIES.SUPER_USER, AUTHORITIES.INSTITUTION_ADMIN].includes(invitation.intendedAuthority)) &&
304+
!skipRoles) &&
258305
<ErrorIndicator msg={I18n.t("invitations.requiredRole")}/>}
259306
</>}
260307

@@ -285,6 +332,7 @@ export const InvitationForm = () => {
285332
const authorityOptions = allowedAuthoritiesForInvitation(user, selectedRoles)
286333
.map(authority => ({value: authority, label: I18n.t(`access.${authority}`)}));
287334
const overrideSettingsAllowed = selectedRoles.every(role => role.overrideSettingsAllowed);
335+
const skipRoles = [AUTHORITIES.SUPER_USER, AUTHORITIES.INSTITUTION_ADMIN].includes(invitation.intendedAuthority)
288336
return (
289337
<>
290338
{isInviter &&
@@ -342,15 +390,16 @@ export const InvitationForm = () => {
342390
info={I18n.t("tooltips.eduIDOnlyTooltip")}
343391
/>}
344392

345-
{(invitation.intendedAuthority !== AUTHORITIES.GUEST && !isInviter) &&
393+
{(invitation.intendedAuthority !== AUTHORITIES.GUEST && !isInviter &&
394+
!skipRoles) &&
346395
<SwitchField name={"guestRoleIncluded"}
347396
value={invitation.guestRoleIncluded || false}
348397
onChange={val => setInvitation({...invitation, guestRoleIncluded: val})}
349398
label={I18n.t("invitations.guestRoleIncluded")}
350399
info={I18n.t("tooltips.guestRoleIncludedTooltip")}
351400
/>
352401
}
353-
{overrideSettingsAllowed &&
402+
{(overrideSettingsAllowed && !skipRoles) &&
354403
<SwitchField name={"roleExpiryDate"}
355404
value={customRoleExpiryDate}
356405
onChange={() => {

client/src/pages/InvitationForm.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@
3737
grid-column-start: first;
3838
}
3939

40+
p.info {
41+
grid-column-start: first;
42+
margin-top: 4px;
43+
}
44+
4045
.inviter-wrapper {
4146
margin-top: 25px;
4247
padding: 25px 155px 25px 25px;

client/src/tabs/ApplicationUsers.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ import debounce from "lodash.debounce";
1111
import {dateFromEpoch, shortDateFromEpoch} from "../utils/Date";
1212
import {useNavigate} from "react-router-dom";
1313
import {defaultPagination, pageCount} from "../utils/Pagination";
14+
import {AUTHORITIES, isUserAllowed} from "../utils/UserRole";
15+
import {useAppStore} from "../stores/AppStore";
1416

1517

1618
export const ApplicationUsers = () => {
1719

20+
const {user: currentUser} = useAppStore(state => state);
21+
1822
const [paginationQueryParams, setPaginationQueryParams] = useState(defaultPagination());
1923
const [searching, setSearching] = useState(true);
2024
const [users, setUsers] = useState([]);
@@ -121,6 +125,9 @@ export const ApplicationUsers = () => {
121125
inputFocus={true}
122126
totalElements={totalElements}
123127
customSearch={search}
128+
newLabel={I18n.t("invitations.newInvite")}
129+
showNew={isUserAllowed(AUTHORITIES.INSTITUTION_ADMIN, currentUser)}
130+
newEntityFunc={() => navigate(`/invitation/new?institution=true`)}
124131
hideTitle={searching}
125132
busy={searching}/>
126133
</div>

client/src/utils/UserRole.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,9 @@ export const markAndFilterRoles = (user, allRoles, locale, multiple, separator,
167167

168168
export const allowedAuthoritiesForInvitation = (user, selectedRoles) => {
169169
if (user.superUser) {
170-
return Object.keys(AUTHORITIES)
171-
//The superuser has no organization guid
172-
.filter(authority => authority !== AUTHORITIES.INSTITUTION_ADMIN);
170+
//The superuser has no organization guid, but is allowed to add one
171+
return Object.keys(AUTHORITIES);
172+
173173
}
174174
if (user.institutionAdmin && !isEmpty(user.applications)) {
175175
return Object.keys(AUTHORITIES)

server/src/main/java/access/api/InvitationController.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,12 @@ public ResponseEntity<Map<String, Object>> accept(@Validated @RequestBody Accept
225225
if (intendedAuthority.equals(Authority.INSTITUTION_ADMIN)) {
226226
user.setInstitutionAdmin(true);
227227
user.setInstitutionAdminByInvite(true);
228-
user.setOrganizationGUID(inviter.getOrganizationGUID());
228+
//Might be that a super-user has invited the institution admin or a different institution admin
229+
if (inviter.isSuperUser()) {
230+
user.setOrganizationGUID(invitation.getOrganizationGUID());
231+
} else {
232+
user.setOrganizationGUID(inviter.getOrganizationGUID());
233+
}
229234
//Rare case - a new institution admin has logged in, but was not yet enriched by the CustomOidcUserService
230235
if (optionalUser.isEmpty()) {
231236
saveOAuth2AuthenticationToken(authentication, user, servletRequest, servletResponse);

server/src/main/java/access/api/InvitationOperations.java

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.Comparator;
2525
import java.util.List;
2626
import java.util.Map;
27+
import java.util.Optional;
2728

2829
import static java.util.stream.Collectors.toSet;
2930

@@ -95,6 +96,12 @@ public ResponseEntity<InvitationResponse> sendInvitation(InvitationRequest invit
9596
if (user == null) {
9697
invitations.forEach(invitation -> invitation.setRemoteApiUser(remoteUser.getName()));
9798
}
99+
if (intendedAuthority.equals(Authority.INSTITUTION_ADMIN)) {
100+
invitations.forEach(invitation -> invitation.setOrganizationGUID(
101+
user.isSuperUser() ? invitationRequest.getOrganizationGUID() : user.getOrganizationGUID())
102+
);
103+
}
104+
98105
this.invitationResource.getInvitationRepository().saveAll(invitations);
99106

100107
List<GroupedProviders> groupedProviders = this.invitationResource.getManage().getGroupedProviders(requestedRoles);
@@ -103,10 +110,19 @@ public ResponseEntity<InvitationResponse> sendInvitation(InvitationRequest invit
103110
.map(invitation -> new RecipientInvitationURL(invitation.getEmail(),
104111
mailBox.inviteMailURL(invitation)))
105112
.toList();
113+
114+
106115
if (!invitationRequest.isSuppressSendingEmails()) {
107-
invitations.forEach(invitation ->
108-
mailBox.sendInviteMail(user == null ? remoteUser : user,
109-
invitation, groupedProviders, invitationRequest.getLanguage()));
116+
117+
invitations.forEach(invitation -> {
118+
Optional<String> idpName = Optional.ofNullable(invitation.getOrganizationGUID())
119+
.map(organisationGUID -> this.invitationResource.getManage().identityProviderByInstitutionalGUID(organisationGUID))
120+
.flatMap(optional -> optional)
121+
.map(idp -> (String) idp.get("name:en"));
122+
123+
mailBox.sendInviteMail(user == null ? remoteUser : user,
124+
invitation, groupedProviders, invitationRequest.getLanguage(), idpName);
125+
});
110126
}
111127
invitations.forEach(invitation -> AccessLogger.invitation(LOG, Event.Created, invitation));
112128
return ResponseEntity.status(HttpStatus.CREATED).body(new InvitationResponse(HttpStatus.CREATED.value(), recipientInvitationURLs));
@@ -134,8 +150,17 @@ public ResponseEntity<Map<String, Integer>> resendInvitation(Long id,
134150

135151
List<GroupedProviders> groupedProviders = this.invitationResource.getManage().getGroupedProviders(requestedRoles);
136152
Provisionable provisionable = user != null ? user : remoteUser;
137-
138-
this.invitationResource.getMailBox().sendInviteMail(provisionable, invitation, groupedProviders, invitation.getLanguage());
153+
Optional<String> idpName = Optional.ofNullable(invitation.getOrganizationGUID())
154+
.map(organisationGUID -> this.invitationResource.getManage().identityProviderByInstitutionalGUID(organisationGUID))
155+
.flatMap(optional -> optional)
156+
.map(idp -> (String) idp.get("name:en"));
157+
158+
this.invitationResource.getMailBox()
159+
.sendInviteMail(provisionable,
160+
invitation,
161+
groupedProviders,
162+
invitation.getLanguage(),
163+
idpName);
139164
if (invitation.getExpiryDate().isBefore(Instant.now())) {
140165
invitation.setExpiryDate(Instant.now().plus(Period.ofDays(14)));
141166
invitationRepository.save(invitation);

server/src/main/java/access/mail/MailBox.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.util.HashMap;
2222
import java.util.List;
2323
import java.util.Map;
24+
import java.util.Optional;
2425
import java.util.stream.Collectors;
2526

2627
@SuppressWarnings("unchecked")
@@ -58,7 +59,9 @@ public MailBox(ObjectMapper objectMapper,
5859
}
5960

6061
@SneakyThrows
61-
public void sendInviteMail(Provisionable provisionable, Invitation invitation, List<GroupedProviders> groupedProviders, Language language) {
62+
public void sendInviteMail(Provisionable provisionable, Invitation invitation,
63+
List<GroupedProviders> groupedProviders, Language language,
64+
Optional<String> optionalIdpName) {
6265
Authority intendedAuthority = invitation.getIntendedAuthority();
6366
String title = String.format(subjects.get(language.name()).get("newInvitation"),
6467
invitation.getRoles().stream().map(role -> role.getRole().getName()).collect(Collectors.joining(", ")));
@@ -73,6 +76,7 @@ public void sendInviteMail(Provisionable provisionable, Invitation invitation, L
7376
} else {
7477
variables.put("institutionName", "SURF");
7578
}
79+
optionalIdpName.ifPresent(idpName -> variables.put("idpName", idpName));
7680
variables.put("roles", splitListSemantically(invitation.getRoles().stream()
7781
.map(invitationRole -> invitationRole.getRole().getName()).toList()));
7882
if (invitation.getRoles().stream()
@@ -85,7 +89,6 @@ public void sendInviteMail(Provisionable provisionable, Invitation invitation, L
8589
if (StringUtils.hasText(invitation.getMessage())) {
8690
variables.put("message", invitation.getMessage().replaceAll("\n", "<br/>"));
8791
}
88-
8992
variables.put("invitation", invitation);
9093
variables.put("intendedAuthority", invitation.getIntendedAuthority().translate(language.name()));
9194
variables.put("user", provisionable);

0 commit comments

Comments
 (0)