diff --git a/api/api.go b/api/api.go index 4095f22..f50f303 100644 --- a/api/api.go +++ b/api/api.go @@ -131,6 +131,9 @@ func (a *API) initRouter() http.Handler { // invite a new admin member to the organization log.Infow("new route", "method", "POST", "path", organizationAddMemberEndpoint) r.Post(organizationAddMemberEndpoint, a.inviteOrganizationMemberHandler) + // pending organization invitations + log.Infow("new route", "method", "GET", "path", organizationPendingMembersEndpoint) + r.Get(organizationPendingMembersEndpoint, a.pendingOrganizationMembersHandler) }) // Public routes diff --git a/api/docs.md b/api/docs.md index c13c39a..85ed05f 100644 --- a/api/docs.md +++ b/api/docs.md @@ -27,6 +27,7 @@ - [🔍 Organization info](#-organization-info) - [🧑‍🤝‍🧑 Organization members](#-organization-members) - [🧑‍💼 Invite organization member](#-invite-organization-member) + - [⏳ List pending invitations](#-list-pending-invitations) - [🤝 Accept organization invitation](#-accept-organization-invitation) - [🤠 Available organization members roles](#-available-organization-members-roles) - [🏛️ Available organization types](#-available-organization-types) @@ -575,6 +576,8 @@ Only the following parameters can be changed. Every parameter is optional. * **Path** `/organizations/{address}/members` * **Method** `POST` +* **Headers** + * `Authentication: Bearer ` * **Request** ```json { @@ -598,6 +601,35 @@ Only the following parameters can be changed. Every parameter is optional. | `409` | `40901` | `duplicate conflict` | | `500` | `50002` | `internal server error` | +### ⏳ List pending invitations + +* **Path** `/organizations/{address}/members/pending` +* **Method** `GET` +* **Headers** + * `Authentication: Bearer ` +* **Response** +```json +{ + "pending": [ + { + "email": "newuser@email.me", + "role": "admin", + "expiration": "2024-12-12T12:00:00.000Z" + } + ] +} +``` + +* **Errors** + +| HTTP Status | Error code | Message | +|:---:|:---:|:---| +| `401` | `40001` | `user not authorized` | +| `400` | `40009` | `organization not found` | +| `400` | `40011` | `no organization provided` | +| `401` | `40014` | `user account not verified` | +| `500` | `50002` | `internal server error` | + ### 🤝 Accept organization invitation * **Path** `/organizations/{address}/members/accept` diff --git a/api/organizations.go b/api/organizations.go index f0c28d8..22f60f2 100644 --- a/api/organizations.go +++ b/api/organizations.go @@ -397,6 +397,45 @@ func (a *API) acceptOrganizationMemberInvitationHandler(w http.ResponseWriter, r httpWriteOK(w) } +func (a *API) pendingOrganizationMembersHandler(w http.ResponseWriter, r *http.Request) { + // get the user from the request context + user, ok := userFromContext(r.Context()) + if !ok { + ErrUnauthorized.Write(w) + return + } + // get the organization info from the request context + org, _, ok := a.organizationFromRequest(r) + if !ok { + ErrNoOrganizationProvided.Write(w) + return + } + if !user.HasRoleFor(org.Address, db.AdminRole) { + ErrUnauthorized.Withf("user is not admin of organization").Write(w) + return + } + // check if the user is already verified + if !user.Verified { + ErrUserNoVerified.With("user account not verified").Write(w) + return + } + // get the pending invitations + invitations, err := a.db.PendingInvitations(org.Address) + if err != nil { + ErrGenericInternalServerError.Withf("could not get pending invitations: %v", err).Write(w) + return + } + invitationsList := make([]*OrganizationInvite, 0, len(invitations)) + for _, invitation := range invitations { + invitationsList = append(invitationsList, &OrganizationInvite{ + Email: invitation.NewUserEmail, + Role: string(invitation.Role), + Expiration: invitation.Expiration, + }) + } + httpWriteJSON(w, &OrganizationInviteList{Invites: invitationsList}) +} + // memberRolesHandler returns the available roles that can be assigned to a // member of an organization. func (a *API) organizationsMembersRolesHandler(w http.ResponseWriter, _ *http.Request) { diff --git a/api/routes.go b/api/routes.go index 7316018..df412e7 100644 --- a/api/routes.go +++ b/api/routes.go @@ -50,6 +50,8 @@ const ( organizationAddMemberEndpoint = "/organizations/{address}/members" // POST /organizations/{address}/members/invite/accept to accept the invitation organizationAcceptMemberEndpoint = "/organizations/{address}/members/accept" + // GET /organizations/{address}/members/pending to get the pending members + organizationPendingMembersEndpoint = "/organizations/{address}/members/pending" // GET /organizations/roles to get the available organization member roles organizationRolesEndpoint = "/organizations/roles" // GET /organizations/types to get the available organization types diff --git a/api/types.go b/api/types.go index b8bd1d3..61d275a 100644 --- a/api/types.go +++ b/api/types.go @@ -88,8 +88,15 @@ type UserInfo struct { // OrganizationInvite is the struct that represents an invitation to an // organization in the API. type OrganizationInvite struct { - Email string `json:"email"` - Role string `json:"role"` + Email string `json:"email"` + Role string `json:"role"` + Expiration time.Time `json:"expiration"` +} + +// OrganizationInviteList is the struct that represents a list of invitations to +// organizations in the API. +type OrganizationInviteList struct { + Invites []*OrganizationInvite `json:"pending"` } // AcceptOrganizationInvitation is the request to accept an invitation to an diff --git a/db/organization_invites.go b/db/organization_invites.go index 22bf3f9..4a0aac3 100644 --- a/db/organization_invites.go +++ b/db/organization_invites.go @@ -7,6 +7,7 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" + "go.vocdoni.io/dvote/log" ) // CreateInvitation creates a new invitation for a user to join an organization. @@ -79,6 +80,30 @@ func (ms *MongoStorage) Invitation(invitationCode string) (*OrganizationInvite, return invite, nil } +// PendingInvitations returns the pending invitations for the given organization. +func (ms *MongoStorage) PendingInvitations(organizationAddress string) ([]OrganizationInvite, error) { + ms.keysLock.RLock() + defer ms.keysLock.RUnlock() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cursor, err := ms.organizationInvites.Find(ctx, bson.M{"organizationAddress": organizationAddress}) + if err != nil { + return nil, err + } + defer func() { + if err := cursor.Close(ctx); err != nil { + log.Warnw("error closing cursor", "error", err) + } + }() + invitations := []OrganizationInvite{} + if err := cursor.All(ctx, &invitations); err != nil { + return nil, err + } + return invitations, nil +} + // DeleteInvitation removes the invitation from the database. func (ms *MongoStorage) DeleteInvitation(invitationCode string) error { ms.keysLock.Lock() diff --git a/db/organization_invites_test.go b/db/organization_invites_test.go index 84f94af..58f7b8f 100644 --- a/db/organization_invites_test.go +++ b/db/organization_invites_test.go @@ -115,6 +115,59 @@ func TestInvitation(t *testing.T) { c.Assert(invitation.Expiration.Truncate(time.Second).UTC(), qt.Equals, expires.Truncate(time.Second).UTC()) } +func TestPendingInvitations(t *testing.T) { + c := qt.New(t) + defer func() { + if err := db.Reset(); err != nil { + t.Error(err) + } + }() + // list invitations expecting none + invitations, err := db.PendingInvitations(orgAddress) + c.Assert(err, qt.IsNil) + c.Assert(invitations, qt.HasLen, 0) + + // create valid invitation + c.Assert(db.SetOrganization(&Organization{ + Address: orgAddress, + }), qt.IsNil) + _, err = db.SetUser(&User{ + Email: testUserEmail, + Password: testUserPass, + FirstName: testUserFirstName, + LastName: testUserLastName, + Organizations: []OrganizationMember{ + {Address: orgAddress, Role: AdminRole}, + }, + }) + c.Assert(err, qt.IsNil) + c.Assert(db.CreateInvitation(&OrganizationInvite{ + InvitationCode: invitationCode, + OrganizationAddress: orgAddress, + CurrentUserID: currentUserID, + NewUserEmail: newMemberEmail, + Role: AdminRole, + Expiration: expires, + }), qt.IsNil) + // list invitations expecting one + invitations, err = db.PendingInvitations(orgAddress) + c.Assert(err, qt.IsNil) + c.Assert(invitations, qt.HasLen, 1) + c.Assert(invitations[0].InvitationCode, qt.Equals, invitationCode) + c.Assert(invitations[0].OrganizationAddress, qt.Equals, orgAddress) + c.Assert(invitations[0].CurrentUserID, qt.Equals, currentUserID) + c.Assert(invitations[0].NewUserEmail, qt.Equals, newMemberEmail) + c.Assert(invitations[0].Role, qt.Equals, AdminRole) + // truncate expiration to seconds to avoid rounding issues, also set to UTC + c.Assert(invitations[0].Expiration.Truncate(time.Second).UTC(), qt.Equals, expires.Truncate(time.Second).UTC()) + // delete the invitation + c.Assert(db.DeleteInvitation(invitationCode), qt.IsNil) + // list invitations expecting none + invitations, err = db.PendingInvitations(orgAddress) + c.Assert(err, qt.IsNil) + c.Assert(invitations, qt.HasLen, 0) +} + func TestDeleteInvitation(t *testing.T) { c := qt.New(t) defer func() { diff --git a/db/organizations.go b/db/organizations.go index bbe4959..9efe02d 100644 --- a/db/organizations.go +++ b/db/organizations.go @@ -9,6 +9,7 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" + "go.vocdoni.io/dvote/log" ) func (ms *MongoStorage) organization(ctx context.Context, address string) (*Organization, error) { @@ -144,6 +145,11 @@ func (ms *MongoStorage) OrganizationsMembers(address string) ([]User, error) { if err != nil { return nil, err } + defer func() { + if err := cursor.Close(ctx); err != nil { + log.Warnw("error closing cursor", "error", err) + } + }() if err := cursor.All(ctx, &users); err != nil { return nil, err }