Organization Access Flows

Organization Access Flows

How users discover, join, switch, and leave organizations. This document is the single source of truth for all org access user journeys — from first contact to daily use.

Related ADRs: 014 Registration Workflows, 015 Context Resolution, 016 Platform Tenant


Overview

There are six distinct user journeys:

#FlowWhoSurfaceResult
1Join open orgAnyoneWebapp / Native appAuto-join, immediate access
2Join by-request orgAnyoneWebapp / Native appRequest submitted, admin approves
3Join invite-only orgInvited userWebapp / Native appAccept invite, immediate access
4Switch orgExisting memberWebapp / Native appChange context, no re-auth
5Register a churchChurch leaderBackend (/register)New org created, leader is admin
6Leave an orgMemberWebapp / Native appMembership removed

Key Concepts

Organization is always present

In the user-facing app (webapp + native), an organization context is always established before any authenticated API call:

PlatformHow org is determinedUser choice?
WebappSubdomain: {slug}.churchapp.appGET /api/v1/organizations/resolve/{slug}organizationIdNo
Native app (denomination)Hardcoded tenant → org picker within that tenantYes
Native app (platform)Login → GET /api/v1/me/organizations → org pickerYes

Registration mode

Every organization has a registrationMode field:

ModeBehavior for new usersAdmin action needed?
openAuto-join as member on first API callNo
by_requestRequest submitted, user waits for approvalYes — approve or reject
invite_onlyCannot join without invitationYes — send invite first

Default: open. Configurable per org in admin UI.

Authentication is identity + context

Every authenticated API request carries two pieces:

Authorization: Bearer <zitadel-jwt>     → proves identity (who you are)
X-Organization-Id: <org-uuid>           → declares context (which org)

The tenant is always derived server-side from org.tenant_id.


Flow 1: Join Open Org

Scenario: A person opens icf-bern.churchapp.app (or selects ICF Bern in the native app) for the first time. The org has registrationMode: open.

Sequence

┌─────────┐          ┌────────────┐          ┌─────────┐          ┌─────────┐
│  User   │          │  Frontend  │          │  Zitadel │          │ Backend │
└────┬────┘          └─────┬──────┘          └────┬────┘          └────┬────┘
     │  Opens app/subdomain│                      │                    │
     │────────────────────>│                      │                    │
     │                     │  GET /api/v1/organizations/resolve/icf-bern
     │                     │──────────────────────────────────────────>│
     │                     │  { organizationId, tenantId, name,       │
     │                     │    registrationMode: "open", ... }       │
     │                     │<─────────────────────────────────────────│
     │                     │                      │                    │
     │                     │  Show org landing     │                    │
     │                     │  (name, logo, "Join") │                    │
     │<────────────────────│                      │                    │
     │                     │                      │                    │
     │  Taps "Sign in"     │                      │                    │
     │────────────────────>│                      │                    │
     │                     │  OIDC redirect (PKCE) │                    │
     │                     │─────────────────────>│                    │
     │  Authenticates      │                      │                    │
     │────────────────────────────────────────────>│                    │
     │                     │  Callback + JWT      │                    │
     │                     │<─────────────────────│                    │
     │                     │                      │                    │
     │                     │  GET /api/v1/me                           │
     │                     │  Authorization: Bearer <jwt>              │
     │                     │  X-Organization-Id: <icf-bern-uuid>      │
     │                     │──────────────────────────────────────────>│
     │                     │                      │                    │
     │                     │  Authenticator:                           │
     │                     │  1. Validate JWT                          │
     │                     │  2. Resolve org → derive tenantId         │
     │                     │  3. Provision User (if new)               │
     │                     │  4. Check OrgMembership → none found     │
     │                     │  5. registrationMode = "open"             │
     │                     │  6. Auto-create OrgMembership (member)    │
     │                     │  7. Return SecurityUser with org context  │
     │                     │                      │                    │
     │                     │  { id, email, displayName, orgRole: "member" }
     │                     │<─────────────────────────────────────────│
     │                     │                      │                    │
     │  Home screen loaded │                      │                    │
     │<────────────────────│                      │                    │

Key details

  • The auto-join happens inside the authenticator (ZitadelBearerAuthenticator) — there’s no separate “join” endpoint
  • The user doesn’t see a “joining” step — it’s seamless
  • The resolve endpoint is public (no auth needed) — frontend calls it before the user logs in
  • The resolve response includes registrationMode so the frontend can show appropriate UI before login (e.g. “Open community — sign in to join” vs. “Invite only — contact an administrator”)
  • If the user already has a Zitadel account, they just sign in. If not, they create one at Zitadel first

Frontend behavior

  • Before login: show org landing page with name, logo, description
  • If registrationMode: open: show “Sign in to join” button
  • After successful /me call: navigate to Home tab

Flow 2: Join By-Request Org

Scenario: A person wants to join an org with registrationMode: by_request. They must request access and wait for admin approval.

Sequence

User opens app/subdomain
  → Frontend resolves org (public endpoint)
  → registrationMode: "by_request"
  → Show: "This community requires approval. Sign in to request access."

User signs in via Zitadel
  → Frontend calls GET /api/v1/me with X-Organization-Id
  → Authenticator:
    1. Validate JWT
    2. Resolve org → derive tenantId
    3. Provision User (if new)
    4. Check OrgMembership → none found
    5. registrationMode = "by_request"
    6. Return 403 { error_code: "membership_pending_approval" }

  → Frontend shows: "Your request has been submitted. You'll be notified when approved."

What needs to be built (not yet implemented)

Backend:

  • OrgJoinRequest entity — tracks pending requests
    • Fields: id, userId, organizationId, tenantId, status (pending/approved/rejected), message (optional user note), reviewedBy, reviewedAt, timestamps
  • POST /api/v1/organizations/{id}/join-request — submit a join request (called by authenticator or explicitly)
  • GET /api/v1/admin/organizations/{id}/join-requests — list pending requests (admin)
  • POST /api/v1/admin/organizations/{id}/join-requests/{requestId}/approve — approve (creates OrgMembership)
  • POST /api/v1/admin/organizations/{id}/join-requests/{requestId}/reject — reject (with optional reason)
  • Domain events: OrgJoinRequested, OrgJoinRequestApproved, OrgJoinRequestRejected
  • Notification to org admins when a new request arrives
  • Notification to user when request is approved/rejected

Frontend:

  • Request status screen: “Pending approval” with org info
  • Push notification when approved → deep link to org home
  • Admin: “Join Requests” section in org management with approve/reject actions

Open questions

  • Should the authenticator auto-create the join request on 403, or should the frontend explicitly POST to a join-request endpoint after getting the 403?
    • Recommendation: The authenticator returns 403, the frontend then calls a dedicated POST /join-request endpoint. This keeps the authenticator simple and lets the user optionally add a message (“Hi, I’m new to the church, referred by…”).
  • Can a user with a pending request access any content? No — they have no membership, so all org-scoped endpoints return 403.
  • Can a user re-request after being rejected? Yes, after a cooldown period (e.g. 7 days).

Flow 3: Join Invite-Only Org

Scenario: An org admin sends an invitation. The invited person accepts and gains membership.

Invitation creation (admin side)

Admin opens org settings → Members → "Invite"
  → Enters email address (or generates a shareable link / QR code)
  → Backend creates OrgInvitation:
    { id, organizationId, tenantId, email (optional), token, role: "member",
      expiresAt, createdBy, status: "pending" }
  → If email provided: send email with invite link
  → If no email: return shareable link / QR code

Invitation acceptance (invitee side)

Invitee clicks link: churchapp.app/invite/{token}
  → Frontend resolves invitation (public endpoint):
    GET /api/v1/invitations/{token}
    → { organizationId, organizationName, invitedBy, expiresAt, status }
  → Show: "You've been invited to join [Org Name]"
  → User signs in via Zitadel
  → Frontend calls: POST /api/v1/invitations/{token}/accept
    Authorization: Bearer <jwt>
    → Backend:
      1. Validate token (exists, not expired, not already used)
      2. Provision User in tenant (if new)
      3. Create OrgMembership with invited role
      4. Mark invitation as accepted
      5. Return { organizationId, role }
  → Frontend sets X-Organization-Id, navigates to Home

What needs to be built (not yet implemented)

Backend:

  • OrgInvitation entity — tracks invitations
    • Fields: id, organizationId, tenantId, email (nullable — for link-based invites), token (unique, URL-safe), role, expiresAt, createdBy, acceptedBy, status (pending/accepted/expired/revoked), timestamps
  • POST /api/v1/admin/organizations/{id}/invitations — create invitation (admin)
  • GET /api/v1/invitations/{token} — resolve invitation (public, no auth)
  • POST /api/v1/invitations/{token}/accept — accept invitation (auth required)
  • DELETE /api/v1/admin/organizations/{id}/invitations/{id} — revoke invitation (admin)
  • Domain events: OrgInvitationCreated, OrgInvitationAccepted, OrgInvitationRevoked

Frontend:

  • Invite screen: accept/decline with org info
  • Admin: “Invitations” section in org management (send, view pending, revoke)
  • Share sheet: copy invite link, show QR code

Design decisions

  • Link-based vs. email-based: Both. Admin can either enter an email (sends branded email with link) or generate a shareable link (for WhatsApp, printed QR, etc.)
  • Token format: URL-safe, 32-char random string. No UUIDs in invite URLs.
  • Expiration: Default 7 days, configurable by admin (min 1 day, max 90 days)
  • Role on invite: Default member, admin can choose admin for leadership invites
  • Reusable links: A single link can optionally allow multiple uses (for “share this with your friends” scenarios). Controlled by maxUses field (null = unlimited).

What happens when an uninvited user tries to join

User signs in → GET /api/v1/me with X-Organization-Id
  → Authenticator: no membership, registrationMode = "invite_only"
  → Return 403 { error_code: "invite_required",
                  error: "This organization is invite-only. Contact an administrator for access." }
  → Frontend shows: "This community is invite-only" with org contact info

Flow 4: Switch Org

Scenario: A user who belongs to multiple orgs (e.g. ICF Zürich and ICF Bern) wants to switch their active org.

Native app flow

User is in ICF Zürich context
  → Taps "More" tab → "Switch organization"
  → App calls: GET /api/v1/me/organizations
    Authorization: Bearer <jwt>
    → Returns: [
        { organizationId, name, slug, role, tenantId, tenantName, profileImagePath },
        { organizationId, name, slug, role, tenantId, tenantName, profileImagePath },
        ...
      ]
  → Org picker shows list of orgs (grouped by tenant if multi-tenant)
  → User selects ICF Bern
  → Frontend updates X-Organization-Id header for all subsequent requests
  → Refreshes Home screen data with new org context
  → Header updates to show ICF Bern name/logo

Webapp flow

User is on icf-zurich.churchapp.app
  → Taps "Switch organization" in More tab
  → Org picker shows (same as native)
  → User selects ICF Bern
  → Option A: Redirect to icf-bern.churchapp.app (full page navigation)
  → Option B: Stay on same domain, update X-Organization-Id in-memory
  → Recommendation: Option A for webapp (each org has its own subdomain)

What needs to be built

Backend:

  • GET /api/v1/me/organizations — list all orgs the current user belongs to
    • Cross-tenant: uses externalAuthId to find User rows in ALL tenants, then fetches their OrgMemberships
    • Response includes: org name, slug, role, tenant info, profile image
    • Sorted by: tenant name, then org name
    • Referenced in ADR 016 but not yet implemented

Frontend:

  • Org picker component (bottom sheet on mobile, dropdown on desktop)
  • If user has only 1 org: skip picker, auto-select (per ADR 016)
  • If user has orgs in multiple tenants: group by tenant with visual separator
  • Store selected org in local state (AsyncStorage on native, memory on web)
  • On switch: clear cached data, refresh Home/Events/Community

Key technical details

  • No re-authentication needed. The Zitadel JWT stays valid. Only the X-Organization-Id header changes.
  • Cross-tenant switching: If the user switches to an org in a different tenant, the backend resolves a different User row (same externalAuthId, different tenantId). The user’s roles and profile may differ per tenant.
  • Data isolation: After switching, all API responses return data scoped to the new org. Cached data from the previous org must be cleared.

Org picker UX

┌─────────────────────────────────┐
│  Switch Organization      ✕    │
├─────────────────────────────────┤
│                                 │
│  Church App Platform            │  ← tenant name (if multi-tenant)
│  ┌─────────────────────────┐   │
│  │ 🏠 ICF Zürich     admin │   │  ← current org highlighted
│  │ 🏠 ICF Bern      member │   │
│  │ 🏠 Grace Chapel   member │   │
│  └─────────────────────────┘   │
│                                 │
│  ICF Movement                   │  ← different tenant
│  ┌─────────────────────────┐   │
│  │ 🏠 ICF Zürich     admin │   │  ← same person, different tenant
│  └─────────────────────────┘   │
│                                 │
└─────────────────────────────────┘
  • Show org profile image (or placeholder icon) + name + user’s role
  • Current org marked with checkmark or highlight
  • Single-tenant users see a flat list (no tenant grouping)
  • Tapping an org closes the picker and switches context

Flow 5: Register a Church

Scenario: A church leader wants to create their church on the platform.

Sequence

Leader visits churchapp.app/register (backend, Twig form)
  → Fills in:
    - Church name
    - Slug (auto-suggested from name, editable)
    - Type (church, campus, ministry)
    - Address (street, city, postal code, country)
    - Description (optional)
  → Clicks "Register"
  → Redirected to Zitadel for authentication (OIDC PKCE flow)
  → Zitadel callback → backend processes:
    1. Create Organization (child of platform tenant's root org)
       - registrationMode: open (default)
       - status: active
    2. Provision User in platform tenant (or link existing)
    3. Create OrgMembership (role: admin)
    4. Set session active_organization_id
  → Redirect to /admin/dashboard
  → Leader sees their org admin panel, ready to:
    - Configure registration mode
    - Upload logo/branding
    - Create groups
    - Invite members

Current implementation status

  • The /register route, form, and RegisterTenantCommand exist
  • Creates an org in the default tenant (not a new tenant, per ADR 014)
  • Post-registration admin dashboard needs onboarding guidance (not yet designed)

Post-registration admin onboarding (to be designed)

The admin dashboard on first visit should guide the leader through setup:

  1. Upload church logo / profile image
  2. Set registration mode (open recommended for most churches)
  3. Create first groups (suggested templates: “Worship Team”, “Youth”, etc.)
  4. Share invite link / QR code with members
  5. Configure service times / recurring events

Flow 6: Leave an Org

Scenario: A user wants to leave an organization they belong to.

Sequence

User opens More tab → Settings (or org info screen)
  → Taps "Leave organization"
  → Confirmation dialog: "Are you sure you want to leave [Org Name]? You'll lose access to all content."
  → User confirms
  → Frontend calls: DELETE /api/v1/me/organizations/{organizationId}
    → Backend:
      1. Validate user has membership
      2. Check: is this the user's only org? → warn (user must belong to at least one org?)
      3. Check: is user the last admin? → prevent (org must have at least one admin)
      4. Delete OrgMembership
      5. Domain event: OrgMemberRemoved
  → Frontend: switch to another org (if available) or show "no org" state

What needs to be built

Backend:

  • DELETE /api/v1/me/organizations/{organizationId} — leave an org
  • Validation: cannot leave if last admin (must transfer admin role first)
  • Domain event already exists: OrgMemberRemoved

Frontend:

  • Leave confirmation dialog
  • Post-leave: auto-switch to next org or show empty state

Open questions

  • Can a user exist with zero org memberships? Recommendation: Yes — the user still has a Zitadel account and can join/be invited to other orgs. The app shows a “Join an organization” screen.
  • What about group memberships when leaving an org? Recommendation: Auto-remove all group memberships in that org when the user leaves. Groups are org-scoped, so membership in them is meaningless without org membership.

API Endpoint Summary

Existing (implemented)

MethodPathAuthDescription
GET/api/v1/organizations/resolve/{slug}PublicResolve slug → org + tenant info + registrationMode
GET/api/v1/meBearer + X-OrgCurrent user with org context (auto-joins open orgs)
GET/api/v1/organizationsBearer + X-OrgList orgs in tenant
GET/api/v1/organizations/{id}Bearer + X-OrgOrg detail
PUT/api/v1/organizations/{id}Bearer + X-OrgUpdate org (incl. registrationMode)

Needed (not yet implemented)

MethodPathAuthDescriptionFlow
GET/api/v1/me/organizationsBearer (no X-Org)List all orgs user belongs to (cross-tenant)4
POST/api/v1/organizations/{id}/join-requestBearer + X-OrgSubmit join request2
GET/api/v1/admin/organizations/{id}/join-requestsBearer + X-Org (admin)List pending join requests2
POST/api/v1/admin/join-requests/{id}/approveBearer + X-Org (admin)Approve join request2
POST/api/v1/admin/join-requests/{id}/rejectBearer + X-Org (admin)Reject join request2
POST/api/v1/admin/organizations/{id}/invitationsBearer + X-Org (admin)Create invitation3
GET/api/v1/invitations/{token}PublicResolve invitation3
POST/api/v1/invitations/{token}/acceptBearerAccept invitation3
DELETE/api/v1/admin/invitations/{id}Bearer + X-Org (admin)Revoke invitation3
DELETE/api/v1/me/organizations/{id}Bearer + X-OrgLeave an org6

Error Responses

All org access errors use structured JSON responses:

ScenarioHTTPerror_codeerror message
No membership, org is open(auto-join, no error)
No membership, org is by_request403membership_pending_approvalMembership requires approval by an administrator.
No membership, org is invite_only403invite_requiredThis organization is invite-only. Contact an administrator for access.
Invalid/missing X-Organization-Id401Missing or invalid X-Organization-Id header.
Org not found / archived401Organization not found.
User not found, no auto-provision401account_not_foundAccount not found.
Invitation expired410invitation_expiredThis invitation has expired.
Invitation already used409invitation_already_usedThis invitation has already been accepted.
Join request already pending409request_already_pendingYou already have a pending request for this organization.
Cannot leave, last admin422last_adminCannot leave — you are the last admin. Transfer the admin role first.

Implementation Priority

Recommended order for building these flows:

PriorityWhatWhy
P0Flow 1 (open join)Already implemented — the core auto-join flow works
P1Flow 4 (switch org) — GET /me/organizationsRequired for native app to work with multi-org users
P1Flow 5 (register church)Already partially implemented, needs polish
P2Flow 3 (invite-only join)Most churches will want to invite specific people
P2Flow 6 (leave org)Simple to build, needed for user autonomy
P3Flow 2 (by-request join)Less common, requires approval workflow + notifications