Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: members invitations #22

Merged
merged 21 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bba0fe4
new endpoint to invite a new organization member
lucasmenendez Oct 11, 2024
c66ffe6
ensure that only verified admins can invite new members
lucasmenendez Oct 11, 2024
4a44cb1
make phone optional for existing users during invitation process
lucasmenendez Oct 11, 2024
fcf5105
include required user phone in tests
lucasmenendez Oct 11, 2024
e6f3724
Merge branch 'main' into f/admin_invitations
emmdim Oct 16, 2024
3e5c3ae
Merge branch 'main' into f/admin_invitations
emmdim Oct 17, 2024
33da6a1
helper method to check valid roles
lucasmenendez Oct 21, 2024
3b20183
new invite-accept flow with expirable invitations, supporting new and…
lucasmenendez Oct 23, 2024
0affd56
some comments updated
lucasmenendez Oct 23, 2024
d99e59e
wrong emoji :)
lucasmenendez Oct 23, 2024
4bf295f
db invitations tests and phone remove from whole api
lucasmenendez Oct 29, 2024
a2bc2bd
Merge branch 'main' into f/admin_invitations
lucasmenendez Oct 29, 2024
53c1de6
Merge branch 'main' into f/admin_invitations
lucasmenendez Oct 29, 2024
15c1cf9
fixing api tests based on new errors
lucasmenendez Oct 29, 2024
fc2b269
phone index removed
lucasmenendez Oct 31, 2024
ea956ac
Merge branch 'main' into f/admin_invitations
lucasmenendez Oct 31, 2024
0042dec
remove remaining code of sms and user phone number
lucasmenendez Oct 31, 2024
71adb81
Merge branch 'main' into f/admin_invitations
emmdim Oct 31, 2024
77768d4
Minor fix
emmdim Oct 31, 2024
4717adf
fixing tests
lucasmenendez Oct 31, 2024
8233219
old debug log trace removed
lucasmenendez Oct 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ type APIConfig struct {
Client *apiclient.HTTPclient
Account *account.Account
MailService notifications.NotificationService
SMSService notifications.NotificationService
// FullTransparentMode if true allows signing all transactions and does not
// modify any of them.
FullTransparentMode bool
Expand All @@ -46,7 +45,6 @@ type API struct {
client *apiclient.HTTPclient
account *account.Account
mail notifications.NotificationService
sms notifications.NotificationService
secret string
transparentMode bool
}
Expand All @@ -64,7 +62,6 @@ func New(conf *APIConfig) *API {
client: conf.Client,
account: conf.Account,
mail: conf.MailService,
sms: conf.SMSService,
secret: conf.Secret,
transparentMode: conf.FullTransparentMode,
}
Expand Down Expand Up @@ -131,6 +128,9 @@ func (a *API) initRouter() http.Handler {
// update the organization
log.Infow("new route", "method", "PUT", "path", organizationEndpoint)
r.Put(organizationEndpoint, a.updateOrganizationHandler)
// invite a new admin member to the organization
log.Infow("new route", "method", "POST", "path", organizationAddMemberEndpoint)
r.Post(organizationAddMemberEndpoint, a.inviteOrganizationMemberHandler)
})

// Public routes
Expand Down Expand Up @@ -167,6 +167,9 @@ func (a *API) initRouter() http.Handler {
// get organization members
log.Infow("new route", "method", "GET", "path", organizationMembersEndpoint)
r.Get(organizationMembersEndpoint, a.organizationMembersHandler)
// accept organization invitation
log.Infow("new route", "method", "POST", "path", organizationAcceptMemberEndpoint)
r.Post(organizationAcceptMemberEndpoint, a.acceptOrganizationMemberInvitationHandler)
// get organization roles
log.Infow("new route", "method", "GET", "path", organizationRolesEndpoint)
r.Get(organizationRolesEndpoint, a.organizationsMembersRolesHandler)
Expand Down
1 change: 1 addition & 0 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const (
testPass = "password123"
testFirstName = "test"
testLastName = "user"
testPhone = "+1234567890"
testHost = "0.0.0.0"
testPort = 7788

Expand Down
6 changes: 6 additions & 0 deletions api/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ const (
VerificationCodeEmailSubject = "Vocdoni verification code"
// VerificationCodeTextBody is the body of the verification code email
VerificationCodeTextBody = "Your Vocdoni verification code is: "
// InvitationEmailSubject is the subject of the invitation email
InvitationEmailSubject = "Vocdoni organization invitation"
// InvitationTextBody is the body of the invitation email
InvitationTextBody = "You code to join to '%s' organization is: %s"
// InvitationExpiration is the duration of the invitation code before it is invalidated
InvitationExpiration = 5 * 24 * time.Hour // 5 days
)
68 changes: 66 additions & 2 deletions api/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
- [⚙️ Update organization](#-update-organization)
- [🔍 Organization info](#-organization-info)
- [🧑‍🤝‍🧑 Organization members](#-organization-members)
- [🧑‍💼 Invite organization member](#-invite-organization-member)
- [🤝 Accept organization invitation](#-accept-organization-invitation)
- [🤠 Available organization members roles](#-available-organization-members-roles)
- [🏛️ Available organization types](#-available-organization-types)

Expand Down Expand Up @@ -190,7 +192,8 @@ This endpoint only returns the addresses of the organizations where the current
"email": "[email protected]",
"firstName": "Steve",
"lastName": "Urkel",
"password": "secretpass1234"
"password": "secretpass1234",
"phone": "123456789"
}
```

Expand Down Expand Up @@ -568,6 +571,67 @@ Only the following parameters can be changed. Every parameter is optional.
| `400` | `4012` | `no organization provided` |
| `500` | `50002` | `internal server error` |

### 🧑‍💼 Invite organization member

* **Path** `/organizations/{address}/members`
* **Method** `POST`
* **Response**
```json
{
"role": "admin",
"email": "[email protected]"
}
```

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `401` | `40001` | `user not authorized` |
| `400` | `40002` | `email malformed` |
| `400` | `40004` | `malformed JSON body` |
| `400` | `40005` | `invalid user data` |
| `400` | `40009` | `organization not found` |
| `400` | `40011` | `no organization provided` |
| `401` | `40014` | `user account not verified` |
| `400` | `40019` | `inviation code expired` |
| `409` | `40901` | `duplicate conflict` |
| `500` | `50002` | `internal server error` |

### 🤝 Accept organization invitation

* **Path** `/organizations/{address}/members/accept`
* **Method** `POST`
* **Response**
```json
{
"code": "a3f3b5",
"user": { // only if the invited user is not already registered
"email": "[email protected]",
"firstName": "Steve",
"lastName": "Urkel",
"password": "secretpass1234",
"phone": "123456789"
}
}
```
`user` object is only required if invited user is not registered yet.

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `401` | `40001` | `user not authorized` |
| `400` | `40002` | `email malformed` |
| `400` | `40004` | `malformed JSON body` |
| `400` | `40005` | `invalid user data` |
| `400` | `40009` | `organization not found` |
| `400` | `40011` | `no organization provided` |
| `401` | `40014` | `user account not verified` |
| `400` | `40019` | `inviation code expired` |
| `409` | `40901` | `duplicate conflict` |
| `500` | `50002` | `internal server error` |

### 🤠 Available organization members roles
* **Path** `/organizations/roles`
* **Method** `GET`
Expand Down Expand Up @@ -659,4 +723,4 @@ Only the following parameters can be changed. Every parameter is optional.
}
]
}
```
```
1 change: 1 addition & 0 deletions api/errors_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ var (
ErrVerificationCodeExpired = Error{Code: 40016, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("verification code expired")}
ErrVerificationCodeValid = Error{Code: 40017, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("last verification code still valid")}
ErrUserNotFound = Error{Code: 40018, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("user not found")}
ErrInvitationExpired = Error{Code: 40019, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("inviation code expired")}

ErrMarshalingServerJSONFailed = Error{Code: 50001, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("marshaling (server-side) JSON failed")}
ErrGenericInternalServerError = Error{Code: 50002, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("internal server error")}
Expand Down
181 changes: 181 additions & 0 deletions api/organizations.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package api

import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/vocdoni/saas-backend/account"
"github.com/vocdoni/saas-backend/db"
"github.com/vocdoni/saas-backend/internal"
"github.com/vocdoni/saas-backend/notifications"
"go.vocdoni.io/dvote/log"
)

// createOrganizationHandler handles the request to create a new organization.
Expand Down Expand Up @@ -212,6 +217,182 @@ func (a *API) updateOrganizationHandler(w http.ResponseWriter, r *http.Request)
httpWriteOK(w)
}

// inviteOrganizationMemberHandler handles the request to invite a new admin
// member to an organization. Only the admin of the organization can invite a
// new member. It stores the invitation in the database and sends an email to
// the new member with the invitation code.
func (a *API) inviteOrganizationMemberHandler(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 new admin info from the request body
invite := &OrganizationInvite{}
if err := json.NewDecoder(r.Body).Decode(invite); err != nil {
ErrMalformedBody.Write(w)
return
}
// check the email is correct format
if !internal.ValidEmail(invite.Email) {
ErrEmailMalformed.Write(w)
return
}
// check the role is valid
if valid := db.IsValidUserRole(db.UserRole(invite.Role)); !valid {
ErrInvalidUserData.Withf("invalid role").Write(w)
return
}
// check if the new user is already a member of the organization
if _, err := a.db.IsMemberOf(invite.Email, org.Address, db.AdminRole); err == nil {
ErrDuplicateConflict.With("user is already admin of organization").Write(w)
return
}
// create new invitation
inviteCode := internal.RandomHex(VerificationCodeLength)
if err := a.db.CreateInvitation(&db.OrganizationInvite{
InvitationCode: inviteCode,
OrganizationAddress: org.Address,
NewUserEmail: invite.Email,
Role: db.UserRole(invite.Role),
CurrentUserID: user.ID,
Expiration: time.Now().Add(InvitationExpiration),
}); err != nil {
ErrGenericInternalServerError.Withf("could not create invitation: %v", err).Write(w)
return
}
// send the invitation email
ctx, cancel := context.WithTimeout(r.Context(), time.Second*10)
defer cancel()
// send the verification code via email if the mail service is available
if a.mail != nil {
if err := a.mail.SendNotification(ctx, &notifications.Notification{
ToName: fmt.Sprintf("%s %s", user.FirstName, user.LastName),
ToAddress: invite.Email,
Subject: InvitationEmailSubject,
Body: fmt.Sprintf(InvitationTextBody, org.Address, inviteCode),
}); err != nil {
ErrGenericInternalServerError.Withf("could not send verification code: %v", err).Write(w)
return
}
}
httpWriteOK(w)
}

// acceptOrganizationMemberInvitationHandler handles the request to accept an
// invitation to an organization. It checks if the invitation is valid and not
// expired, and if the user is not already a member of the organization. If the
// user does not exist, it creates a new user with the provided information.
// If the user already exists and is verified, it adds the organization to the
// user.
func (a *API) acceptOrganizationMemberInvitationHandler(w http.ResponseWriter, r *http.Request) {
// get the organization info from the request context
org, _, ok := a.organizationFromRequest(r)
if !ok {
ErrNoOrganizationProvided.Write(w)
return
}
// get new member info from the request body
invitationReq := &AcceptOrganizationInvitation{}
if err := json.NewDecoder(r.Body).Decode(invitationReq); err != nil {
ErrMalformedBody.Write(w)
return
}
// get the invitation from the database
invitation, err := a.db.Invitation(invitationReq.Code)
if err != nil {
ErrUnauthorized.Withf("could not get invitation: %v", err).Write(w)
return
}
// check if the organization is correct
if invitation.OrganizationAddress != org.Address {
ErrUnauthorized.Withf("invitation is not for this organization").Write(w)
return
}
// create a helper function to remove the invitation from the database in
// case of error or expiration
removeInvitation := func() {
if err := a.db.DeleteInvitation(invitationReq.Code); err != nil {
log.Warnf("could not delete invitation: %v", err)
}
}
// check if the invitation is expired
if invitation.Expiration.Before(time.Now()) {
go removeInvitation()
emmdim marked this conversation as resolved.
Show resolved Hide resolved
ErrInvitationExpired.Write(w)
return
}
// try to get the user from the database
dbUser, err := a.db.UserByEmail(invitation.NewUserEmail)
if err != nil {
// if the user does not exist, create it
if err != db.ErrNotFound {
ErrGenericInternalServerError.Withf("could not get user: %v", err).Write(w)
return
}
// check if the user info is provided
if invitationReq.User == nil {
ErrMalformedBody.With("user info not provided").Write(w)
return
}
// check the email is correct
if invitationReq.User.Email != invitation.NewUserEmail {
ErrInvalidUserData.With("email does not match").Write(w)
return
}
// create the new user and move on to include the organization
hPassword := internal.HexHashPassword(passwordSalt, invitationReq.User.Password)
dbUser = &db.User{
Email: invitationReq.User.Email,
Password: hPassword,
FirstName: invitationReq.User.FirstName,
LastName: invitationReq.User.LastName,
Verified: true,
}
} else {
// if it does, check if the user is already verified
if !dbUser.Verified {
ErrUserNoVerified.With("user already exists but is not verified").Write(w)
return
}
// check if the user is already a member of the organization
if _, err := a.db.IsMemberOf(invitation.NewUserEmail, org.Address, invitation.Role); err == nil {
go removeInvitation()
ErrDuplicateConflict.With("user is already admin of organization").Write(w)
return
}
}
// include the new organization in the user
dbUser.Organizations = append(dbUser.Organizations, db.OrganizationMember{
Address: org.Address,
Role: invitation.Role,
})
// set the user in the database
if _, err := a.db.SetUser(dbUser); err != nil {
ErrGenericInternalServerError.Withf("could not set user: %v", err).Write(w)
return
}
// delete the invitation
go removeInvitation()
httpWriteOK(w)
}

// memberRolesHandler returns the available roles that can be assigned to a
// member of an organization.
func (a *API) organizationsMembersRolesHandler(w http.ResponseWriter, _ *http.Request) {
Expand Down
4 changes: 4 additions & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ const (
organizationEndpoint = "/organizations/{address}"
// GET /organizations/{address}/members to get the organization members
organizationMembersEndpoint = "/organizations/{address}/members"
// POST /organizations/{address}/members/invite to add a new member
organizationAddMemberEndpoint = "/organizations/{address}/members"
// POST /organizations/{address}/members/invite/accept to accept the invitation
organizationAcceptMemberEndpoint = "/organizations/{address}/members/accept"
// GET /organizations/roles to get the available organization member roles
organizationRolesEndpoint = "/organizations/roles"
// GET /organizations/types to get the available organization types
Expand Down
Loading
Loading