Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
fc02f0e
feat(db): add organizations schema (orgs, members, member roles, invi…
rowforce Jun 5, 2026
c4f5b60
feat(core): add organization models + App/ClientSession org fields
rowforce Jun 5, 2026
7603cc9
feat(repo): organization create/member/role queries
rowforce Jun 5, 2026
7b4931f
feat(repo): persist active organization on client sessions
rowforce Jun 5, 2026
6b80bd9
feat(repo): read organizations_enabled + org_creation_policy on apps
rowforce Jun 5, 2026
3a770c2
feat(auth): include active org id in client access token
rowforce Jun 5, 2026
7d4730f
feat(api): org-aware role/permission resolution + appData org fields
rowforce Jun 5, 2026
326fdf7
feat(api): org-switch endpoint with membership enforcement
rowforce Jun 5, 2026
8b31f45
feat(auth): default single-org users into their active org at login
rowforce Jun 5, 2026
e622693
feat(api): make client CheckPermission organization-aware
rowforce Jun 5, 2026
9ac7941
fix(api): org-enabled apps with no active org resolve to empty roles/…
rowforce Jun 5, 2026
b22810a
fix(api): re-validate org/member status + org→app ownership in role r…
rowforce Jun 5, 2026
cb092e2
feat(repo): organization write methods (unique-slug create, update, a…
rowforce Jun 5, 2026
4ff5631
feat(repo): organization member list/set-role/remove/count-owners
rowforce Jun 5, 2026
c431ce2
feat(api): server org CRUD endpoints (create/list-for-user/get/rename…
rowforce Jun 5, 2026
42412c8
fix(api): atomic create-organization-with-owner; single 201 write
rowforce Jun 5, 2026
b9e8190
feat(api): server org member endpoints (add-by-id/email, list, set-ti…
rowforce Jun 5, 2026
db5b991
fix(api): fail closed when owner count errors in last-owner guard; co…
rowforce Jun 5, 2026
ece9afc
test(api): cover cross-app org 404 + foreign-pool owner 400 guards
rowforce Jun 5, 2026
75013b3
fix(api): org server gate/management honor member+org status; gate cr…
rowforce Jun 6, 2026
5b5b69e
style(ui): titles use Geist sans; drop Fraunces serif and purple titl…
rowforce Jun 6, 2026
b8ecb94
feat(api): add scoped org-enable + list-orgs-for-app repo methods
rowforce Jun 6, 2026
08eaff9
feat(api): add admin endpoint to toggle organizations_enabled
rowforce Jun 6, 2026
bb5bec2
feat(api): add admin list-orgs + list-members endpoints with cross-ap…
rowforce Jun 6, 2026
2aac11b
feat(api): add admin rename + archive organization endpoints
rowforce Jun 6, 2026
4b584ea
feat(ui): add AppOrganizations admin page component
rowforce Jun 6, 2026
501ac5f
feat(ui): wire Organizations nav item, route, and i18n
rowforce Jun 6, 2026
ad6d324
fix(api): enforce app-belongs-to-workspace in org admin handlers
rowforce Jun 6, 2026
2516035
fix(api): gate org membership on app membership, not user pool
rowforce Jun 6, 2026
f8e92d4
fix(api): make org last-owner guard atomic; cap org name/slug length
rowforce Jun 6, 2026
53c9e37
feat(api): server org DELETE hard-deletes the org (admin archive unch…
rowforce Jun 6, 2026
464dc88
feat(ui): full-width org table + status filter (hide archived by defa…
rowforce Jun 6, 2026
192ef71
feat(api): add RestoreOrganization repo method (unarchive)
rowforce Jun 7, 2026
5b21058
feat(api): admin endpoint + route to restore an archived org
rowforce Jun 7, 2026
02afd1e
feat(ui): restore button to unarchive an org in admin panel
rowforce Jun 7, 2026
14bebdc
fix(ui): archive dialog copy reflects in-panel restore
rowforce Jun 7, 2026
93de429
feat(api): org invite repo methods (create/get/list/revoke/accept) + …
rowforce Jun 7, 2026
cb754d9
feat(email): org invite email template + builder
rowforce Jun 7, 2026
fb0c7ff
feat(api): admin endpoint to permanently delete an archived org
rowforce Jun 7, 2026
6c6f774
feat(api): server org-invite endpoints (create/list/revoke) + email send
rowforce Jun 7, 2026
39d0935
feat(ui): permanently delete an archived org from the admin panel
rowforce Jun 7, 2026
9daf9f1
refactor(api): extract shared client sign-in tail from magic-link con…
rowforce Jun 7, 2026
65210ea
feat(api): public org-invite accept endpoint (join + sign-in)
rowforce Jun 7, 2026
c98e355
feat(orgs): scope organizations-enabled to the project
rowforce Jun 7, 2026
1917079
fix(api): revoked org invite must not sign the invitee in
rowforce Jun 7, 2026
3a44774
feat(ui): show org ID column in admin organizations table
rowforce Jun 7, 2026
c684396
feat(orgs): regenerate org slug on server-API rename
rowforce Jun 7, 2026
26dece0
feat(orgs): owner-only org delete on server API
rowforce Jun 7, 2026
ca2a613
docs(readme): add organizations feature details
rowforce Jun 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ docker compose down
projects under workspaces.
- **Role-based access control** - per-project permissions and roles,
default-role assignment on signup.
- **Organizations (multi-tenant)** - opt-in per app: your end-users
belong to organizations (their tenants), one user can join many, each
with a tier (`owner` / `admin` / `member`). Roles and permissions
resolve within the user's active organization and are re-checked on
every request, so a removed member or archived org loses access
immediately. Provision orgs, members, and email invites from your
backend via the server API, or manage them from the admin dashboard.
- **Session management** - per-app session TTL, cookie-domain control,
IP allowlists, CORS origin lists, revocation.
- **Audit logs** - every authentication event recorded per
Expand Down
317 changes: 317 additions & 0 deletions manyrows-core/api/adminOrganizationsHandler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
package api

import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"

"manyrows-core/core"
"manyrows-core/core/repo"
"manyrows-core/utils"

"github.com/gofrs/uuid/v5"
"github.com/rs/zerolog/log"
)

// updateAppOrganizationsEnabledRequest toggles per-app org mode from the admin
// panel. Pointer so a missing field is rejected, not silently treated as false.
type updateAppOrganizationsEnabledRequest struct {
OrganizationsEnabled *bool `json:"organizationsEnabled"`
}

// adminAppScope runs the admin/workspace gate, parses the path ids, AND verifies
// the app belongs to the caller's workspace+project — failing safe (404) if not.
// resolvePathIDs alone only PARSES the ids; without this ownership check a
// workspace-A admin could reach an app in workspace B by supplying its id. Every
// org-management handler must go through this.
func (handler *RequestHandler) adminAppScope(w http.ResponseWriter, r *http.Request) (projectID, appID uuid.UUID, ok bool) {
_, ws, ok := handler.adminAndWorkspace(w, r)
if !ok {
return uuid.Nil, uuid.Nil, false
}
projectID, appID, ok = handler.resolvePathIDs(w, r)
if !ok {
return uuid.Nil, uuid.Nil, false
}
if _, err := handler.repo.GetAppByIDForProject(r.Context(), ws.ID, projectID, appID); err != nil {
if errors.Is(err, repo.ErrNotFound) {
WriteError(w, r, "error.appNotFound", http.StatusNotFound)
return uuid.Nil, uuid.Nil, false
}
log.Err(err).Msg("failed to load app for org admin scope")
WriteError(w, r, "error.internalError", http.StatusInternalServerError)
return uuid.Nil, uuid.Nil, false
}
return projectID, appID, true
}

// HandleUpdateAppOrganizationsEnabled flips organizations_enabled for the whole
// project the addressed app belongs to. The flag is conceptually project-level
// but stored per-app (duplicated across the project's apps); this keeps every
// copy in sync. The endpoint is still addressed via one app's id (the admin UI
// lives on an app's Organizations page) and returns that app.
func (handler *RequestHandler) HandleUpdateAppOrganizationsEnabled(w http.ResponseWriter, r *http.Request) {
_, ws, ok := handler.adminAndWorkspace(w, r)
if !ok {
return
}
projectID, appID, ok := handler.resolvePathIDs(w, r)
if !ok {
return
}

var req updateAppOrganizationsEnabledRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Err(err).Msg("failed to decode json")
WriteError(w, r, "error.invalidJson", http.StatusBadRequest)
return
}
if req.OrganizationsEnabled == nil {
WriteError(w, r, "error.badRequest", http.StatusBadRequest)
return
}

// Validate the addressed app belongs to this workspace+project before
// mutating anything (404 otherwise) — so a bad app id can't trigger a
// project-wide write.
out, err := handler.repo.GetAppByIDForProject(r.Context(), ws.ID, projectID, appID)
if err != nil {
if errors.Is(err, repo.ErrNotFound) {
WriteError(w, r, "error.appNotFound", http.StatusNotFound)
return
}
log.Err(err).Msg("failed to load app for organizations flag update")
WriteError(w, r, "error.internalError", http.StatusInternalServerError)
return
}

if err := handler.repo.SetProjectOrganizationsEnabled(r.Context(), ws.ID, projectID, *req.OrganizationsEnabled); err != nil {
log.Err(err).Msg("failed to update project organizations flag")
WriteError(w, r, "error.internalError", http.StatusInternalServerError)
return
}

// Reflect the new value on the addressed app we return (every app in the
// project now carries it).
out.OrganizationsEnabled = *req.OrganizationsEnabled
utils.WriteJsonWithStatusCode(w, handler.toAdminAppResponse(out, ws), http.StatusOK)
}

// adminOrgListItem is one row of the admin org list.
type adminOrgListItem struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status string `json:"status"`
MemberCount int `json:"memberCount"`
CreatedAt string `json:"createdAt"`
}

type adminOrgListResponse struct {
Organizations []adminOrgListItem `json:"organizations"`
}

// HandleListAppOrganizations lists every org in the app (active + archived) with
// active-member counts. App-scoped via the path appId.
func (handler *RequestHandler) HandleListAppOrganizations(w http.ResponseWriter, r *http.Request) {
_, appID, ok := handler.adminAppScope(w, r)
if !ok {
return
}
views, err := handler.repo.ListOrganizationsForApp(r.Context(), appID)
if err != nil {
log.Err(err).Msg("failed to list organizations for app")
WriteError(w, r, "error.internalError", http.StatusInternalServerError)
return
}
out := adminOrgListResponse{Organizations: make([]adminOrgListItem, 0, len(views))}
for _, v := range views {
out.Organizations = append(out.Organizations, adminOrgListItem{
ID: v.ID.String(),
Name: v.Name,
Slug: v.Slug,
Status: v.Status,
MemberCount: v.MemberCount,
CreatedAt: v.CreatedAt.Format(time.RFC3339),
})
}
utils.WriteJsonWithStatusCode(w, out, http.StatusOK)
}

// adminOrgFromURL loads {orgId} and enforces it belongs to appID, returning 404
// otherwise. Archived orgs pass (admin must view/rename/archive them); only
// cross-app access is denied. Caller has already run adminAndWorkspace +
// resolvePathIDs.
func (handler *RequestHandler) adminOrgFromURL(w http.ResponseWriter, r *http.Request, appID uuid.UUID) (*core.Organization, bool) {
orgID, err := utils.GetPathUUID("orgId", r)
if err != nil || orgID == uuid.Nil {
WriteError(w, r, "error.organizationNotFound", http.StatusNotFound)
return nil, false
}
org, err := handler.repo.GetOrganizationByID(r.Context(), orgID)
if err != nil || org == nil || org.AppID != appID {
WriteError(w, r, "error.organizationNotFound", http.StatusNotFound)
return nil, false
}
return org, true
}

type adminOrgMembersResponse struct {
Members []repo.OrganizationMemberView `json:"members"`
}

// HandleListAppOrganizationMembers returns an org's members (read-only).
func (handler *RequestHandler) HandleListAppOrganizationMembers(w http.ResponseWriter, r *http.Request) {
_, appID, ok := handler.adminAppScope(w, r)
if !ok {
return
}
org, ok := handler.adminOrgFromURL(w, r, appID)
if !ok {
return
}
members, err := handler.repo.ListOrganizationMembers(r.Context(), org.ID)
if err != nil {
log.Err(err).Msg("failed to list organization members")
WriteError(w, r, "error.internalError", http.StatusInternalServerError)
return
}
if members == nil {
members = []repo.OrganizationMemberView{}
}
utils.WriteJsonWithStatusCode(w, adminOrgMembersResponse{Members: members}, http.StatusOK)
}

type renameAppOrganizationRequest struct {
Name string `json:"name"`
}

type adminOrgResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status string `json:"status"`
}

// HandleRenameAppOrganization renames an org (name only; slug is preserved so
// downstream mirrors keyed on id/slug don't drift).
func (handler *RequestHandler) HandleRenameAppOrganization(w http.ResponseWriter, r *http.Request) {
_, appID, ok := handler.adminAppScope(w, r)
if !ok {
return
}
org, ok := handler.adminOrgFromURL(w, r, appID)
if !ok {
return
}
var req renameAppOrganizationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Err(err).Msg("failed to decode json")
WriteError(w, r, "error.invalidJson", http.StatusBadRequest)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
WriteError(w, r, "error.badRequest", http.StatusBadRequest)
return
}
updated, err := handler.repo.UpdateOrganization(r.Context(), org.ID, name, org.Slug)
if err != nil {
if errors.Is(err, repo.ErrNotFound) {
WriteError(w, r, "error.organizationNotFound", http.StatusNotFound)
return
}
log.Err(err).Msg("failed to rename organization")
WriteError(w, r, "error.internalError", http.StatusInternalServerError)
return
}
utils.WriteJsonWithStatusCode(w, adminOrgResponse{
ID: updated.ID.String(),
Name: updated.Name,
Slug: updated.Slug,
Status: updated.Status,
}, http.StatusOK)
}

// HandleArchiveAppOrganization archives an org (status='archived'). Idempotent:
// archiving an already-archived org still returns 204.
func (handler *RequestHandler) HandleArchiveAppOrganization(w http.ResponseWriter, r *http.Request) {
_, appID, ok := handler.adminAppScope(w, r)
if !ok {
return
}
org, ok := handler.adminOrgFromURL(w, r, appID)
if !ok {
return
}
if err := handler.repo.ArchiveOrganization(r.Context(), org.ID); err != nil {
if errors.Is(err, repo.ErrNotFound) {
// Row physically gone — treat as already-archived (idempotent).
// (Re-archiving an existing archived row returns nil, not ErrNotFound.)
w.WriteHeader(http.StatusNoContent)
return
}
log.Err(err).Msg("failed to archive organization")
WriteError(w, r, "error.internalError", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

// HandleRestoreAppOrganization restores an archived org (status='active').
// Idempotent for an already-active org. adminOrgFromURL has already loaded and
// ownership-checked the org, so the ErrNotFound->404 branch below is a defensive
// guard against a concurrent hard-delete between that load and the update
// (restoring a gone row is an error, unlike archive's idempotent-gone 204).
func (handler *RequestHandler) HandleRestoreAppOrganization(w http.ResponseWriter, r *http.Request) {
_, appID, ok := handler.adminAppScope(w, r)
if !ok {
return
}
org, ok := handler.adminOrgFromURL(w, r, appID)
if !ok {
return
}
if err := handler.repo.RestoreOrganization(r.Context(), org.ID); err != nil {
if errors.Is(err, repo.ErrNotFound) {
WriteError(w, r, "error.organizationNotFound", http.StatusNotFound)
return
}
log.Err(err).Msg("failed to restore organization")
WriteError(w, r, "error.internalError", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

// HandleDeleteAppOrganization permanently hard-deletes an org. Gated to archived
// orgs: an active org returns 409 (must archive first). Members, member-roles and
// invites cascade; client_sessions.organization_id is set NULL. adminOrgFromURL has
// already loaded and ownership-checked the org, so the ErrNotFound->404 branch is a
// defensive guard against a concurrent delete.
func (handler *RequestHandler) HandleDeleteAppOrganization(w http.ResponseWriter, r *http.Request) {
_, appID, ok := handler.adminAppScope(w, r)
if !ok {
return
}
org, ok := handler.adminOrgFromURL(w, r, appID)
if !ok {
return
}
if org.Status != core.OrgStatusArchived {
WriteError(w, r, "error.organizationNotArchived", http.StatusConflict)
return
}
if err := handler.repo.DeleteOrganization(r.Context(), org.ID); err != nil {
if errors.Is(err, repo.ErrNotFound) {
WriteError(w, r, "error.organizationNotFound", http.StatusNotFound)
return
}
log.Err(err).Msg("failed to delete organization")
WriteError(w, r, "error.internalError", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
Loading