From 0d11472372b0f02d80d89b79e8b2a18da50e3397 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 6 Aug 2025 17:32:42 +0000 Subject: [PATCH 01/19] Add project invite system with create, accept, and manage endpoints Co-authored-by: denguk --- api/projects/invites.go | 296 ++++++++++++++++++++++++++++++++++ api/router.go | 16 ++ db/ProjectInvite.go | 43 +++++ db/Store.go | 17 ++ db/bolt/project_invite.go | 87 ++++++++++ db/sql/migrations/v2.16.3.sql | 21 +++ db/sql/project_invite.go | 160 ++++++++++++++++++ 7 files changed, 640 insertions(+) create mode 100644 api/projects/invites.go create mode 100644 db/ProjectInvite.go create mode 100644 db/bolt/project_invite.go create mode 100644 db/sql/migrations/v2.16.3.sql create mode 100644 db/sql/project_invite.go diff --git a/api/projects/invites.go b/api/projects/invites.go new file mode 100644 index 000000000..c37134bc8 --- /dev/null +++ b/api/projects/invites.go @@ -0,0 +1,296 @@ +package projects + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "time" + + "github.com/semaphoreui/semaphore/api/helpers" + "github.com/semaphoreui/semaphore/db" +) + +// 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, + InvitedBy: user.ID, + Created: time.Now(), + ExpiresAt: expiresAt, + } + + newInvite, err := helpers.Store(r).CreateProjectInvite(invite) + if err != nil { + helpers.WriteError(w, err) + return + } + + 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), + }) + + w.WriteHeader(http.StatusNoContent) +} + +// 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) +} \ No newline at end of file diff --git a/api/router.go b/api/router.go index 0de6d3323..f41a4b0a1 100644 --- a/api/router.go +++ b/api/router.go @@ -360,6 +360,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() diff --git a/db/ProjectInvite.go b/db/ProjectInvite.go new file mode 100644 index 000000000..5bbd548e6 --- /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 + InvitedBy int `db:"invited_by" json:"invited_by"` // 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:"invited_by_user,omitempty"` + User *User `json:"user,omitempty"` +} \ No newline at end of file diff --git a/db/Store.go b/db/Store.go index ab0b6182f..81f575cea 100644 --- a/db/Store.go +++ b/db/Store.go @@ -250,6 +250,14 @@ type ProjectStore interface { DeleteProjectUser(projectID int, userID int) error GetProjectUser(projectID int, userID int) (ProjectUser, error) UpdateProjectUser(projectUser ProjectUser) error + + // 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 @@ -556,6 +564,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..cc5a21e96 --- /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.InvitedBy) + 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) +} \ No newline at end of file diff --git a/db/sql/migrations/v2.16.3.sql b/db/sql/migrations/v2.16.3.sql new file mode 100644 index 000000000..0d4d048c1 --- /dev/null +++ b/db/sql/migrations/v2.16.3.sql @@ -0,0 +1,21 @@ +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, + `invited_by` 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 (`invited_by`) 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..20b03b6b0 --- /dev/null +++ b/db/sql/project_invite.go @@ -0,0 +1,160 @@ +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 + } + + q := squirrel.Select("pi.*"). + Column("ib.name as invited_by_name"). + Column("ib.username as invited_by_username"). + Column("ib.email as invited_by_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.invited_by=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.InvitedBy, + &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.InvitedBy, + 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) { + result, err := d.exec( + "insert into project__invite (project_id, user_id, email, role, status, token, invited_by, created, expires_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", + invite.ProjectID, + invite.UserID, + invite.Email, + invite.Role, + invite.Status, + invite.Token, + invite.InvitedBy, + invite.Created, + invite.ExpiresAt) + + if err != nil { + return + } + + insertID, err := result.LastInsertId() + if err != nil { + return + } + + newInvite = invite + newInvite.ID = int(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 +} \ No newline at end of file From f8c6ce661f2b8c211bce8254d684efb7a769e12a Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Wed, 6 Aug 2025 22:48:04 +0500 Subject: [PATCH 02/19] refactor(invite): separete interface --- db/Store.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/db/Store.go b/db/Store.go index 81f575cea..07ca78970 100644 --- a/db/Store.go +++ b/db/Store.go @@ -250,7 +250,9 @@ type ProjectStore interface { DeleteProjectUser(projectID int, userID int) error GetProjectUser(projectID int, userID int) (ProjectUser, error) UpdateProjectUser(projectUser ProjectUser) error - +} + +type ProjectInviteRepository interface { // Project invites GetProjectInvites(projectID int, params RetrieveQueryParams) ([]ProjectInviteWithUser, error) CreateProjectInvite(invite ProjectInvite) (ProjectInvite, error) @@ -459,6 +461,7 @@ type Store interface { OptionsManager UserManager ProjectStore + ProjectInviteRepository TemplateManager InventoryManager RepositoryManager From 96ce0d28accb79228961c7efcbcc710df2915bc5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 6 Aug 2025 17:58:28 +0000 Subject: [PATCH 03/19] Add project invite management API and related tests Co-authored-by: denguk --- api-docs.yml | 224 +++++++++++++++++++- api/projects/invites_test.go | 394 +++++++++++++++++++++++++++++++++++ db/ProjectInvite_test.go | 171 +++++++++++++++ openapi.yml | 270 +++++++++++++++++++++++- 4 files changed, 1037 insertions(+), 22 deletions(-) create mode 100644 api/projects/invites_test.go create mode 100644 db/ProjectInvite_test.go diff --git a/api-docs.yml b/api-docs.yml index 4b11ecc43..e77b17925 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 + invited_by: + 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) + invited_by_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}]} @@ -1053,83 +1134,90 @@ parameters: type: integer required: true x-example: 2 + invite_id: + name: invite_id + description: Invite ID + in: path + type: integer + required: true + x-example: 3 key_id: name: key_id description: key ID in: path type: integer required: true - x-example: 3 + x-example: 4 repository_id: name: repository_id description: repository ID in: path type: integer required: true - x-example: 4 + x-example: 5 inventory_id: name: inventory_id description: inventory ID in: path type: integer required: true - x-example: 5 + x-example: 6 environment_id: name: environment_id description: environment ID in: path type: integer required: true - x-example: 6 + x-example: 7 template_id: name: template_id description: template ID in: path type: integer required: true - x-example: 7 + x-example: 8 task_id: name: task_id description: task ID in: path type: integer required: true - x-example: 8 + x-example: 9 schedule_id: name: schedule_id description: schedule ID in: path type: integer required: true - x-example: 9 + x-example: 10 view_id: name: view_id description: view ID in: path type: integer required: true - x-example: 10 + x-example: 11 integration_id: name: integration_id description: integration ID in: path type: integer required: true - x-example: 11 + x-example: 12 extractvalue_id: name: extractvalue_id description: extractValue ID in: path type: integer required: true - x-example: 12 + x-example: 13 matcher_id: name: matcher_id description: matcher ID in: path type: integer required: true - x-example: 13 + x-example: 14 paths: /debug/gc: @@ -1632,6 +1720,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_test.go b/api/projects/invites_test.go new file mode 100644 index 000000000..c6e19af37 --- /dev/null +++ b/api/projects/invites_test.go @@ -0,0 +1,394 @@ +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.InvitedBy]; 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", + InvitedBy: 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", + InvitedBy: 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", + InvitedBy: 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", + InvitedBy: 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", + InvitedBy: 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) + } +} \ No newline at end of file diff --git a/db/ProjectInvite_test.go b/db/ProjectInvite_test.go new file mode 100644 index 000000000..6f1286ef0 --- /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", + InvitedBy: 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", + InvitedBy: 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", + InvitedBy: 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", + InvitedBy: 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", + InvitedBy: 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") + } +} \ No newline at end of file diff --git a/openapi.yml b/openapi.yml index 7b610672a..2fe1dc3bb 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 + invited_by: + 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) + invited_by_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 From 46d8a5b0234689ac3125c271c8381ea4cc0b95e9 Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Wed, 6 Aug 2025 23:59:54 +0500 Subject: [PATCH 04/19] feat(invite): tests --- .dredd/hooks/capabilities.go | 14 ++- .dredd/hooks/helpers.go | 20 +++ .dredd/hooks/main.go | 5 + api-docs.yml | 224 +++++++++++++++++----------------- db/Migration.go | 2 + db/sql/migrations/v2.16.3.sql | 39 +++--- db/sql/project_invite.go | 12 +- 7 files changed, 176 insertions(+), 140 deletions(-) diff --git a/.dredd/hooks/capabilities.go b/.dredd/hooks/capabilities.go index 7b55dd0ad..21cb9949c 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": @@ -188,7 +192,12 @@ var skipTest = func(t *trans.Transaction) { var pathSubPatterns = []func() string{ func() string { return strconv.Itoa(userProject.ID) }, func() string { return strconv.Itoa(userPathTestUser.ID) }, - func() string { return strconv.Itoa(userKey.ID) }, + func() string { + if userKey == nil { + return "" + } + return strconv.Itoa(userKey.ID) + }, func() string { return strconv.Itoa(repoID) }, func() string { return strconv.Itoa(inventoryID) }, func() string { return strconv.Itoa(environmentID) }, @@ -230,6 +239,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", invite.InvitedBy, &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..13fdfb8e1 100644 --- a/.dredd/hooks/helpers.go +++ b/.dredd/hooks/helpers.go @@ -195,6 +195,26 @@ 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(), + InvitedBy: testRunnerUser.ID, + Created: tz.Now(), + ExpiresAt: nil, // No expiration for this test + AcceptedAt: nil, + }) + 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 6f44f4c37..2c531f3d9 100644 --- a/.dredd/hooks/main.go +++ b/.dredd/hooks/main.go @@ -78,6 +78,11 @@ func main() { deleteUserProjectRelation(userProject.ID, userPathTestUser.ID) 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("project > /api/project/{project_id}/integrations > get all integrations > 200 > application/json", capabilityWrapper("integration")) h.Before("project > /api/project/{project_id}/integrations/{integration_id} > Get Integration > 200 > application/json", capabilityWrapper("integration")) diff --git a/api-docs.yml b/api-docs.yml index e77b17925..def2cea3d 100644 --- a/api-docs.yml +++ b/api-docs.yml @@ -1721,118 +1721,118 @@ paths: 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}/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: 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/sql/migrations/v2.16.3.sql b/db/sql/migrations/v2.16.3.sql index 0d4d048c1..932662756 100644 --- a/db/sql/migrations/v2.16.3.sql +++ b/db/sql/migrations/v2.16.3.sql @@ -1,21 +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, - `invited_by` int not null, - `created` datetime not null, - `expires_at` datetime null, - `accepted_at` datetime null, +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, + `invited_by` 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 (`invited_by`) references `user`(`id`) on delete cascade, - - unique (`token`), - unique (`project_id`, `user_id`), - unique (`project_id`, `email`) + foreign key (`project_id`) references project (`id`) on delete cascade, + foreign key (`user_id`) references `user` (`id`) on delete cascade, + foreign key (`invited_by`) 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 index 20b03b6b0..f8c3c16ae 100644 --- a/db/sql/project_invite.go +++ b/db/sql/project_invite.go @@ -101,7 +101,8 @@ func (d *SqlDb) GetProjectInvites(projectID int, params db.RetrieveQueryParams) } func (d *SqlDb) CreateProjectInvite(invite db.ProjectInvite) (newInvite db.ProjectInvite, err error) { - result, err := d.exec( + insertID, err := d.insert( + "id", "insert into project__invite (project_id, user_id, email, role, status, token, invited_by, created, expires_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", invite.ProjectID, invite.UserID, @@ -117,13 +118,8 @@ func (d *SqlDb) CreateProjectInvite(invite db.ProjectInvite) (newInvite db.Proje return } - insertID, err := result.LastInsertId() - if err != nil { - return - } - newInvite = invite - newInvite.ID = int(insertID) + newInvite.ID = insertID return } @@ -157,4 +153,4 @@ func (d *SqlDb) DeleteProjectInvite(projectID int, inviteID int) error { projectID, inviteID) return err -} \ No newline at end of file +} From 7901be32c2f4329540abcdc295b3ccea2a642ebd Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Thu, 7 Aug 2025 00:28:06 +0500 Subject: [PATCH 05/19] feat(invite): return get list --- .dredd/hooks/main.go | 11 +++--- api-docs.yml | 92 ++++++++++++++++++++++---------------------- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/.dredd/hooks/main.go b/.dredd/hooks/main.go index 2c531f3d9..a5bd7b015 100644 --- a/.dredd/hooks/main.go +++ b/.dredd/hooks/main.go @@ -78,11 +78,12 @@ func main() { deleteUserProjectRelation(userProject.ID, userPathTestUser.ID) 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("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("project > /api/project/{project_id}/integrations > get all integrations > 200 > application/json", capabilityWrapper("integration")) h.Before("project > /api/project/{project_id}/integrations/{integration_id} > Get Integration > 200 > application/json", capabilityWrapper("integration")) diff --git a/api-docs.yml b/api-docs.yml index def2cea3d..658c134b4 100644 --- a/api-docs.yml +++ b/api-docs.yml @@ -1134,89 +1134,89 @@ parameters: type: integer required: true x-example: 2 - invite_id: - name: invite_id - description: Invite ID - in: path - type: integer - required: true - x-example: 3 key_id: name: key_id description: key ID in: path type: integer required: true - x-example: 4 + x-example: 3 repository_id: name: repository_id description: repository ID in: path type: integer required: true - x-example: 5 + x-example: 4 inventory_id: name: inventory_id description: inventory ID in: path type: integer required: true - x-example: 6 + x-example: 5 environment_id: name: environment_id description: environment ID in: path type: integer required: true - x-example: 7 + x-example: 6 template_id: name: template_id description: template ID in: path type: integer required: true - x-example: 8 + x-example: 7 task_id: name: task_id description: task ID in: path type: integer required: true - x-example: 9 + x-example: 8 schedule_id: name: schedule_id description: schedule ID in: path type: integer required: true - x-example: 10 + x-example: 9 view_id: name: view_id description: view ID in: path type: integer required: true - x-example: 11 + x-example: 10 integration_id: name: integration_id description: integration ID in: path type: integer required: true - x-example: 12 + x-example: 11 extractvalue_id: name: extractvalue_id description: extractValue ID in: path type: integer required: true - x-example: 13 + x-example: 12 matcher_id: name: matcher_id description: matcher ID in: path 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: @@ -1721,35 +1721,35 @@ paths: 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" + /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 From def1f7bf8f9d57a6152bf9763d5621cfafbf314c Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Thu, 7 Aug 2025 00:44:18 +0500 Subject: [PATCH 06/19] feat(invite): add post endpoint test --- api-docs.yml | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/api-docs.yml b/api-docs.yml index 658c134b4..4a77671c3 100644 --- a/api-docs.yml +++ b/api-docs.yml @@ -198,10 +198,10 @@ definitions: ProjectInviteRequest: type: object properties: - user_id: - type: integer - minimum: 1 - description: User ID to invite (use either user_id or email, not both) +# user_id: +# type: integer +# minimum: 1 +# description: User ID to invite (use either user_id or email, not both) email: type: string format: email @@ -1750,25 +1750,25 @@ paths: 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 + 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: From 09218e4ac0e2a45f20abca60cf5151afc7516e45 Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Thu, 7 Aug 2025 01:04:17 +0500 Subject: [PATCH 07/19] feat: rename field --- .dredd/hooks/capabilities.go | 2 +- .dredd/hooks/helpers.go | 21 ++++---- api-docs.yml | 36 ++++++------- api/projects/invites.go | 30 +++++------ api/projects/invites_test.go | 96 +++++++++++++++++------------------ db/ProjectInvite.go | 26 +++++----- db/ProjectInvite_test.go | 90 ++++++++++++++++---------------- db/bolt/project_invite.go | 6 +-- db/sql/migrations/v2.16.3.sql | 4 +- db/sql/project_invite.go | 16 +++--- openapi.yml | 4 +- 11 files changed, 165 insertions(+), 166 deletions(-) diff --git a/.dredd/hooks/capabilities.go b/.dredd/hooks/capabilities.go index 21cb9949c..8652c13ba 100644 --- a/.dredd/hooks/capabilities.go +++ b/.dredd/hooks/capabilities.go @@ -240,7 +240,7 @@ func alterRequestBody(t *trans.Transaction) { bodyFieldProcessor("become_key_id", userKey.ID, &request) } if invite != nil { - bodyFieldProcessor("invite_id", invite.InvitedBy, &request) + bodyFieldProcessor("invite_id", invite.ID, &request) } bodyFieldProcessor("environment_id", environmentID, &request) bodyFieldProcessor("inventory_id", inventoryID, &request) diff --git a/.dredd/hooks/helpers.go b/.dredd/hooks/helpers.go index 13fdfb8e1..9f1c70ed1 100644 --- a/.dredd/hooks/helpers.go +++ b/.dredd/hooks/helpers.go @@ -197,17 +197,18 @@ func addView() *db.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(), - InvitedBy: testRunnerUser.ID, - Created: tz.Now(), - ExpiresAt: nil, // No expiration for this test - AcceptedAt: nil, + 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, }) + if err != nil { panic(err) } diff --git a/api-docs.yml b/api-docs.yml index 4a77671c3..e31bced8a 100644 --- a/api-docs.yml +++ b/api-docs.yml @@ -172,7 +172,7 @@ definitions: type: string enum: [pending, accepted, declined, expired] example: pending - invited_by: + inviter_user_id: type: integer minimum: 1 description: ID of the user who created the invite @@ -188,7 +188,7 @@ definitions: type: string format: date-time description: When the invite was accepted (optional) - invited_by_user: + inviter_user: $ref: "#/definitions/User" description: Details of the user who created the invite user: @@ -1769,22 +1769,22 @@ paths: 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 + + /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 diff --git a/api/projects/invites.go b/api/projects/invites.go index c37134bc8..5d6aced47 100644 --- a/api/projects/invites.go +++ b/api/projects/invites.go @@ -56,10 +56,10 @@ func CreateInvite(w http.ResponseWriter, r *http.Request) { 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"` + 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) { @@ -109,15 +109,15 @@ func CreateInvite(w http.ResponseWriter, r *http.Request) { } invite := db.ProjectInvite{ - ProjectID: project.ID, - UserID: request.UserID, - Email: request.Email, - Role: request.Role, - Status: db.ProjectInvitePending, - Token: token, - InvitedBy: user.ID, - Created: time.Now(), - ExpiresAt: expiresAt, + 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) @@ -165,7 +165,7 @@ func AcceptInvite(w http.ResponseWriter, r *http.Request) { } 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) @@ -293,4 +293,4 @@ func getInviteTarget(invite db.ProjectInvite) string { return *invite.Email } return fmt.Sprintf("User ID %d", *invite.UserID) -} \ No newline at end of file +} diff --git a/api/projects/invites_test.go b/api/projects/invites_test.go index c6e19af37..28794937b 100644 --- a/api/projects/invites_test.go +++ b/api/projects/invites_test.go @@ -10,12 +10,12 @@ import ( // 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 + 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 + nextInviteID int } func newMockInviteStore() *mockInviteStore { @@ -64,7 +64,7 @@ func (m *mockInviteStore) GetProjectInvites(projectID int, params db.RetrieveQue inviteWithUser := db.ProjectInviteWithUser{ ProjectInvite: invite, } - if invitedByUser, exists := m.users[invite.InvitedBy]; exists { + if invitedByUser, exists := m.users[invite.InviterUserID]; exists { inviteWithUser.InvitedByUser = &invitedByUser } if invite.UserID != nil { @@ -125,26 +125,26 @@ func TestMockStore_GetProjectInvites(t *testing.T) { // Add test invites invite1 := db.ProjectInvite{ - ID: 1, - ProjectID: 1, - Email: stringPtr("user1@example.com"), - Role: db.ProjectManager, - Status: db.ProjectInvitePending, - Token: "token1", - InvitedBy: 1, - Created: time.Now(), + 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", - InvitedBy: 1, - Created: time.Now(), + 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) @@ -162,7 +162,7 @@ func TestMockStore_GetProjectInvites(t *testing.T) { // Find email-based invite var emailInvite *db.ProjectInviteWithUser var userInvite *db.ProjectInviteWithUser - + for i := range invites { if invites[i].Email != nil { emailInvite = &invites[i] @@ -191,13 +191,13 @@ 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", - InvitedBy: 1, - Created: time.Now(), + 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) @@ -237,14 +237,14 @@ func TestMockStore_UpdateProjectInvite(t *testing.T) { // Create test invite invite := db.ProjectInvite{ - ID: 1, - ProjectID: 1, - Email: stringPtr("test@example.com"), - Role: db.ProjectManager, - Status: db.ProjectInvitePending, - Token: "test-token", - InvitedBy: 1, - Created: time.Now(), + 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) @@ -274,14 +274,14 @@ func TestMockStore_DeleteProjectInvite(t *testing.T) { // Create test invite invite := db.ProjectInvite{ - ID: 1, - ProjectID: 1, - Email: stringPtr("test@example.com"), - Role: db.ProjectManager, - Status: db.ProjectInvitePending, - Token: "test-token", - InvitedBy: 1, - Created: time.Now(), + 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) @@ -348,8 +348,6 @@ func timePtr(t time.Time) *time.Time { return &t } - - // Test ProjectInvite model validation func TestProjectInviteStatus_IsValid(t *testing.T) { validStatuses := []db.ProjectInviteStatus{ @@ -391,4 +389,4 @@ func TestGetInviteTarget(t *testing.T) { if target != expected { t.Errorf("Expected '%s', got %s", expected, target) } -} \ No newline at end of file +} diff --git a/db/ProjectInvite.go b/db/ProjectInvite.go index 5bbd548e6..ea9a5be4b 100644 --- a/db/ProjectInvite.go +++ b/db/ProjectInvite.go @@ -23,21 +23,21 @@ func (s ProjectInviteStatus) IsValid() bool { } 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 - InvitedBy int `db:"invited_by" json:"invited_by"` // 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"` + 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:"invited_by_user,omitempty"` + InvitedByUser *User `json:"inviter_user,omitempty"` User *User `json:"user,omitempty"` -} \ No newline at end of file +} diff --git a/db/ProjectInvite_test.go b/db/ProjectInvite_test.go index 6f1286ef0..d119cf46d 100644 --- a/db/ProjectInvite_test.go +++ b/db/ProjectInvite_test.go @@ -28,14 +28,14 @@ func TestProjectInviteStatus_IsValid(t *testing.T) { 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", - InvitedBy: 1, - Created: time.Now(), + ID: 1, + ProjectID: 1, + Email: &email, + Role: ProjectManager, + Status: ProjectInvitePending, + Token: "test-token", + InviterUserID: 1, + Created: time.Now(), } if invite.UserID != nil { @@ -54,14 +54,14 @@ func TestProjectInvite_EmailBasedInvite(t *testing.T) { func TestProjectInvite_UserBasedInvite(t *testing.T) { userID := 42 invite := ProjectInvite{ - ID: 1, - ProjectID: 1, - UserID: &userID, - Role: ProjectTaskRunner, - Status: ProjectInvitePending, - Token: "test-token", - InvitedBy: 1, - Created: time.Now(), + ID: 1, + ProjectID: 1, + UserID: &userID, + Role: ProjectTaskRunner, + Status: ProjectInvitePending, + Token: "test-token", + InviterUserID: 1, + Created: time.Now(), } if invite.Email != nil { @@ -80,17 +80,17 @@ func TestProjectInvite_UserBasedInvite(t *testing.T) { 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", - InvitedBy: 1, - Created: time.Now(), - ExpiresAt: &expiresAt, + ID: 1, + ProjectID: 1, + Email: &email, + Role: ProjectManager, + Status: ProjectInvitePending, + Token: "test-token", + InviterUserID: 1, + Created: time.Now(), + ExpiresAt: &expiresAt, } if invite.ExpiresAt == nil { @@ -105,17 +105,17 @@ func TestProjectInvite_WithExpiration(t *testing.T) { 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", - InvitedBy: 1, - Created: time.Now().Add(-1 * time.Hour), - AcceptedAt: &acceptedAt, + 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 { @@ -130,14 +130,14 @@ func TestProjectInvite_AcceptedInvite(t *testing.T) { 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", - InvitedBy: 1, - Created: time.Now(), + ID: 1, + ProjectID: 1, + Email: &email, + Role: ProjectManager, + Status: ProjectInvitePending, + Token: "test-token", + InviterUserID: 1, + Created: time.Now(), } invitedByUser := User{ @@ -168,4 +168,4 @@ func TestProjectInviteWithUser_Structure(t *testing.T) { if inviteWithUser.User != nil { t.Error("User should be nil for email-based invite") } -} \ No newline at end of file +} diff --git a/db/bolt/project_invite.go b/db/bolt/project_invite.go index cc5a21e96..717515e7a 100644 --- a/db/bolt/project_invite.go +++ b/db/bolt/project_invite.go @@ -17,7 +17,7 @@ func (d *BoltDb) GetProjectInvites(projectID int, params db.RetrieveQueryParams) } // Get invited by user info - invitedByUser, err := d.GetUser(invite.InvitedBy) + invitedByUser, err := d.GetUser(invite.InviterUserID) if err == nil { inviteWithUser.InvitedByUser = &invitedByUser } @@ -51,7 +51,7 @@ func (d *BoltDb) GetProjectInvite(projectID int, inviteID int) (invite db.Projec 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 { @@ -84,4 +84,4 @@ func (d *BoltDb) UpdateProjectInvite(invite db.ProjectInvite) error { func (d *BoltDb) DeleteProjectInvite(projectID int, inviteID int) error { return d.deleteObject(projectID, db.ProjectInviteProps, intObjectID(inviteID), nil) -} \ No newline at end of file +} diff --git a/db/sql/migrations/v2.16.3.sql b/db/sql/migrations/v2.16.3.sql index 932662756..a79980a67 100644 --- a/db/sql/migrations/v2.16.3.sql +++ b/db/sql/migrations/v2.16.3.sql @@ -7,14 +7,14 @@ create table project__invite `role` varchar(50) not null, `status` varchar(50) not null default 'pending', `token` varchar(255) not null, - `invited_by` int 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 (`invited_by`) references `user` (`id`) on delete cascade, + foreign key (`inviter_user_id`) references `user` (`id`) on delete cascade, unique (`token`), unique (`project_id`, `user_id`), diff --git a/db/sql/project_invite.go b/db/sql/project_invite.go index f8c3c16ae..af7451de3 100644 --- a/db/sql/project_invite.go +++ b/db/sql/project_invite.go @@ -14,14 +14,14 @@ func (d *SqlDb) GetProjectInvites(projectID int, params db.RetrieveQueryParams) } q := squirrel.Select("pi.*"). - Column("ib.name as invited_by_name"). - Column("ib.username as invited_by_username"). - Column("ib.email as invited_by_email"). + 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.invited_by=ib.id"). + 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) @@ -61,7 +61,7 @@ func (d *SqlDb) GetProjectInvites(projectID int, params db.RetrieveQueryParams) &invite.Role, &invite.Status, &invite.Token, - &invite.InvitedBy, + &invite.InviterUserID, &invite.Created, &invite.ExpiresAt, &invite.AcceptedAt, @@ -78,7 +78,7 @@ func (d *SqlDb) GetProjectInvites(projectID int, params db.RetrieveQueryParams) // Set invited by user info invite.InvitedByUser = &db.User{ - ID: invite.InvitedBy, + ID: invite.InviterUserID, Name: invitedByName.String, Username: invitedByUsername.String, Email: invitedByEmail.String, @@ -103,14 +103,14 @@ func (d *SqlDb) GetProjectInvites(projectID int, params db.RetrieveQueryParams) 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, invited_by, created, expires_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "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.InvitedBy, + invite.InviterUserID, invite.Created, invite.ExpiresAt) diff --git a/openapi.yml b/openapi.yml index 2fe1dc3bb..b39a3d135 100644 --- a/openapi.yml +++ b/openapi.yml @@ -1993,7 +1993,7 @@ components: - declined - expired example: pending - invited_by: + inviter_user_id: minimum: 1 type: integer description: ID of the user who created the invite @@ -2009,7 +2009,7 @@ components: type: string format: date-time description: When the invite was accepted (optional) - invited_by_user: + inviter_user: "$ref": "#/components/schemas/User" description: Details of the user who created the invite user: From aae59b63cc9ca759aa3d2e5c63fe3745bdc6950c Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Thu, 7 Aug 2025 09:59:30 +0500 Subject: [PATCH 08/19] chore: spacles --- db/sql/migrations/v2.16.3.sql | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/db/sql/migrations/v2.16.3.sql b/db/sql/migrations/v2.16.3.sql index a79980a67..007ee81a4 100644 --- a/db/sql/migrations/v2.16.3.sql +++ b/db/sql/migrations/v2.16.3.sql @@ -1,16 +1,16 @@ 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, + `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, From 54e11381f8b2aaca2bd63503950a1d32835e0bcc Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Sat, 9 Aug 2025 15:07:07 +0500 Subject: [PATCH 09/19] test(dredd): add missed invite id --- .dredd/hooks/capabilities.go | 10 +++------- .dredd/hooks/helpers.go | 11 ++++++++++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.dredd/hooks/capabilities.go b/.dredd/hooks/capabilities.go index 8652c13ba..42a3f8857 100644 --- a/.dredd/hooks/capabilities.go +++ b/.dredd/hooks/capabilities.go @@ -192,12 +192,7 @@ var skipTest = func(t *trans.Transaction) { var pathSubPatterns = []func() string{ func() string { return strconv.Itoa(userProject.ID) }, func() string { return strconv.Itoa(userPathTestUser.ID) }, - func() string { - if userKey == nil { - return "" - } - return strconv.Itoa(userKey.ID) - }, + func() string { return strconv.Itoa(userKey.ID) }, func() string { return strconv.Itoa(repoID) }, func() string { return strconv.Itoa(inventoryID) }, func() string { return strconv.Itoa(environmentID) }, @@ -208,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 @@ -240,7 +236,7 @@ func alterRequestBody(t *trans.Transaction) { bodyFieldProcessor("become_key_id", userKey.ID, &request) } if invite != nil { - bodyFieldProcessor("invite_id", invite.ID, &request) + bodyFieldProcessor("invite_id", 4, &request) } bodyFieldProcessor("environment_id", environmentID, &request) bodyFieldProcessor("inventory_id", inventoryID, &request) diff --git a/.dredd/hooks/helpers.go b/.dredd/hooks/helpers.go index 9f1c70ed1..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" @@ -209,6 +210,14 @@ func addInvite() *db.ProjectInvite { AcceptedAt: nil, }) + fmt.Println("***************************************") + fmt.Println("***************************************") + fmt.Println("***************************************") + fmt.Println(invite.ID) + fmt.Println("***************************************") + fmt.Println("***************************************") + fmt.Println("***************************************") + if err != nil { panic(err) } From 556958f17a9b4a3277df1699c3367cee2919e1c2 Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Sat, 9 Aug 2025 21:43:15 +0500 Subject: [PATCH 10/19] feat(invites): add config options --- util/config.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/util/config.go b/util/config.go index fae4b8f55..6826d549d 100644 --- a/util/config.go +++ b/util/config.go @@ -178,6 +178,12 @@ type DebuggingConfig struct { PprofDumpDir string `json:"pprof_dump_dir,omitempty" env:"SEMAPHORE_PPROF_DUMP_DIR"` } +type TeamConfig struct { + InvitationsEnabled bool `json:"invitations_enabled,omitempty" env:"SEMAPHORE_TEAM_INVITATIONS_ENABLED"` + MembersCanLeave bool `json:"members_can_leave,omitempty" env:"SEMAPHORE_TEAM_MEMBERS_CAN_LEAVE"` + EmailInvitationsOnly bool `json:"email_invitations_only,omitempty" env:"SEMAPHORE_TEAM_EMAIL_INVITATIONS_ONLY"` +} + // ConfigType mapping between Config and the json file that sets it type ConfigType struct { MySQL *DbConfig `json:"mysql,omitempty"` @@ -281,6 +287,8 @@ type ConfigType struct { ForwardedEnvVars []string `json:"forwarded_env_vars,omitempty" env:"SEMAPHORE_FORWARDED_ENV_VARS"` + Team *TeamConfig `json:"team,omitempty"` + Log *ConfigLog `json:"log,omitempty"` Process *ConfigProcess `json:"process,omitempty"` From 2804d736029c5301a09e6c4b41db1e95220e191d Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Sat, 9 Aug 2025 22:25:06 +0500 Subject: [PATCH 11/19] feat(invites): add ui --- api/router.go | 4 +++- util/config.go | 18 ++++++++++---- web/src/components/TeamMemberForm.vue | 34 ++++++++++++++++++++++++++- web/src/views/project/Team.vue | 14 ++++++++++- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/api/router.go b/api/router.go index f41a4b0a1..bccba9574 100644 --- a/api/router.go +++ b/api/router.go @@ -4,13 +4,14 @@ import ( "bytes" "embed" "fmt" - "github.com/semaphoreui/semaphore/pro_interfaces" "net/http" "os" "path" "strings" "time" + "github.com/semaphoreui/semaphore/pro_interfaces" + proApi "github.com/semaphoreui/semaphore/pro/api" proProjects "github.com/semaphoreui/semaphore/pro/api/projects" proFeatures "github.com/semaphoreui/semaphore/pro/pkg/features" @@ -654,6 +655,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/util/config.go b/util/config.go index 6826d549d..7ebb5ab7e 100644 --- a/util/config.go +++ b/util/config.go @@ -178,10 +178,18 @@ type DebuggingConfig struct { PprofDumpDir string `json:"pprof_dump_dir,omitempty" env:"SEMAPHORE_PPROF_DUMP_DIR"` } -type TeamConfig struct { - InvitationsEnabled bool `json:"invitations_enabled,omitempty" env:"SEMAPHORE_TEAM_INVITATIONS_ENABLED"` - MembersCanLeave bool `json:"members_can_leave,omitempty" env:"SEMAPHORE_TEAM_MEMBERS_CAN_LEAVE"` - EmailInvitationsOnly bool `json:"email_invitations_only,omitempty" env:"SEMAPHORE_TEAM_EMAIL_INVITATIONS_ONLY"` +type TeamInvitationType string + +const ( + TeamInvitationEmail TeamInvitationType = "email" + TeamInvitationUsername TeamInvitationType = "username" + TeamInvitationBoth TeamInvitationType = "both" +) + +type TeamsConfig struct { + InvitationsEnabled bool `json:"invitations_enabled,omitempty" env:"SEMAPHORE_TEAMS_INVITATIONS_ENABLED"` + InvitationType TeamInvitationType `json:"invitation_type,omitempty" env:"SEMAPHORE_TEAMS_INVITATION_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 @@ -287,7 +295,7 @@ type ConfigType struct { ForwardedEnvVars []string `json:"forwarded_env_vars,omitempty" env:"SEMAPHORE_FORWARDED_ENV_VARS"` - Team *TeamConfig `json:"team,omitempty"` + Teams *TeamsConfig `json:"teams,omitempty"` Log *ConfigLog `json:"log,omitempty"` diff --git a/web/src/components/TeamMemberForm.vue b/web/src/components/TeamMemberForm.vue index dc4882e61..112fb0580 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 + + +
+ + @@ -14,6 +18,8 @@ @error="onError" :need-save="needSave" :need-reset="needReset" + :invitation="systemInfo.teams.invitations_enabled" + :invitation-type="systemInfo.teams.invitation_type" /> @@ -30,6 +36,7 @@ {{ $t('team2') }} Date: Sat, 9 Aug 2025 22:37:43 +0500 Subject: [PATCH 12/19] refactor(invites): rename fields --- util/config.go | 14 +++++++------- web/src/components/TeamMemberForm.vue | 12 ++++++------ web/src/views/project/Team.vue | 6 +++--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/util/config.go b/util/config.go index 7ebb5ab7e..4df6764d5 100644 --- a/util/config.go +++ b/util/config.go @@ -178,18 +178,18 @@ type DebuggingConfig struct { PprofDumpDir string `json:"pprof_dump_dir,omitempty" env:"SEMAPHORE_PPROF_DUMP_DIR"` } -type TeamInvitationType string +type TeamInviteType string const ( - TeamInvitationEmail TeamInvitationType = "email" - TeamInvitationUsername TeamInvitationType = "username" - TeamInvitationBoth TeamInvitationType = "both" + TeamInviteEmail TeamInviteType = "email" + TeamInviteUsername TeamInviteType = "username" + TeamInviteBoth TeamInviteType = "both" ) type TeamsConfig struct { - InvitationsEnabled bool `json:"invitations_enabled,omitempty" env:"SEMAPHORE_TEAMS_INVITATIONS_ENABLED"` - InvitationType TeamInvitationType `json:"invitation_type,omitempty" env:"SEMAPHORE_TEAMS_INVITATION_TYPE" default:"username"` - MembersCanLeave bool `json:"members_can_leave,omitempty" env:"SEMAPHORE_TEAMS_MEMBERS_CAN_LEAVE"` + 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 diff --git a/web/src/components/TeamMemberForm.vue b/web/src/components/TeamMemberForm.vue index 112fb0580..7b92d2613 100644 --- a/web/src/components/TeamMemberForm.vue +++ b/web/src/components/TeamMemberForm.vue @@ -12,9 +12,9 @@ >{{ formError }} -
+
@@ -28,7 +28,7 @@
From f9cf57710001bcb2398b23ff4fe82dd882032027 Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Sat, 9 Aug 2025 22:46:38 +0500 Subject: [PATCH 13/19] feat(invites): send invite from UI --- db/sql/project_invite.go | 2 ++ web/src/components/TeamMemberForm.vue | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/db/sql/project_invite.go b/db/sql/project_invite.go index af7451de3..07f3aeab0 100644 --- a/db/sql/project_invite.go +++ b/db/sql/project_invite.go @@ -13,6 +13,8 @@ func (d *SqlDb) GetProjectInvites(projectID int, params db.RetrieveQueryParams) return } + invites = make([]db.ProjectInviteWithUser, 0) + q := squirrel.Select("pi.*"). Column("ib.name as inviter_user_id_name"). Column("ib.username as inviter_username"). diff --git a/web/src/components/TeamMemberForm.vue b/web/src/components/TeamMemberForm.vue index 7b92d2613..40c4b0946 100644 --- a/web/src/components/TeamMemberForm.vue +++ b/web/src/components/TeamMemberForm.vue @@ -73,7 +73,7 @@ export default { mixins: [ItemFormBase], props: { - inviteEnabled: Boolean, + invitesEnabled: Boolean, inviteType: String, }, @@ -103,6 +103,9 @@ export default { methods: { getItemsUrl() { + if (this.invitesEnabled) { + return `/api/project/${this.projectId}/invites`; + } return `/api/project/${this.projectId}/users`; }, From 18c69e9caa7d120794f2851c6eb0f1e07d4530d4 Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Sun, 10 Aug 2025 00:31:05 +0500 Subject: [PATCH 14/19] feat(invites): invitees page --- web/src/router/index.js | 5 ++ web/src/views/project/Invites.vue | 132 ++++++++++++++++++++++++++++++ web/src/views/project/Team.vue | 20 ++++- 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 web/src/views/project/Invites.vue diff --git a/web/src/router/index.js b/web/src/router/index.js index 997081312..a34380238 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -5,6 +5,7 @@ import Tasks from '@/views/Tasks.vue'; import TaskList from '@/components/TaskList.vue'; import TemplateDetails from '@/views/project/template/TemplateDetails.vue'; import TemplateTerraformState from '@/views/project/template/TemplateTerraformState.vue'; +import Invites from '@/views/project/Invites.vue'; import Schedule from '../views/project/Schedule.vue'; import History from '../views/project/History.vue'; import Activity from '../views/project/Activity.vue'; @@ -136,6 +137,10 @@ const routes = [ path: '/project/:projectId/team', component: Team, }, + { + path: '/project/:projectId/invites', + component: Invites, + }, { path: '/auth/login', component: Auth, diff --git a/web/src/views/project/Invites.vue b/web/src/views/project/Invites.vue new file mode 100644 index 000000000..a5b19e977 --- /dev/null +++ b/web/src/views/project/Invites.vue @@ -0,0 +1,132 @@ + + diff --git a/web/src/views/project/Team.vue b/web/src/views/project/Team.vue index 627869225..da99b7c6e 100644 --- a/web/src/views/project/Team.vue +++ b/web/src/views/project/Team.vue @@ -51,7 +51,25 @@ - + + + Members + + + + Invites + + + + Date: Sun, 10 Aug 2025 00:54:02 +0500 Subject: [PATCH 15/19] refactor(invite): add dialog component --- web/src/components/EditTeamMemberDialog.vue | 73 +++++++++++++++++++++ web/src/components/TeamMemberForm.vue | 2 +- web/src/views/project/Invites.vue | 22 ++++++- web/src/views/project/Team.vue | 43 +++++------- 4 files changed, 112 insertions(+), 28 deletions(-) create mode 100644 web/src/components/EditTeamMemberDialog.vue diff --git a/web/src/components/EditTeamMemberDialog.vue b/web/src/components/EditTeamMemberDialog.vue new file mode 100644 index 000000000..7142ecee3 --- /dev/null +++ b/web/src/components/EditTeamMemberDialog.vue @@ -0,0 +1,73 @@ + + + + + + + diff --git a/web/src/components/TeamMemberForm.vue b/web/src/components/TeamMemberForm.vue index 40c4b0946..cee70a38e 100644 --- a/web/src/components/TeamMemberForm.vue +++ b/web/src/components/TeamMemberForm.vue @@ -83,7 +83,7 @@ export default { userId: null, teamMembers: null, USER_ROLES, - selectedInviteType: 'username', + selectedInviteType: this.inviteType === 'both' ? 'username' : this.inviteType, }; }, diff --git a/web/src/views/project/Invites.vue b/web/src/views/project/Invites.vue index a5b19e977..a7d4e5a5d 100644 --- a/web/src/views/project/Invites.vue +++ b/web/src/views/project/Invites.vue @@ -1,5 +1,13 @@