Skip to content

Commit 905a5ab

Browse files
committed
Merge branch 'feature/validated-eduID-574'
2 parents e54c49f + 107c6f0 commit 905a5ab

28 files changed

Lines changed: 338 additions & 34 deletions

client/src/api/index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,14 @@ export function allProviders() {
163163
return fetchJson("/api/v1/manage/providers");
164164
}
165165

166+
export function eduidIdentityProvider() {
167+
return fetchJson("/api/v1/manage/eduid-identity-provider");
168+
}
169+
170+
export function requestedAuthnContextValues() {
171+
return fetchJson("/api/v1/manage/requested-authn-context-values");
172+
}
173+
166174
export function allApplications() {
167175
return fetchJson("/api/v1/manage/applications")
168176
}

client/src/components/SelectField.jsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ export default function SelectField({
99
onChange, name, value, options, placeholder = "", disabled = false,
1010
toolTip = null, searchable = false, small = false,
1111
clearable = false, isMulti = false, creatable = false,
12-
onInputChange = null, required = false
12+
onInputChange = null, required = false, className = "",
13+
children
1314
}) {
1415
return (
15-
<div className="select-field">
16+
<div className={`select-field ${className}`}>
1617
<label htmlFor={name}>{name}{required && <sup className="required">*</sup>}
1718
{toolTip && <Tooltip tip={toolTip}/>}
1819
</label>
@@ -42,6 +43,7 @@ export default function SelectField({
4243
isSearchable={searchable}
4344
isClearable={clearable}
4445
/>}
46+
{children && children}
4547
</div>
4648
);
4749
}

client/src/components/SelectField.scss

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,10 @@
2525
border: 1px solid var(--sds--color--gray--300);
2626
border-radius: vars.$br;
2727
font-size: 16px;
28-
min-height: 48px;
28+
height: 48px;
2929

3030
&.creatable {
31-
height: auto;
32-
min-height: 48px;
31+
height: 48px;
3332
}
3433

3534
.select-inner__control {
@@ -43,13 +42,17 @@
4342
outline: none;
4443
box-shadow: 3px 3px 3px var(--sds--color--blue--200), -3px -3px 1px var(--sds--color--blue--200);
4544
border: 1px solid var(--sds--color--gray--300) !important;
45+
height: 48px;
4646
}
4747

4848
&[disabled] {
4949
background-color: vars.$background;
5050
cursor: not-allowed;
5151
}
5252

53+
.select-inner__indicators {
54+
cursor: pointer;
55+
}
5356
}
5457

5558
.select-inner__single-value--is-disabled {

client/src/components/SwitchField.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
display: flex;
33

44
margin-top: 20px;
5-
padding-bottom: 10px;
65

76
&:not(.last) {
87
border-bottom: 1px solid var(--sds--color--gray--200);
8+
padding-bottom: 10px;
99
}
1010

1111
align-items: center;

client/src/locale/en.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const en = {
1+
const en = {
22
code: "EN",
33
name: "English",
44
select_locale: "Change language to English",
@@ -244,6 +244,11 @@
244244
expired: "Expired",
245245
enforceEmailEquality: "Email equality",
246246
eduIDOnly: "eduID only",
247+
requestedAuthnContext: "ACR value",
248+
requestedAuthnContextPlaceHolder: "Choose a ACR value for user stepup...",
249+
requestedAuthnContextWarning: "To enforce the selected ACR also on the application(s) {{applications}} associated " +
250+
"with the role(s) {{roles}}, additional configuration is needed on the eduID identity provider. " +
251+
"Please contact <a href=\"mailto:support@surfconext.nl\">support@surfconext.nl</a> to request this change.",
247252
new: "Invite role manager or inviter",
248253
newInvitation: "Invite inviter",
249254
newInvite: "New invite",
@@ -421,6 +426,7 @@
421426
defaultExpiryDays: "The default number of days the role will expire, from the moment a user has accepted the invitation for this role",
422427
enforceEmailEqualityTooltip: "When checked the invitee must accept the invitation with an account with the email address where the invitation was sent to",
423428
eduIDOnlyTooltip: "When checked the invitees will be required to log in with eduID",
429+
requestedAuthnContextTooltip: "The user will be forced to step up the authentication when logging in with eduID with the specified ARC",
424430
roleExpiryDateTooltip: "The end date of this role. After this date the role is removed from the user.",
425431
expiryDateTooltip: "The date on which this invitation expires",
426432
inviterDisplayName: "The functional address which will used in the invitations of the role.<br><br>Default the name of the inviter is show.",
@@ -529,6 +535,13 @@
529535
title: "Roles to be expired the next month",
530536
searchPlaceHolder: "Zoek...",
531537
noResults: "Yeah, no user-roles to be expired within one month"
538+
},
539+
requestedAuthnContext: {
540+
EduIDLinkedInstitution: "EduID linked institution",
541+
EduIDValidatedName: "EduID validated name",
542+
ValidateNamesExternal: "EduID validated name by an external (non institutional) source",
543+
EduIDRequireStudentAffiliation: "EduID required student affiliation",
544+
TransparentAuthnContext: "Application provides ACR value"
532545
}
533546
}
534547

client/src/locale/nl.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,11 @@ const nl = {
244244
expired: "Verlopen",
245245
enforceEmailEquality: "E-mailadres moet overeenkomen",
246246
eduIDOnly: "Uitsluitend eduID",
247+
requestedAuthnContext: "ACR waarde",
248+
requestedAuthnContextPlaceHolder: "Kies een ACR waarde voor de gebruiker stepup...",
249+
requestedAuthnContextWarning: "Om de geselecteerde ACR ook af te dwingen op de applicatie(s) {{applications}} " +
250+
"die gekoppeld zijn aan de rol(len) {{roles}}, is aanvullende configuratie nodig voor de eduID-identiteitsprovider. " +
251+
"Neem contact op met <a href=\"mailto: support@surfconext.nl\">support@surfconext.nl</a> om deze wijziging aan te vragen.",
247252
new: "Nodig rolmanager of uitnodiger uit",
248253
newInvitation: "Nodig uitnodiger uit",
249254
newInvite: "Nieuwe uitnodiging",
@@ -421,6 +426,7 @@ const nl = {
421426
defaultExpiryDays: "Het standaardaantal dagen waarna de rol verloopt, gerekend vanaf het moment dat de gebruiker de uitnodiging voor de rol accepteert.",
422427
enforceEmailEqualityTooltip: "Indien ingeschakeld moet de genodigde de uitnodiging accepteren met een account dat hetzelfde e-mailadres voert als waarheen deze uitnodiging gestuurd is",
423428
eduIDOnlyTooltip: "Indien ingeschakeld moeten de genodigden eduID gebruiken om in te loggen bij het accepteren",
429+
requestedAuthnContextTooltip: "De gebruiker wordt gedwongen de authenticatie te verhogen bij het inloggen met eduID met de opgegeven ARC",
424430
roleExpiryDateTooltip: "De einddatum van deze rol. Na deze datum wordt de rol verwijderd bij de gebruiker.",
425431
expiryDateTooltip: "De datum waarop deze uitnodiging verloopt",
426432
inviterDisplayName: "De specifieke naam van de uitnodiger zal worden getoond in de uitnodiging.<br><br>Standaard tonen we de naam van de daadwerkelijke uitnodiger.",
@@ -529,7 +535,15 @@ const nl = {
529535
title: "Roles to be expired the next month",
530536
searchPlaceHolder: "Zoek...",
531537
noResults: "Yeah, no user-roles to be expired within one month"
538+
},
539+
requestedAuthnContext: {
540+
EduIDLinkedInstitution: "EduID gekoppelde instelling",
541+
EduIDValidatedName: "EduID gevalideerde naam",
542+
ValidateNamesExternal: "EduID gevalideerde naam bij een externe (niet institutioneel) bron",
543+
EduIDRequireStudentAffiliation: "EduID verplicht student affiliation",
544+
TransparentAuthnContext: "Application levert zelf ACR waarde"
532545
}
546+
533547
}
534548

535549
export default nl;

client/src/pages/InvitationForm.jsx

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,38 @@ import {
1313
import UserIcon from "@surfnet/sds/icons/functional-icons/id-2.svg";
1414
import UpIcon from "@surfnet/sds/icons/functional-icons/arrow-up-2.svg";
1515
import DownIcon from "@surfnet/sds/icons/functional-icons/arrow-down-2.svg";
16-
import {newInvitation, organizationGUIDValidation, rolesByApplication} from "../api";
16+
import WarningIcon from "@surfnet/sds/icons/functional-icons/alert-triangle.svg";
17+
import {
18+
eduidIdentityProvider,
19+
newInvitation,
20+
organizationGUIDValidation,
21+
requestedAuthnContextValues,
22+
rolesByApplication
23+
} from "../api";
1724
import {Button, ButtonType, Loader, Tooltip} from "@surfnet/sds";
1825
import "./InvitationForm.scss";
1926
import {UnitHeader} from "../components/UnitHeader";
2027
import InputField from "../components/InputField";
21-
import {isEmpty, stopEvent} from "../utils/Utils";
28+
import {isEmpty, splitListSemantically, stopEvent} from "../utils/Utils";
2229
import ErrorIndicator from "../components/ErrorIndicator";
2330
import SelectField from "../components/SelectField";
2431
import {DateField} from "../components/DateField";
2532
import EmailField from "../components/EmailField";
2633
import {displayExpiryDate, futureDate} from "../utils/Date";
2734
import SwitchField from "../components/SwitchField";
2835
import {InvitationRoleCard} from "../components/InvitationRoleCard";
36+
import DOMPurify from "dompurify";
37+
2938

3039
export const InviterContainer = ({isInviter, children}) => {
3140
return isInviter ?
3241
<div className="inviter-wrapper">{children}</div> : <>{children}</>
3342

3443
}
3544

45+
const requestedAuthnContextOptions = Object.entries(I18n.translations[I18n.locale].requestedAuthnContext)
46+
.map(arr => ({value: arr[0], label: arr[1]}));
47+
3648
export const InvitationForm = () => {
3749
const location = useLocation();
3850
const navigate = useNavigate();
@@ -59,6 +71,8 @@ export const InvitationForm = () => {
5971
const [customExpiryDate, setCustomExpiryDate] = useState(false);
6072
const [customRoleExpiryDate, setCustomRoleExpiryDate] = useState(false);
6173
const [initial, setInitial] = useState(true);
74+
const [eduIDIdP, setEduIDIdP] = useState(null);
75+
const [acrValues, setACRValues] = useState({});
6276
const [language, setLanguage] = useState(I18n.locale === "en" ? languageOptions[0] : languageOptions[1]);
6377
const required = ["intendedAuthority", "invites"];
6478

@@ -158,7 +172,7 @@ export const InvitationForm = () => {
158172
return required.every(attr => !isEmpty(invitation[attr])) &&
159173
(!isEmpty(selectedRoles) || [AUTHORITIES.SUPER_USER, AUTHORITIES.INSTITUTION_ADMIN].includes(invitation.intendedAuthority))
160174
&& (invitation.intendedAuthority !== AUTHORITIES.INSTITUTION_ADMIN || !user.superUser || validOrganizationGUID)
161-
&& !(user.superUser && invitation.intendedAuthority === AUTHORITIES.INSTITUTION_ADMIN && isEmpty(invitation.organizationGUID))
175+
&& !(user.superUser && invitation.intendedAuthority === AUTHORITIES.INSTITUTION_ADMIN && isEmpty(invitation.organizationGUID))
162176
}
163177

164178
const addEmails = emails => {
@@ -196,6 +210,21 @@ export const InvitationForm = () => {
196210
}
197211
}
198212

213+
const eduIDOnlyChanged = val => {
214+
const requestedAuthnContext = val ? invitation.requestedAuthnContext : null;
215+
setInvitation({...invitation, eduIDOnly: val, requestedAuthnContext: requestedAuthnContext})
216+
}
217+
218+
const requestedAuthnContextChanged = option => {
219+
setInvitation({...invitation, requestedAuthnContext: option ? option.value : null});
220+
if (option && isEmpty(eduIDIdP)) {
221+
Promise.all([eduidIdentityProvider(), requestedAuthnContextValues()]).then(res => {
222+
setEduIDIdP(res[0]);
223+
setACRValues(res[1])
224+
});
225+
}
226+
}
227+
199228
const rolesChanged = selectedOptions => {
200229
if (selectedOptions === null) {
201230
setSelectedRoles([])
@@ -205,13 +234,17 @@ export const InvitationForm = () => {
205234
let intendedAuthority = invitation.intendedAuthority;
206235
//If the chosen authority is no longer allowed, then change it
207236
if (!allowedAuthorities.includes(invitation.intendedAuthority)) {
208-
intendedAuthority = allowedAuthorities[0];
237+
intendedAuthority = allowedAuthorities[0];
209238
}
210239
const newSelectedOptions = Array.isArray(selectedOptions) ? [...selectedOptions] : [selectedOptions];
211240
setSelectedRoles(newSelectedOptions);
212-
const enforceEmailEquality = newSelectedOptions.some(role => role.enforceEmailEquality);
213-
const eduIDOnly = newSelectedOptions.some(role => role.eduIDOnly);
214-
241+
const overrideSettingsAllowed = selectedRoles.every(role => role.overrideSettingsAllowed);
242+
let enforceEmailEquality = invitation.enforceEmailEquality;
243+
let eduIDOnly = invitation.eduIDOnly;
244+
if (!overrideSettingsAllowed) {
245+
enforceEmailEquality = newSelectedOptions.some(role => role.enforceEmailEquality) || enforceEmailEquality;
246+
eduIDOnly = newSelectedOptions.some(role => role.eduIDOnly) || eduIDOnly;
247+
}
215248
setInvitation({
216249
...invitation,
217250
intendedAuthority: intendedAuthority,
@@ -222,6 +255,37 @@ export const InvitationForm = () => {
222255
}
223256
}
224257

258+
const renderACRWarnings = () => {
259+
if (isEmpty(invitation.requestedAuthnContext) || isEmpty(eduIDIdP) || isEmpty(selectedRoles)) {
260+
return null;
261+
}
262+
//Filter out the roles that are linked to applications that are not present in the mfaEntities of the eduIDIdp
263+
//or where the MFA level does not equal the requestedAuthnContext. If the requestedAuthnContext === TransparentAuthnContext,
264+
//then we skip the warning
265+
const mfaEntities = eduIDIdP.mfaEntities;
266+
const acrValue = acrValues[invitation.requestedAuthnContext];
267+
const missingEntities = selectedRoles.reduce((acc, role) => {
268+
const missingMfaApps = role.applicationMaps
269+
.filter(app => {
270+
const mfa = mfaEntities.find(mfa => mfa.name === app.entityid);
271+
return isEmpty(mfa) || (mfa.level !== acrValue && mfa.level !== acrValues.TransparentAuthnContext);
272+
});
273+
if (!isEmpty(missingMfaApps)) {
274+
acc.applications = acc.applications.concat(missingMfaApps.map(app => app[`name:${I18n.locale}`] || app["name:en"]));
275+
acc.roles.push(role.name)
276+
}
277+
return acc;
278+
}, {applications: [], roles: []})
279+
if (isEmpty(missingEntities.roles)) {
280+
return null;
281+
}
282+
const roleNames = splitListSemantically(missingEntities.roles, I18n.t("forms.and"));
283+
const applicationNames = splitListSemantically(missingEntities.applications, I18n.t("forms.and"));
284+
const html = DOMPurify.sanitize(I18n.t("invitations.requestedAuthnContextWarning",
285+
{roles: roleNames, applications: applicationNames}));
286+
return <p className="warning" dangerouslySetInnerHTML={{__html: html}}/>
287+
}
288+
225289
const authorityChanged = option => {
226290
setInvitation({
227291
...invitation,
@@ -397,11 +461,26 @@ export const InvitationForm = () => {
397461
{overrideSettingsAllowed &&
398462
<SwitchField name={"eduIDOnly"}
399463
value={invitation.eduIDOnly || false}
400-
onChange={val => setInvitation({...invitation, eduIDOnly: val})}
464+
onChange={eduIDOnlyChanged}
401465
label={I18n.t("invitations.eduIDOnly")}
402466
info={I18n.t("tooltips.eduIDOnlyTooltip")}
467+
last={invitation.eduIDOnly}
403468
/>}
404469

470+
{(overrideSettingsAllowed && invitation.eduIDOnly) &&
471+
<SelectField
472+
value={requestedAuthnContextOptions.find(option => option.value === invitation.requestedAuthnContext)}
473+
options={requestedAuthnContextOptions}
474+
className={"requested-authn-context"}
475+
name={I18n.t("invitations.requestedAuthnContext")}
476+
toolTip={I18n.t("tooltips.requestedAuthnContextTooltip")}
477+
placeholder={I18n.t("invitations.requestedAuthnContextPlaceHolder")}
478+
clearable={true}
479+
onChange={requestedAuthnContextChanged}
480+
>
481+
{renderACRWarnings()}
482+
</SelectField>}
483+
405484
{(invitation.intendedAuthority !== AUTHORITIES.GUEST && !isInviter &&
406485
!skipRoles) &&
407486
<SwitchField name={"guestRoleIncluded"}
@@ -410,6 +489,7 @@ export const InvitationForm = () => {
410489
label={I18n.t("invitations.guestRoleIncluded")}
411490
info={I18n.t("tooltips.guestRoleIncludedTooltip")}
412491
/>
492+
413493
}
414494
{(overrideSettingsAllowed && !skipRoles) &&
415495
<SwitchField name={"roleExpiryDate"}

client/src/pages/InvitationForm.scss

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,25 @@
3838
grid-column-start: first;
3939
}
4040

41+
.advanced-settings-container {
42+
.select-field.requested-authn-context {
43+
margin-top: 0;
44+
padding-bottom: 20px;
45+
border-bottom: 1px solid var(--sds--color--gray--200);
46+
}
47+
}
48+
4149
p.info {
4250
grid-column-start: first;
4351
margin-top: 4px;
4452
}
4553

54+
p.warning {
55+
margin-top: 14px;
56+
color: var(--sds--color--orange--500);
57+
58+
}
59+
4660
.inviter-wrapper {
4761
margin-top: 25px;
4862
padding: 25px 155px 25px 25px;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ public ResponseEntity<InvitationResponse> sendInvitation(InvitationRequest invit
8888
invite.getEmail(),
8989
invitationRequest.isEnforceEmailEquality(),
9090
invitationRequest.isEduIDOnly(),
91+
invitationRequest.getRequestedAuthnContext(),
9192
invitationRequest.isGuestRoleIncluded(),
9293
invitationRequest.getMessage(),
9394
invitationRequest.getLanguage(),

0 commit comments

Comments
 (0)