package api import ( "context" "database/sql" "encoding/json" "net/http" "strings" "atlas9.dev/c/core" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/demo/lib" "atlas9.dev/c/demo/store" "atlas9.dev/c/demo/tasks" ) type TenantInvitationsImpl struct { DB *sql.DB Guard lib.Guard Invitations dbi.Factory[iam.TenantInvitationStore] Members dbi.Factory[iam.TenantMemberStore] Grants dbi.Factory[iam.GrantStore] Users dbi.Factory[iam.UserStore] Tasks dbi.Factory[tasks.Producer] } func (s *TenantInvitationsImpl) Create(w http.ResponseWriter, r *http.Request) { var req TenantInvitations_CreateReq if read(w, r, &req) { return } if check(w, r, s.Guard, iam.CapTenantInvitationsCreate, req.Tenant, "") { return } ctx := r.Context() email := strings.ToLower(req.Email) var res TenantInvitations_CreateRes err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { token, err := s.Invitations(tx).Create(ctx, req.Tenant, email) if err != nil { return err } res.Token = token payload, err := json.Marshal(store.TenantInvitationTaskPayload{ Email: email, Token: token, }) if err != nil { return err } return s.Tasks(tx).Push(ctx, "Tasks.SendTenantInvitation", payload) }) write(ctx, w, err, res) } func (s *TenantInvitationsImpl) Accept(w http.ResponseWriter, r *http.Request) { var req TenantInvitations_AcceptReq if read(w, r, &req) { return } ctx := r.Context() write(ctx, w, s.acceptToken(ctx, req.Token), nil) } // AcceptPage handles GET /accept-invitation?token=... from email links. // If the user is not logged in they are redirected to /login?next= // so that the token survives the login flow. On success the user is redirected // to /dashboard. func (s *TenantInvitationsImpl) AcceptPage(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if token == "" { http.Error(w, "Missing token.", http.StatusBadRequest) return } // TODO this flow doesn't provide a very nice UX // - you can get redirected to a login page, with any message like "to accept..log in first" // - if the invite is accepted, there's no message (silent success) // TODO should be middleware or a helper function (require auth) ctx := r.Context() if iam.GetPrincipal(ctx).Subject == "" { s.redirectToLoginOrRegister(ctx, w, r, token) return } if err := s.acceptToken(ctx, token); err != nil { // TODO needs an error page. http.Error(w, "Could not accept invitation: "+err.Error(), http.StatusBadRequest) return } http.Redirect(w, r, "/dashboard", http.StatusFound) } func (s *TenantInvitationsImpl) redirectToLoginOrRegister(ctx context.Context, w http.ResponseWriter, r *http.Request, token string) { ctx = lib.PutSystemAccess(ctx, iam.CapUsersGetByEmail) register := false err := dbi.ReadOnly(ctx, s.DB, func(tx dbi.DBI) error { users := s.Users(tx) invitations := s.Invitations(tx) // Load the invitation. var inv iam.TenantInvitation if err := invitations.GetByToken(ctx, token, &inv); err != nil { return err } // Check if the user exists. var user iam.User err := users.GetByEmail(ctx, inv.Email, &user) if err == core.ErrNotFound { register = true return nil } return err }) if err != nil { write(ctx, w, err, nil) return } // TODO "next" redirects should be base64 encoded or something. // but also, this whole server-side flow feels clunky. // and "next" with register doesn't work anyway, because registration requires email verification first. next := "/accept-invitation?token=" + token if register { http.Redirect(w, r, "/register?next="+next, http.StatusFound) } else { http.Redirect(w, r, "/login?next="+next, http.StatusFound) } } // acceptToken contains the core invitation-acceptance logic shared by the JSON // API handler and the server-side page handler. func (s *TenantInvitationsImpl) acceptToken(ctx context.Context, token string) error { ctx = lib.PutSystemAccess(ctx, iam.CapUsersGet, iam.CapTenantMembersCreate, iam.CapGrantsAdd, ) principal := iam.GetPrincipal(ctx) userID, err := core.ParseID(principal.Subject) if err != nil { return iam.ErrForbidden } return dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { users := s.Users(tx) invitations := s.Invitations(tx) members := s.Members(tx) grants := s.Grants(tx) // Load the principal's user record. var user iam.User if err := users.Get(ctx, userID, &user); err != nil { return err } // Load the invitation. var inv iam.TenantInvitation if err := invitations.GetByToken(ctx, token, &inv); err != nil { return err } // Self-check: the principal's email must match the invitation email. if strings.ToLower(user.Email) != inv.Email { return iam.ErrForbidden } // Accept the invite: // - add the user as a member of the tenant // - grant the user the "member" role // - delete the invitation member := iam.TenantMember{ Tenant: inv.Tenant, UserID: userID, Owner: false, } // TODO these grants ("owner" and "member") seem redundant. // i think i intended for these roles to be implied by the tenant member system. grant := iam.Grant{ Tenant: inv.Tenant, Type: iam.GrantTypeUser, Principal: userID.String(), Role: "member", } if err := members.Create(ctx, member); err != nil { return err } if err := grants.Add(ctx, grant); err != nil { return err } return invitations.DeleteByToken(ctx, token) }) } func (s *TenantInvitationsImpl) Decline(w http.ResponseWriter, r *http.Request) { var req TenantInvitations_DeclineReq if read(w, r, &req) { return } ctx := r.Context() // TODO move this pattern into a helper principal := iam.GetPrincipal(ctx) userID, err := core.ParseID(principal.Subject) if err != nil { writeErr(ctx, w, iam.ErrForbidden) return } err = dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { var inv iam.TenantInvitation if err := s.Invitations(tx).GetByToken(ctx, req.Token, &inv); err != nil { return err } userCtx := lib.PutSystemAccess(ctx, iam.CapUsersGet) var user iam.User if err := s.Users(tx).Get(userCtx, userID, &user); err != nil { return err } if strings.ToLower(user.Email) != inv.Email { return iam.ErrForbidden } return s.Invitations(tx).DeleteByToken(ctx, req.Token) }) write(ctx, w, err, nil) } func (s *TenantInvitationsImpl) List(w http.ResponseWriter, r *http.Request) { var req TenantInvitations_ListReq if read(w, r, &req) { return } if check(w, r, s.Guard, iam.CapTenantInvitationsRead, req.Tenant, "") { return } ctx := r.Context() var res TenantInvitations_ListRes err := dbi.ReadOnly(ctx, s.DB, func(tx dbi.DBI) error { return s.Invitations(tx).List(ctx, req.Tenant, req.Page, &res.Page) }) write(ctx, w, err, res) } func (s *TenantInvitationsImpl) ListByEmail(w http.ResponseWriter, r *http.Request) { var req TenantInvitations_ListByEmailReq if read(w, r, &req) { return } ctx := r.Context() var res TenantInvitations_ListByEmailRes err := dbi.ReadOnly(ctx, s.DB, func(tx dbi.DBI) error { return s.Invitations(tx).ListByEmail(ctx, req.Email, req.Page, &res.Page) }) write(ctx, w, err, res) } func (s *TenantInvitationsImpl) Delete(w http.ResponseWriter, r *http.Request) { var req TenantInvitations_DeleteReq if read(w, r, &req) { return } if check(w, r, s.Guard, iam.CapTenantInvitationsDelete, req.Tenant, "") { return } ctx := r.Context() err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { return s.Invitations(tx).Delete(ctx, req.Tenant, req.Email) }) write(ctx, w, err, nil) }