mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-12 15:59:01 +08:00
feat: bulk invite user api (#6057)
This commit is contained in:
parent
96cb8053df
commit
fc4b55cb34
@ -486,6 +486,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *AuthMiddleware) {
|
|||||||
|
|
||||||
// === Authentication APIs ===
|
// === Authentication APIs ===
|
||||||
router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.inviteUser)).Methods(http.MethodPost)
|
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/{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/{email}", am.AdminAccess(aH.revokeInvite)).Methods(http.MethodDelete)
|
||||||
router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.listPendingInvites)).Methods(http.MethodGet)
|
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)
|
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
|
// 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.
|
// protect this API because invite token itself is meant to be private.
|
||||||
func (aH *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) {
|
func (aH *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -22,6 +22,7 @@ import (
|
|||||||
"go.signoz.io/signoz/pkg/query-service/app/queryBuilder"
|
"go.signoz.io/signoz/pkg/query-service/app/queryBuilder"
|
||||||
"go.signoz.io/signoz/pkg/query-service/auth"
|
"go.signoz.io/signoz/pkg/query-service/auth"
|
||||||
"go.signoz.io/signoz/pkg/query-service/common"
|
"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"
|
baseconstants "go.signoz.io/signoz/pkg/query-service/constants"
|
||||||
"go.signoz.io/signoz/pkg/query-service/model"
|
"go.signoz.io/signoz/pkg/query-service/model"
|
||||||
v3 "go.signoz.io/signoz/pkg/query-service/model/v3"
|
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
|
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) {
|
func parseSetApdexScoreRequest(r *http.Request) (*model.ApdexSettings, error) {
|
||||||
var req model.ApdexSettings
|
var req model.ApdexSettings
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
@ -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")
|
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 {
|
if err := validateInviteRequest(req); err != nil {
|
||||||
return nil, errors.Wrap(err, "invalid invite request")
|
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 {
|
if apiErr != nil {
|
||||||
return nil, errors.Wrap(err, "failed to query admin user from the DB")
|
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{
|
inv := &model.InvitationObject{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
|
@ -27,6 +27,34 @@ type InvitationResponseObject struct {
|
|||||||
Organization string `json:"organization" db:"organization"`
|
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 {
|
type LoginRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
Loading…
x
Reference in New Issue
Block a user