diff --git a/.dredd/hooks/capabilities.go b/.dredd/hooks/capabilities.go index 7b55dd0ad..42a3f8857 100644 --- a/.dredd/hooks/capabilities.go +++ b/.dredd/hooks/capabilities.go @@ -22,6 +22,7 @@ var view *db.View var integration *db.Integration var integrationextractvalue *db.IntegrationExtractValue var integrationmatch *db.IntegrationMatcher +var invite *db.ProjectInvite // Runtime created simple ID values for some items we need to reference in other objects var repoID int @@ -45,6 +46,7 @@ var capabilities = map[string][]string{ "integration": {"project", "template"}, "integrationextractvalue": {"integration"}, "integrationmatcher": {"integration"}, + "invite": {"user", "project"}, } func capabilityWrapper(cap string) func(t *trans.Transaction) { @@ -76,6 +78,8 @@ func resolveCapability(caps []string, resolved []string, uid string) { //Add dep specific stuff switch v { + case "invite": + invite = addInvite() case "schedule": schedule = addSchedule() case "view": @@ -199,6 +203,7 @@ var pathSubPatterns = []func() string{ func() string { return strconv.Itoa(integration.ID) }, func() string { return strconv.Itoa(integrationextractvalue.ID) }, func() string { return strconv.Itoa(integrationmatch.ID) }, + func() string { return strconv.Itoa(invite.ID) }, // invite_id, x-example: 14 } // alterRequestPath with the above slice of functions @@ -230,6 +235,9 @@ func alterRequestBody(t *trans.Transaction) { bodyFieldProcessor("ssh_key_id", userKey.ID, &request) bodyFieldProcessor("become_key_id", userKey.ID, &request) } + if invite != nil { + bodyFieldProcessor("invite_id", 4, &request) + } bodyFieldProcessor("environment_id", environmentID, &request) bodyFieldProcessor("inventory_id", inventoryID, &request) bodyFieldProcessor("repository_id", repoID, &request) diff --git a/.dredd/hooks/helpers.go b/.dredd/hooks/helpers.go index 7e072a53c..c02225ecd 100644 --- a/.dredd/hooks/helpers.go +++ b/.dredd/hooks/helpers.go @@ -3,9 +3,10 @@ package main import ( "encoding/json" "fmt" - "github.com/semaphoreui/semaphore/pkg/tz" "os" + "github.com/semaphoreui/semaphore/pkg/tz" + "github.com/go-gorp/gorp/v3" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db/bolt" @@ -195,6 +196,35 @@ func addView() *db.View { return &view } +func addInvite() *db.ProjectInvite { + invite, err := store.CreateProjectInvite(db.ProjectInvite{ + ProjectID: userProject.ID, + UserID: &userPathTestUser.ID, + Email: &userPathTestUser.Email, + Role: "owner", + Status: db.ProjectInvitePending, + Token: getUUID(), + InviterUserID: testRunnerUser.ID, + Created: tz.Now(), + ExpiresAt: nil, // No expiration for this test + AcceptedAt: nil, + }) + + fmt.Println("***************************************") + fmt.Println("***************************************") + fmt.Println("***************************************") + fmt.Println(invite.ID) + fmt.Println("***************************************") + fmt.Println("***************************************") + fmt.Println("***************************************") + + if err != nil { + panic(err) + } + + return &invite +} + func addSchedule() *db.Schedule { schedule, err := store.CreateSchedule(db.Schedule{ TemplateID: int(templateID), diff --git a/.dredd/hooks/main.go b/.dredd/hooks/main.go index c38897c09..6cfe05ca4 100644 --- a/.dredd/hooks/main.go +++ b/.dredd/hooks/main.go @@ -80,6 +80,12 @@ func main() { transaction.Request.Body = "{ \"user_id\": " + strconv.Itoa(userPathTestUser.ID) + ",\"role\": \"owner\"}" }) + h.Before("project > /api/project/{project_id}/invites > Get invitations for project > 200 > application/json", capabilityWrapper("invite")) + h.Before("project > /api/project/{project_id}/invites > Create project invitation > 201 > application/json", capabilityWrapper("invite")) + h.Before("project > /api/project/{project_id}/invites/{invite_id} > Get specific project invitation > 200 > application/json", capabilityWrapper("invite")) + h.Before("project > /api/project/{project_id}/invites/{invite_id} > Update project invitation status > 204 > application/json", capabilityWrapper("invite")) + h.Before("project > /api/project/{project_id}/invites/{invite_id} > Delete project invitation > 204 > application/json", capabilityWrapper("invite")) + h.Before("integration > /api/project/{project_id}/integrations > get all integrations > 200 > application/json", capabilityWrapper("integration")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id} > Get Integration > 200 > application/json", capabilityWrapper("integration")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id} > Update Integration > 204 > application/json", capabilityWrapper("integration")) diff --git a/api-docs.yml b/api-docs.yml index b4a26c71f..39b807675 100644 --- a/api-docs.yml +++ b/api-docs.yml @@ -147,6 +147,87 @@ definitions: type: string enum: [owner, manager, task_runner, guest] + ProjectInvite: + type: object + properties: + id: + type: integer + minimum: 1 + project_id: + type: integer + minimum: 1 + user_id: + type: integer + minimum: 1 + description: User ID for user-based invites (optional) + email: + type: string + format: email + description: Email address for email-based invites (optional) + role: + type: string + enum: [owner, manager, task_runner, guest] + example: manager + status: + type: string + enum: [pending, accepted, declined, expired] + example: pending + inviter_user_id: + type: integer + minimum: 1 + description: ID of the user who created the invite + created: + type: string + format: date-time + description: When the invite was created + expires_at: + type: string + format: date-time + description: When the invite expires (optional) + accepted_at: + type: string + format: date-time + description: When the invite was accepted (optional) + inviter_user: + $ref: "#/definitions/User" + description: Details of the user who created the invite + user: + $ref: "#/definitions/User" + description: Details of the invited user (for user-based invites) + + ProjectInviteRequest: + type: object + properties: +# user_id: +# type: integer +# minimum: 1 +# description: User ID to invite (use either user_id or email, not both) + email: + type: string + format: email + description: Email address to invite (use either user_id or email, not both) + x-example: user@example.com + role: + type: string + enum: [owner, manager, task_runner, guest] + example: manager + expires_at: + type: string + format: date-time + description: When the invite should expire (optional, defaults to 7 days) + required: + - role + + AcceptInviteRequest: + type: object + properties: + token: + type: string + description: The invitation token + x-example: "a1b2c3d4e5f6..." + required: + - token + ProjectBackup: type: object example: {"meta":{"name":"homelab","alert":true,"alert_chat":"Test","max_parallel_tasks":0,"type":null},"templates":[{"inventory":"Build","repository":"Demo","environment":"Empty","name":"Build","playbook":"build.yml","arguments":"[]","allow_override_args_in_task":false,"description":"Build Job","vault_key":null,"type":"build","start_version":"1.0.0","build_template":null,"view":"Build","autorun":false,"survey_vars":[],"suppress_success_alerts":false,"cron":"* * * * *"}],"repositories":[{"name":"Demo","git_url":"https://github.com/semaphoreui/semaphore-demo.git","git_branch":"main","ssh_key":"None"}],"keys":[{"name":"None","type":"none"},{"name":"Vault Password","type":"login_password"}],"views":[{"title":"Build","position":0}],"inventories":[{"name":"Build","inventory":"","ssh_key":"None","become_key":"None","type":"static"},{"name":"Dev","inventory":"","ssh_key":"None","become_key":"None","type":"file"},{"name":"Prod","inventory":"","ssh_key":"None","become_key":"None","type":"file"}],"environments":[{"name":"Empty","password":null,"json":"{}","env":null}]} @@ -1150,6 +1231,13 @@ parameters: type: integer required: true x-example: 13 + invite_id: + name: invite_id + description: Invite ID + in: path + type: integer + required: true + x-example: 14 paths: /debug/gc: @@ -1654,6 +1742,120 @@ paths: 204: description: User updated + # Invite management + /project/{project_id}/invites: + parameters: + - $ref: "#/parameters/project_id" + get: + tags: + - project + summary: Get invitations for project + parameters: + - name: sort + in: query + required: false + type: string + enum: [created, status, role] + description: sorting field + x-example: created + - name: order + in: query + required: false + type: string + enum: [asc, desc] + description: ordering manner + x-example: desc + responses: + 200: + description: Project invitations + schema: + type: array + items: + $ref: "#/definitions/ProjectInvite" + post: + tags: + - project + summary: Create project invitation + parameters: + - name: Invite + in: body + required: true + schema: + $ref: "#/definitions/ProjectInviteRequest" + responses: + 201: + description: Invitation created + schema: + $ref: "#/definitions/ProjectInvite" + 400: + description: Bad request (invalid role, missing user_id/email, or both provided) + 409: + description: User already a member or invitation already exists + + /project/{project_id}/invites/{invite_id}: + parameters: + - $ref: "#/parameters/project_id" + - $ref: "#/parameters/invite_id" + get: + tags: + - project + summary: Get specific project invitation + responses: + 200: + description: Project invitation + schema: + $ref: "#/definitions/ProjectInvite" + 404: + description: Invitation not found +# put: +# tags: +# - project +# summary: Update project invitation status +# parameters: +# - name: Invite Update +# in: body +# required: true +# schema: +# type: object +# properties: +# status: +# type: string +# enum: [pending, declined, expired] +# example: declined +# responses: +# 204: +# description: Invitation updated +# 400: +# description: Invalid status or status transition +# delete: +# tags: +# - project +# summary: Delete project invitation +# responses: +# 204: +# description: Invitation deleted +# +# /invites/accept: +# post: +# tags: +# - project +# summary: Accept project invitation +# parameters: +# - name: Accept Invite +# in: body +# required: true +# schema: +# $ref: "#/definitions/AcceptInviteRequest" +# responses: +# 204: +# description: Invitation accepted successfully +# 400: +# description: Invalid token, invitation expired, or user already a member +# 403: +# description: Invitation not for this user +# 404: +# description: Invitation not found + /project/{project_id}/integrations: parameters: - $ref: "#/parameters/project_id" diff --git a/api/projects/invites.go b/api/projects/invites.go new file mode 100644 index 000000000..cc19b5e4e --- /dev/null +++ b/api/projects/invites.go @@ -0,0 +1,378 @@ +package projects + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "time" + + "text/template" + + "github.com/semaphoreui/semaphore/api/helpers" + emailTemplates "github.com/semaphoreui/semaphore/api/templates" + "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/util" + "github.com/semaphoreui/semaphore/util/mailer" + log "github.com/sirupsen/logrus" +) + +// InviteMiddleware ensures an invite exists and loads it to the context +func InviteMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + project := helpers.GetFromContext(r, "project").(db.Project) + inviteID, err := helpers.GetIntParam("invite_id", w, r) + if err != nil { + return + } + + invite, err := helpers.Store(r).GetProjectInvite(project.ID, inviteID) + if err != nil { + helpers.WriteError(w, err) + return + } + + r = helpers.SetContextValue(r, "projectInvite", invite) + next.ServeHTTP(w, r) + }) +} + +// GetInvites returns all invites for a project +func GetInvites(w http.ResponseWriter, r *http.Request) { + // get single invite if invite ID specified in the request + if invite := helpers.GetFromContext(r, "projectInvite"); invite != nil { + helpers.WriteJSON(w, http.StatusOK, invite.(db.ProjectInvite)) + return + } + + project := helpers.GetFromContext(r, "project").(db.Project) + invites, err := helpers.Store(r).GetProjectInvites(project.ID, helpers.QueryParams(r.URL)) + + if err != nil { + helpers.WriteError(w, err) + return + } + + helpers.WriteJSON(w, http.StatusOK, invites) +} + +// CreateInvite creates a new project invitation +func CreateInvite(w http.ResponseWriter, r *http.Request) { + project := helpers.GetFromContext(r, "project").(db.Project) + user := helpers.UserFromContext(r) + + var request struct { + UserID *int `json:"user_id,omitempty"` + Email *string `json:"email,omitempty"` + Role db.ProjectUserRole `json:"role" binding:"required"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + } + + if !helpers.Bind(w, r, &request) { + return + } + + if !request.Role.IsValid() { + w.WriteHeader(http.StatusBadRequest) + return + } + + // Validate that either user_id or email is provided, but not both + if (request.UserID == nil && request.Email == nil) || (request.UserID != nil && request.Email != nil) { + helpers.WriteErrorStatus(w, "Either user_id or email must be provided, but not both", http.StatusBadRequest) + return + } + + // If user_id is provided, check if user exists + if request.UserID != nil { + _, err := helpers.Store(r).GetUser(*request.UserID) + if err != nil { + helpers.WriteError(w, fmt.Errorf("user not found")) + return + } + + // Check if user is already a member of the project + _, err = helpers.Store(r).GetProjectUser(project.ID, *request.UserID) + if err == nil { + helpers.WriteErrorStatus(w, "User is already a member of this project", http.StatusConflict) + return + } + } + + // Generate secure token for the invite + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + helpers.WriteError(w, fmt.Errorf("failed to generate invite token")) + return + } + token := hex.EncodeToString(tokenBytes) + + // Set default expiration if not provided (7 days from now) + expiresAt := request.ExpiresAt + if expiresAt == nil { + defaultExpiry := time.Now().Add(7 * 24 * time.Hour) + expiresAt = &defaultExpiry + } + + invite := db.ProjectInvite{ + ProjectID: project.ID, + UserID: request.UserID, + Email: request.Email, + Role: request.Role, + Status: db.ProjectInvitePending, + Token: token, + InviterUserID: user.ID, + Created: time.Now(), + ExpiresAt: expiresAt, + } + + newInvite, err := helpers.Store(r).CreateProjectInvite(invite) + if err != nil { + helpers.WriteError(w, err) + return + } + + if newInvite.Email != nil { + // Render email via template + inviterName := user.Username + if user.Name != "" { + inviterName = user.Name + } + + var body bytes.Buffer + data := struct { + InviterName string + ProjectName string + Role db.ProjectUserRole + Token string + ExpiresAt string + WebURL string + AcceptURL string + }{ + InviterName: inviterName, + ProjectName: project.Name, + Role: newInvite.Role, + Token: newInvite.Token, + ExpiresAt: "", + WebURL: util.GetPublicHost(), + AcceptURL: "", + } + + if newInvite.ExpiresAt != nil { + data.ExpiresAt = newInvite.ExpiresAt.Format(time.RFC1123) + } + + // Optionally construct a direct accept URL if we decide to support one later + // data.AcceptURL = fmt.Sprintf("%s/accept?token=%s", data.WebURL, newInvite.Token) + + tpl, err := template.ParseFS(emailTemplates.FS, "invite.tmpl") + if err == nil { + _ = tpl.Execute(&body, data) + } + + if body.Len() == 0 { + // Fallback minimal body + body.WriteString(fmt.Sprintf("Invitation to join %s as %s. Token: %s", data.ProjectName, data.Role, data.Token)) + } + + subject := fmt.Sprintf("Invitation to join project %s", project.Name) + + if err := mailer.Send( + util.Config.EmailSecure, + util.Config.EmailTls, + util.Config.EmailHost, + util.Config.EmailPort, + util.Config.EmailUsername, + util.Config.EmailPassword, + util.Config.EmailSender, + *newInvite.Email, + subject, + body.String(), + ); err != nil { + log.WithError(err).WithFields(log.Fields{ + "email": *newInvite.Email, + "context": "project_invite", + }).Error("failed to send project invitation email") + } else { + log.WithFields(log.Fields{ + "email": *newInvite.Email, + "context": "project_invite", + }).Info("project invitation email sent") + } + } + + helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ + UserID: user.ID, + ProjectID: project.ID, + ObjectType: "project_invite", + ObjectID: newInvite.ID, + Description: fmt.Sprintf("Project invitation created for %s with role %s", getInviteTarget(newInvite), newInvite.Role), + }) + + helpers.WriteJSON(w, http.StatusCreated, newInvite) +} + +// AcceptInvite accepts a project invitation using token +func AcceptInvite(w http.ResponseWriter, r *http.Request) { + var request struct { + Token string `json:"token" binding:"required"` + } + + if !helpers.Bind(w, r, &request) { + return + } + + invite, err := helpers.Store(r).GetProjectInviteByToken(request.Token) + if err != nil { + helpers.WriteErrorStatus(w, "Invalid or expired invitation token", http.StatusNotFound) + return + } + + // Check if invite is still valid + if invite.Status != db.ProjectInvitePending { + helpers.WriteErrorStatus(w, "Invitation is no longer valid", http.StatusBadRequest) + return + } + + if invite.ExpiresAt != nil && time.Now().After(*invite.ExpiresAt) { + helpers.WriteErrorStatus(w, "Invitation has expired", http.StatusBadRequest) + return + } + + currentUser := helpers.UserFromContext(r) + + // If invite is for a specific user, verify it matches + if invite.UserID != nil && *invite.UserID != currentUser.ID { + helpers.WriteErrorStatus(w, "This invitation is not for your account", http.StatusForbidden) + return + } + + // If invite is by email, verify email matches + if invite.Email != nil && *invite.Email != currentUser.Email { + helpers.WriteErrorStatus(w, "This invitation is not for your email address", http.StatusForbidden) + return + } + + // Check if user is already a member of the project + _, err = helpers.Store(r).GetProjectUser(invite.ProjectID, currentUser.ID) + if err == nil { + helpers.WriteErrorStatus(w, "You are already a member of this project", http.StatusConflict) + return + } + + // Create project user + _, err = helpers.Store(r).CreateProjectUser(db.ProjectUser{ + ProjectID: invite.ProjectID, + UserID: currentUser.ID, + Role: invite.Role, + }) + + if err != nil { + helpers.WriteError(w, err) + return + } + + // Update invite status + now := time.Now() + invite.Status = db.ProjectInviteAccepted + invite.AcceptedAt = &now + invite.UserID = ¤tUser.ID // Set user ID if it was an email invite + + err = helpers.Store(r).UpdateProjectInvite(invite) + if err != nil { + helpers.WriteError(w, err) + return + } + + helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ + UserID: currentUser.ID, + ProjectID: invite.ProjectID, + ObjectType: "project_invite", + ObjectID: invite.ID, + Description: fmt.Sprintf("Project invitation accepted by %s", currentUser.Username), + }) + + var result struct { + ProjectID int `json:"project_id"` + } + + result.ProjectID = invite.ProjectID + + helpers.WriteJSON(w, http.StatusOK, result) +} + +// UpdateInvite updates an existing project invitation +func UpdateInvite(w http.ResponseWriter, r *http.Request) { + project := helpers.GetFromContext(r, "project").(db.Project) + invite := helpers.GetFromContext(r, "projectInvite").(db.ProjectInvite) + user := helpers.UserFromContext(r) + + var request struct { + Status db.ProjectInviteStatus `json:"status"` + } + + if !helpers.Bind(w, r, &request) { + return + } + + if !request.Status.IsValid() { + w.WriteHeader(http.StatusBadRequest) + return + } + + // Only allow certain status transitions + if invite.Status != db.ProjectInvitePending && request.Status != db.ProjectInviteExpired { + helpers.WriteErrorStatus(w, "Cannot modify non-pending invitations", http.StatusBadRequest) + return + } + + invite.Status = request.Status + + err := helpers.Store(r).UpdateProjectInvite(invite) + if err != nil { + helpers.WriteError(w, err) + return + } + + helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ + UserID: user.ID, + ProjectID: project.ID, + ObjectType: "project_invite", + ObjectID: invite.ID, + Description: fmt.Sprintf("Project invitation status changed to %s", request.Status), + }) + + w.WriteHeader(http.StatusNoContent) +} + +// DeleteInvite removes a project invitation +func DeleteInvite(w http.ResponseWriter, r *http.Request) { + project := helpers.GetFromContext(r, "project").(db.Project) + invite := helpers.GetFromContext(r, "projectInvite").(db.ProjectInvite) + user := helpers.UserFromContext(r) + + err := helpers.Store(r).DeleteProjectInvite(project.ID, invite.ID) + if err != nil { + helpers.WriteError(w, err) + return + } + + helpers.EventLog(r, helpers.EventLogDelete, helpers.EventLogItem{ + UserID: user.ID, + ProjectID: project.ID, + ObjectType: "project_invite", + ObjectID: invite.ID, + Description: fmt.Sprintf("Project invitation deleted for %s", getInviteTarget(invite)), + }) + + w.WriteHeader(http.StatusNoContent) +} + +// Helper function to get invite target (user or email) +func getInviteTarget(invite db.ProjectInvite) string { + if invite.Email != nil { + return *invite.Email + } + return fmt.Sprintf("User ID %d", *invite.UserID) +} diff --git a/api/projects/invites_test.go b/api/projects/invites_test.go new file mode 100644 index 000000000..28794937b --- /dev/null +++ b/api/projects/invites_test.go @@ -0,0 +1,392 @@ +package projects + +import ( + "fmt" + "testing" + "time" + + "github.com/semaphoreui/semaphore/db" +) + +// Mock store for testing +type mockInviteStore struct { + projects map[int]db.Project + users map[int]db.User + projectUsers map[string]db.ProjectUser + invites map[int]db.ProjectInvite + invitesByToken map[string]db.ProjectInvite + nextInviteID int +} + +func newMockInviteStore() *mockInviteStore { + return &mockInviteStore{ + projects: make(map[int]db.Project), + users: make(map[int]db.User), + projectUsers: make(map[string]db.ProjectUser), + invites: make(map[int]db.ProjectInvite), + invitesByToken: make(map[string]db.ProjectInvite), + nextInviteID: 1, + } +} + +func (m *mockInviteStore) GetProject(projectID int) (db.Project, error) { + if project, exists := m.projects[projectID]; exists { + return project, nil + } + return db.Project{}, db.ErrNotFound +} + +func (m *mockInviteStore) GetUser(userID int) (db.User, error) { + if user, exists := m.users[userID]; exists { + return user, nil + } + return db.User{}, db.ErrNotFound +} + +func (m *mockInviteStore) GetProjectUser(projectID, userID int) (db.ProjectUser, error) { + key := fmt.Sprintf("%d-%d", projectID, userID) + if projectUser, exists := m.projectUsers[key]; exists { + return projectUser, nil + } + return db.ProjectUser{}, db.ErrNotFound +} + +func (m *mockInviteStore) CreateProjectUser(projectUser db.ProjectUser) (db.ProjectUser, error) { + key := fmt.Sprintf("%d-%d", projectUser.ProjectID, projectUser.UserID) + m.projectUsers[key] = projectUser + return projectUser, nil +} + +func (m *mockInviteStore) GetProjectInvites(projectID int, params db.RetrieveQueryParams) ([]db.ProjectInviteWithUser, error) { + var invites []db.ProjectInviteWithUser + for _, invite := range m.invites { + if invite.ProjectID == projectID { + inviteWithUser := db.ProjectInviteWithUser{ + ProjectInvite: invite, + } + if invitedByUser, exists := m.users[invite.InviterUserID]; exists { + inviteWithUser.InvitedByUser = &invitedByUser + } + if invite.UserID != nil { + if user, exists := m.users[*invite.UserID]; exists { + inviteWithUser.User = &user + } + } + invites = append(invites, inviteWithUser) + } + } + return invites, nil +} + +func (m *mockInviteStore) CreateProjectInvite(invite db.ProjectInvite) (db.ProjectInvite, error) { + invite.ID = m.nextInviteID + m.nextInviteID++ + m.invites[invite.ID] = invite + m.invitesByToken[invite.Token] = invite + return invite, nil +} + +func (m *mockInviteStore) GetProjectInvite(projectID, inviteID int) (db.ProjectInvite, error) { + if invite, exists := m.invites[inviteID]; exists && invite.ProjectID == projectID { + return invite, nil + } + return db.ProjectInvite{}, db.ErrNotFound +} + +func (m *mockInviteStore) GetProjectInviteByToken(token string) (db.ProjectInvite, error) { + if invite, exists := m.invitesByToken[token]; exists { + return invite, nil + } + return db.ProjectInvite{}, db.ErrNotFound +} + +func (m *mockInviteStore) UpdateProjectInvite(invite db.ProjectInvite) error { + if _, exists := m.invites[invite.ID]; exists { + m.invites[invite.ID] = invite + m.invitesByToken[invite.Token] = invite + return nil + } + return db.ErrNotFound +} + +func (m *mockInviteStore) DeleteProjectInvite(projectID, inviteID int) error { + if invite, exists := m.invites[inviteID]; exists && invite.ProjectID == projectID { + delete(m.invites, inviteID) + delete(m.invitesByToken, invite.Token) + return nil + } + return db.ErrNotFound +} + +// Test database repository functionality + +func TestMockStore_GetProjectInvites(t *testing.T) { + store := newMockInviteStore() + + // Add test invites + invite1 := db.ProjectInvite{ + ID: 1, + ProjectID: 1, + Email: stringPtr("user1@example.com"), + Role: db.ProjectManager, + Status: db.ProjectInvitePending, + Token: "token1", + InviterUserID: 1, + Created: time.Now(), + } + store.CreateProjectInvite(invite1) + + invite2 := db.ProjectInvite{ + ID: 2, + ProjectID: 1, + UserID: intPtr(2), + Role: db.ProjectTaskRunner, + Status: db.ProjectInvitePending, + Token: "token2", + InviterUserID: 1, + Created: time.Now(), + } + store.users[2] = db.User{ID: 2, Username: "user2", Email: "user2@example.com"} + store.CreateProjectInvite(invite2) + + // Test getting invites + invites, err := store.GetProjectInvites(1, db.RetrieveQueryParams{}) + if err != nil { + t.Errorf("Failed to get invites: %v", err) + } + + if len(invites) != 2 { + t.Errorf("Expected 2 invites, got %d", len(invites)) + } + + // Find email-based invite + var emailInvite *db.ProjectInviteWithUser + var userInvite *db.ProjectInviteWithUser + + for i := range invites { + if invites[i].Email != nil { + emailInvite = &invites[i] + } + if invites[i].UserID != nil { + userInvite = &invites[i] + } + } + + // Verify email invite + if emailInvite == nil { + t.Error("Expected to find email-based invite") + } else if *emailInvite.Email != "user1@example.com" { + t.Errorf("Expected email invite 'user1@example.com', got %v", *emailInvite.Email) + } + + // Verify user invite + if userInvite == nil { + t.Error("Expected to find user-based invite") + } else if *userInvite.UserID != 2 { + t.Errorf("Expected user invite user_id 2, got %v", *userInvite.UserID) + } +} + +func TestMockStore_CreateProjectInvite(t *testing.T) { + store := newMockInviteStore() + + invite := db.ProjectInvite{ + ProjectID: 1, + Email: stringPtr("newuser@example.com"), + Role: db.ProjectManager, + Status: db.ProjectInvitePending, + Token: "test-token", + InviterUserID: 1, + Created: time.Now(), + } + + createdInvite, err := store.CreateProjectInvite(invite) + if err != nil { + t.Errorf("Failed to create invite: %v", err) + } + + if createdInvite.ID == 0 { + t.Error("Expected invite ID to be set") + } + + if createdInvite.Email == nil || *createdInvite.Email != "newuser@example.com" { + t.Errorf("Expected email 'newuser@example.com', got %v", createdInvite.Email) + } + + if createdInvite.Role != db.ProjectManager { + t.Errorf("Expected role 'manager', got %s", createdInvite.Role) + } + + if createdInvite.Status != db.ProjectInvitePending { + t.Errorf("Expected status 'pending', got %s", createdInvite.Status) + } + + // Verify it can be retrieved by token + retrievedInvite, err := store.GetProjectInviteByToken("test-token") + if err != nil { + t.Errorf("Failed to get invite by token: %v", err) + } + + if retrievedInvite.ID != createdInvite.ID { + t.Errorf("Expected invite ID %d, got %d", createdInvite.ID, retrievedInvite.ID) + } +} + +func TestMockStore_UpdateProjectInvite(t *testing.T) { + store := newMockInviteStore() + + // Create test invite + invite := db.ProjectInvite{ + ID: 1, + ProjectID: 1, + Email: stringPtr("test@example.com"), + Role: db.ProjectManager, + Status: db.ProjectInvitePending, + Token: "test-token", + InviterUserID: 1, + Created: time.Now(), + } + store.CreateProjectInvite(invite) + + // Update invite status + invite.Status = db.ProjectInviteAccepted + now := time.Now() + invite.AcceptedAt = &now + + err := store.UpdateProjectInvite(invite) + if err != nil { + t.Errorf("Failed to update invite: %v", err) + } + + // Verify update + updatedInvite := store.invites[1] + if updatedInvite.Status != db.ProjectInviteAccepted { + t.Errorf("Expected status 'accepted', got %s", updatedInvite.Status) + } + + if updatedInvite.AcceptedAt == nil { + t.Error("Expected AcceptedAt to be set") + } +} + +func TestMockStore_DeleteProjectInvite(t *testing.T) { + store := newMockInviteStore() + + // Create test invite + invite := db.ProjectInvite{ + ID: 1, + ProjectID: 1, + Email: stringPtr("test@example.com"), + Role: db.ProjectManager, + Status: db.ProjectInvitePending, + Token: "test-token", + InviterUserID: 1, + Created: time.Now(), + } + store.CreateProjectInvite(invite) + + // Verify invite exists + if _, exists := store.invites[1]; !exists { + t.Error("Invite should exist before deletion") + } + + // Delete invite + err := store.DeleteProjectInvite(1, 1) + if err != nil { + t.Errorf("Failed to delete invite: %v", err) + } + + // Verify invite was deleted + if _, exists := store.invites[1]; exists { + t.Error("Invite should not exist after deletion") + } + + // Verify token was also removed + if _, exists := store.invitesByToken["test-token"]; exists { + t.Error("Invite token should not exist after deletion") + } +} + +func TestMockStore_GetProjectUser(t *testing.T) { + store := newMockInviteStore() + + // Add project user + projectUser := db.ProjectUser{ + ProjectID: 1, + UserID: 2, + Role: db.ProjectManager, + } + store.CreateProjectUser(projectUser) + + // Test retrieval + retrievedUser, err := store.GetProjectUser(1, 2) + if err != nil { + t.Errorf("Failed to get project user: %v", err) + } + + if retrievedUser.Role != db.ProjectManager { + t.Errorf("Expected role 'manager', got %s", retrievedUser.Role) + } + + // Test non-existent user + _, err = store.GetProjectUser(1, 999) + if err != db.ErrNotFound { + t.Error("Expected ErrNotFound for non-existent project user") + } +} + +// Helper functions +func stringPtr(s string) *string { + return &s +} + +func intPtr(i int) *int { + return &i +} + +func timePtr(t time.Time) *time.Time { + return &t +} + +// Test ProjectInvite model validation +func TestProjectInviteStatus_IsValid(t *testing.T) { + validStatuses := []db.ProjectInviteStatus{ + db.ProjectInvitePending, + db.ProjectInviteAccepted, + db.ProjectInviteDeclined, + db.ProjectInviteExpired, + } + + for _, status := range validStatuses { + if !status.IsValid() { + t.Errorf("Status %s should be valid", status) + } + } + + invalidStatus := db.ProjectInviteStatus("invalid") + if invalidStatus.IsValid() { + t.Error("Invalid status should not be valid") + } +} + +// Test helper function +func TestGetInviteTarget(t *testing.T) { + // Test email-based invite + emailInvite := db.ProjectInvite{ + Email: stringPtr("test@example.com"), + } + target := getInviteTarget(emailInvite) + if target != "test@example.com" { + t.Errorf("Expected 'test@example.com', got %s", target) + } + + // Test user-based invite + userInvite := db.ProjectInvite{ + UserID: intPtr(42), + } + target = getInviteTarget(userInvite) + expected := "User ID 42" + if target != expected { + t.Errorf("Expected '%s', got %s", expected, target) + } +} diff --git a/api/router.go b/api/router.go index 5d8ead844..73fb18ad5 100644 --- a/api/router.go +++ b/api/router.go @@ -362,6 +362,22 @@ func Route( projectUserManagement.HandleFunc("/{user_id}", projects.UpdateUser).Methods("PUT") projectUserManagement.HandleFunc("/{user_id}", projects.RemoveUser).Methods("DELETE") + // + // Manage project invites + projectInvitesAPI := authenticatedAPI.PathPrefix("/project/{project_id}").Subrouter() + projectInvitesAPI.Use(projects.ProjectMiddleware, projects.GetMustCanMiddleware(db.CanManageProjectUsers)) + projectInvitesAPI.Path("/invites").HandlerFunc(projects.GetInvites).Methods("GET", "HEAD") + projectInvitesAPI.Path("/invites").HandlerFunc(projects.CreateInvite).Methods("POST") + + projectInviteManagement := projectInvitesAPI.PathPrefix("/invites").Subrouter() + projectInviteManagement.Use(projects.InviteMiddleware) + projectInviteManagement.HandleFunc("/{invite_id}", projects.GetInvites).Methods("GET", "HEAD") + projectInviteManagement.HandleFunc("/{invite_id}", projects.UpdateInvite).Methods("PUT") + projectInviteManagement.HandleFunc("/{invite_id}", projects.DeleteInvite).Methods("DELETE") + + // Accept invite endpoint (doesn't require project context) + authenticatedAPI.Path("/invites/accept").HandlerFunc(projects.AcceptInvite).Methods("POST") + // // Project resources CRUD (continue) projectKeyManagement := projectUserAPI.PathPrefix("/keys").Subrouter() @@ -640,6 +656,7 @@ func getSystemInfo(w http.ResponseWriter, r *http.Request) { "premium_features": proFeatures.GetFeatures(user), "git_client": util.Config.GitClientId, "schedule_timezone": timezone, + "teams": util.Config.Teams, } helpers.WriteJSON(w, http.StatusOK, body) diff --git a/api/templates/embed.go b/api/templates/embed.go new file mode 100644 index 000000000..ce116288f --- /dev/null +++ b/api/templates/embed.go @@ -0,0 +1,6 @@ +package templates + +import "embed" + +//go:embed *.tmpl +var FS embed.FS diff --git a/api/templates/invite.tmpl b/api/templates/invite.tmpl new file mode 100644 index 000000000..f679577e0 --- /dev/null +++ b/api/templates/invite.tmpl @@ -0,0 +1,7 @@ +

Hello,

+

{{ .InviterName }} invited you to join project {{ .ProjectName }} as {{ .Role }}.

+{{ if .ExpiresAt }}

This invitation expires on {{ .ExpiresAt }}.

{{ end }} +

Invitation token: {{ .Token }}

+{{ if .AcceptURL }}

You can also accept directly by visiting: Accept Invitation

{{ end }} +

Please log in to Semaphore at {{ .WebURL }} and use the token above to accept the invitation.

+ diff --git a/db/Migration.go b/db/Migration.go index e79f698de..f8ad07f63 100644 --- a/db/Migration.go +++ b/db/Migration.go @@ -32,6 +32,7 @@ func GetMigrations(dialect string) []Migration { {Version: "2.16.0"}, {Version: "2.16.1"}, {Version: "2.16.2"}, + {Version: "2.16.3"}, } } @@ -113,6 +114,7 @@ func GetMigrations(dialect string) []Migration { {Version: "2.16.0"}, {Version: "2.16.1"}, {Version: "2.16.2"}, + {Version: "2.16.3"}, } } diff --git a/db/ProjectInvite.go b/db/ProjectInvite.go new file mode 100644 index 000000000..ea9a5be4b --- /dev/null +++ b/db/ProjectInvite.go @@ -0,0 +1,43 @@ +package db + +import ( + "time" +) + +type ProjectInviteStatus string + +const ( + ProjectInvitePending ProjectInviteStatus = "pending" + ProjectInviteAccepted ProjectInviteStatus = "accepted" + ProjectInviteDeclined ProjectInviteStatus = "declined" + ProjectInviteExpired ProjectInviteStatus = "expired" +) + +func (s ProjectInviteStatus) IsValid() bool { + switch s { + case ProjectInvitePending, ProjectInviteAccepted, ProjectInviteDeclined, ProjectInviteExpired: + return true + default: + return false + } +} + +type ProjectInvite struct { + ID int `db:"id" json:"id" backup:"-"` + ProjectID int `db:"project_id" json:"project_id"` + UserID *int `db:"user_id" json:"user_id,omitempty"` // Can be null for email invites + Email *string `db:"email" json:"email,omitempty"` // For email-based invites + Role ProjectUserRole `db:"role" json:"role"` + Status ProjectInviteStatus `db:"status" json:"status"` + Token string `db:"token" json:"-"` // Secret token for accepting invite + InviterUserID int `db:"inviter_user_id" json:"inviter_user_id"` // User who created the invite + Created time.Time `db:"created" json:"created" backup:"-"` + ExpiresAt *time.Time `db:"expires_at" json:"expires_at,omitempty"` + AcceptedAt *time.Time `db:"accepted_at" json:"accepted_at,omitempty"` +} + +type ProjectInviteWithUser struct { + ProjectInvite + InvitedByUser *User `json:"inviter_user,omitempty"` + User *User `json:"user,omitempty"` +} diff --git a/db/ProjectInvite_test.go b/db/ProjectInvite_test.go new file mode 100644 index 000000000..d119cf46d --- /dev/null +++ b/db/ProjectInvite_test.go @@ -0,0 +1,171 @@ +package db + +import ( + "testing" + "time" +) + +func TestProjectInviteStatus_IsValid(t *testing.T) { + tests := []struct { + status ProjectInviteStatus + valid bool + }{ + {ProjectInvitePending, true}, + {ProjectInviteAccepted, true}, + {ProjectInviteDeclined, true}, + {ProjectInviteExpired, true}, + {ProjectInviteStatus("invalid"), false}, + {ProjectInviteStatus(""), false}, + } + + for _, test := range tests { + if test.status.IsValid() != test.valid { + t.Errorf("Status %q: expected valid=%v, got %v", test.status, test.valid, test.status.IsValid()) + } + } +} + +func TestProjectInvite_EmailBasedInvite(t *testing.T) { + email := "test@example.com" + invite := ProjectInvite{ + ID: 1, + ProjectID: 1, + Email: &email, + Role: ProjectManager, + Status: ProjectInvitePending, + Token: "test-token", + InviterUserID: 1, + Created: time.Now(), + } + + if invite.UserID != nil { + t.Error("Email-based invite should not have UserID") + } + + if invite.Email == nil || *invite.Email != "test@example.com" { + t.Errorf("Expected email 'test@example.com', got %v", invite.Email) + } + + if invite.Status != ProjectInvitePending { + t.Errorf("Expected status 'pending', got %s", invite.Status) + } +} + +func TestProjectInvite_UserBasedInvite(t *testing.T) { + userID := 42 + invite := ProjectInvite{ + ID: 1, + ProjectID: 1, + UserID: &userID, + Role: ProjectTaskRunner, + Status: ProjectInvitePending, + Token: "test-token", + InviterUserID: 1, + Created: time.Now(), + } + + if invite.Email != nil { + t.Error("User-based invite should not have Email") + } + + if invite.UserID == nil || *invite.UserID != 42 { + t.Errorf("Expected user_id 42, got %v", invite.UserID) + } + + if invite.Role != ProjectTaskRunner { + t.Errorf("Expected role 'task_runner', got %s", invite.Role) + } +} + +func TestProjectInvite_WithExpiration(t *testing.T) { + expiresAt := time.Now().Add(7 * 24 * time.Hour) + email := "test@example.com" + + invite := ProjectInvite{ + ID: 1, + ProjectID: 1, + Email: &email, + Role: ProjectManager, + Status: ProjectInvitePending, + Token: "test-token", + InviterUserID: 1, + Created: time.Now(), + ExpiresAt: &expiresAt, + } + + if invite.ExpiresAt == nil { + t.Error("Expected ExpiresAt to be set") + } + + if invite.AcceptedAt != nil { + t.Error("AcceptedAt should be nil for pending invite") + } +} + +func TestProjectInvite_AcceptedInvite(t *testing.T) { + acceptedAt := time.Now() + email := "test@example.com" + + invite := ProjectInvite{ + ID: 1, + ProjectID: 1, + Email: &email, + Role: ProjectManager, + Status: ProjectInviteAccepted, + Token: "test-token", + InviterUserID: 1, + Created: time.Now().Add(-1 * time.Hour), + AcceptedAt: &acceptedAt, + } + + if invite.Status != ProjectInviteAccepted { + t.Errorf("Expected status 'accepted', got %s", invite.Status) + } + + if invite.AcceptedAt == nil { + t.Error("AcceptedAt should be set for accepted invite") + } +} + +func TestProjectInviteWithUser_Structure(t *testing.T) { + email := "test@example.com" + invite := ProjectInvite{ + ID: 1, + ProjectID: 1, + Email: &email, + Role: ProjectManager, + Status: ProjectInvitePending, + Token: "test-token", + InviterUserID: 1, + Created: time.Now(), + } + + invitedByUser := User{ + ID: 1, + Username: "admin", + Email: "admin@example.com", + Name: "Administrator", + } + + inviteWithUser := ProjectInviteWithUser{ + ProjectInvite: invite, + InvitedByUser: &invitedByUser, + User: nil, // Email-based invite + } + + if inviteWithUser.ProjectInvite.ID != invite.ID { + t.Error("ProjectInvite should be embedded correctly") + } + + if inviteWithUser.InvitedByUser == nil { + t.Error("InvitedByUser should be set") + } + + if inviteWithUser.InvitedByUser.Username != "admin" { + t.Errorf("Expected inviter username 'admin', got %s", inviteWithUser.InvitedByUser.Username) + } + + if inviteWithUser.User != nil { + t.Error("User should be nil for email-based invite") + } +} diff --git a/db/Store.go b/db/Store.go index ab0b6182f..07ca78970 100644 --- a/db/Store.go +++ b/db/Store.go @@ -252,6 +252,16 @@ type ProjectStore interface { UpdateProjectUser(projectUser ProjectUser) error } +type ProjectInviteRepository interface { + // Project invites + GetProjectInvites(projectID int, params RetrieveQueryParams) ([]ProjectInviteWithUser, error) + CreateProjectInvite(invite ProjectInvite) (ProjectInvite, error) + GetProjectInvite(projectID int, inviteID int) (ProjectInvite, error) + GetProjectInviteByToken(token string) (ProjectInvite, error) + UpdateProjectInvite(invite ProjectInvite) error + DeleteProjectInvite(projectID int, inviteID int) error +} + // TemplateManager handles template-related operations type TemplateManager interface { GetTemplates(projectID int, filter TemplateFilter, params RetrieveQueryParams) ([]Template, error) @@ -451,6 +461,7 @@ type Store interface { OptionsManager UserManager ProjectStore + ProjectInviteRepository TemplateManager InventoryManager RepositoryManager @@ -556,6 +567,15 @@ var ProjectUserProps = ObjectProps{ PrimaryColumnName: "user_id", } +var ProjectInviteProps = ObjectProps{ + TableName: "project__invite", + Type: reflect.TypeOf(ProjectInvite{}), + PrimaryColumnName: "id", + ReferringColumnSuffix: "invite_id", + SortableColumns: []string{"created", "status", "role"}, + DefaultSortingColumn: "created", +} + var ProjectProps = ObjectProps{ TableName: "project", Type: reflect.TypeOf(Project{}), diff --git a/db/bolt/project_invite.go b/db/bolt/project_invite.go new file mode 100644 index 000000000..717515e7a --- /dev/null +++ b/db/bolt/project_invite.go @@ -0,0 +1,87 @@ +package bolt + +import ( + "github.com/semaphoreui/semaphore/db" +) + +func (d *BoltDb) GetProjectInvites(projectID int, params db.RetrieveQueryParams) (invites []db.ProjectInviteWithUser, err error) { + var projectInvites []db.ProjectInvite + err = d.getObjects(projectID, db.ProjectInviteProps, params, nil, &projectInvites) + if err != nil { + return + } + + for _, invite := range projectInvites { + var inviteWithUser = db.ProjectInviteWithUser{ + ProjectInvite: invite, + } + + // Get invited by user info + invitedByUser, err := d.GetUser(invite.InviterUserID) + if err == nil { + inviteWithUser.InvitedByUser = &invitedByUser + } + + // Get user info if user exists + if invite.UserID != nil { + user, err := d.GetUser(*invite.UserID) + if err == nil { + inviteWithUser.User = &user + } + } + + invites = append(invites, inviteWithUser) + } + + return +} + +func (d *BoltDb) CreateProjectInvite(invite db.ProjectInvite) (db.ProjectInvite, error) { + newInvite, err := d.createObject(invite.ProjectID, db.ProjectInviteProps, invite) + if err != nil { + return db.ProjectInvite{}, err + } + return newInvite.(db.ProjectInvite), nil +} + +func (d *BoltDb) GetProjectInvite(projectID int, inviteID int) (invite db.ProjectInvite, err error) { + err = d.getObject(projectID, db.ProjectInviteProps, intObjectID(inviteID), &invite) + return +} + +func (d *BoltDb) GetProjectInviteByToken(token string) (invite db.ProjectInvite, err error) { + var allInvites []db.ProjectInvite + + // Get all projects to search across all invites + projects, err := d.GetAllProjects() + if err != nil { + return + } + + for _, project := range projects { + var projectInvites []db.ProjectInvite + err = d.getObjects(project.ID, db.ProjectInviteProps, db.RetrieveQueryParams{}, nil, &projectInvites) + if err != nil { + continue + } + allInvites = append(allInvites, projectInvites...) + } + + for _, inv := range allInvites { + if inv.Token == token { + invite = inv + return + } + } + + err = db.ErrNotFound + return +} + +func (d *BoltDb) UpdateProjectInvite(invite db.ProjectInvite) error { + return d.updateObject(invite.ProjectID, db.ProjectInviteProps, invite) +} + +func (d *BoltDb) DeleteProjectInvite(projectID int, inviteID int) error { + return d.deleteObject(projectID, db.ProjectInviteProps, intObjectID(inviteID), nil) +} diff --git a/db/sql/migrations/v2.16.3.sql b/db/sql/migrations/v2.16.3.sql new file mode 100644 index 000000000..007ee81a4 --- /dev/null +++ b/db/sql/migrations/v2.16.3.sql @@ -0,0 +1,22 @@ +create table project__invite +( + `id` integer primary key autoincrement, + `project_id` int not null, + `user_id` int null, + `email` varchar(255) null, + `role` varchar(50) not null, + `status` varchar(50) not null default 'pending', + `token` varchar(255) not null, + `inviter_user_id` int not null, + `created` datetime not null, + `expires_at` datetime null, + `accepted_at` datetime null, + + foreign key (`project_id`) references project (`id`) on delete cascade, + foreign key (`user_id`) references `user` (`id`) on delete cascade, + foreign key (`inviter_user_id`) references `user` (`id`) on delete cascade, + + unique (`token`), + unique (`project_id`, `user_id`), + unique (`project_id`, `email`) +); \ No newline at end of file diff --git a/db/sql/project_invite.go b/db/sql/project_invite.go new file mode 100644 index 000000000..07f3aeab0 --- /dev/null +++ b/db/sql/project_invite.go @@ -0,0 +1,158 @@ +package sql + +import ( + "database/sql" + + "github.com/Masterminds/squirrel" + "github.com/semaphoreui/semaphore/db" +) + +func (d *SqlDb) GetProjectInvites(projectID int, params db.RetrieveQueryParams) (invites []db.ProjectInviteWithUser, err error) { + pp, err := params.Validate(db.ProjectInviteProps) + if err != nil { + return + } + + invites = make([]db.ProjectInviteWithUser, 0) + + q := squirrel.Select("pi.*"). + Column("ib.name as inviter_user_id_name"). + Column("ib.username as inviter_username"). + Column("ib.email as inviter_user_id_email"). + Column("u.name as user_name"). + Column("u.username as user_username"). + Column("u.email as user_email"). + From("project__invite as pi"). + LeftJoin("`user` as ib on pi.inviter_user_id=ib.id"). + LeftJoin("`user` as u on pi.user_id=u.id"). + Where("pi.project_id=?", projectID) + + sortDirection := "ASC" + if pp.SortInverted { + sortDirection = "DESC" + } + + switch pp.SortBy { + case "created", "status", "role": + q = q.OrderBy("pi." + pp.SortBy + " " + sortDirection) + default: + q = q.OrderBy("pi.created " + sortDirection) + } + + query, args, err := q.ToSql() + if err != nil { + return + } + + rows, err := d.Sql().Query(d.PrepareQuery(query), args...) + if err != nil { + return + } + defer rows.Close() + + for rows.Next() { + var invite db.ProjectInviteWithUser + var invitedByName, invitedByUsername, invitedByEmail sql.NullString + var userName, userUsername, userEmail sql.NullString + + err = rows.Scan( + &invite.ID, + &invite.ProjectID, + &invite.UserID, + &invite.Email, + &invite.Role, + &invite.Status, + &invite.Token, + &invite.InviterUserID, + &invite.Created, + &invite.ExpiresAt, + &invite.AcceptedAt, + &invitedByName, + &invitedByUsername, + &invitedByEmail, + &userName, + &userUsername, + &userEmail, + ) + if err != nil { + return + } + + // Set invited by user info + invite.InvitedByUser = &db.User{ + ID: invite.InviterUserID, + Name: invitedByName.String, + Username: invitedByUsername.String, + Email: invitedByEmail.String, + } + + // Set user info if user exists + if invite.UserID != nil { + invite.User = &db.User{ + ID: *invite.UserID, + Name: userName.String, + Username: userUsername.String, + Email: userEmail.String, + } + } + + invites = append(invites, invite) + } + + return +} + +func (d *SqlDb) CreateProjectInvite(invite db.ProjectInvite) (newInvite db.ProjectInvite, err error) { + insertID, err := d.insert( + "id", + "insert into project__invite (project_id, user_id, email, role, status, token, inviter_user_id, created, expires_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", + invite.ProjectID, + invite.UserID, + invite.Email, + invite.Role, + invite.Status, + invite.Token, + invite.InviterUserID, + invite.Created, + invite.ExpiresAt) + + if err != nil { + return + } + + newInvite = invite + newInvite.ID = insertID + return +} + +func (d *SqlDb) GetProjectInvite(projectID int, inviteID int) (invite db.ProjectInvite, err error) { + err = d.selectOne(&invite, + "select * from project__invite where project_id=? and id=?", + projectID, + inviteID) + return +} + +func (d *SqlDb) GetProjectInviteByToken(token string) (invite db.ProjectInvite, err error) { + err = d.selectOne(&invite, + "select * from project__invite where token=?", + token) + return +} + +func (d *SqlDb) UpdateProjectInvite(invite db.ProjectInvite) error { + _, err := d.exec( + "update project__invite set status=?, accepted_at=? where id=?", + invite.Status, + invite.AcceptedAt, + invite.ID) + return err +} + +func (d *SqlDb) DeleteProjectInvite(projectID int, inviteID int) error { + _, err := d.exec( + "delete from project__invite where project_id=? and id=?", + projectID, + inviteID) + return err +} diff --git a/openapi.yml b/openapi.yml index 7b610672a..b39a3d135 100644 --- a/openapi.yml +++ b/openapi.yml @@ -642,6 +642,156 @@ paths: parameters: - "$ref": "#/components/parameters/project_id" - "$ref": "#/components/parameters/user_id" + "/project/{project_id}/invites": + get: + tags: + - project + summary: Get invitations for project + parameters: + - name: sort + in: query + required: false + schema: + type: string + enum: + - created + - status + - role + description: sorting field + example: created + - name: order + in: query + required: false + schema: + type: string + enum: + - asc + - desc + description: ordering manner + example: desc + responses: + '200': + description: Project invitations + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/ProjectInvite" + text/plain; charset=utf-8: + schema: + type: array + items: + "$ref": "#/components/schemas/ProjectInvite" + post: + tags: + - project + summary: Create project invitation + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/ProjectInviteRequest" + required: true + responses: + '201': + description: Invitation created + content: + application/json: + schema: + "$ref": "#/components/schemas/ProjectInvite" + text/plain; charset=utf-8: + schema: + "$ref": "#/components/schemas/ProjectInvite" + '400': + description: Bad request (invalid role, missing user_id/email, or both provided) + content: {} + '409': + description: User already a member or invitation already exists + content: {} + x-codegen-request-body-name: Invite + parameters: + - "$ref": "#/components/parameters/project_id" + "/project/{project_id}/invites/{invite_id}": + get: + tags: + - project + summary: Get specific project invitation + responses: + '200': + description: Project invitation + content: + application/json: + schema: + "$ref": "#/components/schemas/ProjectInvite" + text/plain; charset=utf-8: + schema: + "$ref": "#/components/schemas/ProjectInvite" + '404': + description: Invitation not found + content: {} + put: + tags: + - project + summary: Update project invitation status + requestBody: + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: + - pending + - declined + - expired + example: declined + required: true + responses: + '204': + description: Invitation updated + content: {} + '400': + description: Invalid status or status transition + content: {} + x-codegen-request-body-name: Invite Update + delete: + tags: + - project + summary: Delete project invitation + responses: + '204': + description: Invitation deleted + content: {} + parameters: + - "$ref": "#/components/parameters/project_id" + - "$ref": "#/components/parameters/invite_id" + "/invites/accept": + post: + tags: + - project + summary: Accept project invitation + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/AcceptInviteRequest" + required: true + responses: + '204': + description: Invitation accepted successfully + content: {} + '400': + description: Invalid token, invitation expired, or user already a member + content: {} + '403': + description: Invitation not for this user + content: {} + '404': + description: Invitation not found + content: {} + x-codegen-request-body-name: Accept Invite "/project/{project_id}/integrations": get: tags: @@ -1810,6 +1960,96 @@ components: - manager - task_runner - guest + ProjectInvite: + type: object + properties: + id: + minimum: 1 + type: integer + project_id: + minimum: 1 + type: integer + user_id: + minimum: 1 + type: integer + description: User ID for user-based invites (optional) + email: + type: string + format: email + description: Email address for email-based invites (optional) + role: + type: string + enum: + - owner + - manager + - task_runner + - guest + example: manager + status: + type: string + enum: + - pending + - accepted + - declined + - expired + example: pending + inviter_user_id: + minimum: 1 + type: integer + description: ID of the user who created the invite + created: + type: string + format: date-time + description: When the invite was created + expires_at: + type: string + format: date-time + description: When the invite expires (optional) + accepted_at: + type: string + format: date-time + description: When the invite was accepted (optional) + inviter_user: + "$ref": "#/components/schemas/User" + description: Details of the user who created the invite + user: + "$ref": "#/components/schemas/User" + description: Details of the invited user (for user-based invites) + ProjectInviteRequest: + type: object + properties: + user_id: + minimum: 1 + type: integer + description: User ID to invite (use either user_id or email, not both) + email: + type: string + format: email + description: Email address to invite (use either user_id or email, not both) + example: user@example.com + role: + type: string + enum: + - owner + - manager + - task_runner + - guest + example: manager + expires_at: + type: string + format: date-time + description: When the invite should expire (optional, defaults to 7 days) + required: + - role + AcceptInviteRequest: + type: object + properties: + token: + type: string + description: The invitation token + example: "a1b2c3d4e5f6..." + required: + - token ProjectBackup: type: object properties: @@ -2679,6 +2919,14 @@ components: schema: type: integer example: 2 + invite_id: + name: invite_id + in: path + description: Invite ID + required: true + schema: + type: integer + example: 3 key_id: name: key_id in: path @@ -2686,7 +2934,7 @@ components: required: true schema: type: integer - example: 3 + example: 4 repository_id: name: repository_id in: path @@ -2694,7 +2942,7 @@ components: required: true schema: type: integer - example: 4 + example: 5 inventory_id: name: inventory_id in: path @@ -2702,7 +2950,7 @@ components: required: true schema: type: integer - example: 5 + example: 6 environment_id: name: environment_id in: path @@ -2710,7 +2958,7 @@ components: required: true schema: type: integer - example: 6 + example: 7 template_id: name: template_id in: path @@ -2718,7 +2966,7 @@ components: required: true schema: type: integer - example: 7 + example: 8 task_id: name: task_id in: path @@ -2726,7 +2974,7 @@ components: required: true schema: type: integer - example: 8 + example: 9 schedule_id: name: schedule_id in: path @@ -2734,7 +2982,7 @@ components: required: true schema: type: integer - example: 9 + example: 10 view_id: name: view_id in: path @@ -2742,7 +2990,7 @@ components: required: true schema: type: integer - example: 10 + example: 11 integration_id: name: integration_id in: path @@ -2750,7 +2998,7 @@ components: required: true schema: type: integer - example: 11 + example: 12 extractvalue_id: name: extractvalue_id in: path @@ -2758,7 +3006,7 @@ components: required: true schema: type: integer - example: 12 + example: 13 matcher_id: name: matcher_id in: path @@ -2766,7 +3014,7 @@ components: required: true schema: type: integer - example: 13 + example: 14 securitySchemes: cookie: type: apiKey diff --git a/util/config.go b/util/config.go index fae4b8f55..4df6764d5 100644 --- a/util/config.go +++ b/util/config.go @@ -178,6 +178,20 @@ type DebuggingConfig struct { PprofDumpDir string `json:"pprof_dump_dir,omitempty" env:"SEMAPHORE_PPROF_DUMP_DIR"` } +type TeamInviteType string + +const ( + TeamInviteEmail TeamInviteType = "email" + TeamInviteUsername TeamInviteType = "username" + TeamInviteBoth TeamInviteType = "both" +) + +type TeamsConfig struct { + InvitesEnabled bool `json:"invites_enabled,omitempty" env:"SEMAPHORE_TEAMS_INVITES_ENABLED"` + InviteType TeamInviteType `json:"invite_type,omitempty" env:"SEMAPHORE_TEAMS_INVITE_TYPE" default:"username"` + MembersCanLeave bool `json:"members_can_leave,omitempty" env:"SEMAPHORE_TEAMS_MEMBERS_CAN_LEAVE"` +} + // ConfigType mapping between Config and the json file that sets it type ConfigType struct { MySQL *DbConfig `json:"mysql,omitempty"` @@ -281,6 +295,8 @@ type ConfigType struct { ForwardedEnvVars []string `json:"forwarded_env_vars,omitempty" env:"SEMAPHORE_FORWARDED_ENV_VARS"` + Teams *TeamsConfig `json:"teams,omitempty"` + Log *ConfigLog `json:"log,omitempty"` Process *ConfigProcess `json:"process,omitempty"` diff --git a/web/src/App.vue b/web/src/App.vue index 14825f427..14b5feb29 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1151,7 +1151,7 @@ export default { if (this.$route.path !== '/auth/login') { await this.$router.push({ path: '/auth/login', - query: { return: this.$route.fullPath }, + query: { redirect: this.$route.fullPath }, }); } this.state = 'success'; diff --git a/web/src/components/EditTeamMemberDialog.vue b/web/src/components/EditTeamMemberDialog.vue new file mode 100644 index 000000000..8701e7c92 --- /dev/null +++ b/web/src/components/EditTeamMemberDialog.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/web/src/components/TeamMemberForm.vue b/web/src/components/TeamMemberForm.vue index dc4882e61..cee70a38e 100644 --- a/web/src/components/TeamMemberForm.vue +++ b/web/src/components/TeamMemberForm.vue @@ -9,9 +9,26 @@ :value="formError" color="error" class="pb-2" - >{{ formError }} + >{{ formError }} + + +
+ + + Email + + + Username + + +
+ + + + + + Accept Invitation + + + +
+ +
Accepting invitation...
+
+ + + Invitation accepted. You now have access to the project. + + + + {{ errorMessage }} + +
+ + + + + + Go to project + + +
+ + Go to dashboard + + + Try again + +
+
+
+
+ + + diff --git a/web/src/views/Auth.vue b/web/src/views/Auth.vue index 839ca5744..d334ecbcc 100644 --- a/web/src/views/Auth.vue +++ b/web/src/views/Auth.vue @@ -368,7 +368,7 @@ export default { switch (status) { case 'authenticated': - document.location = document.baseURI + window.location.search; + this.redirectAfterLogin(); break; case 'unauthenticated': await this.loadLoginData(); @@ -520,7 +520,8 @@ export default { passcode: this.verificationCode, }, }); - document.location = document.baseURI + window.location.search; + + this.redirectAfterLogin(); } catch (err) { this.signInError = getErrorMessage(err); } finally { @@ -545,7 +546,8 @@ export default { email: this.email, }, }); - document.location = document.baseURI + window.location.search; + + this.redirectAfterLogin(); } catch (err) { if (err.response.status === 401) { this.signInError = this.$t('incorrectEmail'); @@ -575,7 +577,9 @@ export default { password: this.password, }, }); - document.location = document.baseURI + window.location.search; + + this.redirectAfterLogin(); + // document.location = document.baseURI + window.location.search; } catch (err) { if (err.response.status === 401) { this.signInError = this.$t('incorrectUsrPwd'); @@ -588,13 +592,33 @@ export default { }, async oidcSignIn(provider) { - let query = ''; + const params = new URLSearchParams(); + const redirectTo = this.$route.query.redirect; + if (redirectTo) { + params.set('redirect', redirectTo); + } else if (this.$route.query.new_project === 'premium') { + params.set('redirect', '/project/premium'); + } + const qs = params.toString(); + const suffix = qs ? `?${qs}` : ''; + document.location = `${document.baseURI}api/auth/oidc/${provider}/login${suffix}`; + }, - if (this.$route.query.new_project === 'premium') { - query = '?redirect=/project/premium'; + redirectAfterLogin() { + const redirectTo = this.$route.query.redirect; + let baseURI = document.baseURI; + + if (redirectTo) { + if (baseURI.endsWith('/')) { + baseURI = baseURI.substring(0, baseURI.length - 1); + } + + document.location = baseURI + redirectTo; + + return; } - document.location = `${document.baseURI}api/auth/oidc/${provider}/login${query}`; + document.location = document.baseURI + window.location.search; }, }, }; diff --git a/web/src/views/project/Invites.vue b/web/src/views/project/Invites.vue new file mode 100644 index 000000000..a7d4e5a5d --- /dev/null +++ b/web/src/views/project/Invites.vue @@ -0,0 +1,150 @@ + + diff --git a/web/src/views/project/Team.vue b/web/src/views/project/Team.vue index 5d8f73fd2..70638d42d 100644 --- a/web/src/views/project/Team.vue +++ b/web/src/views/project/Team.vue @@ -1,22 +1,13 @@