Skip to content

Commit

Permalink
Fixes #261
Browse files Browse the repository at this point in the history
  • Loading branch information
oharsta committed Nov 27, 2024
1 parent 1a876fa commit 8da1a75
Show file tree
Hide file tree
Showing 14 changed files with 128 additions and 80 deletions.
58 changes: 45 additions & 13 deletions server/src/main/java/access/api/InvitationController.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import access.mail.MailBox;
import access.manage.Manage;
import access.model.*;
import access.provision.Provisioning;
import access.provision.ProvisioningService;
import access.provision.graph.GraphResponse;
import access.provision.scim.OperationType;
Expand Down Expand Up @@ -38,7 +39,6 @@
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.io.Serializable;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -145,10 +145,10 @@ public ResponseEntity<List<Invitation>> all(@Parameter(hidden = true) User user)


@PostMapping("accept")
public ResponseEntity<Map<String, ? extends Serializable>> accept(@Validated @RequestBody AcceptInvitation acceptInvitation,
Authentication authentication,
HttpServletRequest servletRequest,
HttpServletResponse servletResponse) {
public ResponseEntity<Map<String, Object>> accept(@Validated @RequestBody AcceptInvitation acceptInvitation,
Authentication authentication,
HttpServletRequest servletRequest,
HttpServletResponse servletResponse) {
Invitation invitation = invitationRepository.findByHash(acceptInvitation.hash())
.orElseThrow(() -> new NotFoundException("Invitation not found"));

Expand Down Expand Up @@ -230,19 +230,51 @@ public ResponseEntity<List<Invitation>> all(@Parameter(hidden = true) User user)
userRepository.save(user);
AccessLogger.user(LOG, Event.Created, user);

//Only interact with the provisioning service if there is a guest role
boolean isGuest = user.getUserRoles().stream()
.anyMatch(userRole -> userRole.isGuestRoleIncluded() || userRole.getAuthority().equals(Authority.GUEST));
//Already provisioned users in the remote systems are ignored / excluded
Optional<GraphResponse> graphResponse = provisioningService.newUserRequest(user);
newUserRoles.forEach(userRole -> provisioningService.updateGroupRequest(userRole, OperationType.Add));

Optional<GraphResponse> optionalGraphResponse = isGuest ? provisioningService.newUserRequest(user) : Optional.empty();
if (isGuest) {
newUserRoles.forEach(userRole -> provisioningService.updateGroupRequest(userRole, OperationType.Add));
}
LOG.info(String.format("User %s accepted invitation with role(s) %s",
user.getEduPersonPrincipalName(),
invitation.getRoles().stream().map(role -> role.getRole().getName()).collect(Collectors.joining(", "))));

Map<String, ? extends Serializable> body = graphResponse
.map(graph -> graph.isErrorResponse() ?
Map.of("errorResponse", Boolean.TRUE) :
Map.of("inviteRedeemUrl", graph.inviteRedeemUrl())).
orElse(Map.of("status", "ok"));
//Must be mutable, because of possible userWaitTime
Map<String, Object> body = new HashMap<>();
optionalGraphResponse.ifPresentOrElse(graphResponse -> {
if (graphResponse.isErrorResponse()) {
body.put("errorResponse", Boolean.TRUE);
} else {
body.put("inviteRedeemUrl", graphResponse.inviteRedeemUrl());
}
}, () -> body.put("status", "ok"));

if (!isGuest) {
//We are done then
return ResponseEntity.status(HttpStatus.CREATED).body(body);
}
// See if there is a userWaitTime on of the provisionings
List<Provisioning> provisionings = provisioningService.getProvisionings(newUserRoles);
provisionings.stream()
.filter(provisioning -> provisioning.getUserWaitTime() != null)
.max(Comparator.comparingInt(Provisioning::getUserWaitTime))
.ifPresent(provisioning -> {
Set<String> manageIdentifiers = provisioning.getRemoteApplications().stream()
.map(app -> app.manageId()).collect(Collectors.toSet());
newUserRoles.stream()
.filter(userRole -> userRole.getRole().getApplicationUsages().stream()
.anyMatch(appUsage -> manageIdentifiers.contains(appUsage.getApplication().getManageId())))
.map(userRole -> userRole.getRole())
.findFirst()
.ifPresent(role -> {
body.put("userWaitTime", provisioning.getUserWaitTime());
body.put("role", role.getName());
});
});

return ResponseEntity.status(HttpStatus.CREATED).body(body);
}

Expand Down
3 changes: 2 additions & 1 deletion server/src/main/java/access/manage/Manage.java
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ default Map<String, Object> transformProvider(Map<String, Object> provider) {
"graph_url",
"graph_client_id",
"graph_secret",
"graph_tenant"
"graph_tenant",
"user_wait_time"
).forEach(attribute -> application.put(attribute, metaDataFields.get(attribute)));
}
application.put("type", provider.get("type"));
Expand Down
2 changes: 2 additions & 0 deletions server/src/main/java/access/provision/Provisioning.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class Provisioning {
private final String graphTenant;
private final String institutionGUID;
private final List<ManageIdentifier> remoteApplications;
private final Integer userWaitTime;

public Provisioning(Map<String, Object> provider) {
this.id = (String) provider.get("id");
Expand All @@ -52,6 +53,7 @@ public Provisioning(Map<String, Object> provider) {
this.graphSecret = (String) provider.get("graph_secret");
this.graphTenant = (String) provider.getOrDefault("graph_tenant", "common");
this.institutionGUID = (String) provider.get("institutionGuid");
this.userWaitTime = (Integer) provider.get("user_wait_time");
List<Map<String, String>> applicationMaps = (List<Map<String, String>>) provider.getOrDefault("applications", emptyList());
this.remoteApplications = applicationMaps.stream().map(m -> new ManageIdentifier(m.get("id"), EntityType.valueOf(m.get("type").toUpperCase()))).toList();
this.invariant();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,7 @@ public interface ProvisioningService {

void deleteGroupRequest(Role role);

List<Provisioning> getProvisionings(List<UserRole> userRoles);


}
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,15 @@ public void deleteGroupRequest(Role role) {
deleteGroupRequest(role, provisionings);
}

@Override
public List<Provisioning> getProvisionings(List<UserRole> userRoles) {
Set<String> manageIdentifiers = userRoles.stream()
.map(userRole -> this.getManageIdentifiers(userRole.getRole()))
.flatMap(Collection::stream)
.collect(Collectors.toSet());
return manage.provisioning(manageIdentifiers).stream().map(Provisioning::new).toList();
}

private void deleteGroupRequest(Role role, List<Provisioning> provisionings) {
//Delete the group to all provisionings in Manage where the group is known
provisionings.forEach(provisioning ->
Expand Down
6 changes: 3 additions & 3 deletions server/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,12 @@ email:
# ignored then and the different manage entities are loaded from json files in `server/src/main/resources/manage`.
# Each *.json file in this directory corresponds with the contents of that specific entity_type.
# To test the provisioning (e.g. SCIM, EVA, Graphp) with real endpoints you can set the manage.local property below to
# True and then the provisioning.local.json file is used which is not in git as it is n .gitignore. You can safely
# True and then the provisioning.local.json file is used which is not in git as it is in .gitignore. You can safely
# configure real users / passwords and test against those. See server/src/main/java/access/manage/ManageConf.java
# and server/src/main/java/access/manage/LocalManage.java to see how it works.
manage:
enabled: True
# enabled: False
# enabled: True

Check warning on line 188 in server/src/main/resources/application.yml

View workflow job for this annotation

GitHub Actions / Test documentation and generate openapi html documentation

188:1 [comments-indentation] comment not indented like content
enabled: False

Check warning on line 189 in server/src/main/resources/application.yml

View workflow job for this annotation

GitHub Actions / Test documentation and generate openapi html documentation

189:12 [truthy] truthy value should be one of [false, true]
url: "https://manage.test2.surfconext.nl"
user: invite
password: secret
Expand Down
3 changes: 2 additions & 1 deletion server/src/main/resources/manage/provisioning.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"scim_password": "secret",
"scim_update_role_put_method": true,
"scim_user_identifier": "eduID",
"coin:institution_guid": "ad93daef-0911-e511-80d0-005056956c1a"
"coin:institution_guid": "ad93daef-0911-e511-80d0-005056956c1a",
"user_wait_time": 300
},
"applications": [
{
Expand Down
27 changes: 27 additions & 0 deletions server/src/test/java/access/api/InvitationControllerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,33 @@ void acceptInvitationExpired() throws Exception {
.statusCode(410);
}

@Test
void acceptInvitationUserTimeOut() throws Exception {
AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", "[email protected]");
String hash = Authority.INVITER.name();
Invitation invitation = invitationRepository.findByHash(hash).get();

stubForCreateScimUser();
stubForCreateScimRole();
stubForUpdateScimRole();

super.stubForManageProvisioning(List.of("5"));

AcceptInvitation acceptInvitation = new AcceptInvitation(hash, invitation.getId());
Map<String, Object > results = given()
.when()
.filter(accessCookieFilter.cookieFilter())
.accept(ContentType.JSON)
.header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken())
.contentType(ContentType.JSON)
.body(acceptInvitation)
.post("/api/v1/invitations/accept")
.as(new TypeRef<>() {
});
assertEquals(300, results.get("userWaitTime"));
assertEquals("Mail", results.get("role"));
}

@Test
void acceptPatch() throws Exception {
AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", "[email protected]");
Expand Down
4 changes: 3 additions & 1 deletion welcome/src/locale/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ const en = {
emailMismatch: "The inviter has indicated that you must accept this invitation with the email {{email}}, " +
"but you have logged in with an account with a different email. Please log in in with a different account.",
inviteRedeemUrl: "Your new role requires a microsoft account. Please press Continue to register one.",
graphEmailViolation: "Your new role requires a microsoft account, however microsoft does not support your email. We can not create a microsoft account."
graphEmailViolation: "Your new role requires a microsoft account, however microsoft does not support your email. We can not create a microsoft account.",
// userWaitTime: "Your new role {{role}} requires external provisioning. You will receive a mail when this is done (estimation ~{{waitTime}})."
userWaitTime: "Before you can use your new application, your account for {{role}} first needs to created. You will receive a mail when this is done (expected {{waitTime}})."
},
proceed: {
info: "Congrats! You have accepted the {{plural}} {{roles}} and you now can go to the application",
Expand Down
5 changes: 4 additions & 1 deletion welcome/src/locale/nl.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ const nl = {
emailMismatch: "De uitnodiger heeft aangegeven dat je de uitnodiging dient te accepteren met e-mailadres {{email}}, " +
"maar je bent ingelogd met een account met een ander mailadres. Log opnieuw in met een ander account.",
inviteRedeemUrl: "Je nieuwe rol vereist een Microsoft account. Druk op Doorgaan om deze te registreren.",
graphEmailViolation: "Je nieuwe rol vereist een Microsoft account, alleen microsoft ondersteunt je e-mail niet. Er wordt geen MS account voor je aangemaakt."
graphEmailViolation: "Je nieuwe rol vereist een Microsoft account, alleen microsoft ondersteunt je e-mail niet. Er wordt geen MS account voor je aangemaakt.",
// userWaitTime: "Je nieuwe rol {{role}} vereist externe provisioning. Je ontvangt een e-mail als dit is afgerond (inschatting ~{{waitTime}}).",
userWaitTime: "Voordat je je nieuwe applicatie(s) kan gebruiken, moet je account voor {{role}} eerst worden gemaakt. Je ontvangt een e-mail als dit is gedaan (verwacht {{waitTime}})."

},
proceed: {
info: "Gefeliciteerd! Je hebt de {{plural}} {{roles}} geaccepteerd en kunt nu verder naar de applicatie",
Expand Down
5 changes: 3 additions & 2 deletions welcome/src/pages/Invitation.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,11 @@ export const Invitation = ({authenticated}) => {
user: userWithRoles,
authenticated: true
}));
const inviteRedeemUrlQueryParam = res.inviteRedeemUrl && !res.errorResponse ? `&inviteRedeemUrl=${encodeURIComponent(res.inviteRedeemUrl)}` : "";
const inviteRedeemUrlQueryParam = (res.inviteRedeemUrl && !res.errorResponse) ? `&inviteRedeemUrl=${encodeURIComponent(res.inviteRedeemUrl)}` : "";
const errorResponseQueryParam = res.errorResponse ? "&errorResponse=true" : "";
const userWaitTimeQueryParam = res.userWaitTime ? `&userWaitTime=${encodeURIComponent(res.userWaitTime)}&role=${encodeURIComponent(res.role)}` : "";
localStorage.removeItem("location");
navigate(`/proceed?hash=${hashParam}${inviteRedeemUrlQueryParam}${errorResponseQueryParam}`);
navigate(`/proceed?hash=${hashParam}${inviteRedeemUrlQueryParam}${errorResponseQueryParam}${userWaitTimeQueryParam}`);
})
})
.catch(e => {
Expand Down
17 changes: 14 additions & 3 deletions welcome/src/pages/Proceed.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {User} from "../components/User";
import HighFive from "../icons/high-five.svg";
import {useNavigate} from "react-router-dom";
import {reduceApplicationFromUserRoles} from "../utils/Manage";
import {relativeUserWaitTime} from "../utils/Date";

export const Proceed = () => {

Expand All @@ -25,6 +26,8 @@ export const Proceed = () => {
const [showModal, setShowModal] = useState(true);
const [inviteRedeemUrl, setInviteRedeemUrl] = useState(null);
const [errorResponse, setErrorResponse] = useState(null);
const [userWaitTime, setUserWaitTime] = useState(null);
const [role, setRole] = useState(null);

function invariantParams() {
const urlSearchParams = new URLSearchParams(window.location.search);
Expand All @@ -37,6 +40,8 @@ export const Proceed = () => {
setInviteRedeemUrl(DOMPurify.sanitize(decodeURIComponent(inviteRedeemUrlParam)));
}
setErrorResponse(urlSearchParams.get("errorResponse"));
setUserWaitTime(urlSearchParams.get("userWaitTime"));
setRole(urlSearchParams.get("role"))
}

useEffect(() => {
Expand Down Expand Up @@ -123,10 +128,16 @@ export const Proceed = () => {
confirmationButtonLabel={I18n.t("invitationAccept.continue")}
full={true}
title={I18n.t("invitationAccept.access")}>
{reloadedApplications.map((application, index) => renderApplication(index, application, false, true))}
{inviteRedeemUrl && <p className="invite-feedback">{I18n.t("invitationAccept.inviteRedeemUrl")}</p>}
{errorResponse &&
<p className="invite-feedback">{I18n.t("invitationAccept.graphEmailViolation")}</p>}
{userWaitTime && <p className="invite-feedback">{I18n.t("invitationAccept.userWaitTime",
{
role: role,
waitTime: relativeUserWaitTime(userWaitTime)
})}</p>}
<p>{I18n.t(`invitationAccept.applicationInfo${reloadedApplications.length > 1 ? "Multiple" : ""}`)}</p>
{inviteRedeemUrl && <p className="invite-url">{I18n.t("invitationAccept.inviteRedeemUrl")}</p>}
{errorResponse && <p className="invite-error">{I18n.t("invitationAccept.graphEmailViolation")}</p>}
{reloadedApplications.map((application, index) => renderApplication(index, application, false, true))}
</Modal>}
<div className="proceed-container">
{renderProceedStep()}
Expand Down
9 changes: 4 additions & 5 deletions welcome/src/pages/Proceed.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@
padding: 0 15px;
}

p.invite-url {
font-weight: 600;
}

p.invite-error {
p.invite-feedback{
font-weight: 600;
padding: 25px;
background-color: var(--sds--color--blue--100);
border-radius: 4px;
word-break: break-word;
max-width: 620px;
}
Expand Down
57 changes: 7 additions & 50 deletions welcome/src/utils/Date.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,6 @@ import {isEmpty} from "./Utils";

let timeAgoInitialized = false;

export const futureDate = (daysAhead, fromDate = new Date()) => {
const time = fromDate.getTime() + (1000 * 60 * 60 * 24 * daysAhead);
return new Date(time);
}

export const shortDateFromEpoch = epoch => {
const options = {month: "short", day: "numeric"};
const dateTimeFormat = new Intl.DateTimeFormat(`${I18n.locale}-${I18n.locale.toUpperCase()}`, options)
return dateTimeFormat.format(new Date(epoch * 1000));
}

export const dateFromEpoch = epoch => {
if (isEmpty(epoch)) {
return "-";
Expand All @@ -24,28 +13,14 @@ export const dateFromEpoch = epoch => {
return dateTimeFormat.format(new Date(epoch * 1000));
}

export const formatDate = date => {
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${day}/${month}/${date.getFullYear()}`;
}

export const isInvitationExpired = invitation => {
if (!invitation.expiry_date) {
return false;
}
const today = Date.now();
const inp = new Date(invitation.expiry_date * 1000);
return today > inp;
}

export const languageSwitched = () => {
timeAgoInitialized = false;
}

const TIME_AGO_LOCALE = "time-ago-locale";
const LAST_ACTIVITY_LOCALE = "last-activity-locale";
const relativeTimeNotation = (expiryEpoch, translations) => {

const relativeTimeNotation = (date, translations) => {
if (!timeAgoInitialized) {
const timeAgoLocale = (number, index) => {
return [
Expand Down Expand Up @@ -87,29 +62,11 @@ const relativeTimeNotation = (expiryEpoch, translations) => {
register(LAST_ACTIVITY_LOCALE, lastActivityLocale);
timeAgoInitialized = true;
}
const expiryDate = new Date(expiryEpoch * 1000);
const expired = expiryDate < new Date();
const relativeTime = format(expiryDate, translations);
return {expired, relativeTime};
}

export const displayExpiryDate = expiryEpoch => {
if (!expiryEpoch) {
return I18n.t("expirations.never");
}
const {expired, relativeTime} = relativeTimeNotation(expiryEpoch, TIME_AGO_LOCALE);
return I18n.t(`expirations.${expired ? "expired" : "expires"}`, {relativeTime: relativeTime})
}

export const displayMembershipExpiryDate = expiryEpoch => {
if (!expiryEpoch) {
return I18n.t("expirations.never");
}
const {relativeTime} = relativeTimeNotation(expiryEpoch, TIME_AGO_LOCALE);
return relativeTime;
return format(date, translations);
}

export const displayLastActivityDate = expiryEpoch => {
const {relativeTime} = relativeTimeNotation(expiryEpoch, LAST_ACTIVITY_LOCALE);
return relativeTime;
export const relativeUserWaitTime = userWaitTime => {
const date = new Date();
date.setSeconds(date.getSeconds() + parseInt(userWaitTime, 10));
return relativeTimeNotation(date, TIME_AGO_LOCALE);
}

0 comments on commit 8da1a75

Please sign in to comment.