feat: bulk invite user api (#6057)

This commit is contained in:
Vishal Sharma 2024-10-17 18:41:31 +05:30 committed by GitHub
parent 96cb8053df
commit fc4b55cb34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 212 additions and 0 deletions

View File

@ -486,6 +486,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *AuthMiddleware) {
// === Authentication APIs ===
router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.inviteUser)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/invite/bulk", am.AdminAccess(aH.inviteUsers)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(aH.getInvite)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/invite/{email}", am.AdminAccess(aH.revokeInvite)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.listPendingInvites)).Methods(http.MethodGet)
@ -1976,6 +1977,32 @@ func (aH *APIHandler) inviteUser(w http.ResponseWriter, r *http.Request) {
aH.WriteJSON(w, r, resp)
}
func (aH *APIHandler) inviteUsers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
req, err := parseInviteUsersRequest(r)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
response, err := auth.InviteUsers(ctx, req)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
// Check the response status and set the appropriate HTTP status code
if response.Status == "failure" {
w.WriteHeader(http.StatusInternalServerError)
} else if response.Status == "partial_success" {
w.WriteHeader(http.StatusPartialContent) // 206 Partial Content
} else {
w.WriteHeader(http.StatusOK) // 200 OK for success
}
aH.WriteJSON(w, r, response)
}
// getInvite returns the invite object details for the given invite token. We do not need to
// protect this API because invite token itself is meant to be private.
func (aH *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) {

View File

@ -22,6 +22,7 @@ import (
"go.signoz.io/signoz/pkg/query-service/app/queryBuilder"
"go.signoz.io/signoz/pkg/query-service/auth"
"go.signoz.io/signoz/pkg/query-service/common"
"go.signoz.io/signoz/pkg/query-service/constants"
baseconstants "go.signoz.io/signoz/pkg/query-service/constants"
"go.signoz.io/signoz/pkg/query-service/model"
v3 "go.signoz.io/signoz/pkg/query-service/model/v3"
@ -724,6 +725,45 @@ func parseInviteRequest(r *http.Request) (*model.InviteRequest, error) {
return &req, nil
}
func isValidRole(role string) bool {
switch role {
case constants.AdminGroup, constants.EditorGroup, constants.ViewerGroup:
return true
}
return false
}
func parseInviteUsersRequest(r *http.Request) (*model.BulkInviteRequest, error) {
var req model.BulkInviteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, err
}
// Validate that the request contains users
if len(req.Users) == 0 {
return nil, fmt.Errorf("no users provided for invitation")
}
// Trim spaces and validate each user
for i := range req.Users {
req.Users[i].Email = strings.TrimSpace(req.Users[i].Email)
if req.Users[i].Email == "" {
return nil, fmt.Errorf("email is required for each user")
}
if req.Users[i].Name == "" {
return nil, fmt.Errorf("name is required for each user")
}
if req.Users[i].FrontendBaseUrl == "" {
return nil, fmt.Errorf("frontendBaseUrl is required for each user")
}
if !isValidRole(req.Users[i].Role) {
return nil, fmt.Errorf("invalid role for user: %s", req.Users[i].Email)
}
}
return &req, nil
}
func parseSetApdexScoreRequest(r *http.Request) (*model.ApdexSettings, error) {
var req model.ApdexSettings
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {

View File

@ -61,6 +61,16 @@ func Invite(ctx context.Context, req *model.InviteRequest) (*model.InviteRespons
return nil, errors.New("User already exists with the same email")
}
// Check if an invite already exists
invite, apiErr := dao.DB().GetInviteFromEmail(ctx, req.Email)
if apiErr != nil {
return nil, errors.Wrap(apiErr.Err, "Failed to check existing invite")
}
if invite != nil {
return nil, errors.New("An invite already exists for this email")
}
if err := validateInviteRequest(req); err != nil {
return nil, errors.Wrap(err, "invalid invite request")
}
@ -79,6 +89,113 @@ func Invite(ctx context.Context, req *model.InviteRequest) (*model.InviteRespons
if apiErr != nil {
return nil, errors.Wrap(err, "failed to query admin user from the DB")
}
inv := &model.InvitationObject{
Name: req.Name,
Email: req.Email,
Token: token,
CreatedAt: time.Now().Unix(),
Role: req.Role,
OrgId: au.OrgId,
}
if err := dao.DB().CreateInviteEntry(ctx, inv); err != nil {
return nil, errors.Wrap(err.Err, "failed to write to DB")
}
telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_EVENT_USER_INVITATION_SENT, map[string]interface{}{
"invited user email": req.Email,
}, au.Email, true, false)
// send email if SMTP is enabled
if os.Getenv("SMTP_ENABLED") == "true" && req.FrontendBaseUrl != "" {
inviteEmail(req, au, token)
}
return &model.InviteResponse{Email: inv.Email, InviteToken: inv.Token}, nil
}
func InviteUsers(ctx context.Context, req *model.BulkInviteRequest) (*model.BulkInviteResponse, error) {
response := &model.BulkInviteResponse{
Status: "success",
Summary: model.InviteSummary{TotalInvites: len(req.Users)},
SuccessfulInvites: []model.SuccessfulInvite{},
FailedInvites: []model.FailedInvite{},
}
jwtAdmin, ok := ExtractJwtFromContext(ctx)
if !ok {
return nil, errors.New("failed to extract admin jwt token")
}
adminUser, err := validateUser(jwtAdmin)
if err != nil {
return nil, errors.Wrap(err, "failed to validate admin jwt token")
}
au, apiErr := dao.DB().GetUser(ctx, adminUser.Id)
if apiErr != nil {
return nil, errors.Wrap(apiErr.Err, "failed to query admin user from the DB")
}
for _, inviteReq := range req.Users {
inviteResp, err := inviteUser(ctx, &inviteReq, au)
if err != nil {
response.FailedInvites = append(response.FailedInvites, model.FailedInvite{
Email: inviteReq.Email,
Error: err.Error(),
})
response.Summary.FailedInvites++
} else {
response.SuccessfulInvites = append(response.SuccessfulInvites, model.SuccessfulInvite{
Email: inviteResp.Email,
InviteLink: fmt.Sprintf("%s/signup?token=%s", inviteReq.FrontendBaseUrl, inviteResp.InviteToken),
Status: "sent",
})
response.Summary.SuccessfulInvites++
}
}
// Update the status based on the results
if response.Summary.FailedInvites == response.Summary.TotalInvites {
response.Status = "failure"
} else if response.Summary.FailedInvites > 0 {
response.Status = "partial_success"
}
return response, nil
}
// Helper function to handle individual invites
func inviteUser(ctx context.Context, req *model.InviteRequest, au *model.UserPayload) (*model.InviteResponse, error) {
token, err := utils.RandomHex(opaqueTokenSize)
if err != nil {
return nil, errors.Wrap(err, "failed to generate invite token")
}
user, apiErr := dao.DB().GetUserByEmail(ctx, req.Email)
if apiErr != nil {
return nil, errors.Wrap(apiErr.Err, "Failed to check already existing user")
}
if user != nil {
return nil, errors.New("User already exists with the same email")
}
// Check if an invite already exists
invite, apiErr := dao.DB().GetInviteFromEmail(ctx, req.Email)
if apiErr != nil {
return nil, errors.Wrap(apiErr.Err, "Failed to check existing invite")
}
if invite != nil {
return nil, errors.New("An invite already exists for this email")
}
if err := validateInviteRequest(req); err != nil {
return nil, errors.Wrap(err, "invalid invite request")
}
inv := &model.InvitationObject{
Name: req.Name,
Email: req.Email,

View File

@ -27,6 +27,34 @@ type InvitationResponseObject struct {
Organization string `json:"organization" db:"organization"`
}
type BulkInviteRequest struct {
Users []InviteRequest `json:"users"`
}
type BulkInviteResponse struct {
Status string `json:"status"`
Summary InviteSummary `json:"summary"`
SuccessfulInvites []SuccessfulInvite `json:"successful_invites"`
FailedInvites []FailedInvite `json:"failed_invites"`
}
type InviteSummary struct {
TotalInvites int `json:"total_invites"`
SuccessfulInvites int `json:"successful_invites"`
FailedInvites int `json:"failed_invites"`
}
type SuccessfulInvite struct {
Email string `json:"email"`
InviteLink string `json:"invite_link"`
Status string `json:"status"`
}
type FailedInvite struct {
Email string `json:"email"`
Error string `json:"error"`
}
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`