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) {
+ }
+
}