package api_impl import ( "context" "database/sql" "encoding/json" "fmt" "html" "net/http" "strings" "atlas9.dev/c/core" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/demo/api" "atlas9.dev/c/demo/lib/access" "atlas9.dev/c/demo/store" "atlas9.dev/c/demo/tasks" "atlas9.dev/c/mail" ) type TenantInvitationsImpl struct { DB *sql.DB Guard access.Guard Invitations dbi.Factory[iam.TenantInvitationStore] Grants dbi.Factory[iam.GrantStore] Users dbi.Factory[iam.UserStore] Tasks dbi.Factory[tasks.Producer] Audit dbi.Factory[iam.AuditStore] BaseURL string Mailer mail.Mailer } func (s *TenantInvitationsImpl) ServeMux(mux *http.ServeMux) { mux.HandleFunc(api.Path_TenantInvitations_Create, s.Create) mux.HandleFunc(api.Path_TenantInvitations_Accept, s.Accept) mux.HandleFunc(api.Path_TenantInvitations_Decline, s.Decline) mux.HandleFunc(api.Path_TenantInvitations_List, s.List) mux.HandleFunc(api.Path_TenantInvitations_ListByEmail, s.ListByEmail) mux.HandleFunc(api.Path_TenantInvitations_Delete, s.Delete) mux.HandleFunc(api.Path_TenantInvitations_Task_SendTenantInvitation, s.SendTenantInvitation) } func (s *TenantInvitationsImpl) SendTenantInvitation(w http.ResponseWriter, r *http.Request) { var req api.TenantInvitations_Task_SendTenantInvitationReq if read(w, r, &req) { return } ctx := r.Context() acceptURL := s.BaseURL + "/accept-invitation?token=" + req.Token text := "You've been invited to join a team. Click here to accept: " + acceptURL htmlBody := fmt.Sprintf( `

You've been invited to join a team. Click here to accept.

`, html.EscapeString(acceptURL), ) err := s.Mailer.Send(ctx, req.Email, mail.Content{ Subject: "You've been invited", TextBody: text, HtmlBody: htmlBody, }) write(r.Context(), w, err, nil) } func (s *TenantInvitationsImpl) Create(w http.ResponseWriter, r *http.Request) { var req api.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 api.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 } if err := s.Tasks(tx).Push(ctx, api.Path_TenantInvitations_Task_SendTenantInvitation, payload); err != nil { return err } return audit(ctx, s.Audit(tx), iam.AuditEntry{ Tenant: req.Tenant, Action: "TenantInvitations_Create", Resource: email, }) }) write(ctx, w, err, res) } func (s *TenantInvitationsImpl) Accept(w http.ResponseWriter, r *http.Request) { var req api.TenantInvitations_AcceptReq if read(w, r, &req) { return } ctx := r.Context() write(ctx, w, s.acceptToken(ctx, req.Token), nil) } // func (s *TenantInvitationsImpl) redirectToLoginOrRegister(ctx context.Context, w http.ResponseWriter, r *http.Request, token string) { // ctx = access.PutSystem(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 = access.PutSystem(ctx, iam.CapUsersGet, 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) 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 } grant := iam.Grant{ Tenant: inv.Tenant, Type: iam.GrantTypeUser, Principal: userID.String(), Role: "member", } if err := grants.Add(ctx, grant); err != nil { return err } if err := invitations.DeleteByToken(ctx, token); err != nil { return err } return audit(ctx, s.Audit(tx), iam.AuditEntry{ Tenant: inv.Tenant, Action: "TenantInvitations_Accept", Resource: inv.Email, }) }) } func (s *TenantInvitationsImpl) Decline(w http.ResponseWriter, r *http.Request) { var req api.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 := access.PutSystem(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 } if err := s.Invitations(tx).DeleteByToken(ctx, req.Token); err != nil { return err } return audit(ctx, s.Audit(tx), iam.AuditEntry{ Tenant: inv.Tenant, Action: "TenantInvitations_Decline", Resource: inv.Email, }) }) write(ctx, w, err, nil) } func (s *TenantInvitationsImpl) List(w http.ResponseWriter, r *http.Request) { var req api.TenantInvitations_ListReq if read(w, r, &req) { return } if check(w, r, s.Guard, iam.CapTenantInvitationsRead, req.Tenant, "") { return } ctx := r.Context() var res api.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 api.TenantInvitations_ListByEmailReq if read(w, r, &req) { return } ctx := r.Context() var res api.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 api.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 { if err := s.Invitations(tx).Delete(ctx, req.Tenant, req.Email); err != nil { return err } return audit(ctx, s.Audit(tx), iam.AuditEntry{ Tenant: req.Tenant, Action: "TenantInvitations_Delete", Resource: strings.ToLower(req.Email), }) }) write(ctx, w, err, nil) }