From abdfd05c34a764215c9642325fb66e3d6ddbc875 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 07:43:57 +0200 Subject: [PATCH 001/145] feat: org-scope realtime events (inbox, campaign, tracking, account health) and emit EMAIL_SENT/EMAIL_REPLIED/EMAIL_DELETED pulses so the whole team's dashboard updates live --- cmd/backend/main.go | 1 + cmd/consumer/main.go | 2 + internal/app/advanced/events.go | 13 +++++ internal/app/advanced/service.go | 9 ++++ internal/app/campaign/handlers.go | 11 +++++ internal/app/consumer/event_new_email.go | 13 +++-- internal/app/consumer/event_remove_email.go | 16 +++++++ internal/app/consumer/event_tracking.go | 8 +++- internal/app/email/service.go | 5 ++ internal/app/warmup/service.go | 6 +-- internal/infrastructure/pubsub/events.go | 53 ++++++++++++++++++++- internal/tasks/campaign_task.go | 17 ++++++- 12 files changed, 145 insertions(+), 9 deletions(-) diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 341872db..aacbbe6d 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -869,6 +869,7 @@ func main() { // here too). notificationService = notification.NewService(repository.NewNotificationRepository(primaryDB.Pool), streamingPublisher) advancedService.WireNotifier(notificationService) + advancedService.WireRealtime(streamingPublisher) emailSender := tasks.NewEmailSender(emailRepostory, eventsPublisher) tasksService = tasks.NewService( tasksClient, diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index 80fd4191..a4a651b0 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -229,6 +229,8 @@ func main() { // created. notificationService := notification.NewService(repository.NewNotificationRepository(primaryDB.Pool), streamingPublisher) advancedService.WireNotifier(notificationService) + // Reply pulses fire in THIS process too (inbox ingest classifies replies). + advancedService.WireRealtime(streamingPublisher) // Events publisher — wraps the existing Kafka producer in an EventBus, // wraps Avrov2 in a Codec. Once EVENTBUS_PROVIDER=nats is exercised in diff --git a/internal/app/advanced/events.go b/internal/app/advanced/events.go index 3027eb54..b74359d2 100644 --- a/internal/app/advanced/events.go +++ b/internal/app/advanced/events.go @@ -43,6 +43,19 @@ func (s *service) EmitCampaignEvent(ctx context.Context, orgID uuid.UUID, eventT s.emit(ctx, orgID, eventType, data) } +// ReplyRealtimePublisher pushes an org-scoped EMAIL_REPLIED pulse to the live +// dashboard. Satisfied by *pubsub.StreamingPublisher; primitive-typed local +// interface so this package stays decoupled from the pubsub event types. +type ReplyRealtimePublisher interface { + PublishEmailReplied(ctx context.Context, orgID, userID, campaignID, contactID, contactEmail, sequenceID string) +} + +// WireRealtime attaches the realtime publisher after construction. No-op if +// never called: the emit site guards on nil. +func (s *service) WireRealtime(p ReplyRealtimePublisher) { + s.realtime = p +} + // Notifier raises a per-user in-app notification (gated by the user's prefs). // Satisfied by *notification.Service. Local interface to avoid an import cycle; // wired post-construction in the consumer (where reply/bounce/complaint run). diff --git a/internal/app/advanced/service.go b/internal/app/advanced/service.go index afefa9f8..b62cb769 100644 --- a/internal/app/advanced/service.go +++ b/internal/app/advanced/service.go @@ -79,6 +79,8 @@ type Service interface { WireDispatcher(d EventDispatcher) // WireNotifier attaches the in-app notification gate (reply/bounce/complaint). WireNotifier(n Notifier) + // WireRealtime attaches the org-scoped EMAIL_REPLIED realtime pulse. + WireRealtime(p ReplyRealtimePublisher) // EmitCampaignEvent dispatches a campaign event (e.g. from a sequence // "notify" action node) to customer webhooks and wired integrations. @@ -112,6 +114,7 @@ type service struct { warmupService warmupapp.Service dispatcher EventDispatcher notifier Notifier + realtime ReplyRealtimePublisher } func NewService( @@ -788,6 +791,12 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid. if !replyclassify.IsAutomated(replyResult.Class) { _ = s.campaignProgressRepo.RecordEmailReplied(ctx, cID, ctID, sID) _ = s.repo.MarkVariantEvent(ctx, cID, ctID, string(models.DeliverabilityEventReply)) + + // Live org-wide pulse: the team sees the reply land on the + // campaign without a refresh. + if s.realtime != nil && account.OrganizationID != nil { + s.realtime.PublishEmailReplied(ctx, account.OrganizationID.String(), account.UserID, cID.String(), ctID.String(), sender, sID.String()) + } } // INSTANT reply trigger: if the contact's CURRENT step has a reply_* intent diff --git a/internal/app/campaign/handlers.go b/internal/app/campaign/handlers.go index b9abb68b..57d448a8 100644 --- a/internal/app/campaign/handlers.go +++ b/internal/app/campaign/handlers.go @@ -51,6 +51,7 @@ func (s *campaignService) Create(ctx context.Context, userID string, orgID *uuid EventType: pubsub.EventCampaignCreated, UserID: userID, }, + OrgID: modelOrgID(orgID), CampaignID: resp.ID.String(), Name: resp.Name, Status: resp.Status, @@ -218,6 +219,7 @@ func (s *campaignService) StartCampaign(ctx context.Context, orgID uuid.UUID, ca EventType: pubsub.EventCampaignStarted, UserID: campaign.UserID, }, + OrgID: modelOrgID(campaign.OrganizationID), CampaignID: cID.String(), Name: campaign.Name, Status: "active", @@ -341,6 +343,7 @@ func (s *campaignService) StopCampaign(ctx context.Context, orgID uuid.UUID, cam EventType: pubsub.EventCampaignPaused, UserID: campaign.UserID, }, + OrgID: modelOrgID(campaign.OrganizationID), CampaignID: cID.String(), Name: campaign.Name, Status: "paused", @@ -465,3 +468,11 @@ func (s *campaignService) VerifyCampaignTrackingDomain(ctx context.Context, orgI } return status, nil } + +// modelOrgID renders an optional org UUID for org-scoped realtime events. +func modelOrgID(orgID *uuid.UUID) string { + if orgID == nil { + return "" + } + return orgID.String() +} diff --git a/internal/app/consumer/event_new_email.go b/internal/app/consumer/event_new_email.go index 0a103cc8..2601ab30 100644 --- a/internal/app/consumer/event_new_email.go +++ b/internal/app/consumer/event_new_email.go @@ -40,7 +40,7 @@ func (s *JobsService) HandleNewEmail(ctx context.Context, e *models.JobEventNewE return err } if s.StreamingPublisher != nil && e.Message != nil { - s.StreamingPublisher.PublishEmailReceived(ctx, emailInboxEvent(e.UserID, e.Message)) + s.StreamingPublisher.PublishEmailReceived(ctx, s.emailInboxEvent(ctx, e.UserID, e.Message)) } // Advanced reply-intent automation is best-effort and should not block inbox @@ -59,12 +59,19 @@ func (s *JobsService) publishEmailUpdated(ctx context.Context, userID uuid.UUID, if s.StreamingPublisher == nil || message == nil { return } - s.StreamingPublisher.PublishEmailUpdated(ctx, emailInboxEvent(userID, message)) + s.StreamingPublisher.PublishEmailUpdated(ctx, s.emailInboxEvent(ctx, userID, message)) } -func emailInboxEvent(userID uuid.UUID, message *models.EmailMessageStoreData) *pubsub.EmailInboxEvent { +// emailInboxEvent builds the realtime inbox payload. Org-scoped (best-effort) +// so every teammate's unibox updates live, not just the mailbox owner's. +func (s *JobsService) emailInboxEvent(ctx context.Context, userID uuid.UUID, message *models.EmailMessageStoreData) *pubsub.EmailInboxEvent { + var orgID string + if account, err := s.EmailRepository.GetByID(ctx, message.EmailID); err == nil && account != nil && account.OrganizationID != nil { + orgID = account.OrganizationID.String() + } return &pubsub.EmailInboxEvent{ BaseEvent: pubsub.BaseEvent{UserID: userID.String()}, + OrgID: orgID, EmailAccountID: message.EmailID.String(), MessageID: message.ID.String(), ThreadID: message.ThreadID, diff --git a/internal/app/consumer/event_remove_email.go b/internal/app/consumer/event_remove_email.go index 5cf44bc4..b5520455 100644 --- a/internal/app/consumer/event_remove_email.go +++ b/internal/app/consumer/event_remove_email.go @@ -3,6 +3,7 @@ package jobs import ( "context" + "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/models" ) @@ -27,5 +28,20 @@ func (s *JobsService) HandleRemoveEmail(ctx context.Context, e *models.JobEventR if s.UniboxRepository != nil { _ = s.UniboxRepository.Delete(ctx, e.UserID, e.ID) } + + // Tell open dashboards the row is gone (org-scoped so every teammate's + // unibox drops it live, not just the mailbox owner's). + if s.StreamingPublisher != nil { + var orgID string + if account, err := s.EmailRepository.GetByID(ctx, e.EmailID); err == nil && account != nil && account.OrganizationID != nil { + orgID = account.OrganizationID.String() + } + s.StreamingPublisher.PublishEmailDeleted(ctx, &pubsub.EmailInboxEvent{ + BaseEvent: pubsub.BaseEvent{UserID: e.UserID.String()}, + OrgID: orgID, + EmailAccountID: e.EmailID.String(), + MessageID: e.ID.String(), + }) + } return nil } diff --git a/internal/app/consumer/event_tracking.go b/internal/app/consumer/event_tracking.go index 0fff6f17..e5cd7991 100644 --- a/internal/app/consumer/event_tracking.go +++ b/internal/app/consumer/event_tracking.go @@ -246,13 +246,19 @@ func (tc *TrackingConsumer) publishTrackingEvent(ctx context.Context, task *repo return } - // Publish tracking event + // Publish tracking event (org-scoped: opens/clicks pulse live for the + // whole team, not just the campaign owner) + var orgID string + if campaign.OrganizationID != nil { + orgID = campaign.OrganizationID.String() + } trackingPayload := &pubsub.TrackingEventPayload{ BaseEvent: pubsub.BaseEvent{ EventType: eventType, UserID: campaign.UserID, Timestamp: time.Now(), }, + OrgID: orgID, CampaignID: task.CampaignID.String(), ContactID: task.ContactID.String(), ContactEmail: contactEmail, diff --git a/internal/app/email/service.go b/internal/app/email/service.go index 22f45ccd..3c189d3b 100644 --- a/internal/app/email/service.go +++ b/internal/app/email/service.go @@ -132,11 +132,16 @@ func (s *emailService) publishAccountEvent(ctx context.Context, eventType pubsub return } + var orgID string + if account.OrganizationID != nil { + orgID = account.OrganizationID.String() + } s.streamingPublisher.PublishAccountEvent(ctx, &pubsub.AccountEvent{ BaseEvent: pubsub.BaseEvent{ EventType: eventType, UserID: account.UserID, }, + OrgID: orgID, EmailAccountID: account.ID.String(), Email: account.Email, Provider: account.Provider, diff --git a/internal/app/warmup/service.go b/internal/app/warmup/service.go index abfb6423..0b3ba63b 100644 --- a/internal/app/warmup/service.go +++ b/internal/app/warmup/service.go @@ -19,11 +19,11 @@ type WebhookDispatcher interface { Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error) } -// HealthRealtimePublisher pushes a health transition to the owning user's +// HealthRealtimePublisher pushes a health transition to the owning org's // realtime stream. Narrow + primitive-typed so the warmup package doesn't // import the pubsub event types. *pubsub.StreamingPublisher satisfies it. type HealthRealtimePublisher interface { - PublishAccountHealth(ctx context.Context, userID, accountID, email, prevState, newState, reason string) + PublishAccountHealth(ctx context.Context, orgID, userID, accountID, email, prevState, newState, reason string) } const ( @@ -152,7 +152,7 @@ func (s *service) dispatchHealthEvent(ctx context.Context, accountID uuid.UUID, // Realtime push to the dashboard (independent of webhooks). if s.realtime != nil { - s.realtime.PublishAccountHealth(ctx, account.UserID, accountID.String(), account.Email, string(oldState), string(newState), reason) + s.realtime.PublishAccountHealth(ctx, account.OrganizationID.String(), account.UserID, accountID.String(), account.Email, string(oldState), string(newState), reason) } if s.webhooks == nil { diff --git a/internal/infrastructure/pubsub/events.go b/internal/infrastructure/pubsub/events.go index ab1596a1..27ac2817 100644 --- a/internal/infrastructure/pubsub/events.go +++ b/internal/infrastructure/pubsub/events.go @@ -53,6 +53,9 @@ const ( EventEmailOpened EventType = "EMAIL_OPENED" EventEmailClicked EventType = "EMAIL_CLICKED" + // A human reply landed for a campaign contact (org-scoped pulse). + EventEmailReplied EventType = "EMAIL_REPLIED" + // Task progress events EventTaskProgress EventType = "TASK_PROGRESS" @@ -88,6 +91,7 @@ type BaseEvent struct { // EmailInboxEvent for new/updated emails type EmailInboxEvent struct { BaseEvent + OrgID string `json:"org_id,omitempty"` EmailAccountID string `json:"email_account_id"` MessageID string `json:"message_id"` ThreadID string `json:"thread_id,omitempty"` @@ -120,6 +124,7 @@ type BulkOperationEvent struct { // CampaignEvent for campaign changes type CampaignEvent struct { BaseEvent + OrgID string `json:"org_id,omitempty"` CampaignID string `json:"campaign_id"` Name string `json:"name,omitempty"` Status string `json:"status,omitempty"` @@ -139,6 +144,7 @@ type CampaignProgressData struct { // AccountEvent for email account status changes type AccountEvent struct { BaseEvent + OrgID string `json:"org_id,omitempty"` EmailAccountID string `json:"email_account_id"` Email string `json:"email"` Provider string `json:"provider,omitempty"` @@ -164,6 +170,7 @@ type WarmupStatsEvent struct { // TrackingEventPayload for email open/click tracking events type TrackingEventPayload struct { BaseEvent + OrgID string `json:"org_id,omitempty"` CampaignID string `json:"campaign_id"` ContactID string `json:"contact_id,omitempty"` ContactEmail string `json:"contact_email,omitempty"` @@ -174,6 +181,7 @@ type TrackingEventPayload struct { // TaskProgressEvent for detailed campaign task progress type TaskProgressEvent struct { BaseEvent + OrgID string `json:"org_id,omitempty"` CampaignID string `json:"campaign_id"` TaskID string `json:"task_id"` Status string `json:"status"` // pending, active, completed, failed @@ -375,7 +383,7 @@ func (p *StreamingPublisher) PublishAccountEvent(ctx context.Context, event *Acc // owning user's realtime stream. The dashboard treats it as an ACCOUNT event // and refreshes account status live; the explicit state fields let consumers // react without a refetch. -func (p *StreamingPublisher) PublishAccountHealth(ctx context.Context, userID, accountID, email, prevState, newState, reason string) { +func (p *StreamingPublisher) PublishAccountHealth(ctx context.Context, orgID, userID, accountID, email, prevState, newState, reason string) { if p == nil || p.client == nil { return } @@ -384,6 +392,7 @@ func (p *StreamingPublisher) PublishAccountHealth(ctx context.Context, userID, a EventType: EventAccountHealthChanged, UserID: userID, }, + OrgID: orgID, EmailAccountID: accountID, Email: email, Status: newState, @@ -564,6 +573,48 @@ func (p *StreamingPublisher) PublishTaskProgress(ctx context.Context, event *Tas } } +// PublishEmailReplied emits an org-scoped EMAIL_REPLIED pulse when a human +// reply lands for a campaign contact. Primitive-typed so app packages can wire +// it through a narrow local interface. +func (p *StreamingPublisher) PublishEmailReplied(ctx context.Context, orgID, userID, campaignID, contactID, contactEmail, sequenceID string) { + if p == nil || p.client == nil { + return + } + p.PublishTrackingEvent(ctx, &TrackingEventPayload{ + BaseEvent: BaseEvent{ + EventType: EventEmailReplied, + UserID: userID, + }, + OrgID: orgID, + CampaignID: campaignID, + ContactID: contactID, + ContactEmail: contactEmail, + SequenceID: sequenceID, + }) +} + +// PublishEmailSent emits an org-scoped EMAIL_SENT pulse when a campaign email +// goes out, carrying the same rich payload as task progress so the dashboard +// can show which contact/step just fired without a refetch. +func (p *StreamingPublisher) PublishEmailSent(ctx context.Context, event *TaskProgressEvent) { + if p == nil || p.client == nil { + return + } + + event.EventType = EventEmailSent + event.Timestamp = time.Now() + + attrs := map[string]string{ + "user_id": event.UserID, + "campaign_id": event.CampaignID, + "event_type": string(EventEmailSent), + } + + if err := p.client.Publish(ctx, TopicCampaignUpdate, event, attrs); err != nil { + // Best-effort: realtime is a nicety, not a requirement. + } +} + // Subscription info for clients type RealtimeSubscriptionInfo struct { WebsocketURL string `json:"websocket_url"` diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go index 9a17046b..e22d5694 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -117,6 +117,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { } s.streamingPublisher.PublishTaskProgress(ctx, &pubsub.TaskProgressEvent{ BaseEvent: pubsub.BaseEvent{UserID: campaign.UserID}, + OrgID: campaignOrgID(campaign), CampaignID: campaign.ID.String(), TaskID: taskID.String(), Status: "active", @@ -210,6 +211,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { EventType: pubsub.EventCampaignCompleted, UserID: campaign.UserID, }, + OrgID: campaignOrgID(campaign), CampaignID: campaign.ID.String(), Name: campaign.Name, Status: "completed", @@ -518,6 +520,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { } s.streamingPublisher.PublishTaskProgress(ctx, &pubsub.TaskProgressEvent{ BaseEvent: pubsub.BaseEvent{UserID: campaign.UserID}, + OrgID: campaignOrgID(campaign), CampaignID: campaign.ID.String(), TaskID: taskID.String(), Status: "failed", @@ -622,8 +625,11 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { break } } - s.streamingPublisher.PublishTaskProgress(ctx, &pubsub.TaskProgressEvent{ + // EMAIL_SENT (org-scoped): the whole team sees the send + which + // lead/step fired, live in the campaign view. + s.streamingPublisher.PublishEmailSent(ctx, &pubsub.TaskProgressEvent{ BaseEvent: pubsub.BaseEvent{UserID: campaign.UserID}, + OrgID: campaignOrgID(campaign), CampaignID: campaign.ID.String(), TaskID: taskID.String(), Status: "completed", @@ -925,3 +931,12 @@ func (s *tasksService) publishEmailSentEvent( log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", task.ID.String()).Msg("Failed to publish email sent event") } } + +// campaignOrgID returns the campaign's organization id for org-scoped +// realtime events, or "" for legacy orgless rows. +func campaignOrgID(campaign *Campaign) string { + if campaign == nil || campaign.OrganizationID == nil { + return "" + } + return campaign.OrganizationID.String() +} From fe96eff7b4cddfd1dbc6056a70a5ba749110b0aa Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 07:47:29 +0200 Subject: [PATCH 002/145] feat: audit-log coverage for teams, automations (typed entity), lead-sync sources, and manual meetings so the org activity trail and its realtime spine see every mutation --- internal/api/handler/integration.go | 18 ++++++++++++++---- internal/api/handler/lead_sync.go | 4 ++++ internal/api/handler/team.go | 10 ++++++++++ internal/models/audit.go | 6 ++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/internal/api/handler/integration.go b/internal/api/handler/integration.go index 88f57b34..f4c3e11f 100644 --- a/internal/api/handler/integration.go +++ b/internal/api/handler/integration.go @@ -687,7 +687,7 @@ func (h *Handler) CreateAutomation(c *gin.Context) { errx.JSON(c, errx.New(errx.BadRequest, err.Error())) return } - h.auditIntegration(c, userID, models.AuditActionCreate, a.ID, "automation") + h.auditIntegrationEntity(c, userID, models.AuditActionCreate, models.AuditEntityAutomation, a.ID, a.Name) h.StreamingPublisher.PublishAutomationEvent(c.Request.Context(), orgID, userID, pubsub.EventAutomationCreated, a.ID.String(), a.Name) c.JSON(http.StatusCreated, gin.H{"automation": a}) } @@ -712,7 +712,7 @@ func (h *Handler) UpdateAutomation(c *gin.Context) { errx.JSON(c, errx.New(errx.BadRequest, err.Error())) return } - h.auditIntegration(c, userID, models.AuditActionUpdate, id, "automation") + h.auditIntegrationEntity(c, userID, models.AuditActionUpdate, models.AuditEntityAutomation, id, a.Name) h.StreamingPublisher.PublishAutomationEvent(c.Request.Context(), orgID, userID, pubsub.EventAutomationUpdated, id.String(), a.Name) c.JSON(http.StatusOK, gin.H{"automation": a}) } @@ -733,7 +733,7 @@ func (h *Handler) DeleteAutomation(c *gin.Context) { errx.Handle(c, err) return } - h.auditIntegration(c, userID, models.AuditActionDelete, id, "automation") + h.auditIntegrationEntity(c, userID, models.AuditActionDelete, models.AuditEntityAutomation, id, "") h.StreamingPublisher.PublishAutomationEvent(c.Request.Context(), orgID, userID, pubsub.EventAutomationDeleted, id.String(), "") c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -1053,6 +1053,8 @@ func (h *Handler) CreateMeeting(c *gin.Context) { // by hand shouldn't fire "a prospect booked a call" alerts back at them). h.emitMeetingRealtime(c.Request.Context(), orgID, booking, pubsub.EventMeetingBooked) + h.auditOrg(c, models.AuditActionCreate, models.AuditEntityMeeting, &booking.ID, nil, map[string]string{"title": booking.EventName}) + c.JSON(http.StatusCreated, gin.H{"meeting": booking}) } @@ -1081,11 +1083,19 @@ func (h *Handler) DeleteMeeting(c *gin.Context) { return } h.emitMeetingRealtime(c.Request.Context(), orgID, existing, pubsub.EventMeetingCanceled) + h.auditOrg(c, models.AuditActionDelete, models.AuditEntityMeeting, &id, nil, nil) c.JSON(http.StatusOK, gin.H{"deleted": true}) } // auditIntegration is a thin best-effort audit-log wrapper. func (h *Handler) auditIntegration(c *gin.Context, userID uuid.UUID, action models.AuditAction, entityID uuid.UUID, detail string) { + h.auditIntegrationEntity(c, userID, action, models.AuditEntityIntegration, entityID, detail) +} + +// auditIntegrationEntity is auditIntegration with an explicit entity type, so +// automations (and other integration-adjacent surfaces) land in the audit log +// under their own filterable entity instead of a generic "integration" row. +func (h *Handler) auditIntegrationEntity(c *gin.Context, userID uuid.UUID, action models.AuditAction, entityType models.AuditEntityType, entityID uuid.UUID, detail string) { if h.AuditService == nil { return } @@ -1098,5 +1108,5 @@ func (h *Handler) auditIntegration(c *gin.Context, userID uuid.UUID, action mode if orgID == nil { return } - h.AuditService.LogAction(c.Request.Context(), *orgID, userID, action, models.AuditEntityIntegration, &id, c.ClientIP(), c.Request.UserAgent(), nil, meta) + h.AuditService.LogAction(c.Request.Context(), *orgID, userID, action, entityType, &id, c.ClientIP(), c.Request.UserAgent(), nil, meta) } diff --git a/internal/api/handler/lead_sync.go b/internal/api/handler/lead_sync.go index d8f98e9b..c7a28627 100644 --- a/internal/api/handler/lead_sync.go +++ b/internal/api/handler/lead_sync.go @@ -175,6 +175,7 @@ func (h *Handler) CreateLeadSyncSource(c *gin.Context) { errx.JSON(c, xerr) return } + h.auditOrg(c, models.AuditActionCreate, models.AuditEntityLeadSyncSource, &src.ID, nil, map[string]string{"sheet": src.SheetTitle}) c.JSON(http.StatusCreated, src) } @@ -218,6 +219,7 @@ func (h *Handler) UpdateLeadSyncSource(c *gin.Context) { errx.JSON(c, xerr) return } + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityLeadSyncSource, &id, nil, nil) c.JSON(http.StatusOK, src) } @@ -236,6 +238,7 @@ func (h *Handler) DeleteLeadSyncSource(c *gin.Context) { errx.JSON(c, xerr) return } + h.auditOrg(c, models.AuditActionDelete, models.AuditEntityLeadSyncSource, &id, nil, nil) c.Status(http.StatusNoContent) } @@ -257,5 +260,6 @@ func (h *Handler) SyncLeadSyncSourceNow(c *gin.Context) { errx.JSON(c, xerr) return } + h.auditOrg(c, models.AuditActionImport, models.AuditEntityLeadSyncSource, &id, nil, map[string]string{"trigger": "manual_sync"}) c.JSON(http.StatusOK, result) } diff --git a/internal/api/handler/team.go b/internal/api/handler/team.go index 44b4cd2a..30bf3725 100644 --- a/internal/api/handler/team.go +++ b/internal/api/handler/team.go @@ -55,6 +55,8 @@ func (h *Handler) CreateTeam(c *gin.Context) { return } + h.auditOrg(c, models.AuditActionCreate, models.AuditEntityTeam, &team.ID, nil, map[string]string{"name": team.Name}) + c.JSON(http.StatusCreated, team) } @@ -103,6 +105,8 @@ func (h *Handler) UpdateTeam(c *gin.Context) { return } + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityTeam, &teamID, nil, nil) + c.JSON(http.StatusOK, team) } @@ -124,6 +128,8 @@ func (h *Handler) DeleteTeam(c *gin.Context) { return } + h.auditOrg(c, models.AuditActionDelete, models.AuditEntityTeam, &teamID, nil, nil) + c.Status(http.StatusNoContent) } @@ -160,6 +166,8 @@ func (h *Handler) AddTeamMember(c *gin.Context) { return } + h.auditOrg(c, models.AuditActionAssign, models.AuditEntityTeam, &teamID, nil, map[string]string{"member_user_id": data.UserID.String()}) + c.JSON(http.StatusOK, team) } @@ -185,5 +193,7 @@ func (h *Handler) RemoveTeamMember(c *gin.Context) { return } + h.auditOrg(c, models.AuditActionRemove, models.AuditEntityTeam, &teamID, nil, map[string]string{"member_user_id": userID.String()}) + c.Status(http.StatusNoContent) } diff --git a/internal/models/audit.go b/internal/models/audit.go index 99333209..a9104d4e 100644 --- a/internal/models/audit.go +++ b/internal/models/audit.go @@ -84,6 +84,12 @@ const ( // Inbox AuditEntityUnibox AuditEntityType = "unibox" + + // Collaboration / automation surfaces + AuditEntityTeam AuditEntityType = "team" + AuditEntityAutomation AuditEntityType = "automation" + AuditEntityLeadSyncSource AuditEntityType = "lead_sync_source" + AuditEntityMeeting AuditEntityType = "meeting" ) // AuditActor is the minimal identity of the member who performed an action, From 371fe0c0a36bf42413bf40e18f1e9f9e1490e0e4 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 07:51:34 +0200 Subject: [PATCH 003/145] feat: add Phoenix.Presence org collaboration layer (online members, viewing/editing/replying activity) with permission-gated org event fanout in the realtime service --- realtime/lib/realtime/application.ex | 3 + realtime/lib/realtime/auth.ex | 16 ++ .../lib/realtime_web/channels/org_channel.ex | 145 ++++++++++++++---- realtime/lib/realtime_web/presence.ex | 17 ++ 4 files changed, 155 insertions(+), 26 deletions(-) create mode 100644 realtime/lib/realtime_web/presence.ex diff --git a/realtime/lib/realtime/application.ex b/realtime/lib/realtime/application.ex index 19e6f889..3f5fccfc 100644 --- a/realtime/lib/realtime/application.ex +++ b/realtime/lib/realtime/application.ex @@ -22,6 +22,9 @@ defmodule Realtime.Application do # Phoenix PubSub for internal message broadcasting {Phoenix.PubSub, name: Realtime.PubSub}, + # Presence tracker for org-level collaboration (who's online / viewing what) + RealtimeWeb.Presence, + # Phoenix Endpoint (WebSocket server) RealtimeWeb.Endpoint, diff --git a/realtime/lib/realtime/auth.ex b/realtime/lib/realtime/auth.ex index fc4131a9..c40b116c 100644 --- a/realtime/lib/realtime/auth.ex +++ b/realtime/lib/realtime/auth.ex @@ -153,6 +153,22 @@ defmodule Realtime.Auth do end end + @doc """ + Fetch a user's display profile (name + avatar) for presence metadata. + Best-effort: returns nil fields when the user can't be loaded, so a + DB hiccup degrades presence labels rather than blocking the join. + """ + def get_user_profile(user_id) do + query = "SELECT first_name, last_name, avatar_url FROM users WHERE id = $1" + + with {:ok, uuid} <- Ecto.UUID.dump(user_id), + {:ok, %{rows: [[first, last, avatar] | _]}} <- Realtime.Repo.query(query, [uuid]) do + %{name: String.trim("#{first} #{last}"), avatar: avatar} + else + _ -> %{name: nil, avatar: nil} + end + end + @doc """ Check if a user has access to a campaign via organization membership. diff --git a/realtime/lib/realtime_web/channels/org_channel.ex b/realtime/lib/realtime_web/channels/org_channel.ex index b7d481b4..d6635c9b 100644 --- a/realtime/lib/realtime_web/channels/org_channel.ex +++ b/realtime/lib/realtime_web/channels/org_channel.ex @@ -1,15 +1,20 @@ defmodule RealtimeWeb.OrgChannel do @moduledoc """ - Channel for organization-specific events. + Channel for organization-specific events and team presence. - Users can join their organization's channel to receive events like: - - member_joined: New member joined the organization - - member_left: Member left or was removed - - member_role_changed: Member's role/permissions changed - - settings_changed: Organization settings updated - - subscription_changed: Subscription status changed + Users join their organization's channel to receive org-scoped dashboard + events (campaign sends, inbox arrivals, audit entries, member changes, ...) + filtered by their member permissions. - Authorization is handled by checking organization membership. + Presence: every JWT member is tracked in `RealtimeWeb.Presence` with + display metadata and a live activity descriptor. Clients push + `presence:update` (rate-limited like any client event) with: + + %{"page" => "/app/unibox", "resource" => "thread:", "action" => "replying"} + + so teammates see who is online, who is viewing the same record, and who is + already replying to an email. API-key (developer) sockets receive events but + are never tracked as presences. """ use Phoenix.Channel @@ -19,6 +24,9 @@ defmodule RealtimeWeb.OrgChannel do alias Realtime.Auth alias Realtime.Connections alias Realtime.RateLimiter + alias RealtimeWeb.Presence + + @presence_actions ~w(viewing editing replying idle) @impl true def join("org:" <> org_id, _params, socket) do @@ -51,6 +59,25 @@ defmodule RealtimeWeb.OrgChannel do # Subscribe to the organization's Pub/Sub topic org_id = socket.assigns.org_id Phoenix.PubSub.subscribe(Realtime.PubSub, "org:#{org_id}") + + # Track presence for human members only; developer API-key sockets are + # event consumers, not teammates. + if Map.get(socket.assigns, :auth_type) == :jwt do + profile = Auth.get_user_profile(socket.assigns.user_id) + + {:ok, _} = + Presence.track(socket, socket.assigns.user_id, %{ + online_at: System.system_time(:second), + name: profile.name, + avatar: profile.avatar, + page: nil, + resource: nil, + action: nil + }) + + push(socket, "presence_state", Presence.list(socket)) + end + {:noreply, socket} end @@ -80,6 +107,12 @@ defmodule RealtimeWeb.OrgChannel do {:noreply, socket} end + # Presence diffs (and any other channel broadcasts) reach the client through + # the transport fastlane; this clause only swallows the duplicate delivered + # to the channel process by our manual PubSub subscription above. + @impl true + def handle_info(%Phoenix.Socket.Broadcast{}, socket), do: {:noreply, socket} + @impl true def handle_in("ping", _payload, socket) do {:reply, {:ok, %{pong: System.system_time(:millisecond)}}, socket} @@ -116,38 +149,98 @@ defmodule RealtimeWeb.OrgChannel do # Private functions - # Check if user has permission to see a specific event type + # Gate org-broadcast events on member permissions. Event types are + # normalized (upcased, separators collapsed to "_") so both the legacy + # lowercase names and the Go publisher's UPPER_SNAKE names match. defp can_see_event?(socket, event) do - event_type = Map.get(event, "event_type", "") + event_type = + event + |> Map.get("event_type", "") + |> to_string() + |> String.upcase() + |> String.replace(~r/[.:\s-]+/, "_") + permissions = socket.assigns.permissions + has = fn perm -> Auth.has_permission?(%{permissions: permissions}, Auth.permission(perm)) end - case event_type do - # Billing events require billing permission - "subscription_changed" -> - Auth.has_permission?(%{permissions: permissions}, Auth.permission(:manage_billing)) + cond do + # Billing + String.contains?(event_type, "SUBSCRIPTION") or String.contains?(event_type, "BILLING") -> + has.(:manage_billing) - # Member events require team management permission - "member_joined" -> - Auth.has_permission?(%{permissions: permissions}, Auth.permission(:manage_team)) + # Team / member events + String.contains?(event_type, "MEMBER") or String.contains?(event_type, "INVITATION") -> + has.(:manage_team) - "member_left" -> - Auth.has_permission?(%{permissions: permissions}, Auth.permission(:manage_team)) + # Org settings changes + String.contains?(event_type, "SETTINGS") -> + has.(:manage_settings) - "member_role_changed" -> - Auth.has_permission?(%{permissions: permissions}, Auth.permission(:manage_team)) + # Unibox rows carry subject + preview snippets + String.contains?(event_type, "INBOX") or + event_type in ["EMAIL_RECEIVED", "EMAIL_UPDATED", "EMAIL_DELETED"] -> + has.(:access_unibox) - # Settings changes require settings permission - "settings_changed" -> - Auth.has_permission?(%{permissions: permissions}, Auth.permission(:manage_settings)) + # Campaign activity: lifecycle, task progress, send/open/click/reply pulses + String.contains?(event_type, "CAMPAIGN") or String.contains?(event_type, "TASK_PROGRESS") or + event_type in ["EMAIL_SENT", "EMAIL_OPENED", "EMAIL_CLICKED", "EMAIL_REPLIED", "EMAIL_BOUNCED"] -> + has.(:view_campaigns) - # Default: allow all other events - _ -> + # Contact changes + String.contains?(event_type, "CONTACT") -> + has.(:view_contacts) + + # Mailbox account + warmup health transitions + String.contains?(event_type, "ACCOUNT") or String.contains?(event_type, "WARMUP") -> + has.(:manage_emails) + + # Default: allow (audit refresh signals, meetings, automations, ...). + # The corresponding list endpoints enforce their own permissions; these + # events only tell the dashboard to refetch. + true -> true end end + # presence:update — merge the client's sanitized activity descriptor into + # its presence meta. Only tracked (JWT) members can update presence. + defp handle_client_event("presence:update", payload, socket) do + if Map.get(socket.assigns, :auth_type) == :jwt do + patch = sanitize_presence(payload) + Presence.update(socket, socket.assigns.user_id, fn meta -> Map.merge(meta, patch) end) + end + + {:noreply, socket} + end + defp handle_client_event(_event, _payload, socket) do # Default handler for unknown events {:noreply, socket} end + + defp sanitize_presence(payload) when is_map(payload) do + action = + case payload["action"] do + a when a in @presence_actions -> a + _ -> nil + end + + %{ + page: presence_string(payload["page"]), + resource: presence_string(payload["resource"]), + action: action, + updated_at: System.system_time(:second) + } + end + + defp sanitize_presence(_), do: %{page: nil, resource: nil, action: nil} + + defp presence_string(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> String.slice(trimmed, 0, 160) + end + end + + defp presence_string(_), do: nil end diff --git a/realtime/lib/realtime_web/presence.ex b/realtime/lib/realtime_web/presence.ex new file mode 100644 index 00000000..5a7c9b9b --- /dev/null +++ b/realtime/lib/realtime_web/presence.ex @@ -0,0 +1,17 @@ +defmodule RealtimeWeb.Presence do + @moduledoc """ + Phoenix.Presence for org-level collaboration. + + Tracks which members are online per `org:` topic, with display metadata + (name, avatar) and a lightweight activity descriptor (page, resource, + action) that clients update as they navigate — e.g. `resource: + "thread:", action: "replying"` powers the "Mate is replying" indicator + in the unibox and the live-collaborator stack in the automation builder. + + API-key (developer) sockets are never tracked: machines are not teammates. + """ + + use Phoenix.Presence, + otp_app: :realtime, + pubsub_server: Realtime.PubSub +end From a06a525cf9080af50c83e3f5a2a182dd0eca78a1 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 08:04:11 +0200 Subject: [PATCH 004/145] feat: live team presence across the dashboard (online avatar stack in header, viewing/replying indicators on unibox threads and rows, editing collaborators in the automation builder, campaign and contact viewers) plus audit-spine query invalidation --- web/src/app/app/campaigns/[id]/layout.tsx | 7 + .../app/automations/AutomationFlow.tsx | 8 + .../components/app/contacts/ContactEdit.tsx | 7 + .../app/presence/PresenceAvatars.tsx | 117 ++++++++++++ .../app/presence/ResourceViewers.tsx | 82 ++++++++ .../app/unibox/ConversationItem.tsx | 29 +++ web/src/components/app/unibox/ThreadView.tsx | 11 ++ web/src/components/layout/AppHeader.tsx | 2 + web/src/hooks/PresenceProvider.tsx | 177 ++++++++++++++++++ web/src/hooks/RealtimeManager.tsx | 4 +- web/src/hooks/useRealtimeEvents.ts | 45 +++++ web/src/stores/slices/presenceSlice.ts | 78 ++++++++ web/src/stores/useAppStore.ts | 4 +- 13 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 web/src/components/app/presence/PresenceAvatars.tsx create mode 100644 web/src/components/app/presence/ResourceViewers.tsx create mode 100644 web/src/hooks/PresenceProvider.tsx create mode 100644 web/src/stores/slices/presenceSlice.ts diff --git a/web/src/app/app/campaigns/[id]/layout.tsx b/web/src/app/app/campaigns/[id]/layout.tsx index cfa82b88..fc3782e4 100644 --- a/web/src/app/app/campaigns/[id]/layout.tsx +++ b/web/src/app/app/campaigns/[id]/layout.tsx @@ -17,6 +17,8 @@ import useStopCampaign from "@/lib/api/hooks/app/campaigns/useStopCampaign"; import { CampaignContext } from "@/hooks/context/campaign"; import { useConfirm } from "@/hooks/context/confirm"; import LaunchCampaignDialog from "@/components/app/campaigns/LaunchCampaignDialog"; +import ResourceViewers from "@/components/app/presence/ResourceViewers"; +import { usePresenceResource } from "@/hooks/PresenceProvider"; const TABS = [ { label: "Overview", path: "", Icon: BarChart3Icon }, @@ -42,6 +44,10 @@ export default function CampaignLayout() { const stopCampaign = useStopCampaign(); const [launchOpen, setLaunchOpen] = useState(false); + // Collaboration: claim this campaign while it's open so teammates see + // who's already in here (header pill + the org-wide presence stack). + usePresenceResource(id ? `campaign:${id}` : null); + if (campaignData.isLoading) { return (
@@ -106,6 +112,7 @@ export default function CampaignLayout() { > {status} +

{campaign.id}

diff --git a/web/src/components/app/automations/AutomationFlow.tsx b/web/src/components/app/automations/AutomationFlow.tsx index 8931cd9b..b2c8e4af 100644 --- a/web/src/components/app/automations/AutomationFlow.tsx +++ b/web/src/components/app/automations/AutomationFlow.tsx @@ -103,6 +103,8 @@ import TaskTypePicker from "@/components/app/crm/TaskTypePicker"; import AssigneeTeamPicker, { type AssigneeValue } from "@/components/app/crm/AssigneeTeamPicker"; import { useAutomations } from "@/lib/api/hooks/app/automations/useAutomations"; import ProviderGlyph from "@/app/app/integrations/_components/ProviderGlyph"; +import ResourceViewers from "@/components/app/presence/ResourceViewers"; +import { usePresenceResource } from "@/hooks/PresenceProvider"; import { cn } from "@/lib/utils"; const NODE_W = 248; @@ -412,6 +414,11 @@ export default function AutomationFlow({ const update = useUpdateAutomation(); const test = useTestAutomation(); + // Collaboration: claim this automation as "editing" while the builder is + // open, so a teammate opening the same flow sees who's already in it + // before they start moving nodes around. + usePresenceResource(`automation:${automation.id}`, "editing"); + const [name, setName] = React.useState(automation.name); const [enabled, setEnabled] = React.useState(automation.enabled); const [trigger, setTrigger] = React.useState(automation.trigger_event); @@ -734,6 +741,7 @@ export default function AutomationFlow({ placeholder="Automation name" className="h-7 px-2 w-56 max-w-[30vw] md:max-w-[36vw] rounded-md text-[13px] font-medium text-slate-900 outline-none hover:bg-slate-50 focus:bg-white focus:border-sky-400 focus:ring-2 focus:ring-sky-100 border border-transparent" /> + + + + {open && ( + +
+ + + + + Online now +
+ {members.map((m) => ( +
+ + {m.avatar ? : null} + + {initialsOf(m.name)} + + +
+
+ {m.name ?? "Teammate"} +
+
+ {activityLabel(m)} +
+
+
+ ))} +
+ )} +
+ + ); +} diff --git a/web/src/components/app/presence/ResourceViewers.tsx b/web/src/components/app/presence/ResourceViewers.tsx new file mode 100644 index 00000000..f2664931 --- /dev/null +++ b/web/src/components/app/presence/ResourceViewers.tsx @@ -0,0 +1,82 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { useResourceViewers } from "@/hooks/PresenceProvider"; +import { cn } from "@/lib/utils"; + +function initialsOf(name: string | null) { + if (!name) return "?"; + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(""); +} + +const ACTION_LABEL: Record = { + viewing: "viewing", + editing: "editing", + replying: "replying", +}; + +/** + * "Someone is already here" indicator for detail panes and editors. Renders + * nothing when the record has no other live viewers; otherwise an avatar + * stack plus the strongest activity ("editing"/"replying" beats "viewing"). + */ +export default function ResourceViewers({ + resource, + className, +}: { + resource: string | null; + className?: string; +}) { + const viewers = useResourceViewers(resource); + if (viewers.length === 0) return null; + + const strongest = + viewers.find((v) => v.action === "replying") ?? + viewers.find((v) => v.action === "editing") ?? + viewers[0]; + const action = ACTION_LABEL[strongest.action ?? "viewing"] ?? "viewing"; + const hot = action === "editing" || action === "replying"; + const label = + viewers.length === 1 + ? `${strongest.name ?? "A teammate"} is ${action}` + : `${strongest.name ?? "A teammate"} +${viewers.length - 1} ${action}`; + + return ( + v.name ?? "Teammate").join(", ")} + > + + {viewers.slice(0, 3).map((v) => ( + + {v.avatar ? : null} + + {initialsOf(v.name)} + + + ))} + + {label} + {hot && ( + + + + + )} + + ); +} diff --git a/web/src/components/app/unibox/ConversationItem.tsx b/web/src/components/app/unibox/ConversationItem.tsx index 8716f2b4..0eff06b5 100644 --- a/web/src/components/app/unibox/ConversationItem.tsx +++ b/web/src/components/app/unibox/ConversationItem.tsx @@ -7,6 +7,7 @@ import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail"; import { useAppStore } from "@/stores"; +import { useResourceViewers } from "@/hooks/PresenceProvider"; import { cn } from "@/lib/utils"; function relative(d: Date): string { @@ -70,6 +71,11 @@ export function ConversationItem({ email }: ConversationItemProps) { const messageCount = email.message_count ?? 1; const labels = email.labels ?? []; + // A teammate already has this conversation open (or is replying): + // surface it on the row so nobody double-handles the same email. + const viewers = useResourceViewers(`thread:${threadId}`); + const replierName = viewers.find((v) => v.action === "replying")?.name; + return ( + + + )} + + ))} + + + {canManage && ( + + )} + + + {editing && ( + setEditing(null)} + /> + )} + + + ); +} + +function countBits(mask: number) { + let n = 0; + for (let b = mask; b; b >>= 1) n += b & 1; + return n; +} + +function RoleEditor({ role, onClose }: { role: OrganizationRole | null; onClose: () => void }) { + const create = useCreateRole(); + const update = useUpdateRole(); + const [name, setName] = React.useState(role?.name ?? ""); + const [description, setDescription] = React.useState(role?.description ?? ""); + const [permissions, setPermissions] = React.useState( + role?.permissions ?? PRESETS.find((p) => p.id === "viewer")?.permissions ?? 0, + ); + const pending = create.isPending || update.isPending; + + const toggle = (bit: number) => setPermissions((p) => p ^ bit); + + async function save() { + if (!name.trim()) { + toast.error("Give the role a name"); + return; + } + try { + await toast.promise( + role + ? update.mutateAsync({ id: role.id, data: { name: name.trim(), description, permissions } }) + : create.mutateAsync({ name: name.trim(), description, permissions }), + { + loading: "Saving…", + success: role ? "Role updated" : "Role created", + error: (e: AppError) => buildError(e), + }, + ); + onClose(); + } catch { + /* surfaced */ + } + } + + return ( + { + if (e.target === e.currentTarget) onClose(); + }} + > + +
+

+ {role ? `Edit ${role.name}` : "New role"} +

+ {role && role.member_count > 0 && ( + + Changes apply to {role.member_count} {role.member_count === 1 ? "member" : "members"} immediately + + )} + +
+ +
+
+
+ + setName(v.slice(0, 50))} + placeholder="SDR" + /> +
+
+ + +
+
+ +
+ +
+ {PRESETS.map((p) => ( + + ))} +
+

+ Copies a built-in role's permissions as a starting point, then tweak below. +

+
+ + {CATEGORIES.map((cat) => { + const perms = EDITABLE_PERMISSIONS.filter((p) => p.category === cat); + if (perms.length === 0) return null; + return ( +
+
+ {CATEGORY_LABEL[cat].label} +
+
+ {perms.map((p) => { + const on = (permissions & p.bit) === p.bit; + return ( + + ); + })} +
+
+ ); + })} +
+ +
+ + +
+
+
+ ); +} diff --git a/web/src/app/app/settings/members/page.tsx b/web/src/app/app/settings/members/page.tsx index fdcfbae3..3839d20d 100644 --- a/web/src/app/app/settings/members/page.tsx +++ b/web/src/app/app/settings/members/page.tsx @@ -33,6 +33,9 @@ import useInviteMember from "@/lib/api/hooks/app/organizations/useInviteMember"; import useRemoveMember from "@/lib/api/hooks/app/organizations/useRemoveMember"; import useCancelInvitation from "@/lib/api/hooks/app/organizations/useCancelInvitation"; import useUpdateMemberRole from "@/lib/api/hooks/app/organizations/useUpdateMemberRole"; +import useRoles from "@/lib/api/hooks/app/organizations/useRoles"; +import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; +import RolesSection from "./RolesSection"; import { useAppStore } from "@/stores"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; @@ -59,6 +62,7 @@ export default function MembersSettingsPage() { const removeMember = useRemoveMember(); const cancelInvite = useCancelInvitation(); const updateRole = useUpdateMemberRole(); + const customRoles = useRoles(); const currentUserId = useAppStore((s) => s.user?.id); const currentOrg = useAppStore((s) => s.currentOrganization); @@ -98,18 +102,18 @@ export default function MembersSettingsPage() { () => toast.error("Couldn't copy"), ); } - function changeRole(memberId: string, nextRole: string, email: string) { - const def = getRoleDef(nextRole); - confirm?.show(`Change ${email}'s role to ${def.label}?`, async () => { + function changeRole(memberId: string, next: RoleChoice, email: string) { + const label = next.kind === "builtin" ? getRoleDef(next.id).label : next.label; + confirm?.show(`Change ${email}'s role to ${label}?`, async () => { try { await toast.promise( updateRole.mutateAsync({ id: memberId, - data: { role: nextRole }, + data: next.kind === "builtin" ? { role: next.id } : { role_id: next.id }, }), { loading: "Saving…", - success: `Role updated to ${def.label}`, + success: `Role updated to ${label}`, error: (e: AppError) => buildError(e), }, ); @@ -131,12 +135,17 @@ export default function MembersSettingsPage() { > { + customRoles={customRoles.data ?? []} + onSubmit={async (emails, choice) => { let ok = 0; let failed = 0; for (const e of emails) { try { - await invite.mutateAsync({ email: e, role }); + await invite.mutateAsync( + choice.kind === "builtin" + ? { email: e, role: choice.id } + : { email: e, role_id: choice.id }, + ); ok++; } catch { failed++; @@ -206,6 +215,8 @@ export default function MembersSettingsPage() { {access.isOwner && !isOwner ? ( changeRole(m.user_id, next, email)} pending={updateRole.isPending} /> @@ -317,6 +328,12 @@ export default function MembersSettingsPage() { )} +
+ +
); } @@ -346,46 +363,60 @@ const ACCENT_DOT: Record = { amber: "bg-amber-500", }; +type RoleChoice = + | { kind: "builtin"; id: string } + | { kind: "custom"; id: string; label: string }; + function InlineRolePicker({ value, + roleId, + customRoles, onChange, pending, }: { value: string; - onChange: (next: string) => void; + roleId?: string; + customRoles: OrganizationRole[]; + onChange: (next: RoleChoice) => void; pending: boolean; }) { const [open, setOpen] = React.useState(false); - const cur = getRoleDef(value); const assignable = ROLE_CATALOG.filter((r) => r.assignable && r.id !== "member"); + // A member on a custom role renders that role's name; built-ins keep + // their catalog accent. + const customCurrent = roleId ? customRoles.find((r) => r.id === roleId) : undefined; + const cur = getRoleDef(value); + const label = customCurrent?.name ?? (customCurrent === undefined && roleId ? value : cur.label); + const accent = customCurrent || roleId ? "sky" : cur.accent; + return ( {assignable.map((r) => { - const selected = r.id === value; + const selected = !roleId && r.id === value; return ( ); })} + {customRoles.length > 0 && ( +
+ Custom roles +
+ )} + {customRoles.map((r) => { + const selected = roleId === r.id; + return ( + + ); + })}
); @@ -417,13 +478,15 @@ function InlineRolePicker({ function InviteFlow({ onSubmit, pending, + customRoles, }: { - onSubmit: (emails: string[], role: string) => Promise; + onSubmit: (emails: string[], choice: RoleChoice) => Promise; pending: boolean; + customRoles: OrganizationRole[]; }) { const [chips, setChips] = React.useState<{ email: string; valid: boolean }[]>([]); const [draft, setDraft] = React.useState(""); - const [role, setRole] = React.useState("manager"); + const [role, setRole] = React.useState({ kind: "builtin", id: "manager" }); const SEPARATOR_RE = /[\s,;]+/; function commitDrafts(value: string) { @@ -488,7 +551,11 @@ function InviteFlow({ const totalCount = chips.length + (draft.trim() ? draft.trim().split(SEPARATOR_RE).filter(Boolean).length : 0); const assignable = ROLE_CATALOG.filter((r) => r.assignable && r.id !== "member"); - const activeRole = getRoleDef(role); + const activeCustom = role.kind === "custom" ? customRoles.find((r) => r.id === role.id) : undefined; + const activeRole = role.kind === "builtin" ? getRoleDef(role.id) : undefined; + const activeLabel = activeRole?.label ?? activeCustom?.name ?? "Custom role"; + const activeDescription = + activeRole?.description ?? activeCustom?.description ?? "A custom permission set for this workspace."; return (
@@ -557,9 +624,9 @@ function InviteFlow({ ))} + {customRoles.map((r) => ( + + ))}
@@ -591,10 +672,10 @@ function InviteFlow({
- {activeRole.label} + {activeLabel}

- {activeRole.description} + {activeDescription}

diff --git a/web/src/hooks/useRealtimeEvents.ts b/web/src/hooks/useRealtimeEvents.ts index 583f1f51..62378fd3 100644 --- a/web/src/hooks/useRealtimeEvents.ts +++ b/web/src/hooks/useRealtimeEvents.ts @@ -231,6 +231,7 @@ export function useRealtimeEvents() { organization_member: [['organizations'], ['organizations', 'members']], invitation: [['organizations', 'invitations']], team: [['teams']], + role: [['organizations', 'roles'], ['organizations', 'members']], automation: [['automations']], integration: [['integrations', 'connections']], lead_sync_source: [['lead-sync', 'sources']], diff --git a/web/src/lib/api/client/app/organizations/createRole.ts b/web/src/lib/api/client/app/organizations/createRole.ts new file mode 100644 index 00000000..0cd56e03 --- /dev/null +++ b/web/src/lib/api/client/app/organizations/createRole.ts @@ -0,0 +1,11 @@ +import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; +import Request from "../../Request"; + +export default async function createRole(data: { name: string; description?: string; permissions: number }): Promise { + return await Request({ + method: "POST", + url: `/organization/roles`, + data, + authorization: true, + }) +} diff --git a/web/src/lib/api/client/app/organizations/deleteRole.ts b/web/src/lib/api/client/app/organizations/deleteRole.ts new file mode 100644 index 00000000..40462b6c --- /dev/null +++ b/web/src/lib/api/client/app/organizations/deleteRole.ts @@ -0,0 +1,9 @@ +import Request from "../../Request"; + +export default async function deleteRole(id: string): Promise { + await Request({ + method: "DELETE", + url: `/organization/roles/${id}`, + authorization: true, + }) +} diff --git a/web/src/lib/api/client/app/organizations/getRoles.ts b/web/src/lib/api/client/app/organizations/getRoles.ts new file mode 100644 index 00000000..900e048c --- /dev/null +++ b/web/src/lib/api/client/app/organizations/getRoles.ts @@ -0,0 +1,10 @@ +import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; +import Request from "../../Request"; + +export default async function getRoles(): Promise<{ data: OrganizationRole[] }> { + return await Request<{ data: OrganizationRole[] }>({ + method: "GET", + url: `/organization/roles`, + authorization: true, + }) +} diff --git a/web/src/lib/api/client/app/organizations/inviteMember.ts b/web/src/lib/api/client/app/organizations/inviteMember.ts index f21705c3..68d79b2f 100644 --- a/web/src/lib/api/client/app/organizations/inviteMember.ts +++ b/web/src/lib/api/client/app/organizations/inviteMember.ts @@ -1,7 +1,7 @@ import type Invitation from "@/lib/api/models/app/organizations/Invitation"; import Request from "../../Request"; -export default async function inviteMember(data: { email: string; role: string }): Promise { +export default async function inviteMember(data: { email: string; role?: string; role_id?: string }): Promise { return await Request({ method: "POST", url: `/organization/members/invite`, diff --git a/web/src/lib/api/client/app/organizations/updateMemberRole.ts b/web/src/lib/api/client/app/organizations/updateMemberRole.ts index 8049fc1f..00fc6619 100644 --- a/web/src/lib/api/client/app/organizations/updateMemberRole.ts +++ b/web/src/lib/api/client/app/organizations/updateMemberRole.ts @@ -1,7 +1,7 @@ import type OrganizationMember from "@/lib/api/models/app/organizations/OrganizationMember"; import Request from "../../Request"; -export default async function updateMemberRole(id: string, data: { role: string }): Promise { +export default async function updateMemberRole(id: string, data: { role?: string; role_id?: string }): Promise { return await Request({ method: "PATCH", url: `/organization/members/${id}`, diff --git a/web/src/lib/api/client/app/organizations/updateRole.ts b/web/src/lib/api/client/app/organizations/updateRole.ts new file mode 100644 index 00000000..29f9fae6 --- /dev/null +++ b/web/src/lib/api/client/app/organizations/updateRole.ts @@ -0,0 +1,11 @@ +import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; +import Request from "../../Request"; + +export default async function updateRole(id: string, data: { name?: string; description?: string; permissions?: number }): Promise { + return await Request({ + method: "PATCH", + url: `/organization/roles/${id}`, + data, + authorization: true, + }) +} diff --git a/web/src/lib/api/hooks/app/organizations/useInviteMember.ts b/web/src/lib/api/hooks/app/organizations/useInviteMember.ts index c0d4888c..455476c3 100644 --- a/web/src/lib/api/hooks/app/organizations/useInviteMember.ts +++ b/web/src/lib/api/hooks/app/organizations/useInviteMember.ts @@ -5,7 +5,7 @@ export default function useInviteMember() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: { email: string; role: string }) => inviteMember(data), + mutationFn: (data: { email: string; role?: string; role_id?: string }) => inviteMember(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["organizations", "invitations"], diff --git a/web/src/lib/api/hooks/app/organizations/useRoleMutations.ts b/web/src/lib/api/hooks/app/organizations/useRoleMutations.ts new file mode 100644 index 00000000..62c9aa99 --- /dev/null +++ b/web/src/lib/api/hooks/app/organizations/useRoleMutations.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import createRole from "@/lib/api/client/app/organizations/createRole"; +import updateRole from "@/lib/api/client/app/organizations/updateRole"; +import deleteRole from "@/lib/api/client/app/organizations/deleteRole"; + +// Role edits write through to assigned members server-side, so the member +// roster must refresh alongside the role list. +const invalidate = (qc: ReturnType) => { + void qc.invalidateQueries({ queryKey: ["organizations", "roles"] }); + void qc.invalidateQueries({ queryKey: ["organizations", "members"] }); +}; + +export function useCreateRole() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string; description?: string; permissions: number }) => createRole(data), + onSuccess: () => invalidate(queryClient), + }) +} + +export function useUpdateRole() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: { name?: string; description?: string; permissions?: number } }) => updateRole(id, data), + onSuccess: () => invalidate(queryClient), + }) +} + +export function useDeleteRole() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteRole(id), + onSuccess: () => invalidate(queryClient), + }) +} diff --git a/web/src/lib/api/hooks/app/organizations/useRoles.ts b/web/src/lib/api/hooks/app/organizations/useRoles.ts new file mode 100644 index 00000000..80440d07 --- /dev/null +++ b/web/src/lib/api/hooks/app/organizations/useRoles.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import getRoles from "@/lib/api/client/app/organizations/getRoles"; + +export default function useRoles() { + return useQuery({ + queryKey: ["organizations", "roles"], + queryFn: () => getRoles(), + select: (res) => res.data ?? [], + }) +} diff --git a/web/src/lib/api/hooks/app/organizations/useUpdateMemberRole.ts b/web/src/lib/api/hooks/app/organizations/useUpdateMemberRole.ts index 6352ef8f..829d7ccb 100644 --- a/web/src/lib/api/hooks/app/organizations/useUpdateMemberRole.ts +++ b/web/src/lib/api/hooks/app/organizations/useUpdateMemberRole.ts @@ -5,7 +5,7 @@ export default function useUpdateMemberRole() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, data }: { id: string; data: { role: string } }) => updateMemberRole(id, data), + mutationFn: ({ id, data }: { id: string; data: { role?: string; role_id?: string } }) => updateMemberRole(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["organizations", "members"], diff --git a/web/src/lib/api/models/app/organizations/OrganizationMember.ts b/web/src/lib/api/models/app/organizations/OrganizationMember.ts index 687bfbeb..b2aaca40 100644 --- a/web/src/lib/api/models/app/organizations/OrganizationMember.ts +++ b/web/src/lib/api/models/app/organizations/OrganizationMember.ts @@ -12,6 +12,8 @@ export default interface OrganizationMember { email?: string; name?: string; role: OrganizationRole; + // Set when the member is assigned a custom role (id into /organization/roles). + role_id?: string; permissions?: number; joined_at?: Date; } diff --git a/web/src/lib/api/models/app/organizations/OrganizationRole.ts b/web/src/lib/api/models/app/organizations/OrganizationRole.ts new file mode 100644 index 00000000..31d735d7 --- /dev/null +++ b/web/src/lib/api/models/app/organizations/OrganizationRole.ts @@ -0,0 +1,12 @@ +// Custom workspace role: an org-scoped named permission set. Editing one +// propagates to every member assigned to it (server-side write-through). +export default interface OrganizationRole { + id: string; + organization_id: string; + name: string; + description: string; + permissions: number; + member_count: number; + created_at: string; + updated_at: string; +} From 9992eb65c42d9dfd9f17fa3c0fc9387446bfc3d0 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 09:46:15 +0200 Subject: [PATCH 016/145] feat: document custom roles in the team-roles guide (creation flow, start-from presets, propagation and anti-escalation rules, limits) --- docs/content/docs/guides/team-roles.mdx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/guides/team-roles.mdx b/docs/content/docs/guides/team-roles.mdx index 2428ac16..2194e305 100644 --- a/docs/content/docs/guides/team-roles.mdx +++ b/docs/content/docs/guides/team-roles.mdx @@ -58,7 +58,7 @@ A few rules apply: ## Roles and the permission matrix -Warmbly ships with a set of built-in roles. Each role is a fixed bundle of permissions. You pick the closest role for each person; custom permission bundles are not available yet. +Warmbly ships with a set of built-in roles, and you can also define your own custom roles when none of them fit. Each role is a bundle of permissions; built-in bundles are fixed, custom bundles are yours to shape. The **Roles & access** page shows a card for each role with a short description and a live count of how many people currently hold it, followed by the full **permission matrix**. @@ -136,9 +136,23 @@ This is how the built-in roles map onto those capabilities. A check means the ro In short: **Admin** is the owner minus ownership transfer, **Manager** is everything operational without team, settings, billing, or API keys, and **Viewer** is read-only. - -Custom roles with bespoke permission bundles are planned but not available yet. For now the built-in roles cover the common patterns, so choose the one closest to what the person needs. - +## Custom roles + +When the built-in bundles don't fit, anyone with team management access can create custom roles from the **Roles** section of the members page: + +1. Click **New role** and give it a name (up to 50 characters) and an optional description. Built-in role names are reserved. +2. Pick a built-in role under **Start from** to copy its permissions as a starting point, then toggle individual permissions on or off. +3. Save, then assign the role from the member roster's role picker or directly in the invite flow. + +A few rules keep custom roles safe: + +- **Editing a role updates everyone assigned to it, immediately.** The editor shows how many members will be affected before you save. +- **You can only grant permissions you hold yourself.** A manager with team access cannot mint a role stronger than their own and assign it to someone. +- **Ownership transfer can never be part of a custom role.** It stays exclusive to the owner. +- **A role in use cannot be deleted.** Reassign its members first, so nobody silently loses access. +- Each workspace can have up to 25 custom roles. + +Custom roles apply everywhere permissions do: API access checks, dashboard visibility, and which realtime events a member's live dashboard receives. ## Removing members From 0b253337fcff94d22b94a0024c46f26e7fcf5a45 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 10:08:46 +0200 Subject: [PATCH 017/145] feat: integrate custom roles across Roles & access settings (manager replaces the coming-soon placeholder, custom roles join the permission matrix and summary cards, permission-aware canManage gating via the org context bitmask) --- docs/content/docs/guides/team-roles.mdx | 2 +- web/src/app/app/settings/members/page.tsx | 7 -- .../{members => roles}/RolesSection.tsx | 0 web/src/app/app/settings/roles/page.tsx | 70 ++++++++++++++----- web/src/hooks/useFeatureAccess.ts | 8 ++- .../app/organizations/getOrganizations.ts | 4 +- .../models/app/organizations/Organization.ts | 5 +- web/src/stores/slices/organizationSlice.ts | 5 +- 8 files changed, 71 insertions(+), 30 deletions(-) rename web/src/app/app/settings/{members => roles}/RolesSection.tsx (100%) diff --git a/docs/content/docs/guides/team-roles.mdx b/docs/content/docs/guides/team-roles.mdx index 2194e305..8d353706 100644 --- a/docs/content/docs/guides/team-roles.mdx +++ b/docs/content/docs/guides/team-roles.mdx @@ -138,7 +138,7 @@ In short: **Admin** is the owner minus ownership transfer, **Manager** is everyt ## Custom roles -When the built-in bundles don't fit, anyone with team management access can create custom roles from the **Roles** section of the members page: +When the built-in bundles don't fit, anyone with team management access can create custom roles on the **Roles & access** settings page: 1. Click **New role** and give it a name (up to 50 characters) and an optional description. Built-in role names are reserved. 2. Pick a built-in role under **Start from** to copy its permissions as a starting point, then toggle individual permissions on or off. diff --git a/web/src/app/app/settings/members/page.tsx b/web/src/app/app/settings/members/page.tsx index 3839d20d..169a4114 100644 --- a/web/src/app/app/settings/members/page.tsx +++ b/web/src/app/app/settings/members/page.tsx @@ -35,7 +35,6 @@ import useCancelInvitation from "@/lib/api/hooks/app/organizations/useCancelInvi import useUpdateMemberRole from "@/lib/api/hooks/app/organizations/useUpdateMemberRole"; import useRoles from "@/lib/api/hooks/app/organizations/useRoles"; import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; -import RolesSection from "./RolesSection"; import { useAppStore } from "@/stores"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; @@ -328,12 +327,6 @@ export default function MembersSettingsPage() { )} -
- -
); } diff --git a/web/src/app/app/settings/members/RolesSection.tsx b/web/src/app/app/settings/roles/RolesSection.tsx similarity index 100% rename from web/src/app/app/settings/members/RolesSection.tsx rename to web/src/app/app/settings/roles/RolesSection.tsx diff --git a/web/src/app/app/settings/roles/page.tsx b/web/src/app/app/settings/roles/page.tsx index e95c4761..89119069 100644 --- a/web/src/app/app/settings/roles/page.tsx +++ b/web/src/app/app/settings/roles/page.tsx @@ -5,7 +5,7 @@ // the Members section so there's no duplication. import React from "react"; -import { CheckIcon, InfoIcon, LockIcon, XIcon } from "lucide-react"; +import { CheckIcon, LockIcon, XIcon } from "lucide-react"; import { Link } from "react-router-dom"; import useFeatureAccess from "@/hooks/useFeatureAccess"; import useMembers from "@/lib/api/hooks/app/organizations/useMembers"; @@ -19,6 +19,9 @@ import { type RoleDef, } from "@/lib/permissions"; import { Section, SectionShell, TableSurface } from "../_components/SectionShell"; +import RolesSection from "./RolesSection"; +import useRoles from "@/lib/api/hooks/app/organizations/useRoles"; +import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; const ACCENT = { sky: { dot: "bg-sky-500", pill: "bg-sky-50 text-sky-700 border-sky-100" }, @@ -31,6 +34,7 @@ const ACCENT = { export default function RolesSettingsPage() { const access = useFeatureAccess(); const members = useMembers(); + const customRoles = useRoles(); const currentOrg = useAppStore((s) => s.currentOrganization); if (!access.loading && !access.isOwner) { @@ -70,7 +74,7 @@ export default function RolesSettingsPage() { >
{ROLE_CATALOG.filter((r) => r.id !== "member").map((r) => ( @@ -83,6 +87,9 @@ export default function RolesSettingsPage() { } /> ))} + {(customRoles.data ?? []).map((r) => ( + + ))}

Want to change a member's role?{" "} @@ -100,21 +107,15 @@ export default function RolesSettingsPage() { description="Every capability and which role grants it." > - +

-
-
-
- -
-
- Custom roles with bespoke permission bundles are coming soon. For now, - the four built-in roles cover the common patterns — pick the one closest - to what the member needs. -
-
+
+
); @@ -138,8 +139,41 @@ function RoleSummaryCard({ role, count }: { role: RoleDef; count: number }) { ); } -function MatrixTable() { - const cols = ROLE_CATALOG.filter((r) => r.id !== "member"); +function CustomRoleSummaryCard({ role }: { role: OrganizationRole }) { + return ( +
+
+ + + {role.name} + + + {role.member_count} + +
+

+ {role.description || "Custom role"} +

+
+ ); +} + +function MatrixTable({ customRoles }: { customRoles: OrganizationRole[] }) { + // Built-ins keep their catalog accents; custom roles join as sky columns. + const cols: { id: string; label: string; accent: keyof typeof ACCENT; permissions: number }[] = [ + ...ROLE_CATALOG.filter((r) => r.id !== "member").map((r) => ({ + id: r.id, + label: r.label, + accent: r.accent as keyof typeof ACCENT, + permissions: r.permissions, + })), + ...customRoles.map((r) => ({ + id: r.id, + label: r.name, + accent: "sky" as const, + permissions: r.permissions, + })), + ]; const grouped = React.useMemo(() => { const out: Record = { data: [], @@ -160,7 +194,7 @@ function MatrixTable() { Capability {cols.map((r) => { - const a = ACCENT[r.accent as keyof typeof ACCENT]; + const a = ACCENT[r.accent]; return ( diff --git a/web/src/hooks/useFeatureAccess.ts b/web/src/hooks/useFeatureAccess.ts index 34b9efbb..379fcf0d 100644 --- a/web/src/hooks/useFeatureAccess.ts +++ b/web/src/hooks/useFeatureAccess.ts @@ -15,6 +15,7 @@ import useSubscription from "@/lib/api/hooks/app/subscription/useSubscription"; import { useAppStore } from "@/stores"; +import { PERMISSION_BITS, hasPermission } from "@/lib/permissions"; import { getPlan, isAtLeast, @@ -82,6 +83,11 @@ export default function useFeatureAccess(): FeatureAccess { hasTeam: isPaid && isAtLeast(plan, "starter"), hasWebhooks: isPaid && isAtLeast(plan, "business"), isOwner: currentOrg?.role === "owner", - canManage: currentOrg?.role === "owner" || currentOrg?.role === "admin", + // Permission-aware: a custom role carrying MANAGE_TEAM unlocks the + // same management surfaces as the built-in admin role. + canManage: + currentOrg?.role === "owner" || + currentOrg?.role === "admin" || + hasPermission(currentOrg?.permissions, PERMISSION_BITS.MANAGE_TEAM), }; } diff --git a/web/src/lib/api/client/app/organizations/getOrganizations.ts b/web/src/lib/api/client/app/organizations/getOrganizations.ts index c6a48332..af26287d 100644 --- a/web/src/lib/api/client/app/organizations/getOrganizations.ts +++ b/web/src/lib/api/client/app/organizations/getOrganizations.ts @@ -6,7 +6,8 @@ import Request from "../../Request"; // shape the rest of the app expects. interface RawMembership { organization_id: string; - role: "owner" | "admin" | "member"; + role: string; + permissions?: number; organization?: { id: string; name: string; @@ -37,6 +38,7 @@ export default async function getOrganizations(): Promise { avatar: r.organization!.avatar, plan: r.organization!.plan, role: r.role, + permissions: r.permissions, created_at: new Date(r.organization!.created_at), })); } diff --git a/web/src/lib/api/models/app/organizations/Organization.ts b/web/src/lib/api/models/app/organizations/Organization.ts index 253bd011..c0a73bbe 100644 --- a/web/src/lib/api/models/app/organizations/Organization.ts +++ b/web/src/lib/api/models/app/organizations/Organization.ts @@ -3,6 +3,9 @@ export default interface Organization { name: string avatar?: string plan?: string - role: 'owner' | 'admin' | 'member' + // Built-in role id or a custom role's name. + role: string + // Caller's effective permission bitmask in this org (custom-role aware). + permissions?: number created_at: Date } diff --git a/web/src/stores/slices/organizationSlice.ts b/web/src/stores/slices/organizationSlice.ts index fa57b4cf..557f5efb 100644 --- a/web/src/stores/slices/organizationSlice.ts +++ b/web/src/stores/slices/organizationSlice.ts @@ -6,7 +6,10 @@ export interface Organization { avatar?: string avatar_url?: string | null plan?: string - role: 'owner' | 'admin' | 'member' + // Built-in role id or a custom role's name. + role: string + // Caller's effective permission bitmask in this org (custom-role aware). + permissions?: number } export interface OrganizationSlice { From 091b39f34e56b683908814581794aa2fae462809 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 10:18:45 +0200 Subject: [PATCH 018/145] =?UTF-8?q?feat:=20roles=20become=20workspace=20da?= =?UTF-8?q?ta=20=E2=80=94=20seed=20editable=20Admin/Manager/Viewer=20rows?= =?UTF-8?q?=20per=20org=20(migration=20000043=20with=20member=20backfill),?= =?UTF-8?q?=20require=20role=5Fid=20for=20invites=20and=20role=20changes,?= =?UTF-8?q?=20add=20role=20colors=20with=20a=20shared=20colored=20RoleSele?= =?UTF-8?q?ct=20dropdown=20in=20the=20roster=20and=20invite=20flow,=20and?= =?UTF-8?q?=20rebuild=20Roles=20&=20access=20around=20real=20roles=20with?= =?UTF-8?q?=20an=20Owner=20reference=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/organization/service.go | 115 +++++----- .../migrations/000043_seeded_roles.down.sql | 1 + .../db/migrations/000043_seeded_roles.up.sql | 30 +++ internal/models/organization.go | 15 +- internal/models/organization_permission.go | 32 ++- internal/repository/pg_organization_roles.go | 18 +- .../app/settings/_components/RoleSelect.tsx | 93 +++++++++ web/src/app/app/settings/members/page.tsx | 196 +++--------------- .../app/app/settings/roles/RolesSection.tsx | 72 ++++--- web/src/app/app/settings/roles/page.tsx | 169 ++++----------- .../client/app/organizations/createRole.ts | 2 +- .../client/app/organizations/updateRole.ts | 2 +- .../app/organizations/useRoleMutations.ts | 4 +- .../app/organizations/OrganizationRole.ts | 1 + web/src/lib/permissions.ts | 106 ++-------- 15 files changed, 365 insertions(+), 491 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000043_seeded_roles.down.sql create mode 100644 internal/infrastructure/db/migrations/000043_seeded_roles.up.sql create mode 100644 web/src/app/app/settings/_components/RoleSelect.tsx diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 868f7833..b2026104 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -13,6 +13,7 @@ import ( "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/pkg/crypt" "github.com/warmbly/warmbly/internal/repository" ) @@ -211,6 +212,22 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name return nil, errx.New(errx.Internal, "failed to add owner member") } + // Seed the default roles (Admin/Manager/Viewer). Ordinary rows from here + // on: the owner can rename, reshape, or delete them. Best-effort — a + // failure leaves a usable org where roles can be created manually. + for _, seed := range models.DefaultSeedRoles() { + if err := s.orgRepo.CreateRole(ctx, &models.OrganizationRole{ + ID: uuid.New(), + OrganizationID: org.ID, + Name: seed.Name, + Description: seed.Description, + Color: seed.Color, + Permissions: seed.Permissions, + }); err != nil { + sentry.CaptureException(err) + } + } + return org, nil } @@ -337,34 +354,22 @@ func (s *organizationService) InviteMember(ctx context.Context, orgID uuid.UUID, // First, we need to check if there's already a user with this email // For now, we'll just create the invitation - // Determine role and permissions. A custom role wins and snapshots its + // Roles are data rows: every invite lands in one, snapshotting its // name + permissions onto the invitation (kept in sync via role_id). - role := string(models.RoleViewer) - var roleID *uuid.UUID - var permissions models.OrganizationPermission - - if req.RoleID != nil { - customRole, rerr := s.orgRepo.GetRoleByID(ctx, orgID, *req.RoleID) - if rerr != nil { - sentry.CaptureException(rerr) - return nil, errx.New(errx.Internal, "failed to load role") - } - if customRole == nil { - return nil, errx.New(errx.BadRequest, "role not found") - } - role = customRole.Name - roleID = &customRole.ID - permissions = customRole.Permissions - } else { - if req.Role != "" && models.IsValidRole(req.Role) { - role = req.Role - } - if req.Permissions != nil { - permissions = models.OrganizationPermission(*req.Permissions) - } else { - permissions = models.GetRolePermissions(models.Role(role)) - } + if req.RoleID == nil { + return nil, errx.New(errx.BadRequest, "a role is required") } + workspaceRole, rerr := s.orgRepo.GetRoleByID(ctx, orgID, *req.RoleID) + if rerr != nil { + sentry.CaptureException(rerr) + return nil, errx.New(errx.Internal, "failed to load role") + } + if workspaceRole == nil { + return nil, errx.New(errx.BadRequest, "role not found") + } + role := workspaceRole.Name + roleID := &workspaceRole.ID + permissions := workspaceRole.Permissions // Generate invitation token token, xerr := generateInvitationToken() @@ -465,39 +470,22 @@ func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, membe return nil, errx.New(errx.Forbidden, "cannot modify owner role") } - if req.RoleID != nil { - // Assign a custom role: snapshot its name + permissions; role edits - // propagate to this member via role_id. - customRole, rerr := s.orgRepo.GetRoleByID(ctx, orgID, *req.RoleID) - if rerr != nil { - sentry.CaptureException(rerr) - return nil, errx.New(errx.Internal, "failed to load role") - } - if customRole == nil { - return nil, errx.New(errx.BadRequest, "role not found") - } - member.Role = customRole.Name - member.RoleID = &customRole.ID - member.Permissions = customRole.Permissions - } else if req.Role != nil { - if !models.IsValidRole(*req.Role) { - return nil, errx.New(errx.BadRequest, "invalid role") - } - // Cannot promote to owner - if *req.Role == string(models.RoleOwner) { - return nil, errx.New(errx.Forbidden, "cannot promote to owner, use transfer ownership") - } - member.Role = *req.Role - member.RoleID = nil - // Update permissions to match new role unless custom permissions provided - if req.Permissions == nil { - member.Permissions = models.GetRolePermissions(models.Role(*req.Role)) - } + if req.RoleID == nil { + return nil, errx.New(errx.BadRequest, "a role is required") } - - if req.RoleID == nil && req.Permissions != nil { - member.Permissions = models.OrganizationPermission(*req.Permissions) + // Snapshot the role's name + permissions; role edits keep propagating + // to this member via role_id. + workspaceRole, rerr := s.orgRepo.GetRoleByID(ctx, orgID, *req.RoleID) + if rerr != nil { + sentry.CaptureException(rerr) + return nil, errx.New(errx.Internal, "failed to load role") } + if workspaceRole == nil { + return nil, errx.New(errx.BadRequest, "role not found") + } + member.Role = workspaceRole.Name + member.RoleID = &workspaceRole.ID + member.Permissions = workspaceRole.Permissions if err := s.orgRepo.UpdateMember(ctx, member); err != nil { sentry.CaptureException(err) @@ -1226,11 +1214,17 @@ func (s *organizationService) CreateRole(ctx context.Context, orgID, actorID uui return nil, errx.New(errx.Forbidden, "custom role limit reached") } + color := strings.TrimSpace(req.Color) + if color != "" && !crypt.IsValidHexColor(color) { + return nil, errx.New(errx.BadRequest, "color must be a hex value like #0ea5e9") + } + role := &models.OrganizationRole{ ID: uuid.New(), OrganizationID: orgID, Name: name, Description: strings.TrimSpace(req.Description), + Color: color, Permissions: perms, } if err := s.orgRepo.CreateRole(ctx, role); err != nil { @@ -1260,6 +1254,13 @@ func (s *organizationService) UpdateRole(ctx context.Context, orgID, actorID, ro if req.Description != nil { role.Description = strings.TrimSpace(*req.Description) } + if req.Color != nil { + color := strings.TrimSpace(*req.Color) + if color != "" && !crypt.IsValidHexColor(color) { + return nil, errx.New(errx.BadRequest, "color must be a hex value like #0ea5e9") + } + role.Color = color + } if req.Permissions != nil { perms := models.OrganizationPermission(*req.Permissions) if xerr := s.validateRolePermissions(ctx, orgID, actorID, perms); xerr != nil { diff --git a/internal/infrastructure/db/migrations/000043_seeded_roles.down.sql b/internal/infrastructure/db/migrations/000043_seeded_roles.down.sql new file mode 100644 index 00000000..69dde4a2 --- /dev/null +++ b/internal/infrastructure/db/migrations/000043_seeded_roles.down.sql @@ -0,0 +1 @@ +ALTER TABLE organization_roles DROP COLUMN IF EXISTS color; diff --git a/internal/infrastructure/db/migrations/000043_seeded_roles.up.sql b/internal/infrastructure/db/migrations/000043_seeded_roles.up.sql new file mode 100644 index 00000000..9c783d92 --- /dev/null +++ b/internal/infrastructure/db/migrations/000043_seeded_roles.up.sql @@ -0,0 +1,30 @@ +-- Roles become pure data: every org gets seeded default roles (Admin, +-- Manager, Viewer) that are editable and deletable like any other role, and +-- members reference roles only via role_id. "Owner" stays a special status +-- on the membership row, not a role. Permission values are the defined-bit +-- bundles: Admin = all 15 defined bits minus transfer-ownership (28671), +-- Manager = operational bundle (19964), Viewer = read-only (3104). +ALTER TABLE organization_roles ADD COLUMN color varchar(7) NOT NULL DEFAULT ''; + +INSERT INTO organization_roles (id, organization_id, name, description, permissions, color) +SELECT gen_random_uuid(), o.id, d.name, d.description, d.permissions, d.color +FROM organizations o +CROSS JOIN (VALUES + ('Admin', 'Everything except transferring ownership.', 28671, '#8b5cf6'), + ('Manager', 'Runs campaigns, contacts, mailboxes, and integrations. No team, billing, or settings access.', 19964, '#10b981'), + ('Viewer', 'Read-only access to campaigns, contacts, and reports.', 3104, '#f59e0b') +) AS d(name, description, permissions, color) +ON CONFLICT (organization_id, name) DO NOTHING; + +-- Re-home existing members onto the seeded roles (owner rows stay as-is). +UPDATE organization_members om +SET role_id = r.id, role = r.name, permissions = r.permissions +FROM organization_roles r +WHERE om.role_id IS NULL + AND om.role <> 'owner' + AND r.organization_id = om.organization_id + AND r.name = CASE + WHEN om.role = 'admin' THEN 'Admin' + WHEN om.role IN ('manager', 'member') THEN 'Manager' + ELSE 'Viewer' + END; diff --git a/internal/models/organization.go b/internal/models/organization.go index 78197bb5..80b8fb34 100644 --- a/internal/models/organization.go +++ b/internal/models/organization.go @@ -128,18 +128,16 @@ type UpdateOrganizationRequest struct { // InviteMemberRequest represents the request to invite a new member type InviteMemberRequest struct { - Email string `json:"email" binding:"required,email"` - Role string `json:"role,omitempty"` - Permissions *uint16 `json:"permissions,omitempty"` - // RoleID invites straight into a custom role (wins over Role/Permissions). + Email string `json:"email" binding:"required,email"` + // RoleID is the workspace role the invitee lands in (required: roles + // are data rows, there are no hardcoded role names anymore). RoleID *uuid.UUID `json:"role_id,omitempty"` } // UpdateMemberRequest represents the request to update a member's role/permissions type UpdateMemberRequest struct { - Role *string `json:"role,omitempty"` - Permissions *uint16 `json:"permissions,omitempty"` - // RoleID assigns a custom role (wins over Role/Permissions). + // RoleID is the only way to change a member's access (owner is a + // membership status, not a role). RoleID *uuid.UUID `json:"role_id,omitempty"` } @@ -152,6 +150,7 @@ type OrganizationRole struct { OrganizationID uuid.UUID `json:"organization_id"` Name string `json:"name"` Description string `json:"description"` + Color string `json:"color"` Permissions OrganizationPermission `json:"permissions"` MemberCount int `json:"member_count"` CreatedAt time.Time `json:"created_at"` @@ -162,6 +161,7 @@ type OrganizationRole struct { type CreateOrganizationRoleRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description,omitempty"` + Color string `json:"color,omitempty"` Permissions uint16 `json:"permissions"` } @@ -170,6 +170,7 @@ type CreateOrganizationRoleRequest struct { type UpdateOrganizationRoleRequest struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` + Color *string `json:"color,omitempty"` Permissions *uint16 `json:"permissions,omitempty"` } diff --git a/internal/models/organization_permission.go b/internal/models/organization_permission.go index 2be522fd..8c386499 100644 --- a/internal/models/organization_permission.go +++ b/internal/models/organization_permission.go @@ -101,15 +101,33 @@ var RolePermissions = map[Role]OrganizationPermission{ RoleViewer: PermViewCampaigns | PermViewContacts | PermViewAnalytics, } -// IsReservedRoleName reports whether a custom role name collides with a -// built-in role (case-insensitive); such names are rejected so member.role -// strings stay unambiguous. +// IsReservedRoleName reports whether a role name collides with the owner +// status (case-insensitive). Owner is a membership flag, never a role row. func IsReservedRoleName(name string) bool { - switch Role(strings.ToLower(strings.TrimSpace(name))) { - case RoleOwner, RoleAdmin, RoleManager, RoleViewer, "member": - return true + return strings.EqualFold(strings.TrimSpace(name), string(RoleOwner)) +} + +// SeedRole is one of the default roles minted for every new workspace. +// They are ordinary rows afterwards: renameable, editable, deletable. +type SeedRole struct { + Name string + Description string + Color string + Permissions OrganizationPermission +} + +// DefaultSeedRoles returns the roles seeded at organization creation, +// mirroring migration 000043 for orgs created after it ran. +func DefaultSeedRoles() []SeedRole { + allDefined := PermManageTeam | PermManageBilling | PermManageCampaigns | PermManageContacts | + PermManageEmails | PermViewAnalytics | PermSendCampaigns | PermAccessUnibox | + PermManageSequences | PermManageSettings | PermViewCampaigns | PermViewContacts | + PermManageAPIKeys | PermUseIntegrations + return []SeedRole{ + {Name: "Admin", Description: "Everything except transferring ownership.", Color: "#8b5cf6", Permissions: allDefined}, + {Name: "Manager", Description: "Runs campaigns, contacts, mailboxes, and integrations. No team, billing, or settings access.", Color: "#10b981", Permissions: GetRolePermissions(RoleManager)}, + {Name: "Viewer", Description: "Read-only access to campaigns, contacts, and reports.", Color: "#f59e0b", Permissions: GetRolePermissions(RoleViewer)}, } - return false } // GetRolePermissions returns the default permissions for a role diff --git a/internal/repository/pg_organization_roles.go b/internal/repository/pg_organization_roles.go index d7954643..4397bc89 100644 --- a/internal/repository/pg_organization_roles.go +++ b/internal/repository/pg_organization_roles.go @@ -17,7 +17,7 @@ import ( func (r *organizationRepository) ListRoles(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationRole, error) { query := ` SELECT - rl.id, rl.organization_id, rl.name, rl.description, rl.permissions, + rl.id, rl.organization_id, rl.name, rl.description, rl.color, rl.permissions, rl.created_at, rl.updated_at, (SELECT COUNT(*) FROM organization_members om WHERE om.role_id = rl.id) AS member_count FROM organization_roles rl @@ -34,7 +34,7 @@ func (r *organizationRepository) ListRoles(ctx context.Context, orgID uuid.UUID) for rows.Next() { var role models.OrganizationRole if err := rows.Scan( - &role.ID, &role.OrganizationID, &role.Name, &role.Description, &role.Permissions, + &role.ID, &role.OrganizationID, &role.Name, &role.Description, &role.Color, &role.Permissions, &role.CreatedAt, &role.UpdatedAt, &role.MemberCount, ); err != nil { return nil, err @@ -48,7 +48,7 @@ func (r *organizationRepository) ListRoles(ctx context.Context, orgID uuid.UUID) func (r *organizationRepository) GetRoleByID(ctx context.Context, orgID, roleID uuid.UUID) (*models.OrganizationRole, error) { query := ` SELECT - rl.id, rl.organization_id, rl.name, rl.description, rl.permissions, + rl.id, rl.organization_id, rl.name, rl.description, rl.color, rl.permissions, rl.created_at, rl.updated_at, (SELECT COUNT(*) FROM organization_members om WHERE om.role_id = rl.id) AS member_count FROM organization_roles rl @@ -56,7 +56,7 @@ func (r *organizationRepository) GetRoleByID(ctx context.Context, orgID, roleID ` var role models.OrganizationRole err := r.db.QueryRow(ctx, query, orgID, roleID).Scan( - &role.ID, &role.OrganizationID, &role.Name, &role.Description, &role.Permissions, + &role.ID, &role.OrganizationID, &role.Name, &role.Description, &role.Color, &role.Permissions, &role.CreatedAt, &role.UpdatedAt, &role.MemberCount, ) if err == pgx.ErrNoRows { @@ -78,10 +78,10 @@ func (r *organizationRepository) CountRoles(ctx context.Context, orgID uuid.UUID // CreateRole inserts a custom role. func (r *organizationRepository) CreateRole(ctx context.Context, role *models.OrganizationRole) error { query := ` - INSERT INTO organization_roles (id, organization_id, name, description, permissions) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO organization_roles (id, organization_id, name, description, color, permissions) + VALUES ($1, $2, $3, $4, $5, $6) ` - _, err := r.db.Exec(ctx, query, role.ID, role.OrganizationID, role.Name, role.Description, role.Permissions) + _, err := r.db.Exec(ctx, query, role.ID, role.OrganizationID, role.Name, role.Description, role.Color, role.Permissions) return err } @@ -97,9 +97,9 @@ func (r *organizationRepository) UpdateRole(ctx context.Context, role *models.Or if _, err := tx.Exec(ctx, ` UPDATE organization_roles - SET name = $3, description = $4, permissions = $5, updated_at = NOW() + SET name = $3, description = $4, color = $5, permissions = $6, updated_at = NOW() WHERE organization_id = $1 AND id = $2 - `, role.OrganizationID, role.ID, role.Name, role.Description, role.Permissions); err != nil { + `, role.OrganizationID, role.ID, role.Name, role.Description, role.Color, role.Permissions); err != nil { return err } diff --git a/web/src/app/app/settings/_components/RoleSelect.tsx b/web/src/app/app/settings/_components/RoleSelect.tsx new file mode 100644 index 00000000..b171dbc1 --- /dev/null +++ b/web/src/app/app/settings/_components/RoleSelect.tsx @@ -0,0 +1,93 @@ +// Shared workspace-role dropdown: colored dot, name, description, check on +// the active row. Roles are data (seeded Admin/Manager/Viewer + anything the +// workspace created); Owner is a membership status and never appears here. + +import React from "react"; +import { CheckIcon, ChevronDownIcon, Loader2Icon } from "lucide-react"; +import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; + +const FALLBACK_COLOR = "#64748b"; + +export function roleColor(role?: Pick | null) { + return role?.color || FALLBACK_COLOR; +} + +export default function RoleSelect({ + roles, + value, + fallbackLabel, + onChange, + pending = false, + align = "start", +}: { + roles: OrganizationRole[]; + /** Currently selected role id (member.role_id / draft selection). */ + value?: string | null; + /** Shown when value matches no role (e.g. the role was deleted). */ + fallbackLabel?: string; + onChange: (role: OrganizationRole) => void; + pending?: boolean; + align?: "start" | "end"; +}) { + const [open, setOpen] = React.useState(false); + const current = roles.find((r) => r.id === value); + const label = current?.name ?? fallbackLabel ?? "Select role"; + const color = current ? roleColor(current) : FALLBACK_COLOR; + + return ( + + + + + + {roles.map((r) => { + const selected = r.id === value; + return ( + + ); + })} + {roles.length === 0 && ( +
+ No roles yet. Create one under Settings → Roles & access. +
+ )} +
+
+ ); +} diff --git a/web/src/app/app/settings/members/page.tsx b/web/src/app/app/settings/members/page.tsx index 169a4114..7abe2edb 100644 --- a/web/src/app/app/settings/members/page.tsx +++ b/web/src/app/app/settings/members/page.tsx @@ -38,7 +38,7 @@ import type OrganizationRole from "@/lib/api/models/app/organizations/Organizati import { useAppStore } from "@/stores"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -import { ROLE_CATALOG, getRoleDef } from "@/lib/permissions"; +import RoleSelect from "../_components/RoleSelect"; import { RolePill, Section, @@ -101,18 +101,14 @@ export default function MembersSettingsPage() { () => toast.error("Couldn't copy"), ); } - function changeRole(memberId: string, next: RoleChoice, email: string) { - const label = next.kind === "builtin" ? getRoleDef(next.id).label : next.label; - confirm?.show(`Change ${email}'s role to ${label}?`, async () => { + function changeRole(memberId: string, next: OrganizationRole, email: string) { + confirm?.show(`Change ${email}'s role to ${next.name}?`, async () => { try { await toast.promise( - updateRole.mutateAsync({ - id: memberId, - data: next.kind === "builtin" ? { role: next.id } : { role_id: next.id }, - }), + updateRole.mutateAsync({ id: memberId, data: { role_id: next.id } }), { loading: "Saving…", - success: `Role updated to ${label}`, + success: `Role updated to ${next.name}`, error: (e: AppError) => buildError(e), }, ); @@ -135,16 +131,12 @@ export default function MembersSettingsPage() { { + onSubmit={async (emails, role) => { let ok = 0; let failed = 0; for (const e of emails) { try { - await invite.mutateAsync( - choice.kind === "builtin" - ? { email: e, role: choice.id } - : { email: e, role_id: choice.id }, - ); + await invite.mutateAsync({ email: e, role_id: role.id }); ok++; } catch { failed++; @@ -212,10 +204,10 @@ export default function MembersSettingsPage() { {access.isOwner && !isOwner ? ( - changeRole(m.user_id, next, email)} pending={updateRole.isPending} /> @@ -356,113 +348,6 @@ const ACCENT_DOT: Record = { amber: "bg-amber-500", }; -type RoleChoice = - | { kind: "builtin"; id: string } - | { kind: "custom"; id: string; label: string }; - -function InlineRolePicker({ - value, - roleId, - customRoles, - onChange, - pending, -}: { - value: string; - roleId?: string; - customRoles: OrganizationRole[]; - onChange: (next: RoleChoice) => void; - pending: boolean; -}) { - const [open, setOpen] = React.useState(false); - const assignable = ROLE_CATALOG.filter((r) => r.assignable && r.id !== "member"); - - // A member on a custom role renders that role's name; built-ins keep - // their catalog accent. - const customCurrent = roleId ? customRoles.find((r) => r.id === roleId) : undefined; - const cur = getRoleDef(value); - const label = customCurrent?.name ?? (customCurrent === undefined && roleId ? value : cur.label); - const accent = customCurrent || roleId ? "sky" : cur.accent; - - return ( - - - - - - {assignable.map((r) => { - const selected = !roleId && r.id === value; - return ( - - ); - })} - {customRoles.length > 0 && ( -
- Custom roles -
- )} - {customRoles.map((r) => { - const selected = roleId === r.id; - return ( - - ); - })} -
-
- ); -} - /** * Multi-email invite flow. Email chips + role selector + send button, * with the role-description rail on the right so the owner sees what @@ -473,13 +358,18 @@ function InviteFlow({ pending, customRoles, }: { - onSubmit: (emails: string[], choice: RoleChoice) => Promise; + onSubmit: (emails: string[], role: OrganizationRole) => Promise; pending: boolean; customRoles: OrganizationRole[]; }) { const [chips, setChips] = React.useState<{ email: string; valid: boolean }[]>([]); const [draft, setDraft] = React.useState(""); - const [role, setRole] = React.useState({ kind: "builtin", id: "manager" }); + const [roleId, setRoleId] = React.useState(null); + // Default to the seeded Viewer (least privilege), else the first role. + const selectedRole = + customRoles.find((r) => r.id === roleId) ?? + customRoles.find((r) => r.name === "Viewer") ?? + customRoles[0]; const SEPARATOR_RE = /[\s,;]+/; function commitDrafts(value: string) { @@ -537,18 +427,20 @@ function InviteFlow({ icon: "⚠️", }); } - await onSubmit(valid, role); + if (!selectedRole) { + toast.error("Create a role first (Settings → Roles & access)"); + return; + } + await onSubmit(valid, selectedRole); setChips([]); setDraft(""); } const totalCount = chips.length + (draft.trim() ? draft.trim().split(SEPARATOR_RE).filter(Boolean).length : 0); - const assignable = ROLE_CATALOG.filter((r) => r.assignable && r.id !== "member"); - const activeCustom = role.kind === "custom" ? customRoles.find((r) => r.id === role.id) : undefined; - const activeRole = role.kind === "builtin" ? getRoleDef(role.id) : undefined; - const activeLabel = activeRole?.label ?? activeCustom?.name ?? "Custom role"; + const activeLabel = selectedRole?.name ?? "No roles yet"; const activeDescription = - activeRole?.description ?? activeCustom?.description ?? "A custom permission set for this workspace."; + selectedRole?.description ?? + "Create a role under Settings → Roles & access before inviting members."; return (
@@ -612,36 +504,12 @@ function InviteFlow({
-
- {assignable.map((r) => ( - - ))} - {customRoles.map((r) => ( - - ))} -
+ setRoleId(r.id)} + pending={false} + />
diff --git a/web/src/app/app/settings/roles/RolesSection.tsx b/web/src/app/app/settings/roles/RolesSection.tsx index f8ba5bab..96a58ffa 100644 --- a/web/src/app/app/settings/roles/RolesSection.tsx +++ b/web/src/app/app/settings/roles/RolesSection.tsx @@ -16,24 +16,22 @@ import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import { CATEGORY_LABEL, - PERMISSION_BITS, PERMISSION_CATALOG, - ROLE_CATALOG, + ROLE_TEMPLATES, } from "@/lib/permissions"; +import { roleColor } from "../_components/RoleSelect"; -// Ownership transfer can never live in a custom role (the API rejects it). +// Ownership transfer can never live in a role (the API rejects it). const EDITABLE_PERMISSIONS = PERMISSION_CATALOG.filter( (p) => p.key !== "TRANSFER_OWNERSHIP", ); const CATEGORIES = ["data", "send", "people", "admin"] as const; -// Presets the editor can start from ("preceding roles"): a built-in's -// permission set minus anything a custom role cannot carry. -const PRESETS = ROLE_CATALOG.filter((r) => r.assignable && r.id !== "member").map((r) => ({ - id: r.id, - label: r.label, - permissions: r.permissions & ~PERMISSION_BITS.TRANSFER_OWNERSHIP, -})); +// Permission templates the editor can start from. +const PRESETS = ROLE_TEMPLATES; + +// Swatch palette for role colors. +const COLORS = ["#0ea5e9", "#8b5cf6", "#10b981", "#f59e0b", "#f43f5e", "#06b6d4", "#84cc16", "#64748b"]; export default function RolesSection({ canManage }: { canManage: boolean }) { const roles = useRoles(); @@ -60,31 +58,15 @@ export default function RolesSection({ canManage }: { canManage: boolean }) { return (
- {ROLE_CATALOG.filter((r) => r.id !== "member").map((r) => ( -
-
-
- {r.label} - - Built-in - -
-

{r.description}

-
- - {countBits(r.permissions)} perms - -
- ))} - {customRoles.map((role) => (
+ {role.name} - - Custom -

{role.description || `${countBits(role.permissions)} permissions`} @@ -151,6 +133,7 @@ function RoleEditor({ role, onClose }: { role: OrganizationRole | null; onClose: const update = useUpdateRole(); const [name, setName] = React.useState(role?.name ?? ""); const [description, setDescription] = React.useState(role?.description ?? ""); + const [color, setColor] = React.useState(role?.color || COLORS[0]); const [permissions, setPermissions] = React.useState( role?.permissions ?? PRESETS.find((p) => p.id === "viewer")?.permissions ?? 0, ); @@ -166,8 +149,8 @@ function RoleEditor({ role, onClose }: { role: OrganizationRole | null; onClose: try { await toast.promise( role - ? update.mutateAsync({ id: role.id, data: { name: name.trim(), description, permissions } }) - : create.mutateAsync({ name: name.trim(), description, permissions }), + ? update.mutateAsync({ id: role.id, data: { name: name.trim(), description, color, permissions } }) + : create.mutateAsync({ name: name.trim(), description, color, permissions }), { loading: "Saving…", success: role ? "Role updated" : "Role created", @@ -242,7 +225,10 @@ function RoleEditor({ role, onClose }: { role: OrganizationRole | null; onClose:

- Copies a built-in role's permissions as a starting point, then tweak below. + Copies a template's permissions as a starting point, then tweak below.

+
+ +
+ {COLORS.map((c) => ( +
+
+ {CATEGORIES.map((cat) => { const perms = EDITABLE_PERMISSIONS.filter((p) => p.category === cat); if (perms.length === 0) return null; diff --git a/web/src/app/app/settings/roles/page.tsx b/web/src/app/app/settings/roles/page.tsx index 89119069..dc5e17bb 100644 --- a/web/src/app/app/settings/roles/page.tsx +++ b/web/src/app/app/settings/roles/page.tsx @@ -1,45 +1,36 @@ // Roles & access — workspace permission catalogue. // -// Structural page: shows the permission matrix and a summary of each -// role. The actual member roster + per-member role editing lives in -// the Members section so there's no duplication. +// Roles are data: every workspace starts with seeded Admin / Manager / +// Viewer rows that can be renamed, recolored, reshaped, or deleted like any +// other role. Owner is a membership status, not a role, and appears here +// only as a reference column. import React from "react"; import { CheckIcon, LockIcon, XIcon } from "lucide-react"; import { Link } from "react-router-dom"; import useFeatureAccess from "@/hooks/useFeatureAccess"; -import useMembers from "@/lib/api/hooks/app/organizations/useMembers"; +import useRoles from "@/lib/api/hooks/app/organizations/useRoles"; +import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; import { useAppStore } from "@/stores"; import { CATEGORY_LABEL, + OWNER_DEF, PERMISSION_CATALOG, - ROLE_CATALOG, hasPermission, type PermissionDef, - type RoleDef, } from "@/lib/permissions"; import { Section, SectionShell, TableSurface } from "../_components/SectionShell"; +import { roleColor } from "../_components/RoleSelect"; import RolesSection from "./RolesSection"; -import useRoles from "@/lib/api/hooks/app/organizations/useRoles"; -import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; - -const ACCENT = { - sky: { dot: "bg-sky-500", pill: "bg-sky-50 text-sky-700 border-sky-100" }, - violet: { dot: "bg-violet-500", pill: "bg-violet-50 text-violet-700 border-violet-100" }, - emerald: { dot: "bg-emerald-500", pill: "bg-emerald-50 text-emerald-700 border-emerald-100" }, - slate: { dot: "bg-slate-400", pill: "bg-slate-50 text-slate-700 border-slate-200" }, - amber: { dot: "bg-amber-500", pill: "bg-amber-50 text-amber-700 border-amber-100" }, -} as const; export default function RolesSettingsPage() { const access = useFeatureAccess(); - const members = useMembers(); const customRoles = useRoles(); const currentOrg = useAppStore((s) => s.currentOrganization); - if (!access.loading && !access.isOwner) { + if (!access.loading && !access.canManage) { return ( - +
@@ -47,11 +38,11 @@ export default function RolesSettingsPage() {
- Only the workspace owner can manage roles + You need team management access to manage roles

- Roles control who can do what inside this workspace. Ask your owner - to review or change your permissions. + Roles control who can do what inside this workspace. Ask someone + with team access to review or change your permissions.

@@ -60,12 +51,7 @@ export default function RolesSettingsPage() { ); } - const memberList = members.data ?? []; - const roleCounts = React.useMemo(() => { - const out: Record = {}; - for (const m of memberList) out[m.role] = (out[m.role] ?? 0) + 1; - return out; - }, [memberList]); + const roles = customRoles.data ?? []; return (
-
- {ROLE_CATALOG.filter((r) => r.id !== "member").map((r) => ( - - ))} - {(customRoles.data ?? []).map((r) => ( - - ))} -
+

- Want to change a member's role?{" "} + Assign roles from the member roster or the invite flow.{" "} - +

- -
- -
); } -function RoleSummaryCard({ role, count }: { role: RoleDef; count: number }) { - const accent = ACCENT[role.accent as keyof typeof ACCENT]; - return ( -
-
- - - {role.label} - - - {count} - -
-

{role.description}

-
- ); -} - -function CustomRoleSummaryCard({ role }: { role: OrganizationRole }) { - return ( -
-
- - - {role.name} - - - {role.member_count} - -
-

- {role.description || "Custom role"} -

-
- ); -} - -function MatrixTable({ customRoles }: { customRoles: OrganizationRole[] }) { - // Built-ins keep their catalog accents; custom roles join as sky columns. - const cols: { id: string; label: string; accent: keyof typeof ACCENT; permissions: number }[] = [ - ...ROLE_CATALOG.filter((r) => r.id !== "member").map((r) => ({ - id: r.id, - label: r.label, - accent: r.accent as keyof typeof ACCENT, - permissions: r.permissions, - })), - ...customRoles.map((r) => ({ - id: r.id, - label: r.name, - accent: "sky" as const, - permissions: r.permissions, - })), +function MatrixTable({ roles }: { roles: OrganizationRole[] }) { + const cols: { id: string; label: string; color: string; permissions: number }[] = [ + { id: "owner", label: OWNER_DEF.label, color: OWNER_DEF.color, permissions: OWNER_DEF.permissions }, + ...roles.map((r) => ({ id: r.id, label: r.name, color: roleColor(r), permissions: r.permissions })), ]; const grouped = React.useMemo(() => { const out: Record = { @@ -193,22 +110,19 @@ function MatrixTable({ customRoles }: { customRoles: OrganizationRole[] }) { Capability - {cols.map((r) => { - const a = ACCENT[r.accent]; - return ( - -
- - - {r.label} - -
- - ); - })} + {cols.map((r) => ( + +
+ + + {r.label} + +
+ + ))} @@ -247,9 +161,12 @@ function MatrixTable({ customRoles }: { customRoles: OrganizationRole[] }) { {allowed ? ( diff --git a/web/src/lib/api/client/app/organizations/createRole.ts b/web/src/lib/api/client/app/organizations/createRole.ts index 0cd56e03..62c501d4 100644 --- a/web/src/lib/api/client/app/organizations/createRole.ts +++ b/web/src/lib/api/client/app/organizations/createRole.ts @@ -1,7 +1,7 @@ import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; import Request from "../../Request"; -export default async function createRole(data: { name: string; description?: string; permissions: number }): Promise { +export default async function createRole(data: { name: string; description?: string; color?: string; permissions: number }): Promise { return await Request({ method: "POST", url: `/organization/roles`, diff --git a/web/src/lib/api/client/app/organizations/updateRole.ts b/web/src/lib/api/client/app/organizations/updateRole.ts index 29f9fae6..dc8144d0 100644 --- a/web/src/lib/api/client/app/organizations/updateRole.ts +++ b/web/src/lib/api/client/app/organizations/updateRole.ts @@ -1,7 +1,7 @@ import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; import Request from "../../Request"; -export default async function updateRole(id: string, data: { name?: string; description?: string; permissions?: number }): Promise { +export default async function updateRole(id: string, data: { name?: string; description?: string; color?: string; permissions?: number }): Promise { return await Request({ method: "PATCH", url: `/organization/roles/${id}`, diff --git a/web/src/lib/api/hooks/app/organizations/useRoleMutations.ts b/web/src/lib/api/hooks/app/organizations/useRoleMutations.ts index 62c9aa99..0692b756 100644 --- a/web/src/lib/api/hooks/app/organizations/useRoleMutations.ts +++ b/web/src/lib/api/hooks/app/organizations/useRoleMutations.ts @@ -13,7 +13,7 @@ const invalidate = (qc: ReturnType) => { export function useCreateRole() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: { name: string; description?: string; permissions: number }) => createRole(data), + mutationFn: (data: { name: string; description?: string; color?: string; permissions: number }) => createRole(data), onSuccess: () => invalidate(queryClient), }) } @@ -21,7 +21,7 @@ export function useCreateRole() { export function useUpdateRole() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, data }: { id: string; data: { name?: string; description?: string; permissions?: number } }) => updateRole(id, data), + mutationFn: ({ id, data }: { id: string; data: { name?: string; description?: string; color?: string; permissions?: number } }) => updateRole(id, data), onSuccess: () => invalidate(queryClient), }) } diff --git a/web/src/lib/api/models/app/organizations/OrganizationRole.ts b/web/src/lib/api/models/app/organizations/OrganizationRole.ts index 31d735d7..88831435 100644 --- a/web/src/lib/api/models/app/organizations/OrganizationRole.ts +++ b/web/src/lib/api/models/app/organizations/OrganizationRole.ts @@ -5,6 +5,7 @@ export default interface OrganizationRole { organization_id: string; name: string; description: string; + color: string; permissions: number; member_count: number; created_at: string; diff --git a/web/src/lib/permissions.ts b/web/src/lib/permissions.ts index 0878a2d4..344f6bb6 100644 --- a/web/src/lib/permissions.ts +++ b/web/src/lib/permissions.ts @@ -66,103 +66,43 @@ export const CATEGORY_LABEL = { admin: { label: "Workspace", description: "Settings, billing, API." }, } as const; -export interface RoleDef { - id: "owner" | "admin" | "manager" | "member" | "viewer"; - label: string; - description: string; - permissions: number; // bitmask - builtIn: true; - /** Some roles can't be assigned via UI (owner is set by ownership transfer). */ - assignable: boolean; - accent: string; // tailwind color hint -} +// Roles are workspace data (see /organization/roles). The only hardcoded +// concept left is the OWNER membership status and the permission templates +// the role editor can start from. +export const OWNER_DEF = { + label: "Owner", + description: "Full control of the workspace. There is exactly one owner; transfer it from workspace settings.", + color: "#0ea5e9", + permissions: ALL_PERMISSIONS, +} as const; -// Manager bundle — campaign-y stuff without team/billing/settings. -const MANAGER_PERMS = - PERMISSION_BITS.MANAGE_CAMPAIGNS | - PERMISSION_BITS.MANAGE_CONTACTS | - PERMISSION_BITS.MANAGE_EMAILS | - PERMISSION_BITS.SEND_CAMPAIGNS | - PERMISSION_BITS.MANAGE_SEQUENCES | - PERMISSION_BITS.VIEW_ANALYTICS | - PERMISSION_BITS.VIEW_CAMPAIGNS | - PERMISSION_BITS.VIEW_CONTACTS | - PERMISSION_BITS.ACCESS_UNIBOX | - PERMISSION_BITS.USE_INTEGRATIONS; +const ALL_DEFINED = PERMISSION_CATALOG.reduce((m, p) => m | p.bit, 0); -// Viewer bundle — read-only. -const VIEWER_PERMS = - PERMISSION_BITS.VIEW_CAMPAIGNS | - PERMISSION_BITS.VIEW_CONTACTS | - PERMISSION_BITS.VIEW_ANALYTICS; - -// Backwards-compat "member" role from the older 3-role world — treat -// it as Manager-equivalent so existing rows resolve cleanly. -const MEMBER_PERMS = MANAGER_PERMS; - -export const ROLE_CATALOG: RoleDef[] = [ - { - id: "owner", - label: "Owner", - description: "Full control. There can only be one owner per workspace.", - permissions: ALL_PERMISSIONS, - builtIn: true, - assignable: false, - accent: "sky", - }, - { - id: "admin", - label: "Admin", - description: "Everything except transferring ownership.", - permissions: ALL_PERMISSIONS & ~PERMISSION_BITS.TRANSFER_OWNERSHIP, - builtIn: true, - assignable: true, - accent: "violet", - }, +export const ROLE_TEMPLATES = [ + { id: "admin", label: "Admin", color: "#8b5cf6", permissions: ALL_DEFINED & ~PERMISSION_BITS.TRANSFER_OWNERSHIP }, { id: "manager", label: "Manager", - description: "Day-to-day operator. Can run campaigns, no team or billing access.", - permissions: MANAGER_PERMS, - builtIn: true, - assignable: true, - accent: "emerald", - }, - { - id: "member", - label: "Member", - description: "Legacy alias for Manager — kept for backwards compatibility.", - permissions: MEMBER_PERMS, - builtIn: true, - assignable: true, - accent: "slate", + color: "#10b981", + permissions: + PERMISSION_BITS.MANAGE_CAMPAIGNS | PERMISSION_BITS.MANAGE_CONTACTS | PERMISSION_BITS.MANAGE_EMAILS | + PERMISSION_BITS.SEND_CAMPAIGNS | PERMISSION_BITS.MANAGE_SEQUENCES | PERMISSION_BITS.VIEW_ANALYTICS | + PERMISSION_BITS.VIEW_CAMPAIGNS | PERMISSION_BITS.VIEW_CONTACTS | PERMISSION_BITS.ACCESS_UNIBOX | + PERMISSION_BITS.USE_INTEGRATIONS, }, { id: "viewer", label: "Viewer", - description: "Read-only. Sees reports and lists but can't change anything.", - permissions: VIEWER_PERMS, - builtIn: true, - assignable: true, - accent: "amber", + color: "#f59e0b", + permissions: PERMISSION_BITS.VIEW_CAMPAIGNS | PERMISSION_BITS.VIEW_CONTACTS | PERMISSION_BITS.VIEW_ANALYTICS, }, -]; +] as const; export function hasPermission(mask: number | undefined, bit: number): boolean { if (mask === undefined) return false; return (mask & bit) === bit; } -export function getRoleDef(role: string): RoleDef { - return ROLE_CATALOG.find((r) => r.id === role) ?? ROLE_CATALOG[ROLE_CATALOG.length - 1]; -} -/** - * Effective permission mask for a member, accounting for the member's - * per-row override and falling back to the role's default if the - * override is missing. - */ -export function effectivePermissions(role: string, permissions?: number): number { - if (typeof permissions === "number" && permissions > 0) return permissions; - return getRoleDef(role).permissions; -} + + From 2badeb7f503977e93bd385319bd6a1b2a6de0ffc Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 10:20:41 +0200 Subject: [PATCH 019/145] feat: align team-roles docs with data-driven roles (seeded editable defaults, owner as status, color in the role editor) and drop dead accent maps from the members page --- docs/content/docs/guides/team-roles.mdx | 18 +++++++++--------- web/src/app/app/settings/members/page.tsx | 20 -------------------- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/docs/content/docs/guides/team-roles.mdx b/docs/content/docs/guides/team-roles.mdx index 8d353706..235cc7a8 100644 --- a/docs/content/docs/guides/team-roles.mdx +++ b/docs/content/docs/guides/team-roles.mdx @@ -58,20 +58,20 @@ A few rules apply: ## Roles and the permission matrix -Warmbly ships with a set of built-in roles, and you can also define your own custom roles when none of them fit. Each role is a bundle of permissions; built-in bundles are fixed, custom bundles are yours to shape. +Roles in Warmbly are workspace data. Every new workspace starts with three seeded roles (Admin, Manager, and Viewer), and all of them are ordinary roles: rename them, recolor them, change their permissions, delete them, or add your own. Owner is not a role but a membership status; there is exactly one owner per workspace. The **Roles & access** page shows a card for each role with a short description and a live count of how many people currently hold it, followed by the full **permission matrix**. -### The built-in roles +### The seeded roles -| Role | What it can do | +| Role | What it starts with | | --- | --- | -| **Owner** | Full control of the workspace. There is exactly one owner. Only the owner can transfer ownership. | -| **Admin** | Everything the owner can do except transfer ownership. | +| **Owner** (status, not a role) | Full control of the workspace, including ownership transfer. | +| **Admin** | Everything except transferring ownership. | | **Manager** | The day-to-day operator. Can run campaigns, manage contacts and mailboxes, and use integrations, but has no team, billing, settings, or API-key access. | | **Viewer** | Read-only. Can see campaigns, contacts, and reports but cannot change anything. | -There is also a legacy **Member** role kept only for backwards compatibility. It behaves the same as **Manager**. New members are not assigned Member; pick Manager instead. +These are starting points, not fixed tiers. Any of them can be edited or deleted once the workspace exists. ### What each permission means @@ -138,10 +138,10 @@ In short: **Admin** is the owner minus ownership transfer, **Manager** is everyt ## Custom roles -When the built-in bundles don't fit, anyone with team management access can create custom roles on the **Roles & access** settings page: +Anyone with team management access can create additional roles on the **Roles & access** settings page: -1. Click **New role** and give it a name (up to 50 characters) and an optional description. Built-in role names are reserved. -2. Pick a built-in role under **Start from** to copy its permissions as a starting point, then toggle individual permissions on or off. +1. Click **New role** and give it a name (up to 50 characters), a color for the role pickers, and an optional description. Only the name "owner" is reserved. +2. Pick a template under **Start from** to copy a permission bundle as a starting point, then toggle individual permissions on or off. 3. Save, then assign the role from the member roster's role picker or directly in the invite flow. A few rules keep custom roles safe: diff --git a/web/src/app/app/settings/members/page.tsx b/web/src/app/app/settings/members/page.tsx index 7abe2edb..5e5735be 100644 --- a/web/src/app/app/settings/members/page.tsx +++ b/web/src/app/app/settings/members/page.tsx @@ -9,7 +9,6 @@ import React from "react"; import { CheckIcon, - ChevronDownIcon, CopyIcon, Loader2Icon, MailIcon, @@ -20,11 +19,6 @@ import { } from "lucide-react"; import toast from "react-hot-toast"; import { Label } from "@/components/ui/field"; -import { - PopoverMenu, - PopoverMenuContent, - PopoverMenuTrigger, -} from "@/components/ui/popover-menu"; import { useConfirm } from "@/hooks/context/confirm"; import useFeatureAccess from "@/hooks/useFeatureAccess"; import useMembers from "@/lib/api/hooks/app/organizations/useMembers"; @@ -333,20 +327,6 @@ function Th({ children, className }: { children: React.ReactNode; className?: st ); } -const ACCENT_PILL: Record = { - sky: "bg-sky-50 text-sky-700 border-sky-100", - violet: "bg-violet-50 text-violet-700 border-violet-100", - emerald: "bg-emerald-50 text-emerald-700 border-emerald-100", - slate: "bg-slate-50 text-slate-700 border-slate-200", - amber: "bg-amber-50 text-amber-700 border-amber-100", -}; -const ACCENT_DOT: Record = { - sky: "bg-sky-500", - violet: "bg-violet-500", - emerald: "bg-emerald-500", - slate: "bg-slate-400", - amber: "bg-amber-500", -}; /** * Multi-email invite flow. Email chips + role selector + send button, From 3473d09bcc7eb41497ebd9f00ded92fd405a54d1 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 10:45:21 +0200 Subject: [PATCH 020/145] =?UTF-8?q?feat:=20fix=20review=20findings=20in=20?= =?UTF-8?q?the=20roles=20redesign=20=E2=80=94=20GetMembers=20role=5Fid=20c?= =?UTF-8?q?olumn=20(members=20endpoint=20500),=20TransferOwnership=20role?= =?UTF-8?q?=5Fid=20hygiene,=20accept-time=20role=20re-resolution,=20race-f?= =?UTF-8?q?ree=20in-use=20delete=20guard=20covering=20invitations,=20assig?= =?UTF-8?q?nment=20anti-escalation=20with=20self-role-change=20block,=20ca?= =?UTF-8?q?nManage-gated=20members=20UI,=20colored=20RolePills,=20fresh=20?= =?UTF-8?q?currentOrganization=20on=20refetch,=20dev=20JWT=5FSECRET=20wiri?= =?UTF-8?q?ng=20for=20make=20realtime,=20docs=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 1 + docs/content/docs/guides/team-roles.mdx | 6 +-- internal/api/handler/organization.go | 8 ++- internal/app/organization/service.go | 49 ++++++++++++++++--- internal/repository/pg_organization.go | 23 ++++++--- internal/repository/pg_organization_roles.go | 43 ++++++++++++---- realtime/mix.exs | 1 + .../app/settings/_components/SectionShell.tsx | 19 ++++--- web/src/app/app/settings/members/page.tsx | 12 ++--- web/src/hooks/useRealtimeEvents.ts | 2 +- .../models/app/organizations/Invitation.ts | 4 +- .../app/organizations/OrganizationMember.ts | 5 +- web/src/stores/slices/organizationSlice.ts | 10 ++-- 13 files changed, 133 insertions(+), 50 deletions(-) diff --git a/Makefile b/Makefile index c7e98ddf..b2f8e549 100644 --- a/Makefile +++ b/Makefile @@ -450,6 +450,7 @@ tracking: realtime: cd realtime && \ export MIX_ENV=dev \ + JWT_SECRET=local-dev-auth-secret-minimum-32-characters-long \ PORT=4000 \ PHX_HOST=$(WEB_HOST) \ DATABASE_HOST=localhost \ diff --git a/docs/content/docs/guides/team-roles.mdx b/docs/content/docs/guides/team-roles.mdx index 235cc7a8..fed8ffaf 100644 --- a/docs/content/docs/guides/team-roles.mdx +++ b/docs/content/docs/guides/team-roles.mdx @@ -8,8 +8,8 @@ A Warmbly workspace can be shared with your whole team. You invite people by ema Everything here lives under **Settings**: the roster and invitations are on the **Members** page, and the roles and permission matrix are on the **Roles & access** page. - -Inviting members, changing someone's role, and removing members are reserved for the workspace **owner**. If you are not the owner, you can still see the Members page, but the invite box, the role pickers, and the remove buttons will not appear. The Roles & access page is owner-only and shows a "permission denied" notice to everyone else. + +Inviting members, changing roles, removing members, and managing roles all require the **Manage team** permission (the owner and the seeded Admin role have it; any custom role can carry it). Members without it see the roster read-only, and the Roles & access page shows a permission notice instead. ## Inviting members @@ -150,7 +150,7 @@ A few rules keep custom roles safe: - **You can only grant permissions you hold yourself.** A manager with team access cannot mint a role stronger than their own and assign it to someone. - **Ownership transfer can never be part of a custom role.** It stays exclusive to the owner. - **A role in use cannot be deleted.** Reassign its members first, so nobody silently loses access. -- Each workspace can have up to 25 custom roles. +- Each workspace can have up to 25 roles in total (including the seeded ones). Custom roles apply everywhere permissions do: API access checks, dashboard visibility, and which realtime events a member's live dashboard receives. diff --git a/internal/api/handler/organization.go b/internal/api/handler/organization.go index 01a3146f..c9174c2e 100644 --- a/internal/api/handler/organization.go +++ b/internal/api/handler/organization.go @@ -254,7 +254,13 @@ func (h *Handler) UpdateMemberRole(c *gin.Context) { return } - member, xerr := h.OrganizationService.UpdateMemberRole(c.Request.Context(), *orgID, memberUserID, &req) + actorID, uerr := middleware.GetUserUUID(c) + if uerr != nil { + errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) + return + } + + member, xerr := h.OrganizationService.UpdateMemberRole(c.Request.Context(), *orgID, actorID, memberUserID, &req) if xerr != nil { errx.JSON(c, xerr) return diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index b2026104..6caaf49c 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -49,7 +49,7 @@ type OrganizationService interface { CreateRole(ctx context.Context, orgID, actorID uuid.UUID, req *models.CreateOrganizationRoleRequest) (*models.OrganizationRole, *errx.Error) UpdateRole(ctx context.Context, orgID, actorID, roleID uuid.UUID, req *models.UpdateOrganizationRoleRequest) (*models.OrganizationRole, *errx.Error) DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) *errx.Error - UpdateMemberRole(ctx context.Context, orgID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error) + UpdateMemberRole(ctx context.Context, orgID, actorID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error) RemoveMember(ctx context.Context, orgID, memberUserID uuid.UUID) *errx.Error // Invitations @@ -367,6 +367,12 @@ func (s *organizationService) InviteMember(ctx context.Context, orgID uuid.UUID, if workspaceRole == nil { return nil, errx.New(errx.BadRequest, "role not found") } + // Assignment is also an escalation surface: the inviter must hold every + // permission the role grants (the owner's 0xFFFF passes trivially). + if xerr := s.validateActorHoldsPermissions(ctx, orgID, inviterID, workspaceRole.Permissions); xerr != nil { + return nil, xerr + } + role := workspaceRole.Name roleID := &workspaceRole.ID permissions := workspaceRole.Permissions @@ -429,15 +435,31 @@ func (s *organizationService) AcceptInvitation(ctx context.Context, token string return existing, nil } + // Re-resolve the role at accept time: the invitation's snapshot may be + // stale (role edited) or dangling (role deleted) since it was sent. + role := (*models.OrganizationRole)(nil) + if inv.RoleID != nil { + var rerr error + role, rerr = s.orgRepo.GetRoleByID(ctx, inv.OrganizationID, *inv.RoleID) + if rerr != nil { + sentry.CaptureException(rerr) + return nil, errx.New(errx.Internal, "failed to load role") + } + } + if role == nil { + _ = s.orgRepo.DeleteInvitation(ctx, inv.ID) + return nil, errx.New(errx.BadRequest, "the role for this invitation no longer exists — ask for a new invite") + } + // Add member now := time.Now() member := &models.OrganizationMember{ ID: uuid.New(), OrganizationID: inv.OrganizationID, UserID: userID, - Role: inv.Role, - RoleID: inv.RoleID, - Permissions: inv.Permissions, + Role: role.Name, + RoleID: &role.ID, + Permissions: role.Permissions, InvitedBy: &inv.InvitedBy, InvitedAt: inv.CreatedAt, AcceptedAt: &now, @@ -455,7 +477,7 @@ func (s *organizationService) AcceptInvitation(ctx context.Context, token string } // UpdateMemberRole updates a member's role and permissions -func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error) { +func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, actorID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error) { member, err := s.orgRepo.GetMember(ctx, orgID, memberUserID) if err != nil { sentry.CaptureException(err) @@ -483,6 +505,15 @@ func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, membe if workspaceRole == nil { return nil, errx.New(errx.BadRequest, "role not found") } + // Assignment is an escalation surface: the actor must hold every + // permission the role grants, and may not re-role themselves. + if actorID == memberUserID { + return nil, errx.New(errx.Forbidden, "you cannot change your own role") + } + if xerr := s.validateActorHoldsPermissions(ctx, orgID, actorID, workspaceRole.Permissions); xerr != nil { + return nil, xerr + } + member.Role = workspaceRole.Name member.RoleID = &workspaceRole.ID member.Permissions = workspaceRole.Permissions @@ -1158,6 +1189,12 @@ func (s *organizationService) validateRolePermissions(ctx context.Context, orgID if perms&models.PermTransferOwnership != 0 { return errx.New(errx.BadRequest, "custom roles cannot include ownership transfer") } + return s.validateActorHoldsPermissions(ctx, orgID, actorID, perms) +} + +// validateActorHoldsPermissions rejects granting (via role create/edit OR +// assignment) any permission the actor does not hold themselves. +func (s *organizationService) validateActorHoldsPermissions(ctx context.Context, orgID, actorID uuid.UUID, perms models.OrganizationPermission) *errx.Error { actor, err := s.orgRepo.GetMember(ctx, orgID, actorID) if err != nil { sentry.CaptureException(err) @@ -1167,7 +1204,7 @@ func (s *organizationService) validateRolePermissions(ctx context.Context, orgID return errx.New(errx.Forbidden, "not a member") } if perms&^actor.Permissions != 0 { - return errx.New(errx.Forbidden, "a role cannot grant permissions you do not hold") + return errx.New(errx.Forbidden, "you cannot grant permissions you do not hold") } return nil } diff --git a/internal/repository/pg_organization.go b/internal/repository/pg_organization.go index 593ad895..453ce2e3 100644 --- a/internal/repository/pg_organization.go +++ b/internal/repository/pg_organization.go @@ -235,7 +235,7 @@ func (r *organizationRepository) GetUserDefaultOrganization(ctx context.Context, func (r *organizationRepository) GetMembers(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationMember, error) { query := ` SELECT - om.id, om.organization_id, om.user_id, om.role, om.permissions, + om.id, om.organization_id, om.user_id, om.role, om.role_id, om.permissions, om.invited_by, om.invited_at, om.accepted_at, u.id, u.first_name, u.last_name, u.email, u.created_at, u.updated_at FROM organization_members om @@ -414,7 +414,7 @@ func (r *organizationRepository) GetInvitationByEmail(ctx context.Context, orgID // GetPendingInvitations retrieves all pending invitations for an organization func (r *organizationRepository) GetPendingInvitations(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationInvitation, error) { query := ` - SELECT id, organization_id, email, role, permissions, invited_by, token, expires_at, created_at + SELECT id, organization_id, email, role, role_id, permissions, invited_by, token, expires_at, created_at FROM organization_invitations WHERE organization_id = $1 AND expires_at > NOW() ORDER BY created_at DESC @@ -428,7 +428,7 @@ func (r *organizationRepository) GetPendingInvitations(ctx context.Context, orgI var invitations []models.OrganizationInvitation for rows.Next() { var inv models.OrganizationInvitation - err := rows.Scan(&inv.ID, &inv.OrganizationID, &inv.Email, &inv.Role, &inv.Permissions, &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt) + err := rows.Scan(&inv.ID, &inv.OrganizationID, &inv.Email, &inv.Role, &inv.RoleID, &inv.Permissions, &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt) if err != nil { return nil, err } @@ -507,15 +507,24 @@ func (r *organizationRepository) TransferOwnership(ctx context.Context, orgID, n return err } - // Update old owner to admin - _, err = tx.Exec(ctx, `UPDATE organization_members SET role = 'admin', permissions = $3 WHERE organization_id = $1 AND user_id = $2`, + // Re-home the old owner onto the org's Admin role row when one exists + // (roles are data); otherwise fall back to a detached Admin-mask member. + _, err = tx.Exec(ctx, ` + UPDATE organization_members om SET + role = COALESCE(r.name, 'Admin'), + role_id = r.id, + permissions = COALESCE(r.permissions, $3) + FROM (SELECT 1) one + LEFT JOIN organization_roles r ON r.organization_id = $1 AND r.name = 'Admin' + WHERE om.organization_id = $1 AND om.user_id = $2`, orgID, currentOwnerID, models.RolePermissions[models.RoleAdmin]) if err != nil { return err } - // Update new owner to owner - _, err = tx.Exec(ctx, `UPDATE organization_members SET role = 'owner', permissions = $3 WHERE organization_id = $1 AND user_id = $2`, + // Owner is a membership status, not a role: role_id must be NULL so role + // edits can never write through onto the owner row. + _, err = tx.Exec(ctx, `UPDATE organization_members SET role = 'owner', role_id = NULL, permissions = $3 WHERE organization_id = $1 AND user_id = $2`, orgID, newOwnerUserID, models.RolePermissions[models.RoleOwner]) if err != nil { return err diff --git a/internal/repository/pg_organization_roles.go b/internal/repository/pg_organization_roles.go index 4397bc89..292c2c01 100644 --- a/internal/repository/pg_organization_roles.go +++ b/internal/repository/pg_organization_roles.go @@ -103,9 +103,21 @@ func (r *organizationRepository) UpdateRole(ctx context.Context, role *models.Or return err } + // role <> 'owner' is belt-and-suspenders: owner rows must never carry a + // role_id, but a stray one must still not let an edit demote the owner. if _, err := tx.Exec(ctx, ` UPDATE organization_members SET role = $2, permissions = $3 + WHERE role_id = $1 AND role <> 'owner' + `, role.ID, role.Name, role.Permissions); err != nil { + return err + } + + // Pending invitations snapshot the role too; keep them in sync so an + // invite accepted after an edit lands with the role's CURRENT shape. + if _, err := tx.Exec(ctx, ` + UPDATE organization_invitations + SET role = $2, permissions = $3 WHERE role_id = $1 `, role.ID, role.Name, role.Permissions); err != nil { return err @@ -115,19 +127,30 @@ func (r *organizationRepository) UpdateRole(ctx context.Context, role *models.Or } // DeleteRole removes a custom role. Returns inUse=true (and deletes nothing) -// while members are still assigned, so an assignment is always deliberate. +// while members or pending invitations still reference it. The guard lives +// inside the DELETE itself, so a concurrent assignment can never race past +// the check and strand a member on a phantom permission snapshot. func (r *organizationRepository) DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) (bool, error) { - var inUse bool - if err := r.db.QueryRow(ctx, - `SELECT EXISTS(SELECT 1 FROM organization_members WHERE role_id = $1)`, roleID, - ).Scan(&inUse); err != nil { + tag, err := r.db.Exec(ctx, ` + DELETE FROM organization_roles rl + WHERE rl.organization_id = $1 AND rl.id = $2 + AND NOT EXISTS (SELECT 1 FROM organization_members om WHERE om.role_id = rl.id) + AND NOT EXISTS (SELECT 1 FROM organization_invitations i WHERE i.role_id = rl.id) + `, orgID, roleID) + if err != nil { return false, err } - if inUse { - return true, nil + if tag.RowsAffected() > 0 { + return false, nil } - _, err := r.db.Exec(ctx, - `DELETE FROM organization_roles WHERE organization_id = $1 AND id = $2`, orgID, roleID) - return false, err + // Nothing deleted: in use, or already gone (idempotent success). + var exists bool + if err := r.db.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM organization_roles WHERE organization_id = $1 AND id = $2)`, + orgID, roleID, + ).Scan(&exists); err != nil { + return false, err + } + return exists, nil } diff --git a/realtime/mix.exs b/realtime/mix.exs index 28bf5c11..d1d5e0f0 100644 --- a/realtime/mix.exs +++ b/realtime/mix.exs @@ -6,6 +6,7 @@ defmodule Realtime.MixProject do app: :realtime, version: "0.1.0", elixir: "~> 1.18", + listeners: [Phoenix.CodeReloader], start_permanent: Mix.env() == :prod, deps: deps() ] diff --git a/web/src/app/app/settings/_components/SectionShell.tsx b/web/src/app/app/settings/_components/SectionShell.tsx index 8cd8b09f..a58621a7 100644 --- a/web/src/app/app/settings/_components/SectionShell.tsx +++ b/web/src/app/app/settings/_components/SectionShell.tsx @@ -298,19 +298,22 @@ export function Card({ ); } -export function RolePill({ role }: { role: string }) { +export function RolePill({ role, color }: { role: string; color?: string }) { + // Roles are data: tint from the role's stored color when known; + // owner keeps its fixed sky accent, anything else falls back to slate. const cls = role === "owner" ? "bg-sky-50 text-sky-700 border-sky-100" - : role === "admin" - ? "bg-violet-50 text-violet-700 border-violet-100" - : role === "manager" - ? "bg-emerald-50 text-emerald-700 border-emerald-100" - : role === "viewer" - ? "bg-amber-50 text-amber-700 border-amber-100" - : "bg-slate-50 text-slate-600 border-slate-200"; + : color + ? "" + : "bg-slate-50 text-slate-600 border-slate-200"; + const style = + role !== "owner" && color + ? { backgroundColor: `${color}14`, borderColor: `${color}55`, color } + : undefined; return ( {role} diff --git a/web/src/app/app/settings/members/page.tsx b/web/src/app/app/settings/members/page.tsx index 5e5735be..0f1a04c6 100644 --- a/web/src/app/app/settings/members/page.tsx +++ b/web/src/app/app/settings/members/page.tsx @@ -117,7 +117,7 @@ export default function MembersSettingsPage() { title="Members" description={`Everyone with access to ${currentOrg?.name ?? "this workspace"}.`} > - {access.isOwner && ( + {access.canManage && (
- {access.isOwner && !isOwner ? ( + {access.canManage && !isOwner ? ( ) : ( - + r.id === m.role_id)?.color} /> )} @@ -220,7 +220,7 @@ export default function MembersSettingsPage() { : "—"} - {access.isOwner && !isOwner && !isSelf && ( + {access.canManage && !isOwner && !isSelf && (
- + r.id === inv.role_id)?.color} /> {new Date(inv.expires_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })} - {access.isOwner && ( + {access.canManage && (
- + {roles.map((r) => { const selected = r.id === value; return (
- {r.description && ( -

{r.description}

- )} + ); })} diff --git a/web/src/app/app/settings/layout.tsx b/web/src/app/app/settings/layout.tsx index 1726a46e..a5b21375 100644 --- a/web/src/app/app/settings/layout.tsx +++ b/web/src/app/app/settings/layout.tsx @@ -20,7 +20,6 @@ import { AlertOctagonIcon, BellIcon, BriefcaseIcon, - CableIcon, CreditCardIcon, GaugeIcon, ShieldCheckIcon, @@ -119,15 +118,6 @@ export default function SettingsLayout() { )} ))} - {/* Integrations is a top-level surface, not a settings sub-page; - cross-link out to it so people who look here still find it. */} - - - Integrations -
diff --git a/web/src/app/app/settings/roles/page.tsx b/web/src/app/app/settings/roles/page.tsx index dc5e17bb..cb43082b 100644 --- a/web/src/app/app/settings/roles/page.tsx +++ b/web/src/app/app/settings/roles/page.tsx @@ -1,31 +1,18 @@ -// Roles & access — workspace permission catalogue. +// Roles & access — workspace role management. // // Roles are data: every workspace starts with seeded Admin / Manager / // Viewer rows that can be renamed, recolored, reshaped, or deleted like any -// other role. Owner is a membership status, not a role, and appears here -// only as a reference column. +// other role. Owner is a membership status, not a role. -import React from "react"; -import { CheckIcon, LockIcon, XIcon } from "lucide-react"; +import { LockIcon } from "lucide-react"; import { Link } from "react-router-dom"; import useFeatureAccess from "@/hooks/useFeatureAccess"; -import useRoles from "@/lib/api/hooks/app/organizations/useRoles"; -import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; import { useAppStore } from "@/stores"; -import { - CATEGORY_LABEL, - OWNER_DEF, - PERMISSION_CATALOG, - hasPermission, - type PermissionDef, -} from "@/lib/permissions"; -import { Section, SectionShell, TableSurface } from "../_components/SectionShell"; -import { roleColor } from "../_components/RoleSelect"; +import { Section, SectionShell } from "../_components/SectionShell"; import RolesSection from "./RolesSection"; export default function RolesSettingsPage() { const access = useFeatureAccess(); - const customRoles = useRoles(); const currentOrg = useAppStore((s) => s.currentOrganization); if (!access.loading && !access.canManage) { @@ -51,8 +38,6 @@ export default function RolesSettingsPage() { ); } - const roles = customRoles.data ?? []; - return (

- -
- - - -
); } - -function MatrixTable({ roles }: { roles: OrganizationRole[] }) { - const cols: { id: string; label: string; color: string; permissions: number }[] = [ - { id: "owner", label: OWNER_DEF.label, color: OWNER_DEF.color, permissions: OWNER_DEF.permissions }, - ...roles.map((r) => ({ id: r.id, label: r.name, color: roleColor(r), permissions: r.permissions })), - ]; - const grouped = React.useMemo(() => { - const out: Record = { - data: [], - people: [], - send: [], - admin: [], - }; - for (const p of PERMISSION_CATALOG) out[p.category].push(p); - return out; - }, []); - - return ( -
- - - - - {cols.map((r) => ( - - ))} - - - - {(Object.entries(grouped) as [PermissionDef["category"], PermissionDef[]][]).map( - ([cat, perms]) => ( - - - - - {perms.map((p) => ( - - - {cols.map((r) => { - const allowed = hasPermission(r.permissions, p.bit); - return ( - - ); - })} - - ))} - - ), - )} - -
- Capability - -
- - - {r.label} - -
-
-
- {CATEGORY_LABEL[cat].label} - - {CATEGORY_LABEL[cat].description} - -
-
-
- {p.label} -
-
- {p.description} -
-
- {allowed ? ( - - - - ) : ( - - - - )} -
-
- ); -} From 8540f7db1515634ad488c693ec48dd3fe076da13 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 11:24:19 +0200 Subject: [PATCH 022/145] =?UTF-8?q?feat:=20members=20can=20hold=20multiple?= =?UTF-8?q?=20roles=20=E2=80=94=20join=20tables=20for=20member/invitation?= =?UTF-8?q?=20role=20sets=20(migration=20000044)=20with=20effective=20perm?= =?UTF-8?q?issions=20as=20the=20bitwise=20OR=20recomputed=20on=20every=20a?= =?UTF-8?q?ssignment=20and=20role=20edit/delete,=20role=5Fids=20in=20invit?= =?UTF-8?q?e/update=20APIs,=20multi-select=20checkbox=20role=20picker=20wi?= =?UTF-8?q?th=20colored=20chips=20in=20roster=20and=20invite=20flow,=20fre?= =?UTF-8?q?ely=20deletable=20roles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/docs/guides/team-roles.mdx | 3 +- internal/app/organization/service.go | 165 +++++++++++------ .../000044_member_multi_roles.down.sql | 2 + .../000044_member_multi_roles.up.sql | 43 +++++ internal/models/organization.go | 63 ++++++- internal/repository/pg_member_roles.go | 175 ++++++++++++++++++ internal/repository/pg_organization.go | 9 +- internal/repository/pg_organization_roles.go | 93 ++++++---- .../settings/_components/RoleMultiSelect.tsx | 114 ++++++++++++ web/src/app/app/settings/members/page.tsx | 79 ++++---- .../client/app/organizations/inviteMember.ts | 2 +- .../app/organizations/updateMemberRole.ts | 2 +- .../app/organizations/useInviteMember.ts | 2 +- .../app/organizations/useUpdateMemberRole.ts | 2 +- .../models/app/organizations/Invitation.ts | 1 + .../app/organizations/OrganizationMember.ts | 8 + 16 files changed, 616 insertions(+), 147 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000044_member_multi_roles.down.sql create mode 100644 internal/infrastructure/db/migrations/000044_member_multi_roles.up.sql create mode 100644 internal/repository/pg_member_roles.go create mode 100644 web/src/app/app/settings/_components/RoleMultiSelect.tsx diff --git a/docs/content/docs/guides/team-roles.mdx b/docs/content/docs/guides/team-roles.mdx index fed8ffaf..9c713051 100644 --- a/docs/content/docs/guides/team-roles.mdx +++ b/docs/content/docs/guides/team-roles.mdx @@ -149,7 +149,8 @@ A few rules keep custom roles safe: - **Editing a role updates everyone assigned to it, immediately.** The editor shows how many members will be affected before you save. - **You can only grant permissions you hold yourself.** A manager with team access cannot mint a role stronger than their own and assign it to someone. - **Ownership transfer can never be part of a custom role.** It stays exclusive to the owner. -- **A role in use cannot be deleted.** Reassign its members first, so nobody silently loses access. +- **Members can hold several roles at once.** Their effective access is the combined (union) permissions of every role assigned to them. +- **Deleting a role is always allowed.** It is removed from anyone holding it; their remaining roles still apply. A member left with no roles keeps their membership but has no permissions until reassigned. - Each workspace can have up to 25 roles in total (including the seeded ones). Custom roles apply everywhere permissions do: API access checks, dashboard visibility, and which realtime events a member's live dashboard receives. diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 6caaf49c..5811428d 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -326,6 +326,9 @@ func (s *organizationService) GetMembers(ctx context.Context, orgID uuid.UUID) ( sentry.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get members") } + if err := s.orgRepo.HydrateMemberRoles(ctx, orgID, members); err != nil { + sentry.CaptureException(err) + } return members, nil } @@ -354,33 +357,25 @@ func (s *organizationService) InviteMember(ctx context.Context, orgID uuid.UUID, // First, we need to check if there's already a user with this email // For now, we'll just create the invitation - // Roles are data rows: every invite lands in one, snapshotting its - // name + permissions onto the invitation (kept in sync via role_id). - if req.RoleID == nil { - return nil, errx.New(errx.BadRequest, "a role is required") + // Roles are data rows: every invite lands in one or more, snapshotting + // the effective (OR) permissions + primary name onto the invitation. + roleIDs, roles, permissions, xerr := s.resolveRoleSet(ctx, orgID, req.Resolved()) + if xerr != nil { + return nil, xerr } - workspaceRole, rerr := s.orgRepo.GetRoleByID(ctx, orgID, *req.RoleID) - if rerr != nil { - sentry.CaptureException(rerr) - return nil, errx.New(errx.Internal, "failed to load role") - } - if workspaceRole == nil { - return nil, errx.New(errx.BadRequest, "role not found") - } - // Assignment is also an escalation surface: the inviter must hold every - // permission the role grants (the owner's 0xFFFF passes trivially). - if xerr := s.validateActorHoldsPermissions(ctx, orgID, inviterID, workspaceRole.Permissions); xerr != nil { + // Assignment is an escalation surface: the inviter must hold every + // permission the role set grants (the owner's full mask passes trivially). + if xerr := s.validateActorHoldsPermissions(ctx, orgID, inviterID, permissions); xerr != nil { return nil, xerr } - role := workspaceRole.Name - roleID := &workspaceRole.ID - permissions := workspaceRole.Permissions + role := roles[0].Name + roleID := &roles[0].ID // Generate invitation token - token, xerr := generateInvitationToken() - if xerr != nil { - sentry.CaptureException(xerr) + token, tokErr := generateInvitationToken() + if tokErr != nil { + sentry.CaptureException(tokErr) return nil, errx.New(errx.Internal, "failed to generate invitation token") } @@ -401,10 +396,47 @@ func (s *organizationService) InviteMember(ctx context.Context, orgID uuid.UUID, sentry.CaptureException(err) return nil, errx.New(errx.Internal, "failed to create invitation") } + if err := s.orgRepo.SetInvitationRoles(ctx, inv.ID, roleIDs); err != nil { + sentry.CaptureException(err) + return nil, errx.New(errx.Internal, "failed to attach roles") + } + inv.Roles = toMemberRoles(roles) return inv, nil } +// resolveRoleSet loads + validates a set of org role ids, returning the +// deduped ids, the role rows, and the effective (OR) permission mask. At +// least one valid role is required. +func (s *organizationService) resolveRoleSet(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, []models.OrganizationRole, models.OrganizationPermission, *errx.Error) { + if len(ids) == 0 { + return nil, nil, 0, errx.New(errx.BadRequest, "at least one role is required") + } + var roles []models.OrganizationRole + var perms models.OrganizationPermission + for _, id := range ids { + role, err := s.orgRepo.GetRoleByID(ctx, orgID, id) + if err != nil { + sentry.CaptureException(err) + return nil, nil, 0, errx.New(errx.Internal, "failed to load role") + } + if role == nil { + return nil, nil, 0, errx.New(errx.BadRequest, "role not found") + } + roles = append(roles, *role) + perms |= role.Permissions + } + return ids, roles, perms, nil +} + +func toMemberRoles(roles []models.OrganizationRole) []models.MemberRole { + out := make([]models.MemberRole, 0, len(roles)) + for _, r := range roles { + out = append(out, models.MemberRole{ID: r.ID, Name: r.Name, Color: r.Color}) + } + return out +} + // AcceptInvitation accepts an invitation and adds the user as a member func (s *organizationService) AcceptInvitation(ctx context.Context, token string, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) { inv, err := s.orgRepo.GetInvitationByToken(ctx, token) @@ -435,44 +467,64 @@ func (s *organizationService) AcceptInvitation(ctx context.Context, token string return existing, nil } - // Re-resolve the role at accept time: the invitation's snapshot may be - // stale (role edited) or dangling (role deleted) since it was sent. - role := (*models.OrganizationRole)(nil) - if inv.RoleID != nil { - var rerr error - role, rerr = s.orgRepo.GetRoleByID(ctx, inv.OrganizationID, *inv.RoleID) + // Re-resolve the invitation's role set at accept time: snapshots may be + // stale (edited) or dangling (deleted) since the invite was sent. Roles + // deleted in the meantime are dropped; at least one must survive. + invRoleIDs, ierr := s.orgRepo.GetInvitationRoles(ctx, inv.ID) + if ierr != nil { + sentry.CaptureException(ierr) + return nil, errx.New(errx.Internal, "failed to load invitation roles") + } + var liveRoleIDs []uuid.UUID + var primary *models.OrganizationRole + for _, id := range invRoleIDs { + role, rerr := s.orgRepo.GetRoleByID(ctx, inv.OrganizationID, id) if rerr != nil { sentry.CaptureException(rerr) return nil, errx.New(errx.Internal, "failed to load role") } + if role == nil { + continue + } + if primary == nil { + primary = role + } + liveRoleIDs = append(liveRoleIDs, id) } - if role == nil { + if len(liveRoleIDs) == 0 { _ = s.orgRepo.DeleteInvitation(ctx, inv.ID) - return nil, errx.New(errx.BadRequest, "the role for this invitation no longer exists — ask for a new invite") + return nil, errx.New(errx.BadRequest, "the roles for this invitation no longer exist — ask for a new invite") } - // Add member + // Add the membership row, then assign the full role set (which recomputes + // the effective permission snapshot). now := time.Now() member := &models.OrganizationMember{ ID: uuid.New(), OrganizationID: inv.OrganizationID, UserID: userID, - Role: role.Name, - RoleID: &role.ID, - Permissions: role.Permissions, + Role: primary.Name, + RoleID: &primary.ID, + Permissions: primary.Permissions, InvitedBy: &inv.InvitedBy, InvitedAt: inv.CreatedAt, AcceptedAt: &now, } - if err := s.orgRepo.AddMember(ctx, member); err != nil { sentry.CaptureException(err) return nil, errx.New(errx.Internal, "failed to add member") } + if err := s.orgRepo.SetMemberRoles(ctx, inv.OrganizationID, userID, liveRoleIDs); err != nil { + sentry.CaptureException(err) + return nil, errx.New(errx.Internal, "failed to assign roles") + } // Delete the invitation _ = s.orgRepo.DeleteInvitation(ctx, inv.ID) + if updated, _ := s.orgRepo.GetMember(ctx, inv.OrganizationID, userID); updated != nil { + member = updated + } return member, nil } @@ -492,38 +544,33 @@ func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, actor return nil, errx.New(errx.Forbidden, "cannot modify owner role") } - if req.RoleID == nil { - return nil, errx.New(errx.BadRequest, "a role is required") - } - // Snapshot the role's name + permissions; role edits keep propagating - // to this member via role_id. - workspaceRole, rerr := s.orgRepo.GetRoleByID(ctx, orgID, *req.RoleID) - if rerr != nil { - sentry.CaptureException(rerr) - return nil, errx.New(errx.Internal, "failed to load role") - } - if workspaceRole == nil { - return nil, errx.New(errx.BadRequest, "role not found") + roleIDs, _, permissions, xerr := s.resolveRoleSet(ctx, orgID, req.Resolved()) + if xerr != nil { + return nil, xerr } // Assignment is an escalation surface: the actor must hold every - // permission the role grants, and may not re-role themselves. + // permission the new role set grants, and may not re-role themselves. if actorID == memberUserID { - return nil, errx.New(errx.Forbidden, "you cannot change your own role") + return nil, errx.New(errx.Forbidden, "you cannot change your own roles") } - if xerr := s.validateActorHoldsPermissions(ctx, orgID, actorID, workspaceRole.Permissions); xerr != nil { + if xerr := s.validateActorHoldsPermissions(ctx, orgID, actorID, permissions); xerr != nil { return nil, xerr } - member.Role = workspaceRole.Name - member.RoleID = &workspaceRole.ID - member.Permissions = workspaceRole.Permissions - - if err := s.orgRepo.UpdateMember(ctx, member); err != nil { + if err := s.orgRepo.SetMemberRoles(ctx, orgID, memberUserID, roleIDs); err != nil { sentry.CaptureException(err) - return nil, errx.New(errx.Internal, "failed to update member") + return nil, errx.New(errx.Internal, "failed to update roles") } - return member, nil + updated, gerr := s.orgRepo.GetMember(ctx, orgID, memberUserID) + if gerr != nil { + sentry.CaptureException(gerr) + return nil, errx.New(errx.Internal, "failed to load member") + } + if updated != nil { + updated.Roles, _ = s.orgRepo.GetMemberRoles(ctx, orgID, memberUserID) + } + return updated, nil } // RemoveMember removes a member from the organization @@ -1316,13 +1363,9 @@ func (s *organizationService) UpdateRole(ctx context.Context, orgID, actorID, ro } func (s *organizationService) DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) *errx.Error { - inUse, err := s.orgRepo.DeleteRole(ctx, orgID, roleID) - if err != nil { + if err := s.orgRepo.DeleteRole(ctx, orgID, roleID); err != nil { sentry.CaptureException(err) return errx.New(errx.Internal, "failed to delete role") } - if inUse { - return errx.New(errx.Conflict, "reassign the members using this role first") - } return nil } diff --git a/internal/infrastructure/db/migrations/000044_member_multi_roles.down.sql b/internal/infrastructure/db/migrations/000044_member_multi_roles.down.sql new file mode 100644 index 00000000..71ec39a9 --- /dev/null +++ b/internal/infrastructure/db/migrations/000044_member_multi_roles.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS organization_invitation_roles; +DROP TABLE IF EXISTS organization_member_roles; diff --git a/internal/infrastructure/db/migrations/000044_member_multi_roles.up.sql b/internal/infrastructure/db/migrations/000044_member_multi_roles.up.sql new file mode 100644 index 00000000..96a5fb2e --- /dev/null +++ b/internal/infrastructure/db/migrations/000044_member_multi_roles.up.sql @@ -0,0 +1,43 @@ +-- Members can hold several roles at once. Assignments live in a join table; +-- organization_members.permissions stays the denormalized effective snapshot +-- (the bitwise OR of every assigned role's permissions) so all readers (Go +-- middleware, realtime auth) remain JOIN-free. Owner is unaffected: it is a +-- membership status with no role rows and keeps its full mask. +CREATE TABLE organization_member_roles ( + organization_id uuid NOT NULL, + user_id uuid NOT NULL, + role_id uuid NOT NULL REFERENCES organization_roles(id) ON DELETE CASCADE, + created_at timestamp with time zone NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, user_id, role_id) +); +CREATE INDEX idx_member_roles_role ON organization_member_roles (role_id); +CREATE INDEX idx_member_roles_member ON organization_member_roles (organization_id, user_id); + +-- Invitations can likewise carry several roles. +CREATE TABLE organization_invitation_roles ( + invitation_id uuid NOT NULL REFERENCES organization_invitations(id) ON DELETE CASCADE, + role_id uuid NOT NULL REFERENCES organization_roles(id) ON DELETE CASCADE, + PRIMARY KEY (invitation_id, role_id) +); +CREATE INDEX idx_invitation_roles_invite ON organization_invitation_roles (invitation_id); + +-- Backfill: each member's single role_id becomes one assignment row. +INSERT INTO organization_member_roles (organization_id, user_id, role_id) +SELECT organization_id, user_id, role_id +FROM organization_members +WHERE role_id IS NOT NULL +ON CONFLICT DO NOTHING; + +INSERT INTO organization_invitation_roles (invitation_id, role_id) +SELECT id, role_id FROM organization_invitations WHERE role_id IS NOT NULL +ON CONFLICT DO NOTHING; + +-- Recompute every non-owner member's effective permission snapshot. +UPDATE organization_members om +SET permissions = COALESCE(( + SELECT bit_or(r.permissions) + FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = om.organization_id AND mr.user_id = om.user_id +), 0) +WHERE om.role <> 'owner'; diff --git a/internal/models/organization.go b/internal/models/organization.go index 80b8fb34..c7f4b5f7 100644 --- a/internal/models/organization.go +++ b/internal/models/organization.go @@ -38,9 +38,11 @@ type OrganizationMember struct { OrganizationID uuid.UUID `json:"organization_id"` UserID uuid.UUID `json:"user_id"` Role string `json:"role"` - // RoleID links to a custom organization_roles row; nil for built-in - // roles. Permissions stays the effective snapshot either way. + // RoleID is the member's primary role (first assigned), kept for legacy + // single-role consumers. Roles is the full assigned set; Permissions is + // the effective OR snapshot across all of them. RoleID *uuid.UUID `json:"role_id,omitempty"` + Roles []MemberRole `json:"roles,omitempty"` Permissions OrganizationPermission `json:"permissions"` InvitedBy *uuid.UUID `json:"invited_by,omitempty"` InvitedAt time.Time `json:"invited_at"` @@ -74,6 +76,7 @@ type OrganizationInvitation struct { Email string `json:"email"` Role string `json:"role"` RoleID *uuid.UUID `json:"role_id,omitempty"` + Roles []MemberRole `json:"roles,omitempty"` Permissions OrganizationPermission `json:"permissions"` InvitedBy uuid.UUID `json:"invited_by"` Token string `json:"-"` // Never expose token in JSON @@ -129,16 +132,60 @@ type UpdateOrganizationRequest struct { // InviteMemberRequest represents the request to invite a new member type InviteMemberRequest struct { Email string `json:"email" binding:"required,email"` - // RoleID is the workspace role the invitee lands in (required: roles - // are data rows, there are no hardcoded role names anymore). - RoleID *uuid.UUID `json:"role_id,omitempty"` + // RoleIDs are the workspace roles the invitee lands in (at least one). + // RoleID stays accepted as a single-role shorthand. + RoleIDs []uuid.UUID `json:"role_ids,omitempty"` + RoleID *uuid.UUID `json:"role_id,omitempty"` +} + +// Resolved returns the requested role ids, merging the single-role shorthand. +func (r *InviteMemberRequest) Resolved() []uuid.UUID { + ids := append([]uuid.UUID(nil), r.RoleIDs...) + if r.RoleID != nil { + ids = append(ids, *r.RoleID) + } + return dedupeUUIDs(ids) } // UpdateMemberRequest represents the request to update a member's role/permissions type UpdateMemberRequest struct { - // RoleID is the only way to change a member's access (owner is a - // membership status, not a role). - RoleID *uuid.UUID `json:"role_id,omitempty"` + // RoleIDs replaces the member's assigned role set (at least one). RoleID + // stays accepted as a single-role shorthand. + RoleIDs []uuid.UUID `json:"role_ids,omitempty"` + RoleID *uuid.UUID `json:"role_id,omitempty"` +} + +// Resolved returns the requested role ids, merging the single-role shorthand. +func (r *UpdateMemberRequest) Resolved() []uuid.UUID { + ids := append([]uuid.UUID(nil), r.RoleIDs...) + if r.RoleID != nil { + ids = append(ids, *r.RoleID) + } + return dedupeUUIDs(ids) +} + +func dedupeUUIDs(ids []uuid.UUID) []uuid.UUID { + seen := make(map[uuid.UUID]struct{}, len(ids)) + out := make([]uuid.UUID, 0, len(ids)) + for _, id := range ids { + if id == uuid.Nil { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +// MemberRole is a lightweight role reference for rendering a member's +// assigned roles (chips) without the full permission payload. +type MemberRole struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Color string `json:"color"` } // OrganizationRole is an org-scoped custom role: a named permission set diff --git a/internal/repository/pg_member_roles.go b/internal/repository/pg_member_roles.go new file mode 100644 index 00000000..60a0f888 --- /dev/null +++ b/internal/repository/pg_member_roles.go @@ -0,0 +1,175 @@ +package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/warmbly/warmbly/internal/models" +) + +// Multi-role assignment storage. organization_members.permissions stays the +// effective OR snapshot across every assigned role, recomputed in the same +// transaction as any membership/role change so all permission readers stay +// JOIN-free. + +// recomputeMemberPermissions sets a member's permission snapshot to the +// bitwise OR of its assigned roles (0 when none). Owner is never touched. +func recomputeMemberPermissions(ctx context.Context, tx pgx.Tx, orgID, userID uuid.UUID) error { + _, err := tx.Exec(ctx, ` + UPDATE organization_members om + SET permissions = COALESCE(( + SELECT bit_or(r.permissions) + FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = om.organization_id AND mr.user_id = om.user_id + ), 0), + role = COALESCE(( + SELECT r.name FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = om.organization_id AND mr.user_id = om.user_id + ORDER BY r.created_at ASC LIMIT 1 + ), om.role), + role_id = ( + SELECT r.id FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = om.organization_id AND mr.user_id = om.user_id + ORDER BY r.created_at ASC LIMIT 1 + ) + WHERE om.organization_id = $1 AND om.user_id = $2 AND om.role <> 'owner' + `, orgID, userID) + return err +} + +// SetMemberRoles replaces a member's assigned role set and recomputes the +// effective permission snapshot atomically. All role ids must belong to the +// org (enforced by the FK + the caller's validation). +func (r *organizationRepository) SetMemberRoles(ctx context.Context, orgID, userID uuid.UUID, roleIDs []uuid.UUID) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + if _, err := tx.Exec(ctx, + `DELETE FROM organization_member_roles WHERE organization_id = $1 AND user_id = $2`, + orgID, userID); err != nil { + return err + } + for _, roleID := range roleIDs { + if _, err := tx.Exec(ctx, ` + INSERT INTO organization_member_roles (organization_id, user_id, role_id) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING + `, orgID, userID, roleID); err != nil { + return err + } + } + if err := recomputeMemberPermissions(ctx, tx, orgID, userID); err != nil { + return err + } + return tx.Commit(ctx) +} + +// GetMemberRoles returns a member's assigned role refs (for display chips). +func (r *organizationRepository) GetMemberRoles(ctx context.Context, orgID, userID uuid.UUID) ([]models.MemberRole, error) { + return scanMemberRoles(ctx, r.db, ` + SELECT r.id, r.name, r.color + FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = $1 AND mr.user_id = $2 + ORDER BY r.created_at ASC + `, orgID, userID) +} + +func scanMemberRoles(ctx context.Context, db *pgxpool.Pool, query string, args ...any) ([]models.MemberRole, error) { + rows, err := db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.MemberRole + for rows.Next() { + var mr models.MemberRole + if err := rows.Scan(&mr.ID, &mr.Name, &mr.Color); err != nil { + return nil, err + } + out = append(out, mr) + } + return out, nil +} + +// HydrateMemberRoles fills the Roles slice on each member from one query, so +// the roster shows every assigned role without an N+1. +func (r *organizationRepository) HydrateMemberRoles(ctx context.Context, orgID uuid.UUID, members []models.OrganizationMember) error { + if len(members) == 0 { + return nil + } + rows, err := r.db.Query(ctx, ` + SELECT mr.user_id, r.id, r.name, r.color + FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = $1 + ORDER BY r.created_at ASC + `, orgID) + if err != nil { + return err + } + defer rows.Close() + + byUser := make(map[uuid.UUID][]models.MemberRole) + for rows.Next() { + var userID uuid.UUID + var mr models.MemberRole + if err := rows.Scan(&userID, &mr.ID, &mr.Name, &mr.Color); err != nil { + return err + } + byUser[userID] = append(byUser[userID], mr) + } + for i := range members { + members[i].Roles = byUser[members[i].UserID] + } + return nil +} + +// SetInvitationRoles replaces an invitation's role set (used at invite time). +func (r *organizationRepository) SetInvitationRoles(ctx context.Context, invitationID uuid.UUID, roleIDs []uuid.UUID) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + if _, err := tx.Exec(ctx, + `DELETE FROM organization_invitation_roles WHERE invitation_id = $1`, invitationID); err != nil { + return err + } + for _, roleID := range roleIDs { + if _, err := tx.Exec(ctx, ` + INSERT INTO organization_invitation_roles (invitation_id, role_id) + VALUES ($1, $2) ON CONFLICT DO NOTHING + `, invitationID, roleID); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +// GetInvitationRoles returns the role ids attached to an invitation. +func (r *organizationRepository) GetInvitationRoles(ctx context.Context, invitationID uuid.UUID) ([]uuid.UUID, error) { + rows, err := r.db.Query(ctx, + `SELECT role_id FROM organization_invitation_roles WHERE invitation_id = $1`, invitationID) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil +} diff --git a/internal/repository/pg_organization.go b/internal/repository/pg_organization.go index 453ce2e3..ddd6f7f4 100644 --- a/internal/repository/pg_organization.go +++ b/internal/repository/pg_organization.go @@ -39,7 +39,14 @@ type OrganizationRepository interface { CountRoles(ctx context.Context, orgID uuid.UUID) (int, error) CreateRole(ctx context.Context, role *models.OrganizationRole) error UpdateRole(ctx context.Context, role *models.OrganizationRole) error - DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) (bool, error) + DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) error + + // Multi-role assignment (pg_member_roles.go) + SetMemberRoles(ctx context.Context, orgID, userID uuid.UUID, roleIDs []uuid.UUID) error + GetMemberRoles(ctx context.Context, orgID, userID uuid.UUID) ([]models.MemberRole, error) + HydrateMemberRoles(ctx context.Context, orgID uuid.UUID, members []models.OrganizationMember) error + SetInvitationRoles(ctx context.Context, invitationID uuid.UUID, roleIDs []uuid.UUID) error + GetInvitationRoles(ctx context.Context, invitationID uuid.UUID) ([]uuid.UUID, error) RemoveMember(ctx context.Context, orgID, userID uuid.UUID) error GetMemberCount(ctx context.Context, orgID uuid.UUID) (int, error) diff --git a/internal/repository/pg_organization_roles.go b/internal/repository/pg_organization_roles.go index 292c2c01..00516c37 100644 --- a/internal/repository/pg_organization_roles.go +++ b/internal/repository/pg_organization_roles.go @@ -103,54 +103,81 @@ func (r *organizationRepository) UpdateRole(ctx context.Context, role *models.Or return err } - // role <> 'owner' is belt-and-suspenders: owner rows must never carry a - // role_id, but a stray one must still not let an edit demote the owner. + // Recompute the effective OR snapshot for every member assigned this + // role (they may hold others), excluding the owner. if _, err := tx.Exec(ctx, ` - UPDATE organization_members - SET role = $2, permissions = $3 - WHERE role_id = $1 AND role <> 'owner' - `, role.ID, role.Name, role.Permissions); err != nil { + UPDATE organization_members om + SET permissions = COALESCE(( + SELECT bit_or(r.permissions) + FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = om.organization_id AND mr.user_id = om.user_id + ), 0), + role = COALESCE(( + SELECT r2.name FROM organization_member_roles mr2 + JOIN organization_roles r2 ON r2.id = mr2.role_id + WHERE mr2.organization_id = om.organization_id AND mr2.user_id = om.user_id + ORDER BY r2.created_at ASC LIMIT 1 + ), om.role) + WHERE om.role <> 'owner' AND EXISTS ( + SELECT 1 FROM organization_member_roles mx + WHERE mx.organization_id = om.organization_id + AND mx.user_id = om.user_id AND mx.role_id = $1 + ) + `, role.ID); err != nil { return err } - // Pending invitations snapshot the role too; keep them in sync so an - // invite accepted after an edit lands with the role's CURRENT shape. + // Pending invitations snapshot the role name for display; keep in sync. if _, err := tx.Exec(ctx, ` UPDATE organization_invitations - SET role = $2, permissions = $3 + SET role = $2 WHERE role_id = $1 - `, role.ID, role.Name, role.Permissions); err != nil { + `, role.ID, role.Name); err != nil { return err } return tx.Commit(ctx) } -// DeleteRole removes a custom role. Returns inUse=true (and deletes nothing) -// while members or pending invitations still reference it. The guard lives -// inside the DELETE itself, so a concurrent assignment can never race past -// the check and strand a member on a phantom permission snapshot. -func (r *organizationRepository) DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) (bool, error) { - tag, err := r.db.Exec(ctx, ` - DELETE FROM organization_roles rl - WHERE rl.organization_id = $1 AND rl.id = $2 - AND NOT EXISTS (SELECT 1 FROM organization_members om WHERE om.role_id = rl.id) - AND NOT EXISTS (SELECT 1 FROM organization_invitations i WHERE i.role_id = rl.id) - `, orgID, roleID) +// DeleteRole removes a role. Members keep their other roles; the join FK +// cascades the assignment rows away and each affected member's effective +// snapshot is recomputed in the same transaction. Roles are freely +// deletable (a member left with no roles simply has no permissions). +func (r *organizationRepository) DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) error { + tx, err := r.db.Begin(ctx) if err != nil { - return false, err - } - if tag.RowsAffected() > 0 { - return false, nil + return err } + defer tx.Rollback(ctx) //nolint:errcheck - // Nothing deleted: in use, or already gone (idempotent success). - var exists bool - if err := r.db.QueryRow(ctx, - `SELECT EXISTS(SELECT 1 FROM organization_roles WHERE organization_id = $1 AND id = $2)`, - orgID, roleID, - ).Scan(&exists); err != nil { - return false, err + // Capture who holds this role before the cascade clears the rows. + rows, err := tx.Query(ctx, + `SELECT user_id FROM organization_member_roles WHERE role_id = $1`, roleID) + if err != nil { + return err } - return exists, nil + var affected []uuid.UUID + for rows.Next() { + var uid uuid.UUID + if err := rows.Scan(&uid); err != nil { + rows.Close() + return err + } + affected = append(affected, uid) + } + rows.Close() + + if _, err := tx.Exec(ctx, + `DELETE FROM organization_roles WHERE organization_id = $1 AND id = $2`, orgID, roleID); err != nil { + return err + } + // FK ON DELETE CASCADE already removed the member/invitation role rows; + // recompute each affected member's snapshot. + for _, uid := range affected { + if err := recomputeMemberPermissions(ctx, tx, orgID, uid); err != nil { + return err + } + } + return tx.Commit(ctx) } diff --git a/web/src/app/app/settings/_components/RoleMultiSelect.tsx b/web/src/app/app/settings/_components/RoleMultiSelect.tsx new file mode 100644 index 00000000..358bdfbd --- /dev/null +++ b/web/src/app/app/settings/_components/RoleMultiSelect.tsx @@ -0,0 +1,114 @@ +// Multi-role picker: checkbox dropdown + colored chips for the selected +// roles. A member can hold several roles; effective access is the union. + +import React from "react"; +import { CheckIcon, ChevronDownIcon, Loader2Icon } from "lucide-react"; +import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; +import type { MemberRole } from "@/lib/api/models/app/organizations/OrganizationMember"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; +import { roleColor } from "./RoleSelect"; + +export function RoleChips({ roles }: { roles: MemberRole[] }) { + if (roles.length === 0) { + return No role; + } + return ( + + {roles.map((r) => ( + + + {r.name} + + ))} + + ); +} + +export default function RoleMultiSelect({ + roles, + value, + onChange, + pending = false, + align = "start", +}: { + roles: OrganizationRole[]; + /** Selected role ids. */ + value: string[]; + /** Fires with the next full set; never empty (at least one role). */ + onChange: (roleIds: string[]) => void; + pending?: boolean; + align?: "start" | "end"; +}) { + const [open, setOpen] = React.useState(false); + const selected = roles.filter((r) => value.includes(r.id)); + const summary = + selected.length === 0 + ? "Select roles" + : selected.length === 1 + ? selected[0].name + : `${selected[0].name} +${selected.length - 1}`; + + const toggle = (id: string) => { + const next = value.includes(id) ? value.filter((v) => v !== id) : [...value, id]; + if (next.length === 0) return; // keep at least one + onChange(next); + }; + + return ( + + + + + + {roles.map((r) => { + const on = value.includes(r.id); + return ( + + ); + })} + {roles.length === 0 && ( +
+ No roles yet. Create one under Settings → Roles & access. +
+ )} +
+
+ ); +} diff --git a/web/src/app/app/settings/members/page.tsx b/web/src/app/app/settings/members/page.tsx index 0f1a04c6..15d83b25 100644 --- a/web/src/app/app/settings/members/page.tsx +++ b/web/src/app/app/settings/members/page.tsx @@ -32,7 +32,7 @@ import type OrganizationRole from "@/lib/api/models/app/organizations/Organizati import { useAppStore } from "@/stores"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -import RoleSelect from "../_components/RoleSelect"; +import RoleMultiSelect, { RoleChips } from "../_components/RoleMultiSelect"; import { RolePill, Section, @@ -95,21 +95,19 @@ export default function MembersSettingsPage() { () => toast.error("Couldn't copy"), ); } - function changeRole(memberId: string, next: OrganizationRole, email: string) { - confirm?.show(`Change ${email}'s role to ${next.name}?`, async () => { - try { - await toast.promise( - updateRole.mutateAsync({ id: memberId, data: { role_id: next.id } }), - { - loading: "Saving…", - success: `Role updated to ${next.name}`, - error: (e: AppError) => buildError(e), - }, - ); - } catch { - /* surfaced */ - } - }); + async function changeRoles(memberId: string, roleIds: string[]) { + try { + await toast.promise( + updateRole.mutateAsync({ id: memberId, data: { role_ids: roleIds } }), + { + loading: "Saving…", + success: "Roles updated", + error: (e: AppError) => buildError(e), + }, + ); + } catch { + /* surfaced */ + } } return ( @@ -125,12 +123,12 @@ export default function MembersSettingsPage() { { + onSubmit={async (emails, roleIds) => { let ok = 0; let failed = 0; for (const e of emails) { try { - await invite.mutateAsync({ email: e, role_id: role.id }); + await invite.mutateAsync({ email: e, role_ids: roleIds }); ok++; } catch { failed++; @@ -198,11 +196,10 @@ export default function MembersSettingsPage() { {access.canManage && !isOwner ? ( - changeRole(m.user_id, next, email)} + value={(m.roles ?? []).map((r) => r.id)} + onChange={(ids) => changeRoles(m.user_id, ids)} pending={updateRole.isPending} /> ) : isOwner ? ( @@ -211,7 +208,7 @@ export default function MembersSettingsPage() { Owner
) : ( - r.id === m.role_id)?.color} /> + )} @@ -338,18 +335,17 @@ function InviteFlow({ pending, customRoles, }: { - onSubmit: (emails: string[], role: OrganizationRole) => Promise; + onSubmit: (emails: string[], roleIds: string[]) => Promise; pending: boolean; customRoles: OrganizationRole[]; }) { const [chips, setChips] = React.useState<{ email: string; valid: boolean }[]>([]); const [draft, setDraft] = React.useState(""); - const [roleId, setRoleId] = React.useState(null); + const [roleIds, setRoleIds] = React.useState([]); // Default to the seeded Viewer (least privilege), else the first role. - const selectedRole = - customRoles.find((r) => r.id === roleId) ?? - customRoles.find((r) => r.name === "Viewer") ?? - customRoles[0]; + const defaultRole = customRoles.find((r) => r.name === "Viewer") ?? customRoles[0]; + const effectiveRoleIds = roleIds.length > 0 ? roleIds : defaultRole ? [defaultRole.id] : []; + const selectedRoles = customRoles.filter((r) => effectiveRoleIds.includes(r.id)); const SEPARATOR_RE = /[\s,;]+/; function commitDrafts(value: string) { @@ -407,20 +403,26 @@ function InviteFlow({ icon: "⚠️", }); } - if (!selectedRole) { + if (effectiveRoleIds.length === 0) { toast.error("Create a role first (Settings → Roles & access)"); return; } - await onSubmit(valid, selectedRole); + await onSubmit(valid, effectiveRoleIds); setChips([]); setDraft(""); } const totalCount = chips.length + (draft.trim() ? draft.trim().split(SEPARATOR_RE).filter(Boolean).length : 0); - const activeLabel = selectedRole?.name ?? "No roles yet"; + const activeLabel = + selectedRoles.length === 0 + ? "No roles yet" + : selectedRoles.map((r) => r.name).join(", "); const activeDescription = - selectedRole?.description ?? - "Create a role under Settings → Roles & access before inviting members."; + selectedRoles.length === 0 + ? "Create a role under Settings → Roles & access before inviting members." + : selectedRoles.length === 1 + ? (selectedRoles[0].description || "This role's permissions apply to the invitee.") + : "The invitee gets the combined permissions of every selected role."; return (
@@ -483,12 +485,11 @@ function InviteFlow({
- - Roles + setRoleId(r.id)} - pending={false} + value={effectiveRoleIds} + onChange={setRoleIds} />
diff --git a/web/src/lib/api/client/app/organizations/inviteMember.ts b/web/src/lib/api/client/app/organizations/inviteMember.ts index 68d79b2f..15f1edbc 100644 --- a/web/src/lib/api/client/app/organizations/inviteMember.ts +++ b/web/src/lib/api/client/app/organizations/inviteMember.ts @@ -1,7 +1,7 @@ import type Invitation from "@/lib/api/models/app/organizations/Invitation"; import Request from "../../Request"; -export default async function inviteMember(data: { email: string; role?: string; role_id?: string }): Promise { +export default async function inviteMember(data: { email: string; role_ids?: string[]; role_id?: string }): Promise { return await Request({ method: "POST", url: `/organization/members/invite`, diff --git a/web/src/lib/api/client/app/organizations/updateMemberRole.ts b/web/src/lib/api/client/app/organizations/updateMemberRole.ts index 00fc6619..569c7902 100644 --- a/web/src/lib/api/client/app/organizations/updateMemberRole.ts +++ b/web/src/lib/api/client/app/organizations/updateMemberRole.ts @@ -1,7 +1,7 @@ import type OrganizationMember from "@/lib/api/models/app/organizations/OrganizationMember"; import Request from "../../Request"; -export default async function updateMemberRole(id: string, data: { role?: string; role_id?: string }): Promise { +export default async function updateMemberRole(id: string, data: { role_ids?: string[]; role_id?: string }): Promise { return await Request({ method: "PATCH", url: `/organization/members/${id}`, diff --git a/web/src/lib/api/hooks/app/organizations/useInviteMember.ts b/web/src/lib/api/hooks/app/organizations/useInviteMember.ts index 455476c3..e094fd34 100644 --- a/web/src/lib/api/hooks/app/organizations/useInviteMember.ts +++ b/web/src/lib/api/hooks/app/organizations/useInviteMember.ts @@ -5,7 +5,7 @@ export default function useInviteMember() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: { email: string; role?: string; role_id?: string }) => inviteMember(data), + mutationFn: (data: { email: string; role_ids?: string[]; role_id?: string }) => inviteMember(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["organizations", "invitations"], diff --git a/web/src/lib/api/hooks/app/organizations/useUpdateMemberRole.ts b/web/src/lib/api/hooks/app/organizations/useUpdateMemberRole.ts index 829d7ccb..4ddd64eb 100644 --- a/web/src/lib/api/hooks/app/organizations/useUpdateMemberRole.ts +++ b/web/src/lib/api/hooks/app/organizations/useUpdateMemberRole.ts @@ -5,7 +5,7 @@ export default function useUpdateMemberRole() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, data }: { id: string; data: { role?: string; role_id?: string } }) => updateMemberRole(id, data), + mutationFn: ({ id, data }: { id: string; data: { role_ids?: string[]; role_id?: string } }) => updateMemberRole(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["organizations", "members"], diff --git a/web/src/lib/api/models/app/organizations/Invitation.ts b/web/src/lib/api/models/app/organizations/Invitation.ts index c72cab53..b5dfa7ab 100644 --- a/web/src/lib/api/models/app/organizations/Invitation.ts +++ b/web/src/lib/api/models/app/organizations/Invitation.ts @@ -6,6 +6,7 @@ export default interface Invitation { role: string; // Workspace role row this invitation lands in. role_id?: string + roles?: { id: string; name: string; color: string }[] invited_by: string created_at: Date expires_at: Date diff --git a/web/src/lib/api/models/app/organizations/OrganizationMember.ts b/web/src/lib/api/models/app/organizations/OrganizationMember.ts index 053614cb..840b1699 100644 --- a/web/src/lib/api/models/app/organizations/OrganizationMember.ts +++ b/web/src/lib/api/models/app/organizations/OrganizationMember.ts @@ -2,6 +2,12 @@ // `role` and `permissions` come from the server; `permissions` is a // uint16 bitmask matching internal/models/organization_permission.go. +export interface MemberRole { + id: string; + name: string; + color: string; +} + export default interface OrganizationMember { id: string; user_id: string; @@ -13,6 +19,8 @@ export default interface OrganizationMember { role: string; // Set when the member is assigned a custom role (id into /organization/roles). role_id?: string; + // Full assigned role set (a member can hold several). + roles?: MemberRole[]; permissions?: number; joined_at?: Date; } From 19f66507b4b4a0fe7d14c63533bc127ef5b7f191 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 11:32:57 +0200 Subject: [PATCH 023/145] =?UTF-8?q?feat:=20fix=20multi-role=20review=20fin?= =?UTF-8?q?dings=20=E2=80=94=20atomic=20member+roles=20insert=20on=20invit?= =?UTF-8?q?e=20accept=20(no=20partial-failure=20stranding),=20gate=20role?= =?UTF-8?q?=20deletion=20on=20the=20actor=20holding=20the=20role's=20permi?= =?UTF-8?q?ssions=20(blocks=20team-managers=20de-privileging=20admins),=20?= =?UTF-8?q?and=20hydrate+chip=20pending-invitation=20role=20sets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/handler/organization_roles.go | 8 ++- internal/app/organization/service.go | 28 ++++++--- internal/repository/pg_member_roles.go | 69 ++++++++++++++++++++++ internal/repository/pg_organization.go | 2 + web/src/app/app/settings/members/page.tsx | 2 +- 5 files changed, 100 insertions(+), 9 deletions(-) diff --git a/internal/api/handler/organization_roles.go b/internal/api/handler/organization_roles.go index 0377faa5..1f5a4935 100644 --- a/internal/api/handler/organization_roles.go +++ b/internal/api/handler/organization_roles.go @@ -107,7 +107,13 @@ func (h *Handler) DeleteOrganizationRole(c *gin.Context) { return } - if xerr := h.OrganizationService.DeleteRole(c.Request.Context(), *orgID, roleID); xerr != nil { + actorID, uerr := middleware.GetUserUUID(c) + if uerr != nil { + errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) + return + } + + if xerr := h.OrganizationService.DeleteRole(c.Request.Context(), *orgID, actorID, roleID); xerr != nil { errx.JSON(c, xerr) return } diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 5811428d..862eaab9 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -48,7 +48,7 @@ type OrganizationService interface { ListRoles(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationRole, *errx.Error) CreateRole(ctx context.Context, orgID, actorID uuid.UUID, req *models.CreateOrganizationRoleRequest) (*models.OrganizationRole, *errx.Error) UpdateRole(ctx context.Context, orgID, actorID, roleID uuid.UUID, req *models.UpdateOrganizationRoleRequest) (*models.OrganizationRole, *errx.Error) - DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) *errx.Error + DeleteRole(ctx context.Context, orgID, actorID, roleID uuid.UUID) *errx.Error UpdateMemberRole(ctx context.Context, orgID, actorID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error) RemoveMember(ctx context.Context, orgID, memberUserID uuid.UUID) *errx.Error @@ -510,14 +510,10 @@ func (s *organizationService) AcceptInvitation(ctx context.Context, token string InvitedAt: inv.CreatedAt, AcceptedAt: &now, } - if err := s.orgRepo.AddMember(ctx, member); err != nil { + if err := s.orgRepo.AddMemberWithRoles(ctx, member, liveRoleIDs); err != nil { sentry.CaptureException(err) return nil, errx.New(errx.Internal, "failed to add member") } - if err := s.orgRepo.SetMemberRoles(ctx, inv.OrganizationID, userID, liveRoleIDs); err != nil { - sentry.CaptureException(err) - return nil, errx.New(errx.Internal, "failed to assign roles") - } // Delete the invitation _ = s.orgRepo.DeleteInvitation(ctx, inv.ID) @@ -604,6 +600,9 @@ func (s *organizationService) GetPendingInvitations(ctx context.Context, orgID u sentry.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get invitations") } + if err := s.orgRepo.HydrateInvitationRoles(ctx, invitations); err != nil { + sentry.CaptureException(err) + } return invitations, nil } @@ -1362,7 +1361,22 @@ func (s *organizationService) UpdateRole(ctx context.Context, orgID, actorID, ro return role, nil } -func (s *organizationService) DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) *errx.Error { +func (s *organizationService) DeleteRole(ctx context.Context, orgID, actorID, roleID uuid.UUID) *errx.Error { + // Deleting a role strips its permissions from everyone holding it, so the + // actor must hold those permissions themselves (symmetry with the grant + // paths — otherwise a team-manager could de-privilege admins by deleting + // the Admin role). + role, rerr := s.orgRepo.GetRoleByID(ctx, orgID, roleID) + if rerr != nil { + sentry.CaptureException(rerr) + return errx.New(errx.Internal, "failed to load role") + } + if role == nil { + return nil // already gone — idempotent + } + if xerr := s.validateActorHoldsPermissions(ctx, orgID, actorID, role.Permissions); xerr != nil { + return xerr + } if err := s.orgRepo.DeleteRole(ctx, orgID, roleID); err != nil { sentry.CaptureException(err) return errx.New(errx.Internal, "failed to delete role") diff --git a/internal/repository/pg_member_roles.go b/internal/repository/pg_member_roles.go index 60a0f888..2002afcb 100644 --- a/internal/repository/pg_member_roles.go +++ b/internal/repository/pg_member_roles.go @@ -42,6 +42,75 @@ func recomputeMemberPermissions(ctx context.Context, tx pgx.Tx, orgID, userID uu return err } +// AddMemberWithRoles inserts a membership row and its role assignments and +// recomputes the effective permission snapshot, all in one transaction. +// Used by invite-accept so a partial failure can never strand a member with +// no role rows. +func (r *organizationRepository) AddMemberWithRoles(ctx context.Context, member *models.OrganizationMember, roleIDs []uuid.UUID) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + if _, err := tx.Exec(ctx, ` + INSERT INTO organization_members (id, organization_id, user_id, role, role_id, permissions, invited_by, invited_at, accepted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, member.ID, member.OrganizationID, member.UserID, member.Role, member.RoleID, + member.Permissions, member.InvitedBy, member.InvitedAt, member.AcceptedAt); err != nil { + return err + } + for _, roleID := range roleIDs { + if _, err := tx.Exec(ctx, ` + INSERT INTO organization_member_roles (organization_id, user_id, role_id) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING + `, member.OrganizationID, member.UserID, roleID); err != nil { + return err + } + } + if err := recomputeMemberPermissions(ctx, tx, member.OrganizationID, member.UserID); err != nil { + return err + } + return tx.Commit(ctx) +} + +// HydrateInvitationRoles fills the Roles slice on each pending invitation +// from one query (mirrors HydrateMemberRoles for the roster). +func (r *organizationRepository) HydrateInvitationRoles(ctx context.Context, invitations []models.OrganizationInvitation) error { + if len(invitations) == 0 { + return nil + } + ids := make([]uuid.UUID, 0, len(invitations)) + for _, inv := range invitations { + ids = append(ids, inv.ID) + } + rows, err := r.db.Query(ctx, ` + SELECT ir.invitation_id, r.id, r.name, r.color + FROM organization_invitation_roles ir + JOIN organization_roles r ON r.id = ir.role_id + WHERE ir.invitation_id = ANY($1) + ORDER BY r.created_at ASC + `, ids) + if err != nil { + return err + } + defer rows.Close() + + byInvite := make(map[uuid.UUID][]models.MemberRole) + for rows.Next() { + var invID uuid.UUID + var mr models.MemberRole + if err := rows.Scan(&invID, &mr.ID, &mr.Name, &mr.Color); err != nil { + return err + } + byInvite[invID] = append(byInvite[invID], mr) + } + for i := range invitations { + invitations[i].Roles = byInvite[invitations[i].ID] + } + return nil +} + // SetMemberRoles replaces a member's assigned role set and recomputes the // effective permission snapshot atomically. All role ids must belong to the // org (enforced by the FK + the caller's validation). diff --git a/internal/repository/pg_organization.go b/internal/repository/pg_organization.go index ddd6f7f4..478202eb 100644 --- a/internal/repository/pg_organization.go +++ b/internal/repository/pg_organization.go @@ -42,6 +42,8 @@ type OrganizationRepository interface { DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) error // Multi-role assignment (pg_member_roles.go) + AddMemberWithRoles(ctx context.Context, member *models.OrganizationMember, roleIDs []uuid.UUID) error + HydrateInvitationRoles(ctx context.Context, invitations []models.OrganizationInvitation) error SetMemberRoles(ctx context.Context, orgID, userID uuid.UUID, roleIDs []uuid.UUID) error GetMemberRoles(ctx context.Context, orgID, userID uuid.UUID) ([]models.MemberRole, error) HydrateMemberRoles(ctx context.Context, orgID uuid.UUID, members []models.OrganizationMember) error diff --git a/web/src/app/app/settings/members/page.tsx b/web/src/app/app/settings/members/page.tsx index 15d83b25..3da02224 100644 --- a/web/src/app/app/settings/members/page.tsx +++ b/web/src/app/app/settings/members/page.tsx @@ -273,7 +273,7 @@ export default function MembersSettingsPage() { - r.id === inv.role_id)?.color} /> + {(inv.roles?.length ?? 0) > 0 ? : r.id === inv.role_id)?.color} />} {new Date(inv.expires_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })} From 39a9752d63cbd109965d1fc9fa86908f06527f64 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 11:57:45 +0200 Subject: [PATCH 024/145] =?UTF-8?q?feat:=20real=20tokenized=20invite-accep?= =?UTF-8?q?t=20link=20=E2=80=94=20public=20/invite=20landing=20page=20with?= =?UTF-8?q?=20safe=20preview=20(org,=20inviter,=20roles),=20accept-by-toke?= =?UTF-8?q?n=20plus=20the=20previously-broken=20accept-by-invitation-id,?= =?UTF-8?q?=20public=20preview=20+=20admin=20copy-link=20endpoints,=20logi?= =?UTF-8?q?n=20next-param=20redirect,=20and=20a=20Copy=20button=20that=20y?= =?UTF-8?q?ields=20a=20working=20/invite=3Ftoken=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/handler/organization.go | 47 ++++- internal/api/routes.go | 5 + internal/app/organization/service.go | 66 +++++++ internal/models/organization.go | 17 +- internal/repository/pg_organization.go | 31 +++ web/src/app/app/settings/members/page.tsx | 16 +- web/src/app/auth/login/page.tsx | 9 +- web/src/app/invite/page.tsx | 178 ++++++++++++++++++ .../app/organizations/acceptInvitation.ts | 2 +- .../app/organizations/getInvitationLink.ts | 9 + .../app/organizations/previewInvitation.ts | 11 ++ .../app/organizations/useAcceptInvitation.ts | 2 +- .../app/organizations/usePreviewInvitation.ts | 11 ++ .../app/organizations/InvitationPreview.ts | 8 + web/src/main.tsx | 5 + 15 files changed, 405 insertions(+), 12 deletions(-) create mode 100644 web/src/app/invite/page.tsx create mode 100644 web/src/lib/api/client/app/organizations/getInvitationLink.ts create mode 100644 web/src/lib/api/client/app/organizations/previewInvitation.ts create mode 100644 web/src/lib/api/hooks/app/organizations/usePreviewInvitation.ts create mode 100644 web/src/lib/api/models/app/organizations/InvitationPreview.ts diff --git a/internal/api/handler/organization.go b/internal/api/handler/organization.go index c9174c2e..2c0e121f 100644 --- a/internal/api/handler/organization.go +++ b/internal/api/handler/organization.go @@ -356,6 +356,43 @@ func (h *Handler) CancelInvitation(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "invitation cancelled"}) } +// PreviewInvitation is the public landing-page lookup for the /invite link. +// No auth: anyone holding the secret token can see who invited them where. +func (h *Handler) PreviewInvitation(c *gin.Context) { + token := c.Query("token") + if token == "" { + errx.JSON(c, errx.New(errx.BadRequest, "token is required")) + return + } + preview, xerr := h.OrganizationService.PreviewInvitation(c.Request.Context(), token) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, preview) +} + +// GetInvitationLink returns the shareable /invite token for a pending +// invitation so a team manager can copy a real accept link. +func (h *Handler) GetInvitationLink(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + invitationID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + token, xerr := h.OrganizationService.GetInvitationToken(c.Request.Context(), *orgID, invitationID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"token": token}) +} + // AcceptInvitation accepts an invitation (public endpoint - can be called before login or after) func (h *Handler) AcceptInvitation(c *gin.Context) { userID, err := uuid.Parse(middleware.GetUserID(c)) @@ -377,7 +414,15 @@ func (h *Handler) AcceptInvitation(c *gin.Context) { return } - member, xerr := h.OrganizationService.AcceptInvitation(c.Request.Context(), req.Token, userID, user.Email) + var member *models.OrganizationMember + if req.Token != "" { + member, xerr = h.OrganizationService.AcceptInvitation(c.Request.Context(), req.Token, userID, user.Email) + } else if req.InvitationID != nil { + member, xerr = h.OrganizationService.AcceptInvitationByID(c.Request.Context(), *req.InvitationID, userID, user.Email) + } else { + errx.JSON(c, errx.New(errx.BadRequest, "token or invitation_id is required")) + return + } if xerr != nil { errx.JSON(c, xerr) return diff --git a/internal/api/routes.go b/internal/api/routes.go index e1244a30..c5b9d96a 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -61,6 +61,10 @@ func Run( r.GET("/unsubscribe", h.Unsubscribe) r.POST("/unsubscribe", h.Unsubscribe) + // Public invitation preview for the /invite landing page. Unauthenticated: + // the secret token in the query is the capability. + r.GET("/invitations/lookup", h.PreviewInvitation) + // Internal backend-to-backend endpoints. Workers call these instead of // touching Postgres directly, per the no-direct-data-services rule in // CLAUDE.md. Auth: shared bearer token (INTERNAL_API_TOKEN). @@ -684,6 +688,7 @@ func Run( org.GET("/invitations", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.GetPendingInvitations) org.DELETE("/invitations/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.CancelInvitation) + org.GET("/invitations/:id/link", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.GetInvitationLink) org.POST("/transfer-ownership", m.RequireOrganization(), m.RequirePermission(models.PermTransferOwnership), h.TransferOwnership) diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 862eaab9..37ed279f 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -43,6 +43,9 @@ type OrganizationService interface { GetMembership(ctx context.Context, orgID, userID uuid.UUID) (*models.OrganizationMember, *errx.Error) InviteMember(ctx context.Context, orgID uuid.UUID, inviterID uuid.UUID, req *models.InviteMemberRequest) (*models.OrganizationInvitation, *errx.Error) AcceptInvitation(ctx context.Context, token string, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) + AcceptInvitationByID(ctx context.Context, invitationID, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) + PreviewInvitation(ctx context.Context, token string) (*models.InvitationPreview, *errx.Error) + GetInvitationToken(ctx context.Context, orgID, invitationID uuid.UUID) (string, *errx.Error) // Custom roles ListRoles(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationRole, *errx.Error) @@ -447,6 +450,69 @@ func (s *organizationService) AcceptInvitation(ctx context.Context, token string if inv == nil { return nil, errx.New(errx.NotFound, "invitation not found") } + return s.acceptResolved(ctx, inv, userID, email) +} + +// AcceptInvitationByID accepts an invitation the logged-in user found in their +// own pending list (no token needed; the email-match check still gates it). +func (s *organizationService) AcceptInvitationByID(ctx context.Context, invitationID, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) { + inv, err := s.orgRepo.GetInvitationByID(ctx, invitationID) + if err != nil { + sentry.CaptureException(err) + return nil, errx.New(errx.Internal, "failed to get invitation") + } + if inv == nil { + return nil, errx.New(errx.NotFound, "invitation not found") + } + return s.acceptResolved(ctx, inv, userID, email) +} + +// PreviewInvitation returns the safe public view for the /invite landing page. +func (s *organizationService) PreviewInvitation(ctx context.Context, token string) (*models.InvitationPreview, *errx.Error) { + inv, err := s.orgRepo.GetInvitationByToken(ctx, token) + if err != nil { + sentry.CaptureException(err) + return nil, errx.New(errx.Internal, "failed to get invitation") + } + if inv == nil { + return nil, errx.New(errx.NotFound, "invitation not found") + } + preview := &models.InvitationPreview{ + Email: inv.Email, + Expired: inv.IsExpired(), + } + if inv.Organization != nil { + preview.OrganizationName = inv.Organization.Name + if inv.Organization.AvatarURL != nil { + preview.OrganizationAvatar = *inv.Organization.AvatarURL + } + } + if inviter, _ := s.userRepo.GetUser(ctx, inv.InvitedBy); inviter != nil { + preview.InviterName = strings.TrimSpace(inviter.FirstName + " " + inviter.LastName) + } + list := []models.OrganizationInvitation{*inv} + if err := s.orgRepo.HydrateInvitationRoles(ctx, list); err == nil { + preview.Roles = list[0].Roles + } + return preview, nil +} + +// GetInvitationToken returns the secure token for one of the org's pending +// invitations, so a team manager can copy a shareable /invite link. +func (s *organizationService) GetInvitationToken(ctx context.Context, orgID, invitationID uuid.UUID) (string, *errx.Error) { + inv, err := s.orgRepo.GetInvitationByID(ctx, invitationID) + if err != nil { + sentry.CaptureException(err) + return "", errx.New(errx.Internal, "failed to get invitation") + } + if inv == nil || inv.OrganizationID != orgID { + return "", errx.New(errx.NotFound, "invitation not found") + } + return inv.Token, nil +} + +// acceptResolved performs the actual join given an already-loaded invitation. +func (s *organizationService) acceptResolved(ctx context.Context, inv *models.OrganizationInvitation, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) { // Verify email matches if strings.ToLower(email) != strings.ToLower(inv.Email) { diff --git a/internal/models/organization.go b/internal/models/organization.go index c7f4b5f7..a878689e 100644 --- a/internal/models/organization.go +++ b/internal/models/organization.go @@ -228,7 +228,22 @@ type TransferOwnershipRequest struct { // AcceptInvitationRequest represents the request to accept an invitation type AcceptInvitationRequest struct { - Token string `json:"token" binding:"required"` + // Either a secure token (public /invite link) or the invitation id (the + // logged-in user accepting from their own pending list). + Token string `json:"token,omitempty"` + InvitationID *uuid.UUID `json:"invitation_id,omitempty"` +} + +// InvitationPreview is the safe, public view of an invitation rendered on the +// /invite landing page. It deliberately omits the token, permissions bitmask, +// and ids — only what a human needs to decide to accept. +type InvitationPreview struct { + OrganizationName string `json:"organization_name"` + OrganizationAvatar string `json:"organization_avatar,omitempty"` + InviterName string `json:"inviter_name,omitempty"` + Email string `json:"email"` + Roles []MemberRole `json:"roles"` + Expired bool `json:"expired"` } // OrganizationCounts represents resource counts for an organization diff --git a/internal/repository/pg_organization.go b/internal/repository/pg_organization.go index 478202eb..9b8f7e09 100644 --- a/internal/repository/pg_organization.go +++ b/internal/repository/pg_organization.go @@ -55,6 +55,7 @@ type OrganizationRepository interface { // Invitations CreateInvitation(ctx context.Context, inv *models.OrganizationInvitation) error GetInvitationByToken(ctx context.Context, token string) (*models.OrganizationInvitation, error) + GetInvitationByID(ctx context.Context, id uuid.UUID) (*models.OrganizationInvitation, error) GetInvitationByEmail(ctx context.Context, orgID uuid.UUID, email string) (*models.OrganizationInvitation, error) GetPendingInvitations(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationInvitation, error) GetUserPendingInvitations(ctx context.Context, email string) ([]models.OrganizationInvitation, error) @@ -401,6 +402,36 @@ func (r *organizationRepository) GetInvitationByToken(ctx context.Context, token return &inv, nil } +// GetInvitationByID retrieves an invitation by its id, with org joined. +func (r *organizationRepository) GetInvitationByID(ctx context.Context, id uuid.UUID) (*models.OrganizationInvitation, error) { + query := ` + SELECT + i.id, i.organization_id, i.email, i.role, i.role_id, i.permissions, i.invited_by, i.token, i.expires_at, i.created_at, + o.id, o.name, o.slug, o.avatar_url, o.owner_user_id, o.created_at, o.updated_at, + o.deletion_scheduled_at, o.deletion_scheduled_for + FROM organization_invitations i + JOIN organizations o ON o.id = i.organization_id + WHERE i.id = $1 + ` + row := r.db.QueryRow(ctx, query, id) + var inv models.OrganizationInvitation + var org models.Organization + err := row.Scan( + &inv.ID, &inv.OrganizationID, &inv.Email, &inv.Role, &inv.RoleID, &inv.Permissions, + &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt, + &org.ID, &org.Name, &org.Slug, &org.AvatarURL, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt, + &org.DeletionScheduledAt, &org.DeletionScheduledFor, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + inv.Organization = &org + return &inv, nil +} + // GetInvitationByEmail retrieves an invitation by email for a specific organization func (r *organizationRepository) GetInvitationByEmail(ctx context.Context, orgID uuid.UUID, email string) (*models.OrganizationInvitation, error) { query := ` diff --git a/web/src/app/app/settings/members/page.tsx b/web/src/app/app/settings/members/page.tsx index 3da02224..d43e75fb 100644 --- a/web/src/app/app/settings/members/page.tsx +++ b/web/src/app/app/settings/members/page.tsx @@ -32,6 +32,7 @@ import type OrganizationRole from "@/lib/api/models/app/organizations/Organizati import { useAppStore } from "@/stores"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; +import getInvitationLink from "@/lib/api/client/app/organizations/getInvitationLink"; import RoleMultiSelect, { RoleChips } from "../_components/RoleMultiSelect"; import { RolePill, @@ -88,12 +89,15 @@ export default function MembersSettingsPage() { } }); } - function copyInviteLink(invitationId: string) { - const url = `${window.location.origin}/select-org?invitation=${invitationId}`; - navigator.clipboard.writeText(url).then( - () => toast.success("Invite link copied"), - () => toast.error("Couldn't copy"), - ); + async function copyInviteLink(invitationId: string) { + try { + const { token } = await getInvitationLink(invitationId); + const url = `${window.location.origin}/invite?token=${encodeURIComponent(token)}`; + await navigator.clipboard.writeText(url); + toast.success("Invite link copied"); + } catch (e) { + toast.error(buildError(e as AppError)); + } } async function changeRoles(memberId: string, roleIds: string[]) { try { diff --git a/web/src/app/auth/login/page.tsx b/web/src/app/auth/login/page.tsx index 319ef0b5..55bcf549 100644 --- a/web/src/app/auth/login/page.tsx +++ b/web/src/app/auth/login/page.tsx @@ -220,8 +220,13 @@ export default function LoginPage() { } catch { // UserProvider re-attempts and redirects to login on a real failure. } - navigate("/app/emails"); - }, [navigate, queryClient]); + // Honor a post-auth ?next= (internal paths only), else the default + // home. Powers the /invite link: sign in, then bounce back to accept. + const params = new URLSearchParams(location.search); + const next = params.get("next"); + const safeNext = next && next.startsWith("/") && !next.startsWith("//") ? next : "/app/emails"; + navigate(safeNext); + }, [navigate, queryClient, location.search]); // Conditional UI: surface passkeys inside the email field's native autofill // — no modal, no layout shift. Stays pending until the user picks a passkey diff --git a/web/src/app/invite/page.tsx b/web/src/app/invite/page.tsx new file mode 100644 index 00000000..ca8d1437 --- /dev/null +++ b/web/src/app/invite/page.tsx @@ -0,0 +1,178 @@ +// Public invitation landing page (/invite?token=...). +// +// The token is the capability: anyone holding it can see who invited them +// where (a safe preview, no permissions/ids), then accept. Works for both +// brand-new users (sign up, bounce back, accept) and logged-in users. + +import React from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { Loader2Icon, AlertCircleIcon, MailIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import getToken from "@/lib/helper/getToken"; +import usePreviewInvitation from "@/lib/api/hooks/app/organizations/usePreviewInvitation"; +import useAcceptInvitation from "@/lib/api/hooks/app/organizations/useAcceptInvitation"; +import useOrganizations from "@/lib/api/hooks/app/organizations/useOrganizations"; +import useSwitchOrganization from "@/lib/api/hooks/app/organizations/useSwitchOrganization"; +import { useAppStore } from "@/stores"; +import { Logo } from "@/components/svg"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; + +export default function InviteAcceptPage() { + const [params] = useSearchParams(); + const token = params.get("token"); + const navigate = useNavigate(); + const loggedIn = !!getToken(); + + const preview = usePreviewInvitation(token); + const accept = useAcceptInvitation(); + const orgs = useOrganizations(); + const switchOrg = useSwitchOrganization(); + const setOrganizations = useAppStore((s) => s.setOrganizations); + const setCurrentOrganization = useAppStore((s) => s.setCurrentOrganization); + + const nextPath = `/invite?token=${encodeURIComponent(token ?? "")}`; + + async function onAccept() { + if (!token) return; + try { + await toast.promise(accept.mutateAsync({ token }), { + loading: "Joining workspace…", + success: "Joined", + error: (e: AppError) => buildError(e), + }); + const fresh = await orgs.refetch(); + const list = fresh.data ?? []; + const joined = list.find((o) => o.name === preview.data?.organization_name) ?? list[0]; + if (joined) { + await switchOrg.mutateAsync(joined.id).catch(() => undefined); + setOrganizations(list); + setCurrentOrganization(joined); + } + navigate("/app/emails", { replace: true }); + } catch { + /* surfaced */ + } + } + + return ( +
+
+
+ +
+ +
+ {!token ? ( + } title="Invalid link"> + This invitation link is missing its token. Ask whoever invited you for a fresh link. + + ) : preview.isPending ? ( + } title="Loading invitation…" /> + ) : preview.isError || !preview.data ? ( + } title="Invitation not found"> + This invitation is invalid or has been revoked. + + ) : preview.data.expired ? ( + } title="Invitation expired"> + This invitation has expired. Ask for a new one. + + ) : ( +
+
+
+ {preview.data.organization_avatar ? ( + + ) : ( + preview.data.organization_name.slice(0, 2).toUpperCase() + )} +
+
+
+ {preview.data.organization_name} +
+
+ {preview.data.inviter_name + ? `${preview.data.inviter_name} invited you` + : "You've been invited"} +
+
+
+ +
+ + {preview.data.roles.length > 0 && ( +
+ Role{preview.data.roles.length > 1 ? "s" : ""} + + {preview.data.roles.map((r) => ( + + + {r.name} + + ))} + +
+ )} +
+ + {loggedIn ? ( + + ) : ( +
+

+ + Sign in or create an account with {preview.data.email} to join. +

+ + Sign in to accept + + + Create an account + +
+ )} +
+ )} +
+
+
+ ); +} + +function Centered({ icon, title, children }: { icon: React.ReactNode; title: string; children?: React.ReactNode }) { + return ( +
+
{icon}
+
{title}
+ {children &&

{children}

} +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/web/src/lib/api/client/app/organizations/acceptInvitation.ts b/web/src/lib/api/client/app/organizations/acceptInvitation.ts index 30a67abf..64d6944f 100644 --- a/web/src/lib/api/client/app/organizations/acceptInvitation.ts +++ b/web/src/lib/api/client/app/organizations/acceptInvitation.ts @@ -1,6 +1,6 @@ import Request from "../../Request"; -export default async function acceptInvitation(data: { invitation_id: string }): Promise { +export default async function acceptInvitation(data: { invitation_id?: string; token?: string }): Promise { return await Request({ method: "POST", url: `/invitations/accept`, diff --git a/web/src/lib/api/client/app/organizations/getInvitationLink.ts b/web/src/lib/api/client/app/organizations/getInvitationLink.ts new file mode 100644 index 00000000..005e67f6 --- /dev/null +++ b/web/src/lib/api/client/app/organizations/getInvitationLink.ts @@ -0,0 +1,9 @@ +import Request from "../../Request"; + +export default async function getInvitationLink(invitationId: string): Promise<{ token: string }> { + return await Request<{ token: string }>({ + method: "GET", + url: `/organization/invitations/${invitationId}/link`, + authorization: true, + }) +} diff --git a/web/src/lib/api/client/app/organizations/previewInvitation.ts b/web/src/lib/api/client/app/organizations/previewInvitation.ts new file mode 100644 index 00000000..dc565ff3 --- /dev/null +++ b/web/src/lib/api/client/app/organizations/previewInvitation.ts @@ -0,0 +1,11 @@ +import type InvitationPreview from "@/lib/api/models/app/organizations/InvitationPreview"; +import Request from "../../Request"; + +export default async function previewInvitation(token: string): Promise { + return await Request({ + method: "GET", + url: `/invitations/lookup`, + params: { token }, + authorization: false, + }) +} diff --git a/web/src/lib/api/hooks/app/organizations/useAcceptInvitation.ts b/web/src/lib/api/hooks/app/organizations/useAcceptInvitation.ts index 113d20e1..252cd2c8 100644 --- a/web/src/lib/api/hooks/app/organizations/useAcceptInvitation.ts +++ b/web/src/lib/api/hooks/app/organizations/useAcceptInvitation.ts @@ -5,7 +5,7 @@ export default function useAcceptInvitation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: { invitation_id: string }) => acceptInvitation(data), + mutationFn: (data: { invitation_id?: string; token?: string }) => acceptInvitation(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["invitations", "mine"], diff --git a/web/src/lib/api/hooks/app/organizations/usePreviewInvitation.ts b/web/src/lib/api/hooks/app/organizations/usePreviewInvitation.ts new file mode 100644 index 00000000..1fccf0c6 --- /dev/null +++ b/web/src/lib/api/hooks/app/organizations/usePreviewInvitation.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import previewInvitation from "@/lib/api/client/app/organizations/previewInvitation"; + +export default function usePreviewInvitation(token: string | null) { + return useQuery({ + queryKey: ["invitations", "preview", token], + queryFn: () => previewInvitation(token as string), + enabled: !!token, + retry: false, + }) +} diff --git a/web/src/lib/api/models/app/organizations/InvitationPreview.ts b/web/src/lib/api/models/app/organizations/InvitationPreview.ts new file mode 100644 index 00000000..74108aa1 --- /dev/null +++ b/web/src/lib/api/models/app/organizations/InvitationPreview.ts @@ -0,0 +1,8 @@ +export default interface InvitationPreview { + organization_name: string; + organization_avatar?: string; + inviter_name?: string; + email: string; + roles: { id: string; name: string; color: string }[]; + expired: boolean; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 4953d9b5..d52fdb47 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -70,6 +70,7 @@ import ResetPasswordConfirmPage from './app/auth/reset-password/confirm/page'; import OnboardingLayout from './app/onboarding/layout'; import OnboardingPage from './app/onboarding/page'; import SelectOrgPage from './app/select-org/page'; +import InviteAcceptPage from './app/invite/page'; import AdminLayout from './app/app/admin/layout'; import AdminPage from './app/app/admin/page'; import AdminWorkersPage from './app/app/admin/workers/page'; @@ -180,6 +181,10 @@ const router = createBrowserRouter([ path: "select-org", element: , }, + { + path: "invite", + element: , + }, { path: "app", element: , From 160dc0bc76e7b3b7ff8424dca427e2db87ec94bc Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 12:21:48 +0200 Subject: [PATCH 025/145] =?UTF-8?q?feat:=20implement=20the=20coming-soon?= =?UTF-8?q?=20notification=20delivery=20channels=20=E2=80=94=20Email=20(SE?= =?UTF-8?q?S/SMTP=20to=20the=20account=20email)=20and=20Slack=20(posts=20t?= =?UTF-8?q?o=20the=20org's=20connected=20workspace=20via=20a=20new=20integ?= =?UTF-8?q?ration=20NotifySlack),=20wired=20in=20both=20backend=20and=20co?= =?UTF-8?q?nsumer=20with=20per-channel=20gating,=20real=20toggles=20replac?= =?UTF-8?q?ing=20the=20coming-soon=20labels,=20and=20updated=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/backend/main.go | 1 + cmd/consumer/main.go | 14 +++ docs/content/docs/guides/notifications.mdx | 10 +- internal/app/integration/service.go | 34 ++++++ internal/app/notification/service.go | 108 +++++++++++++++--- internal/models/notification.go | 5 +- .../app/app/settings/notifications/page.tsx | 30 ++++- .../models/app/notifications/Notification.ts | 3 +- 8 files changed, 177 insertions(+), 28 deletions(-) diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 91f5e921..d1347c94 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -870,6 +870,7 @@ func main() { // onto the backend's advanced service (deliverability webhooks can ingest // here too). notificationService = notification.NewService(repository.NewNotificationRepository(primaryDB.Pool), streamingPublisher) + notificationService.WireDelivery(emailNotificationService, integrationServiceForHandler, userRepostory) advancedService.WireNotifier(notificationService) advancedService.WireRealtime(streamingPublisher) emailSender := tasks.NewEmailSender(emailRepostory, eventsPublisher) diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index a4a651b0..08cf97f8 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -29,6 +29,7 @@ import ( "github.com/warmbly/warmbly/internal/infrastructure/kms" "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/infrastructure/storage" + "github.com/warmbly/warmbly/internal/notify" "github.com/warmbly/warmbly/internal/observability" "github.com/warmbly/warmbly/internal/repository" ) @@ -228,6 +229,19 @@ func main() { // notifier must be wired here. Missing this = notifications silently never // created. notificationService := notification.NewService(repository.NewNotificationRepository(primaryDB.Pool), streamingPublisher) + // Email + Slack delivery for notifications. Email is best-effort: the + // SES/SMTP service only constructs when email config is present (prod, or + // a dev env that sets it), so a bare dev consumer simply skips the email + // channel. Slack reuses the integration service (token decryption). + var notifEmail notification.EmailSender + if emailCfg, ecErr := cfg.LoadEmailConfig(ctx); ecErr == nil { + if smtpCfg := cfg.LoadSMTPConfig(ctx); smtpCfg != nil { + notifEmail = notify.NewSMTPEmailNotificationService(emailCfg.EmailName, emailCfg.EmailAddress, smtpCfg.Host, smtpCfg.Port) + } else if ses, sErr := notify.NewEmailNotficiationService(ctx, emailCfg.EmailName, emailCfg.EmailAddress); sErr == nil { + notifEmail = ses + } + } + notificationService.WireDelivery(notifEmail, integrationServiceC, repository.NewUserRepostory(primaryDB, kmsClient)) advancedService.WireNotifier(notificationService) // Reply pulses fire in THIS process too (inbox ingest classifies replies). advancedService.WireRealtime(streamingPublisher) diff --git a/docs/content/docs/guides/notifications.mdx b/docs/content/docs/guides/notifications.mdx index 84b3f5f3..a1ee43d8 100644 --- a/docs/content/docs/guides/notifications.mdx +++ b/docs/content/docs/guides/notifications.mdx @@ -74,13 +74,13 @@ Even with the reply notification off, you never miss responses. Every reply stil ## Channels -The settings page also shows where notifications are delivered. +The settings page controls where enabled notifications are delivered. The channel toggles apply across every category above. -- **In-app**: the bell in the dashboard. This is the channel that is live today, and it is controlled by the per-category toggles above. -- **Email**: delivery to your account email. Marked **Coming soon**. -- **Slack**: delivery through a connected Slack integration. Marked **Coming soon**. +- **In-app**: the bell in the dashboard. Always on, controlled by the per-category toggles above. +- **Email**: delivery to your account email. Turn it on to also receive each enabled notification as an email with a link back into the app. +- **Slack**: posts each enabled notification to your workspace's connected Slack, on the channel you chose when connecting. Connect Slack from the [Integrations](/guides/integrations) tab first; until then the toggle saves but nothing is delivered. -For now, the in-app feed is the delivery channel. If you want event-driven Slack or webhook delivery in the meantime, that is what [Automations](/guides/automations) are for: you can route events like replies, bookings, and record changes to outside tools there. +For richer, event-specific routing (custom messages, branching, multiple destinations), use [Automations](/guides/automations) instead: they can route events like replies, bookings, and record changes to outside tools with full control. ## Practical tips diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go index 7dfeb349..846cf2fb 100644 --- a/internal/app/integration/service.go +++ b/internal/app/integration/service.go @@ -139,6 +139,10 @@ type Service interface { // Dispatch; struct payloads are ignored. DispatchAny(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) + // NotifySlack posts a plain message to the org's connected Slack on its + // configured default channel. No-op (nil) when no Slack is connected. + NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error + // Repo exposes the underlying repository for the inbound webhook handlers. Repo() repository.IntegrationRepository } @@ -1105,3 +1109,33 @@ func buildDisplayFields(provider models.IntegrationProvider, config map[string]a } return df } + +// NotifySlack posts a one-off message to the org's connected Slack workspace, +// on the default channel chosen at connect time. Used by the notification +// system's Slack delivery channel (distinct from event-subscription actions). +// Best-effort: returns nil when no healthy Slack connection exists. +func (s *service) NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error { + conns, err := s.repo.ListConnections(ctx, orgID) + if err != nil { + return err + } + for _, c := range conns { + if c.Provider != models.IntegrationSlack || c.Status != models.IntegrationStatusConnected { + continue + } + channel := configString(c.DisplayFields, "channel") + if channel == "" { + continue + } + sec, serr := s.repo.GetConnectionSecrets(ctx, c.ID) + if serr != nil { + continue + } + token, terr := s.accessTokenFor(ctx, sec) + if terr != nil { + continue + } + return slackPostMessage(ctx, token, channel, eventMessage{Title: title, Detail: body}) + } + return nil +} diff --git a/internal/app/notification/service.go b/internal/app/notification/service.go index b5a8fdc3..90d4ed3c 100644 --- a/internal/app/notification/service.go +++ b/internal/app/notification/service.go @@ -7,6 +7,9 @@ package notification import ( "context" + "fmt" + "strings" + "time" "github.com/google/uuid" @@ -16,6 +19,24 @@ import ( "github.com/warmbly/warmbly/internal/repository" ) +// EmailSender delivers a notification to a user's account email. Satisfied by +// notify.EmailNotificationService. +type EmailSender interface { + Send(ctx context.Context, to, cc, bcc []string, subject, message string) error +} + +// SlackNotifier posts to the org's connected Slack. Satisfied by the +// integration service (NotifySlack). +type SlackNotifier interface { + NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error +} + +// UserLookup resolves a user's email + name for email delivery. Satisfied by +// the user repository. +type UserLookup interface { + GetUser(ctx context.Context, id uuid.UUID) (*models.User, error) +} + type Service interface { GetPreferences(ctx context.Context, userID uuid.UUID) (*models.NotificationPreferences, *errx.Error) UpdatePreferences(ctx context.Context, userID uuid.UUID, prefs *models.NotificationPreferences) *errx.Error @@ -26,11 +47,25 @@ type Service interface { // Notify is the gated ingress — best-effort, never errors out the caller. Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any) + + // WireDelivery attaches the email + Slack + user-lookup dependencies for + // the email/Slack channels (wired post-construction in both mains). Any + // may be nil — the matching channel is then skipped. + WireDelivery(email EmailSender, slack SlackNotifier, users UserLookup) } type service struct { repo repository.NotificationRepository publisher *pubsub.StreamingPublisher + email EmailSender + slack SlackNotifier + users UserLookup +} + +func (s *service) WireDelivery(email EmailSender, slack SlackNotifier, users UserLookup) { + s.email = email + s.slack = slack + s.users = users } func NewService(repo repository.NotificationRepository, publisher *pubsub.StreamingPublisher) Service { @@ -93,22 +128,65 @@ func (s *service) Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID return } cat := prefs.CategoryPref(category) - if !cat.Enabled || !cat.Channels.InApp { - return // the gate + if !cat.Enabled { + return // category off — no channel fires } - created, cerr := s.repo.Create(ctx, &models.Notification{ - UserID: userID, - OrganizationID: orgID, - Category: category, - Title: title, - Body: body, - Link: link, - Metadata: meta, - }) - if cerr != nil || created == nil { - return + + // In-app: persist the feed row + push the realtime event. + if cat.Channels.InApp { + created, cerr := s.repo.Create(ctx, &models.Notification{ + UserID: userID, + OrganizationID: orgID, + Category: category, + Title: title, + Body: body, + Link: link, + Metadata: meta, + }) + if cerr == nil && created != nil && s.publisher != nil { + s.publisher.PublishNotificationCreated(ctx, userID.String(), created.ID.String(), string(category), title, link) + } } - if s.publisher != nil { - s.publisher.PublishNotificationCreated(ctx, userID.String(), created.ID.String(), string(category), title, link) + + // Email: deliver to the user's account email (detached, best-effort). + if cat.Channels.Email && s.email != nil && s.users != nil { + go s.deliverEmail(userID, category, title, body, link) + } + + // Slack: post to the org's connected workspace (detached, best-effort). + if cat.Channels.Slack && s.slack != nil && orgID != nil { + org := *orgID + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + _ = s.slack.NotifySlack(ctx, org, title, body) + }() } } + +// deliverEmail renders a minimal HTML notification and emails it to the user. +func (s *service) deliverEmail(userID uuid.UUID, category models.NotificationCategory, title, body, link string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + user, err := s.users.GetUser(ctx, userID) + if err != nil || user == nil || user.Email == "" { + return + } + href := link + if href != "" && len(href) > 0 && href[0] == '/' { + href = "https://app.warmbly.com" + href + } + cta := "" + if href != "" { + cta = fmt.Sprintf(`

Open in Warmbly

`, href) + } + html := fmt.Sprintf(`

%s

%s

%s

You're receiving this because email notifications are on for %s. Manage them in Settings → Notifications.

`, + htmlEscape(title), htmlEscape(body), cta, htmlEscape(string(category))) + _ = s.email.Send(ctx, []string{user.Email}, nil, nil, title, html) +} + +func htmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + return r.Replace(s) +} diff --git a/internal/models/notification.go b/internal/models/notification.go index e1f3c64a..db88bd5f 100644 --- a/internal/models/notification.go +++ b/internal/models/notification.go @@ -20,10 +20,11 @@ const ( ) // ChannelPrefs is the per-category delivery toggles. Only InApp is delivered -// today; Email/Slack are modeled for forward-compat and rendered "coming soon". +// today across in-app, email, and a connected Slack workspace. type ChannelPrefs struct { InApp bool `json:"in_app"` - Email bool `json:"email"` // reserved; not enforced yet + Email bool `json:"email"` + Slack bool `json:"slack"` } // CategoryPref is the enable flag + channel toggles for one category. diff --git a/web/src/app/app/settings/notifications/page.tsx b/web/src/app/app/settings/notifications/page.tsx index b1c1203f..f4733b93 100644 --- a/web/src/app/app/settings/notifications/page.tsx +++ b/web/src/app/app/settings/notifications/page.tsx @@ -35,6 +35,26 @@ export default function NotificationsSettingsPage() { const setEnabled = (key: NotificationCategoryKey, on: boolean) => setDraft((d) => (d ? { ...d, [key]: { ...d[key], enabled: on } } : d)); + const CATEGORY_KEYS: NotificationCategoryKey[] = [ + "inbound_reply", + "inbound_out_of_office", + "health_bounce", + "health_complaint", + "health_worker_downtime", + ]; + // Channels present globally: "on" when every category carries the channel. + const channelOn = (ch: "email" | "slack") => + !!draft && CATEGORY_KEYS.every((k) => draft[k].channels[ch]); + const setChannel = (ch: "email" | "slack", on: boolean) => + setDraft((d) => { + if (!d) return d; + const next = { ...d }; + for (const k of CATEGORY_KEYS) { + next[k] = { ...d[k], channels: { ...d[k].channels, [ch]: on } }; + } + return next; + }); + const save = async () => { if (!draft || !dirty || update.isPending) return; try { @@ -55,7 +75,7 @@ export default function NotificationsSettingsPage() { return ( @@ -82,15 +102,15 @@ export default function NotificationsSettingsPage() {
{rows(HEALTH)}
-
+
On - Coming soon + setChannel("email", v)} /> - - Coming soon + + setChannel("slack", v)} />
diff --git a/web/src/lib/api/models/app/notifications/Notification.ts b/web/src/lib/api/models/app/notifications/Notification.ts index c09838cb..4b3a8c18 100644 --- a/web/src/lib/api/models/app/notifications/Notification.ts +++ b/web/src/lib/api/models/app/notifications/Notification.ts @@ -2,7 +2,8 @@ export interface ChannelPrefs { in_app: boolean; - email: boolean; // reserved; not delivered yet + email: boolean; + slack: boolean; } export interface CategoryPref { From 5bcc2baaa8acffd627b1dd9ce52946e69f9d9409 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 12:30:44 +0200 Subject: [PATCH 026/145] =?UTF-8?q?feat:=20implement=20the=20coming-soon?= =?UTF-8?q?=20security=20features=20=E2=80=94=20logged-in=20change-passwor?= =?UTF-8?q?d=20(verify=20current,=20policy-checked,=20POST=20/me/password)?= =?UTF-8?q?=20with=20a=20real=20dialog,=20and=20new-device=20sign-in=20ale?= =?UTF-8?q?rts=20(security=20notification=20category=20fired=20from=20the?= =?UTF-8?q?=20token=20service=20on=20an=20unrecognized=20OS+browser,=20del?= =?UTF-8?q?ivered=20in-app=20and=20by=20email),=20removing=20the=20comingS?= =?UTF-8?q?oon=20stub=20helper=20and=20updating=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/backend/main.go | 4 + docs/content/docs/guides/security.mdx | 10 ++ internal/api/handler/auth.go | 25 ++++ internal/api/routes.go | 1 + internal/app/auth/model.go | 5 + internal/app/auth/reset_password.go | 36 ++++++ internal/app/auth/service.go | 4 + internal/app/notification/signin.go | 39 ++++++ internal/app/token/gen.go | 28 +++++ internal/app/token/service.go | 12 ++ internal/models/notification.go | 5 + internal/repository/pg_auth.go | 19 +++ .../app/app/settings/notifications/page.tsx | 8 ++ .../security/ChangePasswordDialog.tsx | 113 ++++++++++++++++++ web/src/app/app/settings/security/page.tsx | 15 ++- web/src/lib/api/client/auth/changePassword.ts | 10 ++ .../models/app/notifications/Notification.ts | 1 + web/src/lib/helper/comingSoon.ts | 13 -- 18 files changed, 329 insertions(+), 19 deletions(-) create mode 100644 internal/app/notification/signin.go create mode 100644 web/src/app/app/settings/security/ChangePasswordDialog.tsx create mode 100644 web/src/lib/api/client/auth/changePassword.ts delete mode 100644 web/src/lib/helper/comingSoon.ts diff --git a/cmd/backend/main.go b/cmd/backend/main.go index d1347c94..7029b1c5 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -872,6 +872,10 @@ func main() { notificationService = notification.NewService(repository.NewNotificationRepository(primaryDB.Pool), streamingPublisher) notificationService.WireDelivery(emailNotificationService, integrationServiceForHandler, userRepostory) advancedService.WireNotifier(notificationService) + // New-device sign-in alerts: the token service fires this on session + // creation from an unrecognized device, delivered as a security + // notification (in-app + email per the user's channels). + tokenService.WireSignInAlerter(notification.NewSignInAlerter(notificationService)) advancedService.WireRealtime(streamingPublisher) emailSender := tasks.NewEmailSender(emailRepostory, eventsPublisher) tasksService = tasks.NewService( diff --git a/docs/content/docs/guides/security.mdx b/docs/content/docs/guides/security.mdx index d26a3a10..e654e3e7 100644 --- a/docs/content/docs/guides/security.mdx +++ b/docs/content/docs/guides/security.mdx @@ -121,6 +121,16 @@ If you see a session you do not recognize, sign it out: If you spot a session you do not recognize, sign it out, then change your password and make sure two-factor authentication is on. Signing out other sessions is the fastest way to cut off access from a device that should not have it. +## Changing your password + +Change the password you sign in with from **Settings, then Security**, under **Password**. You will be asked for your current password, then a new one. New passwords must be at least 12 characters with upper and lower case and a number. Accounts that only ever sign in with Google, Apple, or a passkey have no password to change. + +## Sign-in alerts + +Warmbly can notify you when your account is accessed from a device you have not used before (a new browser and operating system combination). The alert tells you the device and, where known, the location, with a reminder to change your password and sign out other sessions if it was not you. + +Sign-in alerts are a notification category: they appear in your in-app feed by default, and you can also receive them by email. Turn email on under **Settings, then Notifications**, in the **Security** section and the **Email** channel. Your very first sign-in is not alerted, since there is no earlier device to compare against. + ## Putting it together For the strongest account protection: diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go index 4547b969..40323652 100644 --- a/internal/api/handler/auth.go +++ b/internal/api/handler/auth.go @@ -206,3 +206,28 @@ func (h *Handler) ResetPasswordConfirm(c *gin.Context) { c.Status(http.StatusOK) } + +// ChangePassword updates the signed-in user's password (current + new). +func (h *Handler) ChangePassword(c *gin.Context) { + uid, err := uuid.Parse(middleware.GetUserID(c)) + if err != nil { + errx.Handle(c, errx.ErrUnauthorized) + return + } + + var data auth.ChangePassword + if berr := c.ShouldBindJSON(&data); berr != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), authRequestTimeout) + defer cancel() + + if xerr := h.AuthService.ChangePassword(ctx, uid, &data); xerr != nil { + errx.Handle(c, xerr) + return + } + + c.Status(http.StatusOK) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index c5b9d96a..896de011 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -191,6 +191,7 @@ func Run( protectedAuth.PATCH("/me/onboarding", h.CompleteOnboarding) protectedAuth.POST("/me/avatar", h.UploadUserAvatar) protectedAuth.DELETE("/me/avatar", h.DeleteUserAvatar) + protectedAuth.POST("/me/password", h.ChangePassword) // Notification preferences + in-app feed (user-scoped, no org gate). protectedAuth.GET("/me/notification-preferences", h.GetNotificationPreferences) diff --git a/internal/app/auth/model.go b/internal/app/auth/model.go index 211d414c..680c92ff 100644 --- a/internal/app/auth/model.go +++ b/internal/app/auth/model.go @@ -22,3 +22,8 @@ type ResetPasswordConfirm struct { Password string `json:"password"` Turnstile string `json:"turnstile"` } + +type ChangePassword struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} diff --git a/internal/app/auth/reset_password.go b/internal/app/auth/reset_password.go index ad74561f..f67d39e7 100644 --- a/internal/app/auth/reset_password.go +++ b/internal/app/auth/reset_password.go @@ -118,3 +118,39 @@ func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPassw return nil } + +// ChangePassword updates a logged-in user's password. It verifies the current +// password first (so a hijacked but unattended session can't silently change +// it), rejects OAuth-only accounts, and enforces the password policy. +func (s *authService) ChangePassword(ctx context.Context, userID uuid.UUID, data *ChangePassword) *errx.Error { + hash, xerr := s.authRepository.GetPasswordHash(ctx, userID) + if xerr != nil { + return xerr + } + if hash == "" { + return errx.New(errx.BadRequest, "this account signs in without a password") + } + + ok, verr := argon2.Verify(data.CurrentPassword, hash) + if verr != nil { + sentry.CaptureException(verr) + return errx.InternalError() + } + if !ok { + return errx.ErrCredentials + } + + if !crypt.ValidatePassword(data.NewPassword) { + return errx.ErrPassword + } + if data.NewPassword == data.CurrentPassword { + return errx.New(errx.BadRequest, "the new password must be different") + } + + newHash, hashErr := argon2.Hash(data.NewPassword) + if hashErr != nil { + sentry.CaptureException(hashErr) + return errx.InternalError() + } + return s.authRepository.ResetPassword(ctx, userID, newHash) +} diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index acec79bd..d223ca5d 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -36,6 +36,10 @@ type AuthService interface { ResetPasswordStart(ctx context.Context, data *ResetPasswordStart, ipaddr string) *errx.Error ResetPasswordConfirm(ctx context.Context, data *ResetPasswordConfirm, session, ipaddr string) *errx.Error + + // ChangePassword updates a logged-in user's password after verifying the + // current one. + ChangePassword(ctx context.Context, userID uuid.UUID, data *ChangePassword) *errx.Error } type authService struct { diff --git a/internal/app/notification/signin.go b/internal/app/notification/signin.go new file mode 100644 index 00000000..98218420 --- /dev/null +++ b/internal/app/notification/signin.go @@ -0,0 +1,39 @@ +package notification + +import ( + "context" + "strings" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" +) + +// SignInAlerter adapts the notification service to the token service's +// new-device hook: a new sign-in becomes a security notification (in-app + +// email when the user has those channels on). Satisfies token.SignInAlerter. +type SignInAlerter struct { + svc Service +} + +// NewSignInAlerter wraps a notification service for the token package. +func NewSignInAlerter(svc Service) *SignInAlerter { return &SignInAlerter{svc: svc} } + +// NewSignIn raises a "new device" security notification for the user. +func (a *SignInAlerter) NewSignIn(ctx context.Context, userID uuid.UUID, browser, os, city, country string) { + if a == nil || a.svc == nil { + return + } + device := strings.TrimSpace(browser + " on " + os) + if device == "on" { + device = "an unrecognized device" + } + loc := strings.Trim(strings.TrimSpace(city+", "+country), ", ") + body := "Signed in from " + device + if loc != "" { + body += " (" + loc + ")" + } + body += ". If this wasn't you, change your password and sign out other sessions." + a.svc.Notify(ctx, userID, nil, models.NotifSecuritySignIn, + "New sign-in to your account", body, "/app/settings/security", nil) +} diff --git a/internal/app/token/gen.go b/internal/app/token/gen.go index 3a345cb6..2cdbb45f 100644 --- a/internal/app/token/gen.go +++ b/internal/app/token/gen.go @@ -81,6 +81,23 @@ func (s *tokenService) GenerateSessionWithOrg(ctx context.Context, userID uuid.U userAgentInfo := useragent.Parse(userAgent) + // New-device check (before inserting this session): does the user already + // have an active session from this OS+browser? Only meaningful when they + // have a prior session to compare against, so a first/fresh login is quiet. + newDevice := false + if s.signInAlert != nil { + if prior, perr := s.tokenRepository.ListSessionsByUser(ctx, userID); perr == nil && len(prior) > 0 { + seen := false + for _, p := range prior { + if p.OSName == userAgentInfo.OS && p.BrowserName == userAgentInfo.Name { + seen = true + break + } + } + newDevice = !seen + } + } + session := &models.Session{ ID: uuid.New(), UserID: userID, @@ -147,6 +164,17 @@ func (s *tokenService) GenerateSessionWithOrg(ctx context.Context, userID uuid.U return nil, errx.InternalError() } + if newDevice && s.signInAlert != nil { + alerter := s.signInAlert + uid, browser, osName := userID, session.BrowserName, session.OSName + city, country := session.LocationCity, session.LocationCountry + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + alerter.NewSignIn(ctx, uid, browser, osName, city, country) + }() + } + return &models.Token{ AccessToken: accessToken, AccessTokenExpiresAt: accessTokenExpiresAt, diff --git a/internal/app/token/service.go b/internal/app/token/service.go index 6f787687..69625af3 100644 --- a/internal/app/token/service.go +++ b/internal/app/token/service.go @@ -18,6 +18,7 @@ type TokenService interface { VerifyToken(tokenStr string) (*TokenClaims, *errx.Error) GenerateSession(ctx context.Context, userID uuid.UUID, email, ipaddr, userAgent, authProvider string) (*models.Token, *errx.Error) GenerateSessionWithOrg(ctx context.Context, userID uuid.UUID, email, ipaddr, userAgent, authProvider string, orgID *uuid.UUID) (*models.Token, *errx.Error) + WireSignInAlerter(a SignInAlerter) GetSession(ctx context.Context, sessionID uuid.UUID) (*models.Session, *errx.Error) ValidateAccessToken(ctx context.Context, accessToken string) (*models.Session, *errx.Error) RefreshToken(ctx context.Context, refreshToken string) (*models.Token, *errx.Error) @@ -40,10 +41,21 @@ type tokenService struct { tokenRepository repository.TokenRepository geo *geo.Client cache *cache.Cache + signInAlert SignInAlerter AuthSecret string } +// SignInAlerter fires a "new device" notification when a session is created +// from a device the user has not signed in from before. Satisfied by an +// adapter over the notification service; wired post-construction (nil = off). +type SignInAlerter interface { + NewSignIn(ctx context.Context, userID uuid.UUID, browser, os, city, country string) +} + +// WireSignInAlerter attaches the new-device alerter after construction. +func (s *tokenService) WireSignInAlerter(a SignInAlerter) { s.signInAlert = a } + func NewService(db *db.DB, tokenRepository repository.TokenRepository, cache *cache.Cache, geo *geo.Client, authSecret string) TokenService { return &tokenService{ db: db, diff --git a/internal/models/notification.go b/internal/models/notification.go index db88bd5f..e2bb204e 100644 --- a/internal/models/notification.go +++ b/internal/models/notification.go @@ -17,6 +17,7 @@ const ( NotifHealthBounce NotificationCategory = "health_bounce" NotifHealthComplaint NotificationCategory = "health_complaint" NotifWorkerDowntime NotificationCategory = "health_worker_downtime" + NotifSecuritySignIn NotificationCategory = "security_new_signin" ) // ChannelPrefs is the per-category delivery toggles. Only InApp is delivered @@ -41,6 +42,7 @@ type NotificationPreferences struct { HealthBounce CategoryPref `json:"health_bounce"` HealthComplaint CategoryPref `json:"health_complaint"` WorkerDowntime CategoryPref `json:"health_worker_downtime"` + SecuritySignIn CategoryPref `json:"security_new_signin"` } // DefaultNotificationPreferences is the merge base. Health categories default ON @@ -55,6 +57,7 @@ func DefaultNotificationPreferences() NotificationPreferences { HealthBounce: on, HealthComplaint: on, WorkerDowntime: on, + SecuritySignIn: on, } } @@ -71,6 +74,8 @@ func (p NotificationPreferences) CategoryPref(c NotificationCategory) CategoryPr return p.HealthComplaint case NotifWorkerDowntime: return p.WorkerDowntime + case NotifSecuritySignIn: + return p.SecuritySignIn default: return CategoryPref{} } diff --git a/internal/repository/pg_auth.go b/internal/repository/pg_auth.go index 07230309..c86c0e78 100644 --- a/internal/repository/pg_auth.go +++ b/internal/repository/pg_auth.go @@ -19,6 +19,7 @@ type AuthRepository interface { IsValidCredentials(ctx context.Context, email, password string) (uuid.UUID, *errx.Error) ExternalLogin(ctx context.Context, email string) (*models.User, *errx.Error) ResetPassword(ctx context.Context, userID uuid.UUID, password string) *errx.Error + GetPasswordHash(ctx context.Context, userID uuid.UUID) (string, *errx.Error) } type authRepository struct { @@ -113,6 +114,24 @@ func (r *authRepository) ExternalLogin(ctx context.Context, email string) (*mode return &u, nil } +// GetPasswordHash returns the stored argon2 hash for a user (empty when the +// account is OAuth-only / passwordless). +func (r *authRepository) GetPasswordHash(ctx context.Context, userID uuid.UUID) (string, *errx.Error) { + var hash *string + err := r.DB.QueryRow(ctx, `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&hash) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", errx.ErrNotFound + } + db.CaptureError(err, "get password hash", []any{userID}, "queryrow") + return "", errx.InternalError() + } + if hash == nil { + return "", nil + } + return *hash, nil +} + func (r *authRepository) ResetPassword(ctx context.Context, userID uuid.UUID, passwordHash string) *errx.Error { query := ` UPDATE users diff --git a/web/src/app/app/settings/notifications/page.tsx b/web/src/app/app/settings/notifications/page.tsx index f4733b93..e511479c 100644 --- a/web/src/app/app/settings/notifications/page.tsx +++ b/web/src/app/app/settings/notifications/page.tsx @@ -21,6 +21,10 @@ const HEALTH: { key: NotificationCategoryKey; label: string; hint: string }[] = { key: "health_worker_downtime", label: "Worker downtime", hint: "A sender worker stops responding." }, ]; +const SECURITY: { key: NotificationCategoryKey; label: string; hint: string }[] = [ + { key: "security_new_signin", label: "New sign-in", hint: "Your account was accessed from a device you haven't used before." }, +]; + export default function NotificationsSettingsPage() { const { data, isLoading } = useNotificationPreferences(); const update = useUpdateNotificationPreferences(); @@ -41,6 +45,7 @@ export default function NotificationsSettingsPage() { "health_bounce", "health_complaint", "health_worker_downtime", + "security_new_signin", ]; // Channels present globally: "on" when every category carries the channel. const channelOn = (ch: "email" | "slack") => @@ -102,6 +107,9 @@ export default function NotificationsSettingsPage() {
{rows(HEALTH)}
+
+ {rows(SECURITY)} +
On diff --git a/web/src/app/app/settings/security/ChangePasswordDialog.tsx b/web/src/app/app/settings/security/ChangePasswordDialog.tsx new file mode 100644 index 00000000..79c5dbed --- /dev/null +++ b/web/src/app/app/settings/security/ChangePasswordDialog.tsx @@ -0,0 +1,113 @@ +// Change-password modal for the security page. Verifies the current password +// server-side, enforces the password policy, and shows clear errors. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Loader2Icon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import { Label, TextInput } from "@/components/ui/field"; +import changePassword from "@/lib/api/client/auth/changePassword"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; + +export default function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const [current, setCurrent] = React.useState(""); + const [next, setNext] = React.useState(""); + const [confirm, setConfirm] = React.useState(""); + const [pending, setPending] = React.useState(false); + + React.useEffect(() => { + if (open) { + setCurrent(""); + setNext(""); + setConfirm(""); + } + }, [open]); + + const valid = + current.length > 0 && + next.length >= 12 && + /[a-z]/.test(next) && + /[A-Z]/.test(next) && + /[0-9]/.test(next) && + next === confirm; + + async function submit() { + if (!valid || pending) return; + setPending(true); + try { + await changePassword({ current_password: current, new_password: next }); + toast.success("Password changed"); + onClose(); + } catch (e) { + toast.error(buildError(e as AppError)); + } finally { + setPending(false); + } + } + + return ( + + {open && ( + { + if (e.target === e.currentTarget && !pending) onClose(); + }} + > + +
+

Change password

+ +
+ +
+
+ + +
+
+ + +

12+ characters with upper and lower case and a number.

+
+
+ + { if (e.key === "Enter") submit(); }} /> + {confirm.length > 0 && next !== confirm && ( +

Passwords do not match.

+ )} +
+
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/web/src/app/app/settings/security/page.tsx b/web/src/app/app/settings/security/page.tsx index 5c513b5b..0faf1c15 100644 --- a/web/src/app/app/settings/security/page.tsx +++ b/web/src/app/app/settings/security/page.tsx @@ -1,10 +1,14 @@ -import { comingSoon } from "@/lib/helper/comingSoon"; +import React from "react"; +import { useNavigate } from "react-router-dom"; import { RowLink, Section, SectionShell } from "../_components/SectionShell"; import PasskeyManager from "./PasskeyManager"; import SessionManager from "./SessionManager"; import TwoFactorManager from "./TwoFactorManager"; +import ChangePasswordDialog from "./ChangePasswordDialog"; export default function SecuritySettingsPage() { + const [pwOpen, setPwOpen] = React.useState(false); + const navigate = useNavigate(); return ( @@ -16,7 +20,7 @@ export default function SecuritySettingsPage() { title="Change password" description="Use 12+ characters with mixed case and a number." cta="Change" - onClick={() => comingSoon("Change password")} + onClick={() => setPwOpen(true)} />
@@ -25,11 +29,9 @@ export default function SecuritySettingsPage() {
comingSoon("Sign-in alerts")} + onClick={() => navigate("/app/settings/notifications")} />
@@ -60,6 +62,7 @@ export default function SecuritySettingsPage() { onClick={() => (window.location.href = "/app/api-keys")} />
+ setPwOpen(false)} />
); } diff --git a/web/src/lib/api/client/auth/changePassword.ts b/web/src/lib/api/client/auth/changePassword.ts new file mode 100644 index 00000000..09be1d20 --- /dev/null +++ b/web/src/lib/api/client/auth/changePassword.ts @@ -0,0 +1,10 @@ +import Request from "../Request"; + +export default async function changePassword(data: { current_password: string; new_password: string }): Promise { + return await Request({ + method: "POST", + url: "/me/password", + authorization: true, + data, + }) +} diff --git a/web/src/lib/api/models/app/notifications/Notification.ts b/web/src/lib/api/models/app/notifications/Notification.ts index 4b3a8c18..69e99e51 100644 --- a/web/src/lib/api/models/app/notifications/Notification.ts +++ b/web/src/lib/api/models/app/notifications/Notification.ts @@ -17,6 +17,7 @@ export interface NotificationPreferences { health_bounce: CategoryPref; health_complaint: CategoryPref; health_worker_downtime: CategoryPref; + security_new_signin: CategoryPref; } export type NotificationCategoryKey = keyof NotificationPreferences; diff --git a/web/src/lib/helper/comingSoon.ts b/web/src/lib/helper/comingSoon.ts deleted file mode 100644 index 9143b183..00000000 --- a/web/src/lib/helper/comingSoon.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Tiny helper for "Coming soon" toasts on actions whose backend or -// flow isn't built yet. Better than a silent no-op — the user sees the -// click registered and gets a clear "this isn't wired yet" signal -// instead of wondering if the page is broken. - -import toast from "react-hot-toast"; - -export function comingSoon(feature: string): void { - toast(`${feature} is coming soon.`, { - icon: "🚧", - duration: 3000, - }); -} From 2d53f4f8193e57b2191a3b0f3a2a4fb0023af4b5 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 12:38:07 +0200 Subject: [PATCH 027/145] fix: rate-limit POST /me/password so a hijacked session can't brute-force the current password unthrottled --- internal/api/routes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/routes.go b/internal/api/routes.go index 896de011..1f2d4aa1 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -191,7 +191,7 @@ func Run( protectedAuth.PATCH("/me/onboarding", h.CompleteOnboarding) protectedAuth.POST("/me/avatar", h.UploadUserAvatar) protectedAuth.DELETE("/me/avatar", h.DeleteUserAvatar) - protectedAuth.POST("/me/password", h.ChangePassword) + protectedAuth.POST("/me/password", m.RateLimitMiddleware(models.RateLimitWrite), h.ChangePassword) // Notification preferences + in-app feed (user-scoped, no org gate). protectedAuth.GET("/me/notification-preferences", h.GetNotificationPreferences) From 3c040649c0e3f2620a8762d2c7e7a2ee6a9a6ca3 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 12:39:24 +0200 Subject: [PATCH 028/145] fix: changing your password now revokes every other session (keeping the current device), matching the security promise in the sign-in alert and docs --- docs/content/docs/guides/security.mdx | 2 ++ internal/api/handler/auth.go | 2 +- internal/app/auth/reset_password.go | 18 ++++++++++++++++-- internal/app/auth/service.go | 2 +- .../settings/security/ChangePasswordDialog.tsx | 3 +++ 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/guides/security.mdx b/docs/content/docs/guides/security.mdx index e654e3e7..a5ea6df9 100644 --- a/docs/content/docs/guides/security.mdx +++ b/docs/content/docs/guides/security.mdx @@ -125,6 +125,8 @@ If you spot a session you do not recognize, sign it out, then change your passwo Change the password you sign in with from **Settings, then Security**, under **Password**. You will be asked for your current password, then a new one. New passwords must be at least 12 characters with upper and lower case and a number. Accounts that only ever sign in with Google, Apple, or a passkey have no password to change. +Changing your password signs out every other device automatically, so it is a complete way to cut off a session you do not recognize. The device you change it on stays signed in. + ## Sign-in alerts Warmbly can notify you when your account is accessed from a device you have not used before (a new browser and operating system combination). The alert tells you the device and, where known, the location, with a reminder to change your password and sign out other sessions if it was not you. diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go index 40323652..6f394329 100644 --- a/internal/api/handler/auth.go +++ b/internal/api/handler/auth.go @@ -224,7 +224,7 @@ func (h *Handler) ChangePassword(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), authRequestTimeout) defer cancel() - if xerr := h.AuthService.ChangePassword(ctx, uid, &data); xerr != nil { + if xerr := h.AuthService.ChangePassword(ctx, uid, currentSessionID(c), &data); xerr != nil { errx.Handle(c, xerr) return } diff --git a/internal/app/auth/reset_password.go b/internal/app/auth/reset_password.go index f67d39e7..ce2dfc55 100644 --- a/internal/app/auth/reset_password.go +++ b/internal/app/auth/reset_password.go @@ -122,7 +122,7 @@ func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPassw // ChangePassword updates a logged-in user's password. It verifies the current // password first (so a hijacked but unattended session can't silently change // it), rejects OAuth-only accounts, and enforces the password policy. -func (s *authService) ChangePassword(ctx context.Context, userID uuid.UUID, data *ChangePassword) *errx.Error { +func (s *authService) ChangePassword(ctx context.Context, userID, currentSessionID uuid.UUID, data *ChangePassword) *errx.Error { hash, xerr := s.authRepository.GetPasswordHash(ctx, userID) if xerr != nil { return xerr @@ -152,5 +152,19 @@ func (s *authService) ChangePassword(ctx context.Context, userID uuid.UUID, data sentry.CaptureException(hashErr) return errx.InternalError() } - return s.authRepository.ResetPassword(ctx, userID, newHash) + if err := s.authRepository.ResetPassword(ctx, userID, newHash); err != nil { + return err + } + + // Changing the password evicts every OTHER signed-in device (the whole + // point of changing it when a session may be compromised). The current + // device keeps its session so the user isn't logged out of the action + // they just performed. + if s.tokenService != nil && currentSessionID != uuid.Nil { + if err := s.tokenService.RevokeOtherSessions(ctx, userID, currentSessionID); err != nil { + sentry.CaptureException(err) + // Non-fatal: the password is already changed. + } + } + return nil } diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index d223ca5d..79b91820 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -39,7 +39,7 @@ type AuthService interface { // ChangePassword updates a logged-in user's password after verifying the // current one. - ChangePassword(ctx context.Context, userID uuid.UUID, data *ChangePassword) *errx.Error + ChangePassword(ctx context.Context, userID, currentSessionID uuid.UUID, data *ChangePassword) *errx.Error } type authService struct { diff --git a/web/src/app/app/settings/security/ChangePasswordDialog.tsx b/web/src/app/app/settings/security/ChangePasswordDialog.tsx index 79c5dbed..704fff56 100644 --- a/web/src/app/app/settings/security/ChangePasswordDialog.tsx +++ b/web/src/app/app/settings/security/ChangePasswordDialog.tsx @@ -94,6 +94,9 @@ export default function ChangePasswordDialog({ open, onClose }: { open: boolean;

Passwords do not match.

)} +

+ Changing your password signs out every other device. This one stays signed in. +