فهرست منبع

fix merge conflict

Alexander Belanger 4 سال پیش
والد
کامیت
cdd6b9c5e6

+ 2 - 2
api/server/authz/invite.go

@@ -23,7 +23,7 @@ func NewInviteScopedFactory(
 }
 
 func (p *InviteScopedFactory) Middleware(next http.Handler) http.Handler {
-	return &RegistryScopedMiddleware{next, p.config}
+	return &InviteScopedMiddleware{next, p.config}
 }
 
 type InviteScopedMiddleware struct {
@@ -35,7 +35,7 @@ func (p *InviteScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	// read the project to check scopes
 	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
-	// get the registry id from the URL param context
+	// get the invite id from the URL param context
 	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
 	inviteID := reqScopes[types.InviteScope].Resource.UInt
 

+ 2 - 0
api/server/authz/policy.go

@@ -110,6 +110,8 @@ func getRequestActionForEndpoint(
 			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamNamespace)
 		case types.ReleaseScope:
 			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamReleaseName)
+		case types.InviteScope:
+			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamInviteID)
 		}
 
 		if reqErr != nil {

+ 1 - 1
api/server/authz/policy/loader.go

@@ -26,7 +26,7 @@ func (b *BasicPolicyDocumentLoader) LoadPolicyDocuments(
 	userID, projectID uint,
 ) ([]*types.PolicyDocument, apierrors.RequestError) {
 	// read role and case on role "kind"
-	role, err := b.projRepo.ReadProjectRole(userID, projectID)
+	role, err := b.projRepo.ReadProjectRole(projectID, userID)
 
 	if err != nil && err == gorm.ErrRecordNotFound {
 		return nil, apierrors.NewErrForbidden(

+ 122 - 0
api/server/handlers/invite/accept.go

@@ -0,0 +1,122 @@
+package invite
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+
+	"github.com/go-chi/chi"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InviteAcceptHandler struct {
+	handlers.PorterHandlerReader
+}
+
+func NewInviteAcceptHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) *InviteAcceptHandler {
+	return &InviteAcceptHandler{
+		PorterHandlerReader: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
+	}
+}
+
+func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.AcceptInviteRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	session, err := c.Config().Store.Get(r, c.Config().ServerConf.CookieName)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	user, err := c.Repo().User().ReadUser(userID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	projectID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projectID == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	invite, err := c.Repo().Invite().ReadInviteByToken(request.Token)
+
+	if err != nil || invite.ProjectID != uint(projectID) {
+		vals := url.Values{}
+		vals.Add("error", "Invalid invite token")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
+
+		return
+	}
+
+	// check that the invite has not expired and has not been accepted
+	if invite.IsExpired() || invite.IsAccepted() {
+		vals := url.Values{}
+		vals.Add("error", "Invite has expired")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
+
+		return
+	}
+
+	// check that the invite email matches the user's email
+	if user.Email != invite.Email {
+		vals := url.Values{}
+		vals.Add("error", "Wrong email for invite")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
+
+		return
+	}
+
+	kind := invite.Kind
+
+	if kind == "" {
+		kind = models.RoleDeveloper
+	}
+
+	project, err := c.Repo().Project().ReadProject(uint(projectID))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if _, err = c.Repo().Project().CreateProjectRole(project, &models.Role{
+		Role: types.Role{
+			UserID:    userID,
+			ProjectID: project.ID,
+			Kind:      types.RoleKind(kind),
+		},
+	}); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// update the invite
+	invite.UserID = userID
+
+	if _, err = c.Repo().Invite().UpdateInvite(invite); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	http.Redirect(w, r, "/dashboard", 302)
+}

+ 90 - 0
api/server/handlers/invite/create.go

@@ -0,0 +1,90 @@
+package invite
+
+import (
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/porter-dev/porter/internal/oauth"
+)
+
+type InviteCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewInviteCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *InviteCreateHandler {
+	return &InviteCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *InviteCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.CreateInviteRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// create invite model
+	invite, err := CreateInviteWithProject(request, project.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// write to database
+	invite, err = c.Repo().Invite().CreateInvite(invite)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// app.Logger.Info().Msgf("New invite created: %d", invite.ID)
+
+	if err := c.Config().UserNotifier.SendProjectInviteEmail(
+		&notifier.SendProjectInviteEmailOpts{
+			InviteeEmail:      request.Email,
+			URL:               fmt.Sprintf("%s/api/projects/%d/invites/accept?token=%s", c.Config().ServerConf.ServerURL, project.ID, invite.Token),
+			Project:           project.Name,
+			ProjectOwnerEmail: user.Email,
+		},
+	); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := types.CreateInviteResponse{
+		Invite: invite.ToInviteType(),
+	}
+
+	c.WriteResult(w, r, res)
+}
+
+func CreateInviteWithProject(invite *types.CreateInviteRequest, projectID uint) (*models.Invite, error) {
+	// generate a token and an expiry time
+	expiry := time.Now().Add(24 * time.Hour)
+
+	return &models.Invite{
+		Email:     invite.Email,
+		Kind:      invite.Kind,
+		Expiry:    &expiry,
+		ProjectID: projectID,
+		Token:     oauth.CreateRandomState(),
+	}, nil
+}

+ 36 - 0
api/server/handlers/invite/delete.go

@@ -0,0 +1,36 @@
+package invite
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InviteDeleteHandler struct {
+	handlers.PorterHandler
+	authz.KubernetesAgentGetter
+}
+
+func NewInviteDeleteHandler(
+	config *config.Config,
+) *InviteDeleteHandler {
+	return &InviteDeleteHandler{
+		PorterHandler:         handlers.NewDefaultPorterHandler(config, nil, nil),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *InviteDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	invite, _ := r.Context().Value(types.InviteScope).(*models.Invite)
+
+	if err := c.Repo().Invite().DeleteInvite(invite); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 44 - 0
api/server/handlers/invite/list.go

@@ -0,0 +1,44 @@
+package invite
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InvitesListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInvitesListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InvitesListHandler {
+	return &InvitesListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InvitesListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	invites, err := c.Repo().Invite().ListInvitesByProjectID(project.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListInvitesResponse = make([]*types.Invite, 0)
+
+	for _, invite := range invites {
+		res = append(res, invite.ToInviteType())
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 43 - 0
api/server/handlers/invite/update_role.go

@@ -0,0 +1,43 @@
+package invite
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InviteUpdateRoleHandler struct {
+	handlers.PorterHandlerReader
+}
+
+func NewInviteUpdateRoleHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) *InviteUpdateRoleHandler {
+	return &InviteUpdateRoleHandler{
+		PorterHandlerReader: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
+	}
+}
+
+func (c *InviteUpdateRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	invite, _ := r.Context().Value(types.InviteScope).(*models.Invite)
+
+	request := &types.UpdateInviteRoleRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	invite.Kind = request.Kind
+
+	if _, err := c.Repo().Invite().UpdateInvite(invite); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 57 - 0
api/server/handlers/project/delete_role.go

@@ -0,0 +1,57 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type RoleDeleteHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewRoleDeleteHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RoleDeleteHandler {
+	return &RoleDeleteHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *RoleDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.DeleteRoleRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	role, err := p.Repo().Project().ReadProjectRole(proj.ID, request.UserID)
+
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	role, err = p.Repo().Project().DeleteProjectRole(proj.ID, request.UserID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	res := &types.DeleteRoleResponse{
+		Role: role.ToRoleType(),
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 66 - 0
api/server/handlers/project/list_collaborators.go

@@ -0,0 +1,66 @@
+package project
+
+import (
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CollaboratorsListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewCollaboratorsListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *CollaboratorsListHandler {
+	return &CollaboratorsListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *CollaboratorsListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	roles, err := p.Repo().Project().ListProjectRoles(proj.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	roleMap := make(map[uint]*models.Role)
+	idArr := make([]uint, 0)
+
+	for _, role := range roles {
+		roleCp := role
+		roleMap[role.UserID] = &roleCp
+		idArr = append(idArr, role.UserID)
+	}
+
+	users, err := p.Repo().User().ListUsersByIDs(idArr)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListCollaboratorsResponse = make([]*types.Collaborator, 0)
+
+	for _, user := range users {
+		res = append(res, &types.Collaborator{
+			ID:        roleMap[user.ID].ID,
+			Kind:      string(roleMap[user.ID].Kind),
+			UserID:    roleMap[user.ID].UserID,
+			Email:     user.Email,
+			ProjectID: roleMap[user.ID].ProjectID,
+		})
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 29 - 0
api/server/handlers/project/list_roles.go

@@ -0,0 +1,29 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type RolesListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewRolesListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *RolesListHandler {
+	return &RolesListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *RolesListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	var res types.ListProjectRolesResponse = []types.RoleKind{types.RoleAdmin, types.RoleDeveloper, types.RoleViewer}
+
+	p.WriteResult(w, r, res)
+}

+ 60 - 0
api/server/handlers/project/update_role.go

@@ -0,0 +1,60 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type RoleUpdateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewRoleUpdateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RoleUpdateHandler {
+	return &RoleUpdateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *RoleUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.UpdateRoleRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	role, err := p.Repo().Project().ReadProjectRole(proj.ID, request.UserID)
+
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	role.Kind = types.RoleKind(request.Kind)
+
+	role, err = p.Repo().Project().UpdateProjectRole(proj.ID, role)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res = types.UpdateRoleResponse{
+		Role: role.ToRoleType(),
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 56 - 1
api/server/router/invite.go

@@ -52,7 +52,7 @@ func getInviteRoutes(
 
 	routes := make([]*Route, 0)
 
-	// GET /api/projects/{project_id}/invites/{invite_id} -> registry.NewInviteGetHandler
+	// GET /api/projects/{project_id}/invites/{invite_id} -> invite.NewInviteGetHandler
 	getEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -80,5 +80,60 @@ func getInviteRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/invites/{invite_id} -> invite.NewInviteUpdateRoleHandler
+	updateRoleEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InviteScope,
+			},
+		},
+	)
+
+	updateRoleHandler := invite.NewInviteUpdateRoleHandler(
+		config,
+		factory.GetDecoderValidator(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: updateRoleEndpoint,
+		Handler:  updateRoleHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/projects/{project_id}/invites/{invite_id} -> invite.NewInviteGetHandler
+	deleteEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InviteScope,
+			},
+		},
+	)
+
+	deleteHandler := invite.NewInviteDeleteHandler(
+		config,
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: deleteEndpoint,
+		Handler:  deleteHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 190 - 0
api/server/router/project.go

@@ -4,6 +4,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
+	"github.com/porter-dev/porter/api/server/handlers/invite"
 	"github.com/porter-dev/porter/api/server/handlers/project"
 	"github.com/porter-dev/porter/api/server/handlers/provision"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
@@ -191,6 +192,116 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/collaborators -> project.NewCollaboratorsListHandler
+	listCollaboratorsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/collaborators",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listCollaboratorsHandler := project.NewCollaboratorsListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listCollaboratorsEndpoint,
+		Handler:  listCollaboratorsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/roles -> project.NewRolesListHandler
+	listRolesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/roles",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listRolesHandler := project.NewRolesListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listRolesEndpoint,
+		Handler:  listRolesHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/roles -> project.NewRoleUpdateHandler
+	updateRoleEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/roles",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	updateRoleHandler := project.NewRoleUpdateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: updateRoleEndpoint,
+		Handler:  updateRoleHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/projects/{project_id}/roles -> project.NewRoleDeleteHandler
+	deleteRoleEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/roles",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	deleteRoleHandler := project.NewRoleDeleteHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: deleteRoleEndpoint,
+		Handler:  deleteRoleHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/registries -> registry.NewRegistryListHandler
 	listRegistriesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -414,5 +525,84 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/invites -> invite.NewInvitesListHandler
+	listInvitesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/invites",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listInvitesHandler := invite.NewInvitesListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listInvitesEndpoint,
+		Handler:  listInvitesHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/invites -> invite.NewInviteCreateHandler
+	createInviteEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/invites",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	createInviteHandler := invite.NewInviteCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: createInviteEndpoint,
+		Handler:  createInviteHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/invites/accept -> invite.NewInviteAcceptHandler
+	acceptInviteEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/invites/accept",
+			},
+			Scopes: []types.PermissionScope{},
+		},
+	)
+
+	acceptInviteHandler := invite.NewInviteAcceptHandler(
+		config,
+		factory.GetDecoderValidator(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: acceptInviteEndpoint,
+		Handler:  acceptInviteHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 19 - 0
api/types/invite.go

@@ -10,3 +10,22 @@ type Invite struct {
 }
 
 type GetInviteResponse Invite
+
+type CreateInviteRequest struct {
+	Email string `json:"email,required"`
+	Kind  string `json:"kind,required"`
+}
+
+type CreateInviteResponse struct {
+	*Invite
+}
+
+type ListInvitesResponse []*Invite
+
+type UpdateInviteRoleRequest struct {
+	Kind string `json:"kind,required"`
+}
+
+type AcceptInviteRequest struct {
+	Token string `schema:"token,required"`
+}

+ 29 - 0
api/types/project.go

@@ -32,3 +32,32 @@ type DeleteProjectResponse Project
 type ListProjectInfraResponse []*Infra
 
 type GetProjectPolicyResponse []*PolicyDocument
+
+type ListProjectRolesResponse []RoleKind
+
+type Collaborator struct {
+	ID        uint   `json:"id"`
+	Kind      string `json:"kind"`
+	UserID    uint   `json:"user_id"`
+	Email     string `json:"email"`
+	ProjectID uint   `json:"project_id"`
+}
+
+type ListCollaboratorsResponse []*Collaborator
+
+type UpdateRoleRequest struct {
+	UserID uint   `json:"user_id,required"`
+	Kind   string `json:"kind,required"`
+}
+
+type UpdateRoleResponse struct {
+	*Role
+}
+
+type DeleteRoleRequest struct {
+	UserID uint `schema:"user_id,required"`
+}
+
+type DeleteRoleResponse struct {
+	*Role
+}

+ 5 - 2
dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx

@@ -47,8 +47,11 @@ const EditCollaboratorModal = () => {
     try {
       await api.updateCollaborator(
         "<token>",
-        { kind: selectedRole },
-        { project_id, user_id: user.id }
+        {
+          kind: selectedRole,
+          user_id: user.id,
+        },
+        { project_id }
       );
       setStatus("successful");
       refetchCallerData().then(() => {

+ 4 - 4
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -182,8 +182,8 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     try {
       api.removeCollaborator(
         "<token>",
-        {},
-        { project_id: currentProject.id, user_id }
+        { user_id },
+        { project_id: currentProject.id }
       );
       getData();
     } catch (error) {
@@ -209,7 +209,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         Header: "Role",
         accessor: "kind",
         Cell: ({ row }) => {
-          return <Role>{row.values.kind || "Admin"}</Role>;
+          return <Role>{row.values.kind || "Developer"}</Role>;
         },
       },
       {
@@ -310,7 +310,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     const buildInviteLink = (token: string) => `
       ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${
       currentProject.id
-    }/invites/${token}
+    }/invites/accept?token=${token}
     `;
 
     const mappedInviteList = inviteList.map(

+ 66 - 60
dashboard/src/shared/api.tsx

@@ -174,8 +174,7 @@ const createProject = baseApi<{ name: string }, {}>("POST", (pathParams) => {
 });
 
 const createSubdomain = baseApi<
-  {
-  },
+  {},
   {
     id: number;
     release_name: string;
@@ -206,20 +205,18 @@ const deleteInvite = baseApi<{}, { id: number; invId: number }>(
 );
 
 const deletePod = baseApi<
-  {
-  },
-  { name: string; namespace: string; id: number; cluster_id: number; }
+  {},
+  { name: string; namespace: string; id: number; cluster_id: number }
 >("DELETE", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams
+  let { id, name, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/pods/${name}`;
 });
 
 const getPodEvents = baseApi<
-  {
-  },
-  { name: string; namespace: string; id: number; cluster_id: number; }
+  {},
+  { name: string; namespace: string; id: number; cluster_id: number }
 >("GET", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams
+  let { id, name, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/pods/${name}/events`;
 });
 
@@ -258,14 +255,13 @@ const updateNotificationConfig = baseApi<
     name: string;
   }
 >("POST", (pathParams) => {
-  let { project_id, cluster_id, namespace, name } = pathParams
+  let { project_id, cluster_id, namespace, name } = pathParams;
 
   return `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/notifications`;
 });
 
 const getNotificationConfig = baseApi<
-  {
-  },
+  {},
   {
     project_id: number;
     cluster_id: number;
@@ -273,7 +269,7 @@ const getNotificationConfig = baseApi<
     name: string;
   }
 >("GET", (pathParams) => {
-  let { project_id, cluster_id, namespace, name } = pathParams
+  let { project_id, cluster_id, namespace, name } = pathParams;
 
   return `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/notifications`;
 });
@@ -407,11 +403,17 @@ const getBranches = baseApi<
 
 const getChart = baseApi<
   {},
-  { id: number; cluster_id: number; namespace: string; name: string; revision: number }
+  {
+    id: number;
+    cluster_id: number;
+    namespace: string;
+    name: string;
+    revision: number;
+  }
 >("GET", (pathParams) => {
-  let { id, cluster_id, namespace, name, revision } = pathParams
+  let { id, cluster_id, namespace, name, revision } = pathParams;
 
-  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/${revision}`
+  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/${revision}`;
 });
 
 const getCharts = baseApi<
@@ -431,22 +433,33 @@ const getCharts = baseApi<
 });
 
 const getChartComponents = baseApi<
+  {},
   {
-  },
-  { id: number; cluster_id: number; namespace: string; name: string; revision: number }
+    id: number;
+    cluster_id: number;
+    namespace: string;
+    name: string;
+    revision: number;
+  }
 >("GET", (pathParams) => {
-  let { id, cluster_id, namespace, name, revision } = pathParams
+  let { id, cluster_id, namespace, name, revision } = pathParams;
 
-  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/${revision}/components`
+  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/${revision}/components`;
 });
 
 const getChartControllers = baseApi<
-{},
-{ id: number; cluster_id: number; namespace: string; name: string; revision: number }
+  {},
+  {
+    id: number;
+    cluster_id: number;
+    namespace: string;
+    name: string;
+    revision: number;
+  }
 >("GET", (pathParams) => {
-  let { id, cluster_id, namespace, name, revision } = pathParams
+  let { id, cluster_id, namespace, name, revision } = pathParams;
 
-  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/${revision}/controllers`
+  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/${revision}/controllers`;
 });
 
 const getClusterIntegrations = baseApi("GET", "/api/integrations/cluster");
@@ -538,11 +551,10 @@ const getInfra = baseApi<
 });
 
 const getIngress = baseApi<
-  {
-  },
+  {},
   { namespace: string; cluster_id: number; name: string; id: number }
 >("GET", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams
+  let { id, name, cluster_id, namespace } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/ingresses/${name}`;
 });
@@ -552,31 +564,28 @@ const getInvites = baseApi<{}, { id: number }>("GET", (pathParams) => {
 });
 
 const getJobs = baseApi<
-  {
-  },
+  {},
   { namespace: string; cluster_id: number; release_name: string; id: number }
 >("GET", (pathParams) => {
-  let { id, release_name, cluster_id, namespace } = pathParams
+  let { id, release_name, cluster_id, namespace } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/0/jobs`;
 });
 
 const getJobStatus = baseApi<
-  {
-  },
+  {},
   { namespace: string; cluster_id: number; release_name: string; id: number }
 >("GET", (pathParams) => {
-  let { id, release_name, cluster_id, namespace } = pathParams
+  let { id, release_name, cluster_id, namespace } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/0/jobs/status`;
 });
 
 const getJobPods = baseApi<
-  {
-  },
-  { name: string; namespace: string; id: number; cluster_id: number; }
+  {},
+  { name: string; namespace: string; id: number; cluster_id: number }
 >("GET", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams
+  let { id, name, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}/pods`;
 });
 
@@ -585,7 +594,7 @@ const getMatchingPods = baseApi<
     namespace: string;
     selectors: string[];
   },
-  { id: number, cluster_id: number; }
+  { id: number; cluster_id: number }
 >("GET", (pathParams) => {
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/pods`;
 });
@@ -670,11 +679,10 @@ const getPrometheusIsInstalled = baseApi<
 const getRegistryIntegrations = baseApi("GET", "/api/integrations/registry");
 
 const getReleaseToken = baseApi<
-  {
-  },
-  { name: string; id: number, namespace: string; cluster_id: number; }
+  {},
+  { name: string; id: number; namespace: string; cluster_id: number }
 >("GET", (pathParams) => {
-  let { id, cluster_id, namespace, name } = pathParams
+  let { id, cluster_id, namespace, name } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/webhook`;
 });
@@ -729,13 +737,12 @@ const getSlackIntegrations = baseApi<{}, { id: number }>(
 );
 
 const getRevisions = baseApi<
-  {
-  },
+  {},
   { id: number; cluster_id: number; namespace: string; name: string }
 >("GET", (pathParams) => {
-  let { id, cluster_id, namespace, name } = pathParams
+  let { id, cluster_id, namespace, name } = pathParams;
 
-  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/history`
+  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/history`;
 });
 
 const getTemplateInfo = baseApi<
@@ -974,11 +981,10 @@ const deleteNamespace = baseApi<
 });
 
 const deleteJob = baseApi<
-{
-},
-{ name: string; namespace: string; id: number; cluster_id: number; }
+  {},
+  { name: string; namespace: string; id: number; cluster_id: number }
 >("DELETE", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams
+  let { id, name, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}`;
 });
 
@@ -987,7 +993,7 @@ const stopJob = baseApi<
   { name: string; namespace: string; id: number; cluster_id: number }
 >("POST", (pathParams) => {
   let { id, name, namespace, cluster_id } = pathParams;
-  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}/stop`
+  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}/stop`;
 });
 
 const getAvailableRoles = baseApi<{}, { project_id: number }>(
@@ -1010,16 +1016,16 @@ const getCollaborators = baseApi<{}, { project_id: number }>(
 );
 
 const updateCollaborator = baseApi<
-  { kind: string },
-  { project_id: number; user_id: number }
->(
-  "POST",
-  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
-);
+  {
+    kind: string;
+    user_id: number;
+  },
+  { project_id: number }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/roles`);
 
-const removeCollaborator = baseApi<{}, { project_id: number; user_id: number }>(
+const removeCollaborator = baseApi<{ user_id: number }, { project_id: number }>(
   "DELETE",
-  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
+  ({ project_id }) => `/api/projects/${project_id}/roles`
 );
 
 const getPolicyDocument = baseApi<{}, { project_id: number }>(

+ 8 - 8
docs/developing/backend-refactor-status.md

@@ -72,11 +72,11 @@
 | <li>- [ ] `POST /api/projects/{project_id}/integrations/basic`                                                              |             |                 |             |                  |
 | <li>- [ ] `POST /api/projects/{project_id}/integrations/gcp`                                                                |             |                 |             |                  |
 | <li>- [ ] `GET /api/projects/{project_id}/integrations/oauth`                                                               |             |                 |             |                  |
-| <li>- [ ] `GET /api/projects/{project_id}/invites`                                                                          |             |                 |             |                  |
-| <li>- [ ] `POST /api/projects/{project_id}/invites`                                                                         |             |                 |             |                  |
-| <li>- [ ] `POST /api/projects/{project_id}/invites/{invite_id}`                                                             |             |                 |             |                  |
-| <li>- [ ] `DELETE /api/projects/{project_id}/invites/{invite_id}`                                                           |             |                 |             |                  |
-| <li>- [ ] `GET /api/projects/{project_id}/invites/{token}`                                                                  |             |                 |             |                  |
+| <li>- [x] `GET /api/projects/{project_id}/invites`                                                                          | AS          |                 |             | yes              |
+| <li>- [x] `POST /api/projects/{project_id}/invites`                                                                         | AS          |                 |             | yes              |
+| <li>- [x] `POST /api/projects/{project_id}/invites/{invite_id}`                                                             | AS          |                 |             | yes              |
+| <li>- [x] `DELETE /api/projects/{project_id}/invites/{invite_id}`                                                           | AS          |                 |             | yes              |
+| <li>- [x] `GET /api/projects/{project_id}/invites/{token}`                                                                  | AS          | yes             |             | yes              |
 | <li>- [x] `GET /api/projects/{project_id}/k8s/configmap`                                                                    | AS          | yes             |             | yes              |
 | <li>- [x] `POST /api/projects/{project_id}/k8s/configmap/create`                                                            | AS          | yes             |             | yes              |
 | <li>- [x] `DELETE /api/projects/{project_id}/k8s/configmap/delete`                                                          | AS          | yes             |             | yes              |
@@ -136,9 +136,9 @@
 | <li>- [X] `GET /api/projects/{project_id}/releases/{name}/{revision}/components`                                            | AB          |                 |             |                  |
 | <li>- [X] `GET /api/projects/{project_id}/releases/{name}/{revision}/controllers`                                           | AB          | yes             |             |                  |
 | <li>- [X] `GET /api/projects/{project_id}/releases/{name}/{revision}/pods/all`                                              | AB          |                 |             |                  |
-| <li>- [ ] `GET /api/projects/{project_id}/roles`                                                                            |             |                 |             |                  |
-| <li>- [ ] `POST /api/projects/{project_id}/roles/{user_id}`                                                                 |             |                 |             |                  |
-| <li>- [ ] `DELETE /api/projects/{project_id}/roles/{user_id}`                                                               |             |                 |             |                  |
+| <li>- [x] `GET /api/projects/{project_id}/roles`                                                                            | AS          |                 |             | yes              |
+| <li>- [x] `POST /api/projects/{project_id}/roles/{user_id}`                                                                 | AS          | yes             |             | yes              |
+| <li>- [x] `DELETE /api/projects/{project_id}/roles/{user_id}`                                                               | AS          | yes             |             | yes              |
 | <li>- [ ] `GET /api/projects/{project_id}/slack_integrations`                                                               |             |                 |             |                  |
 | <li>- [ ] `GET /api/projects/{project_id}/slack_integrations/exists`                                                        |             |                 |             |                  |
 | <li>- [ ] `DELETE /api/projects/{project_id}/slack_integrations/{slack_integration_id}`                                     |             |                 |             |                  |

+ 1 - 1
go.mod

@@ -6,7 +6,7 @@ require (
 	cloud.google.com/go v0.65.0
 	github.com/AlecAivazis/survey/v2 v2.2.9
 	github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
-	github.com/Masterminds/semver v1.5.0
+	github.com/Masterminds/semver v1.5.0 // indirect
 	github.com/Masterminds/semver/v3 v3.1.1
 	github.com/aws/aws-sdk-go v1.35.4
 	github.com/bradleyfalzon/ghinstallation v1.1.1

+ 2 - 1
internal/models/invite.go

@@ -3,8 +3,9 @@ package models
 import (
 	"time"
 
-	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
+
+	"github.com/porter-dev/porter/api/types"
 )
 
 // Invite type that extends gorm.Model