diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/invitation/model/Invitation.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/invitation/model/Invitation.java index 3dcd985fd..e2225c1c1 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/invitation/model/Invitation.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/invitation/model/Invitation.java @@ -25,4 +25,6 @@ public class Invitation extends HasIdAndAuditing { */ private final Set invitedUserIds; + private final Set invitedEmails; + } diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrganizationService.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrganizationService.java index 0a07ea502..a12749cc3 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrganizationService.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrganizationService.java @@ -18,6 +18,10 @@ public interface OrganizationService { "Here is the link to reset your password: %s
" + "Please note that the link will expire after 12 hours.

"; + public static final String INVITATION_EMAIL_TEMPLATE_DEFAULT = "

Hi, sir
" + + "Here is the link to your invitation: %s
" + + "Please note that the link will expire after 12 hours.

"; + @PossibleEmptyMono Mono getOrganizationInEnterpriseMode(); diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/EmailCommunicationService.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/EmailCommunicationService.java index d3775791b..ef4a46db9 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/EmailCommunicationService.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/EmailCommunicationService.java @@ -1,5 +1,10 @@ package org.lowcoder.domain.user.service; +import org.lowcoder.domain.invitation.model.Invitation; + +import java.util.Set; + public interface EmailCommunicationService { boolean sendPasswordResetEmail(String to, String token, String message); + boolean sendInvitationEmail(Invitation invitation, Set emails, String message); } diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/EmailCommunicationServiceImpl.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/EmailCommunicationServiceImpl.java index e83ed917b..6d7d89b4b 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/EmailCommunicationServiceImpl.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/EmailCommunicationServiceImpl.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.http.client.utils.URLEncodedUtils; +import org.lowcoder.domain.invitation.model.Invitation; import org.lowcoder.sdk.config.CommonConfig; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; @@ -11,6 +12,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.Set; @RequiredArgsConstructor @Service @@ -49,4 +51,34 @@ public boolean sendPasswordResetEmail(String to, String token, String message) { } + @Override + public boolean sendInvitationEmail(Invitation invitation, Set emails, String message) { + try { + String subject = "You've got invitation to lowcoder"; + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, true); + + mimeMessageHelper.setFrom(config.getNotificationsEmailSender()); + String[] to = emails.toArray(new String[0]); + mimeMessageHelper.setTo(to); + mimeMessageHelper.setSubject(subject); + + // Construct the message with the token link + String inviteLink = config.getLowcoderPublicUrl() + "/user/auth/invite?code=" + invitation.getId(); + String formattedMessage = String.format(message, inviteLink); + mimeMessageHelper.setText(formattedMessage, true); // Set HTML to true to allow links + + javaMailSender.send(mimeMessage); + + return true; + + } catch (Exception e) { + log.error("Failed to send mail, Exception: ", e); + return false; + } + + + } + } diff --git a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/exception/BizError.java b/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/exception/BizError.java index af8773c09..bb3d7a4a3 100644 --- a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/exception/BizError.java +++ b/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/exception/BizError.java @@ -50,6 +50,7 @@ public enum BizError { INVITED_ORG_DELETED(500, 5203), INVITED_APPLICATION_DELETED(500, 5204), INVITED_USER_NOT_LOGIN(403, 5205), + INVITE_FAILED(403, 5206), // APPLICATION related, code range 5300 - 5400 QUERY_NOT_FOUND(500, 5300), @@ -104,6 +105,7 @@ public enum BizError { ID_NOT_EXIST(500, 5620), DUPLICATE_AUTH_CONFIG_ADDITION(400, 5621), EMAIL_PROVIDER_DISABLED(403, 5622), + LINK_EXPIRED(401, 5623), // asset related, code range 5700 - 5799 diff --git a/server/api-service/lowcoder-sdk/src/main/resources/locale_en.properties b/server/api-service/lowcoder-sdk/src/main/resources/locale_en.properties index 32b74f755..53d5b1c89 100644 --- a/server/api-service/lowcoder-sdk/src/main/resources/locale_en.properties +++ b/server/api-service/lowcoder-sdk/src/main/resources/locale_en.properties @@ -19,6 +19,7 @@ UNABLE_TO_FIND_VALID_ORG=Cannot find a valid workspace for current user. USER_BANNED=Current account is frozen. SENDING_EMAIL_FAILED=Email could not be sent. Please check the smtp settings for the org. TOKEN_EXPIRED=Token to reset the password has expired +LINK_EXPIRED=Link has expired INVALID_TOKEN=Invalid token received for password reset request # invitation INVALID_INVITATION_CODE=Invitation code not found. @@ -283,6 +284,7 @@ DISABLE_AUTH_CONFIG_FORBIDDEN=Can not disable current administrator''s last iden USER_NOT_EXIST=User not exist. DUPLICATE_AUTH_CONFIG_ADDITION=Provider auth type already added to organization EMAIL_PROVIDER_DISABLED=Email provider is disabled. +INVITE_FAILED=Invite failed. SLUG_DUPLICATE_ENTRY=Slug already exists SLUG_INVALID=Slug format is invalid FLOW_ERROR=Flow error message: {0} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/service/AuthenticationApiServiceImpl.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/service/AuthenticationApiServiceImpl.java index df1c9e1d1..41e2f0738 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/service/AuthenticationApiServiceImpl.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/service/AuthenticationApiServiceImpl.java @@ -23,6 +23,7 @@ import org.lowcoder.domain.authentication.FindAuthConfig; import org.lowcoder.domain.authentication.context.AuthRequestContext; import org.lowcoder.domain.authentication.context.FormAuthRequestContext; +import org.lowcoder.domain.invitation.service.InvitationService; import org.lowcoder.domain.organization.model.OrgMember; import org.lowcoder.domain.organization.model.Organization; import org.lowcoder.domain.organization.model.OrganizationDomain; @@ -41,6 +42,8 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.time.Duration; +import java.time.Instant; import java.util.*; import java.util.function.Function; import java.util.function.Predicate; @@ -69,6 +72,7 @@ public class AuthenticationApiServiceImpl implements AuthenticationApiService { private final OrgMemberService orgMemberService; private final JWTUtils jwtUtils; private final AuthProperties authProperties; + private final InvitationService invitationService; @Override public Mono authenticateByForm(String loginId, String password, String source, boolean register, String authId, String orgId) { @@ -120,7 +124,18 @@ protected Mono authenticate(String authId, @Deprecated String source, @Override public Mono loginOrRegister(AuthUser authUser, ServerWebExchange exchange, String invitationId, boolean linKExistingUser) { - return updateOrCreateUser(authUser, linKExistingUser, false) + Mono expiryCheckMono; + if(invitationId != null && !invitationId.trim().isEmpty()) { + expiryCheckMono = invitationService.getById(invitationId) + .handle((invitation, sink) -> { + boolean expired = Instant.now().isAfter(invitation.getCreatedAt().plus(Duration.ofHours(12))); + if(expired) sink.error(new BizException(LINK_EXPIRED, "LINK_EXPIRED")); + sink.next(true); + }); + } else { + expiryCheckMono = Mono.just(true); + } + return expiryCheckMono.then(updateOrCreateUser(authUser, linKExistingUser, false) .delayUntil(user -> ReactiveSecurityContextHolder.getContext() .doOnNext(securityContext -> securityContext.setAuthentication(AuthenticationUtils.toAuthentication(user)))) // save token and set cookie @@ -148,7 +163,7 @@ public Mono loginOrRegister(AuthUser authUser, ServerWebExchange exchange, return invitationApiService.inviteUser(invitationId); }) // publish event - .then(businessEventPublisher.publishUserLoginEvent(authUser.getSource())); + .then(businessEventPublisher.publishUserLoginEvent(authUser.getSource()))); } public Mono updateOrCreateUser(AuthUser authUser, boolean linkExistingUser, boolean isSuperAdmin) { diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationApiService.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationApiService.java index c27937e53..c8306984e 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationApiService.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationApiService.java @@ -3,6 +3,8 @@ import org.lowcoder.api.usermanagement.view.InvitationVO; import reactor.core.publisher.Mono; +import java.util.Set; + public interface InvitationApiService { Mono inviteUser(String invitationId); @@ -10,6 +12,8 @@ public interface InvitationApiService { Mono create(String orgId); + Mono createByEmails(String orgId, Set emails); + public record JoinOrgResult(boolean alreadyInOrg, boolean success) { } diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationApiServiceImpl.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationApiServiceImpl.java index ca769ad5b..d98a7f125 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationApiServiceImpl.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationApiServiceImpl.java @@ -11,12 +11,18 @@ import org.lowcoder.domain.organization.service.OrgMemberService; import org.lowcoder.domain.organization.service.OrganizationService; import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.EmailCommunicationService; +import org.lowcoder.domain.user.service.EmailCommunicationServiceImpl; import org.lowcoder.domain.user.service.UserService; import org.lowcoder.sdk.exception.BizError; import org.lowcoder.sdk.exception.BizException; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; +import java.util.Arrays; +import java.util.Set; + +import static org.lowcoder.domain.organization.service.OrganizationService.INVITATION_EMAIL_TEMPLATE_DEFAULT; import static org.lowcoder.sdk.exception.BizError.INVITED_ORG_DELETED; import static org.lowcoder.sdk.exception.BizError.INVITER_NOT_FOUND; import static org.lowcoder.sdk.util.ExceptionUtils.deferredError; @@ -33,6 +39,7 @@ public class InvitationApiServiceImpl implements InvitationApiService { private final OrganizationService organizationService; private final OrgMemberService orgMemberService; private final AbstractBizThresholdChecker bizThresholdChecker; + private final EmailCommunicationService emailCommunicationService; @Override public Mono inviteUser(String invitationId) { @@ -111,4 +118,27 @@ public Mono create(String orgId) { }); } + @Override + public Mono createByEmails(String orgId, Set emails) { + return sessionUserService.getVisitor() + .zipWith(organizationService.getById(orgId) + .switchIfEmpty(Mono.error(new BizException(BizError.INVALID_ORG_ID, "INVALID_ORG_ID")))) + .flatMap(tuple2 -> { + User user = tuple2.getT1(); + Organization org = tuple2.getT2(); + Invitation invitation = Invitation + .builder() + .createUserId(user.getId()) + .invitedOrganizationId(orgId) + .invitedEmails(emails) + .build(); + return invitationService.create(invitation) + .doOnNext(i -> { + boolean ret = emailCommunicationService.sendInvitationEmail(i, emails, INVITATION_EMAIL_TEMPLATE_DEFAULT); + if(!ret) throw new BizException(BizError.INVITE_FAILED, "INVITE_FAILED"); + }) + .flatMap(i -> Mono.just(InvitationVO.from(i, user, org))); + }); + } + } diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationController.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationController.java index b83c6efc7..3a9947c03 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationController.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationController.java @@ -9,6 +9,7 @@ import org.lowcoder.api.usermanagement.view.InvitationVO; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -50,4 +51,10 @@ public Mono> inviteUser(@PathVariable String invitationId) { ); } + @Override + public Mono> inviteUserByEmail(@RequestBody InviteByEmailRequest inviteByEmailRequest) { + return invitationApiService.createByEmails(inviteByEmailRequest.organizationId(), inviteByEmailRequest.emails()) + .map(ResponseView::success); + } + } diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationEndpoints.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationEndpoints.java index a1c3ba8db..d5957c2a0 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationEndpoints.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/InvitationEndpoints.java @@ -1,19 +1,19 @@ package org.lowcoder.api.usermanagement; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.annotation.Nullable; import org.lowcoder.api.framework.view.ResponseView; import org.lowcoder.api.usermanagement.view.InvitationVO; import org.lowcoder.infra.constant.NewUrl; import org.lowcoder.infra.constant.Url; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import reactor.core.publisher.Mono; +import java.util.Map; +import java.util.Set; + @RestController @RequestMapping(value = {Url.INVITATION_URL, NewUrl.INVITATION_URL}) public interface InvitationEndpoints @@ -47,4 +47,16 @@ public interface InvitationEndpoints @GetMapping("/{invitationId}/invite") public Mono> inviteUser(@PathVariable String invitationId); + @Operation( + tags = TAG_INVITATION_MANAGEMENT, + operationId = "inviteUserByEmail", + summary = "Invite users by their email addresses", + description = "Invite users and send invitation link to user's email addresses" + ) + @PostMapping("/email") + public Mono> inviteUserByEmail(@RequestBody InviteByEmailRequest inviteByEmailRequest); + + public record InviteByEmailRequest(@JsonProperty("orgId") String organizationId, Set emails) { + } + }