mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Access was binary and org-wide: any org member got full read+write on every project. Now each project carries four ordered levels, resolved by one resolver and enforced at one choke point. - `projectPerm` (perms.go) replaces `projectAllowed`; `proj(level, h)` in server.go gates every per-project route by the level it declares at registration, so no handler grows its own check. - `Project` gains Creator/Default/Perms. `Default == ""` means write, so an upgraded hub behaves identically until someone edits permissions. - Creator becomes the first project admin; org owners are implicitly admin everywhere in their org and a grant naming one is refused, not ignored; a project always keeps at least one explicit admin. - Default `none` makes a project invite-only. A `none` member is treated exactly like a non-member, including on create-or-join by name. - Rename/delete move from org-owner-only to project `admin`. - Both metadata backends persist it: the file store rides along, the SQL store gains `project_perms` plus an idempotent ALTER for the two new columns (migrate() had only ever created tables). Client side, a refusal stops looking like an outage: `remote.ErrForbidden` plus `Result.ReadOnly` (push refused → pull-only) and `Result.NoAccess` (pull refused → paused, working folder untouched). Neither sets Offline, neither loses a local op, and re-granting self-heals on the next cycle. `bdrive status`/`sync` and the daemon (once, on transition) say which. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
582 lines
16 KiB
Go
582 lines
16 KiB
Go
package webapp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Organizations group accounts and own projects: every project belongs to
|
|
// exactly one org, and only that org's members can see or sync it. The OSS
|
|
// server supports any number of orgs on one hub (a self-hosted deployment is
|
|
// typically just one); membership is by account email with two roles. Share
|
|
// links (/s/) deliberately stay outside this wall — public is their point.
|
|
//
|
|
// Same file-backed discipline as the other registries: orgs.json is loaded
|
|
// at open and rewritten atomically (temp + rename) on every change.
|
|
|
|
const (
|
|
RoleOwner = "owner"
|
|
RoleMember = "member"
|
|
)
|
|
|
|
// Org is one organization.
|
|
type Org struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Members map[string]string `json:"members"` // lowercase email → role
|
|
Created time.Time `json:"created"`
|
|
}
|
|
|
|
// OrgInvite is a mint-once join link. Redeeming it while signed in adds the
|
|
// account to the org as a member.
|
|
type OrgInvite struct {
|
|
Token string `json:"token"`
|
|
Org string `json:"org"`
|
|
Creator string `json:"creator,omitempty"` // account email
|
|
Created time.Time `json:"created"`
|
|
Expires time.Time `json:"expires"`
|
|
Uses int `json:"uses"` // how many accounts have joined via this link
|
|
}
|
|
|
|
// RecordInviteUse bumps the join counter for an invite (best effort).
|
|
func (db *OrgDB) RecordInviteUse(token string) {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
if inv, ok := db.invites[token]; ok {
|
|
inv.Uses++
|
|
db.invites[token] = inv
|
|
db.repo.PutInvite(inv)
|
|
}
|
|
}
|
|
|
|
func (i OrgInvite) expired() bool { return time.Now().After(i.Expires) }
|
|
|
|
// DefaultInviteTTL bounds invite links that don't ask for an expiry.
|
|
const DefaultInviteTTL = 7 * 24 * time.Hour
|
|
|
|
// OrgDB is the in-memory org registry over a MetaStore OrgRepo (orgs + invites).
|
|
type OrgDB struct {
|
|
repo OrgRepo
|
|
|
|
mu sync.Mutex
|
|
byID map[string]Org
|
|
invites map[string]OrgInvite
|
|
}
|
|
|
|
// NewOrgDB builds the registry over a repo, loading orgs and invites.
|
|
func NewOrgDB(repo OrgRepo) (*OrgDB, error) {
|
|
db := &OrgDB{repo: repo, byID: make(map[string]Org), invites: make(map[string]OrgInvite)}
|
|
orgs, invites, err := repo.Load()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, o := range orgs {
|
|
db.byID[o.ID] = o
|
|
}
|
|
for _, i := range invites {
|
|
db.invites[i.Token] = i
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// OpenOrgDB loads the file-backed registry at path.
|
|
func OpenOrgDB(path string) (*OrgDB, error) {
|
|
return NewOrgDB(newFileOrgRepo(path))
|
|
}
|
|
|
|
func normEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) }
|
|
|
|
// Create makes a new org owned by ownerEmail.
|
|
func (db *OrgDB) Create(name, ownerEmail string) (Org, error) {
|
|
name = trimName(name)
|
|
if name == "" {
|
|
return Org{}, fmt.Errorf("organization name must not be empty")
|
|
}
|
|
o := Org{
|
|
ID: "o-" + randHex(4), Name: name,
|
|
Members: map[string]string{}, Created: time.Now().UTC(),
|
|
}
|
|
if e := normEmail(ownerEmail); e != "" {
|
|
o.Members[e] = RoleOwner
|
|
}
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
db.byID[o.ID] = o
|
|
if err := db.repo.PutOrg(o); err != nil {
|
|
delete(db.byID, o.ID)
|
|
return Org{}, err
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
func (db *OrgDB) Get(id string) (Org, bool) {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
o, ok := db.byID[id]
|
|
return o, ok
|
|
}
|
|
|
|
// Role returns the account's role in the org, or "" for non-members.
|
|
func (db *OrgDB) Role(orgID, email string) string {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
o, ok := db.byID[orgID]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return o.Members[normEmail(email)]
|
|
}
|
|
|
|
// OrgsFor returns the orgs the account belongs to, sorted by name.
|
|
func (db *OrgDB) OrgsFor(email string) []Org {
|
|
e := normEmail(email)
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
var out []Org
|
|
for _, o := range db.byID {
|
|
if o.Members[e] != "" {
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
// AddMember adds (or keeps) the account in the org with the given role. An
|
|
// existing member's role is never downgraded by an invite.
|
|
func (db *OrgDB) AddMember(orgID, email, role string) error {
|
|
e := normEmail(email)
|
|
if e == "" {
|
|
return fmt.Errorf("email must not be empty")
|
|
}
|
|
if role != RoleOwner && role != RoleMember {
|
|
return fmt.Errorf("invalid role %q", role)
|
|
}
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
o, ok := db.byID[orgID]
|
|
if !ok {
|
|
return fmt.Errorf("no such organization")
|
|
}
|
|
if o.Members[e] == RoleOwner {
|
|
return nil
|
|
}
|
|
o.Members[e] = role
|
|
db.byID[orgID] = o
|
|
return db.repo.PutOrg(o)
|
|
}
|
|
|
|
// RemoveMember drops an account from the org. The last owner cannot be
|
|
// removed (an org must always have someone who can administer it).
|
|
func (db *OrgDB) RemoveMember(orgID, email string) error {
|
|
e := normEmail(email)
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
o, ok := db.byID[orgID]
|
|
if !ok {
|
|
return fmt.Errorf("no such organization")
|
|
}
|
|
if o.Members[e] == "" {
|
|
return fmt.Errorf("%s is not a member", email)
|
|
}
|
|
if o.Members[e] == RoleOwner && db.ownerCount(o) <= 1 {
|
|
return fmt.Errorf("cannot remove the last owner")
|
|
}
|
|
delete(o.Members, e)
|
|
db.byID[orgID] = o
|
|
return db.repo.PutOrg(o)
|
|
}
|
|
|
|
// SetRole changes an account's role. Demoting the last owner is refused.
|
|
func (db *OrgDB) SetRole(orgID, email, role string) error {
|
|
if role != RoleOwner && role != RoleMember {
|
|
return fmt.Errorf("invalid role %q", role)
|
|
}
|
|
e := normEmail(email)
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
o, ok := db.byID[orgID]
|
|
if !ok {
|
|
return fmt.Errorf("no such organization")
|
|
}
|
|
if o.Members[e] == "" {
|
|
return fmt.Errorf("%s is not a member", email)
|
|
}
|
|
if o.Members[e] == RoleOwner && role == RoleMember && db.ownerCount(o) <= 1 {
|
|
return fmt.Errorf("cannot demote the last owner")
|
|
}
|
|
o.Members[e] = role
|
|
db.byID[orgID] = o
|
|
return db.repo.PutOrg(o)
|
|
}
|
|
|
|
// Rename changes the org's display name.
|
|
func (db *OrgDB) Rename(orgID, name string) error {
|
|
name = trimName(name)
|
|
if name == "" {
|
|
return fmt.Errorf("organization name must not be empty")
|
|
}
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
o, ok := db.byID[orgID]
|
|
if !ok {
|
|
return fmt.Errorf("no such organization")
|
|
}
|
|
o.Name = name
|
|
db.byID[orgID] = o
|
|
return db.repo.PutOrg(o)
|
|
}
|
|
|
|
// ownerCount counts owners in an org. Callers hold mu.
|
|
func (db *OrgDB) ownerCount(o Org) int {
|
|
n := 0
|
|
for _, role := range o.Members {
|
|
if role == RoleOwner {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// ListInvites returns the org's live (non-expired) invites.
|
|
func (db *OrgDB) ListInvites(orgID string) []OrgInvite {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
var out []OrgInvite
|
|
for _, inv := range db.invites {
|
|
if inv.Org == orgID && !inv.expired() {
|
|
out = append(out, inv)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) })
|
|
return out
|
|
}
|
|
|
|
// RevokeInvite deletes an invite so its link stops working immediately.
|
|
func (db *OrgDB) RevokeInvite(token string) bool {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
if _, ok := db.invites[token]; !ok {
|
|
return false
|
|
}
|
|
delete(db.invites, token)
|
|
db.repo.DeleteInvite(token)
|
|
return true
|
|
}
|
|
|
|
// CreateInvite mints a join link for the org.
|
|
func (db *OrgDB) CreateInvite(orgID, creator string, ttl time.Duration) (OrgInvite, error) {
|
|
if ttl <= 0 {
|
|
ttl = DefaultInviteTTL
|
|
}
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
if _, ok := db.byID[orgID]; !ok {
|
|
return OrgInvite{}, fmt.Errorf("no such organization")
|
|
}
|
|
inv := OrgInvite{
|
|
Token: randHex(16), Org: orgID, Creator: normEmail(creator),
|
|
Created: time.Now().UTC(), Expires: time.Now().UTC().Add(ttl),
|
|
}
|
|
db.invites[inv.Token] = inv
|
|
if err := db.repo.PutInvite(inv); err != nil {
|
|
delete(db.invites, inv.Token)
|
|
return OrgInvite{}, err
|
|
}
|
|
return inv, nil
|
|
}
|
|
|
|
// Redeem consumes nothing — an invite link can onboard a whole team until it
|
|
// expires — it just resolves the token to its live invite.
|
|
func (db *OrgDB) Redeem(token string) (OrgInvite, bool) {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
inv, ok := db.invites[token]
|
|
if !ok || inv.expired() {
|
|
return OrgInvite{}, false
|
|
}
|
|
return inv, true
|
|
}
|
|
|
|
// ValidInvite reports whether a token is a live invite, without consuming it.
|
|
// It lets the signup page permit account creation from an invite link even
|
|
// when public self-signup is closed (invite-only hubs).
|
|
func (db *OrgDB) ValidInvite(token string) bool {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
inv, ok := db.invites[token]
|
|
return ok && !inv.expired()
|
|
}
|
|
|
|
// ---- migration ----
|
|
|
|
// MigrateOrgs assigns every org-less project to a default org so a hub that
|
|
// predates organizations keeps working with zero manual steps. All existing
|
|
// accounts join it — they could all see every project before, so anything
|
|
// narrower would lock someone out — with the oldest account as owner.
|
|
// orgWriter is the slice of Directory that MigrateOrgs needs: it creates one
|
|
// org and fills it. Taking the narrow type keeps the sweep usable with a bare
|
|
// OrgDB (which is what the CLI has at that point) instead of forcing a
|
|
// LocalDirectory wrapper on a function that has no use for ManageURL.
|
|
type orgWriter interface {
|
|
Create(name, ownerEmail string) (Org, error)
|
|
AddMember(orgID, email, role string) error
|
|
}
|
|
|
|
func MigrateOrgs(projects *ProjectDB, orgs orgWriter, accounts []User) error {
|
|
var orphans []Project
|
|
for _, p := range projects.List() {
|
|
if p.Org == "" {
|
|
orphans = append(orphans, p)
|
|
}
|
|
}
|
|
if len(orphans) == 0 {
|
|
return nil
|
|
}
|
|
owner := ""
|
|
if len(accounts) > 0 {
|
|
owner = accounts[0].Email
|
|
}
|
|
def, err := orgs.Create("default", owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, u := range accounts[min(1, len(accounts)):] {
|
|
if err := orgs.AddMember(def.ID, u.Email, RoleMember); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, p := range orphans {
|
|
if err := projects.SetOrg(p.ID, def.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- HTTP ----
|
|
|
|
// orgFor resolves a project's org; zero value when orgs are off.
|
|
func (s *Server) orgOf(projectID string) string {
|
|
if s.Projects == nil {
|
|
return ""
|
|
}
|
|
p, _ := s.Projects.Get(projectID)
|
|
return p.Org
|
|
}
|
|
|
|
// handleOrgList returns the caller's orgs with members (visible to any
|
|
// member) and the caller's role.
|
|
func (s *Server) handleOrgList(w http.ResponseWriter, r *http.Request) {
|
|
if s.Dir == nil {
|
|
writeJSON(w, map[string]any{"orgs": []any{}})
|
|
return
|
|
}
|
|
me := s.requestUser(r)
|
|
out := []map[string]any{}
|
|
for _, o := range s.Dir.OrgsFor(me.Email) {
|
|
members := make([]map[string]string, 0, len(o.Members))
|
|
for email, role := range o.Members {
|
|
members = append(members, map[string]string{"email": email, "role": role})
|
|
}
|
|
sort.Slice(members, func(i, j int) bool { return members[i]["email"] < members[j]["email"] })
|
|
out = append(out, map[string]any{
|
|
"id": o.ID, "name": o.Name, "role": o.Members[normEmail(me.Email)],
|
|
"members": members, "created": o.Created,
|
|
"manage_url": s.Dir.ManageURL(o.ID),
|
|
})
|
|
}
|
|
writeJSON(w, map[string]any{"orgs": out})
|
|
}
|
|
|
|
// writeDirErr answers a failed directory write. A directory that does not own
|
|
// its organizations says so with ErrManagedElsewhere, and the answer is 409
|
|
// plus the page that does own them — the request was well-formed, it is the
|
|
// state of the world that makes it wrong. The hub never learns WHY the write
|
|
// was refused, only where to send the user.
|
|
func (s *Server) writeDirErr(w http.ResponseWriter, orgID string, err error) {
|
|
if errors.Is(err, ErrManagedElsewhere) {
|
|
http.Error(w, err.Error()+": "+s.Dir.ManageURL(orgID), http.StatusConflict)
|
|
return
|
|
}
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
|
|
// requireOwner returns true and the caller's email when they own the org;
|
|
// otherwise it writes the error response and returns false.
|
|
func (s *Server) requireOwner(w http.ResponseWriter, r *http.Request, orgID string) (string, bool) {
|
|
if s.Dir == nil {
|
|
http.Error(w, "organizations are not enabled on this server", http.StatusNotFound)
|
|
return "", false
|
|
}
|
|
me := s.requestUser(r)
|
|
if s.Dir.Role(orgID, me.Email) != RoleOwner {
|
|
http.Error(w, "only an organization owner can do that", http.StatusForbidden)
|
|
return "", false
|
|
}
|
|
return normEmail(me.Email), true
|
|
}
|
|
|
|
// handleOrgRename renames the org. Owners only.
|
|
func (s *Server) handleOrgRename(w http.ResponseWriter, r *http.Request) {
|
|
orgID := r.PathValue("org")
|
|
if _, ok := s.requireOwner(w, r, orgID); !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := s.Dir.Rename(orgID, req.Name); err != nil {
|
|
s.writeDirErr(w, orgID, err)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleMemberUpdate changes a member's role. Owners only.
|
|
func (s *Server) handleMemberUpdate(w http.ResponseWriter, r *http.Request) {
|
|
orgID := r.PathValue("org")
|
|
if _, ok := s.requireOwner(w, r, orgID); !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Role string `json:"role"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := s.Dir.SetRole(orgID, r.PathValue("email"), req.Role); err != nil {
|
|
s.writeDirErr(w, orgID, err)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleMemberRemove drops a member. Owners only.
|
|
func (s *Server) handleMemberRemove(w http.ResponseWriter, r *http.Request) {
|
|
orgID := r.PathValue("org")
|
|
if _, ok := s.requireOwner(w, r, orgID); !ok {
|
|
return
|
|
}
|
|
if err := s.Dir.RemoveMember(orgID, r.PathValue("email")); err != nil {
|
|
s.writeDirErr(w, orgID, err)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleInviteList shows an org's live invite links. Owners only.
|
|
func (s *Server) handleInviteList(w http.ResponseWriter, r *http.Request) {
|
|
orgID := r.PathValue("org")
|
|
if _, ok := s.requireOwner(w, r, orgID); !ok {
|
|
return
|
|
}
|
|
invs := s.Dir.ListInvites(orgID)
|
|
out := make([]map[string]any, 0, len(invs))
|
|
for _, inv := range invs {
|
|
out = append(out, map[string]any{
|
|
"token": inv.Token, "url": requestBaseURL(r) + "/join/" + inv.Token,
|
|
"creator": inv.Creator, "created": inv.Created, "expires": inv.Expires, "uses": inv.Uses,
|
|
})
|
|
}
|
|
writeJSON(w, map[string]any{"invites": out})
|
|
}
|
|
|
|
// handleInviteRevoke kills an invite link. Owners only.
|
|
func (s *Server) handleInviteRevoke(w http.ResponseWriter, r *http.Request) {
|
|
orgID := r.PathValue("org")
|
|
if _, ok := s.requireOwner(w, r, orgID); !ok {
|
|
return
|
|
}
|
|
// Confirm the invite belongs to this org before revoking.
|
|
inv, ok := s.Dir.Redeem(r.PathValue("token"))
|
|
if !ok || inv.Org != orgID {
|
|
http.Error(w, "no such invite", http.StatusNotFound)
|
|
return
|
|
}
|
|
s.Dir.RevokeInvite(r.PathValue("token"))
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleInviteCreate mints an invite link. Owners only.
|
|
func (s *Server) handleInviteCreate(w http.ResponseWriter, r *http.Request) {
|
|
if s.Dir == nil {
|
|
http.Error(w, "organizations are not enabled on this server", http.StatusNotFound)
|
|
return
|
|
}
|
|
orgID := r.PathValue("org")
|
|
if s.Dir.Role(orgID, s.requestUser(r).Email) != RoleOwner {
|
|
http.Error(w, "only an organization owner can invite", http.StatusForbidden)
|
|
return
|
|
}
|
|
var req struct {
|
|
ExpiresIn string `json:"expires_in,omitempty"` // Go duration, e.g. "168h"
|
|
}
|
|
json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req) // body is optional
|
|
var ttl time.Duration
|
|
if req.ExpiresIn != "" {
|
|
d, err := time.ParseDuration(req.ExpiresIn)
|
|
if err != nil || d <= 0 {
|
|
http.Error(w, "invalid expires_in", http.StatusBadRequest)
|
|
return
|
|
}
|
|
ttl = d
|
|
}
|
|
inv, err := s.Dir.CreateInvite(orgID, s.requestUser(r).Email, ttl)
|
|
if err != nil {
|
|
s.writeDirErr(w, orgID, err)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"token": inv.Token,
|
|
"url": requestBaseURL(r) + "/join/" + inv.Token,
|
|
"expires": inv.Expires,
|
|
})
|
|
}
|
|
|
|
// handleInviteAccept joins the signed-in account to the invite's org.
|
|
func (s *Server) handleInviteAccept(w http.ResponseWriter, r *http.Request) {
|
|
if s.Dir == nil {
|
|
http.Error(w, "organizations are not enabled on this server", http.StatusNotFound)
|
|
return
|
|
}
|
|
me := s.requestUser(r)
|
|
if me.Email == "" {
|
|
http.Error(w, "sign in to accept an invite", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
inv, ok := s.Dir.Redeem(r.PathValue("token"))
|
|
if !ok {
|
|
http.Error(w, "this invite is invalid or expired", http.StatusNotFound)
|
|
return
|
|
}
|
|
org, _ := s.Dir.Get(inv.Org)
|
|
if org.Members[normEmail(me.Email)] == "" {
|
|
if err := s.quota().CheckSeat(inv.Org, len(org.Members)); err != nil {
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
newMember := org.Members[normEmail(me.Email)] == ""
|
|
if err := s.Dir.AddMember(inv.Org, me.Email, RoleMember); err != nil {
|
|
s.writeDirErr(w, inv.Org, err)
|
|
return
|
|
}
|
|
if newMember {
|
|
s.Dir.RecordInviteUse(r.PathValue("token"))
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true, "org": map[string]string{"id": org.ID, "name": org.Name}})
|
|
}
|