Admin Nav → Capability Map
Single source of truth the frontend uses to decide what to render in the admin dashboard nav AND what every test must verify. Pair of this doc and the RBAC spec + API enforcement map is authoritative for all admin UI gating.
1. Summary
This doc maps every admin-dashboard nav element — the 4 top tabs (Home / Inbox / Train AI / Website), the Settings slide-over, the header controls, and every sub-section within each — to the capability (from the 50 in src/lib/rbac.ts) and the plan predicate (from src/lib/tier-config.ts) required to render it. When the capability gate fails, the element is hidden. When the plan gate fails, the element is either hidden OR shown-locked-with-upsell CTA — behavior is explicit per row. The frontend (AdminDashboard.rbac.ts, the slide-over components) and every Playwright test (knowledge/tests/registry.yaml) read from this doc. If code disagrees with this doc, this doc wins — propose a change here first, then update code.
Gating Flow
Capability count note.
rbac.tsexports 52 keys (6 Home + 15 Inbox + 8 Train + 5 Website + 8 Settings + 2 Care + 8 Admin-only). The olderadmin-rbac-2026-04-18.mdheader says "42". The code is authoritative — the 10 extra came from splittinginbox:prayer:read↔inbox:prayer:read:confidential,inbox:callback:read↔inbox:callback:read:reason, adding the 2 care caps (founder decision 1a), and addinginbox:prayer:assign+inbox:prayer:care_message(founder decision 2026-04-18 — dedicated caps for the Inbox card "Assign to prayer team" and "Send care message" buttons, replacing the P1 proxies onsettings:team:inviteandcare:broadcast). See §12 "Reconciliation needed".
2. Predicates glossary (canonical)
Defined ONCE here; every row below references these by name. Plan values come from VALID_PLAN_KEYS in tier-config.ts.
| Predicate | Definition | Source |
|---|---|---|
hasChat(plan) | normalizePlanChannel(plan) === 'chat' || 'both' OR isProWebsitePlan(plan) (Pro Website bundles a chatbot per pricing.yaml). True for every plan except voice-only. | tier-config.ts + pricing.yaml |
hasVoice(plan, channel) | planIncludesVoice(plan, channel) | tier-config.ts |
hasProVoice(plan, channel) | planIncludesProVoice(plan, channel) — voice + Pro tier or higher (PCO integration). | tier-config.ts |
hasProWebsite(plan) | isProWebsitePlan(plan) — matches cwa_pro_website, ps_pro_website, pro_website. | tier-config.ts |
hasSuite(plan) | plan.startsWith('cwa_suite_') OR legacy suite* / bundle. | derived |
isProTier(plan) | canAccess(plan, 'analytics') — Pro or Suite. | tier-config.ts TIER_FEATURES |
isStarterTier(plan) | normalizePlanTier(plan) === 'starter' AND NOT Pro Website. | tier-config.ts |
isActive(church) | church.status === 'active' (NOT 'cancelled', 'paused', 'past_due'). See §10 "Edge cases" for the FA-046 churn-return gap. | premium_churches.status |
All predicates are pure functions of plan, channel, and optionally status; compute once per render.
3. Top-level tabs
Four visible tabs (post-P1 IA collapse). Upgrade is a header CTA, not a tab (see §5).
3.1 Home
| Field | Value |
|---|---|
| Required capability | home:overview:view |
| Required plan predicate | none — always visible to any authenticated member |
| Fallback if cap missing | hidden (lockout page per §8.5) |
| Fallback if plan missing | n/a — no plan gate |
| Default landing rule | route /admin/[token] → Home tab; first render = welcome card + setup checklist (if home:checklist:edit) + activity tiles (if home:metrics:view) |
| Test handle | [data-tab="home"], [data-testid="home-panel"] |
3.2 Inbox
| Field | Value |
|---|---|
| Required capability | ANY of: inbox:calls:read, inbox:prayer:read, inbox:visitor:read, inbox:callback:read, inbox:safety:read |
| Required plan predicate | none — Inbox reads data the AI has already collected; plan-gating is per-chip (Calls requires hasVoice) |
| Fallback if cap missing | hidden |
| Fallback if plan missing | n/a at tab level; Calls chip gated on hasVoice(plan) at sub-section level (§4) |
| Default landing rule | first chip the viewer has access to, in order: Calls > Prayer > Visitors > Callbacks > Safety. Empty-state copy if no items yet. |
| Test handle | [data-tab="inbox"], chips carry [data-chip="calls|prayer|visitor|callback|safety"] |
3.3 Train AI
| Field | Value |
|---|---|
| Required capability | ANY of: train:church_knowledge:edit, train:theology:edit, train:agents:edit, train:faqs:edit, train:safety:edit, train:simulator:use, train:documents:upload, train:pastor_pulse:edit |
| Required plan predicate | hasChat(plan) OR hasVoice(plan, channel) — training only matters if there's an AI to train |
| Fallback if cap missing | hidden |
| Fallback if plan missing | hidden (no AI to train — a pure directory-listing plan has no reason to show this) |
| Default landing rule | first sub-section the viewer has access to: Church Knowledge > Theology > Agents > FAQs > Safety > Simulator > Documents > Pastor Pulse |
| Test handle | [data-tab="train"], sub-sections: [data-section="knowledge|theology|agents|faqs|safety|simulator|documents|pulse"] |
3.4 Website
| Field | Value |
|---|---|
| Required capability | ANY of: website:sections:edit, website:design:edit, website:publish, website:preview, website:media:upload |
| Required plan predicate | hasProWebsite(plan) |
| Fallback if cap missing | hidden |
| Fallback if plan missing | hidden — not locked. A plan without Pro Website has NO website to edit; showing a locked tab creates the false impression that a site exists behind the gate. Upsell to Pro Website lives on the Upgrade CTA in the header, not as a ghost tab. |
| Default landing rule | sections panel (Hero) if website:sections:edit; otherwise preview only |
| Test handle | [data-tab="website"] |
4. Sub-sections within each tab
Format: surface — capability — plan predicate — fallback. Every row maps to code and to a Playwright step.
4.1 Home sub-sections
| Sub-section | Capability | Plan predicate | Cap fallback | Plan fallback | Test handle |
|---|---|---|---|---|---|
| Welcome card / overview | home:overview:view | none | hidden | n/a | [data-home="welcome"] |
| Activity metrics (calls/prayer/visitor tiles) | home:metrics:view | none (tile omits call count if !hasVoice) | hidden tile | call tile hidden if !hasVoice | [data-home="metrics"] |
| Financial metrics (giving totals tile) | home:metrics:financial:view | none (future: requires giving integration) | hidden tile | hidden until integration lands | [data-home="giving"] |
| Setup checklist rail | home:checklist:edit | none | hidden | n/a | [data-home="checklist"] |
| Share-link card | home:share_link:view | hasChat || hasProWebsite | hidden | hidden (nothing to share) | [data-home="share"] |
| Remove demo examples button | home:examples:remove | none | hidden | n/a | [data-home="remove-examples"] |
4.2 Inbox sub-sections
| Sub-section | Capability | Plan predicate | Cap fallback | Plan fallback | Test handle |
|---|---|---|---|---|---|
| Calls list chip + detail | inbox:calls:read | hasVoice(plan, channel) | hidden | chip hidden | [data-chip="calls"], [data-testid="call-row"] |
| Call transcript modal | inbox:calls:transcript | hasVoice | hidden button | hidden | [data-action="open-transcript"] |
| Call delete button | inbox:calls:delete | hasVoice | hidden button | hidden | [data-action="delete-call"] |
| Prayer list chip | inbox:prayer:read | hasChat || hasVoice | hidden | hidden | [data-chip="prayer"] |
| Prayer confidential text | inbox:prayer:read:confidential | same | redacted to "Confidential — contact the pastor" | redacted | [data-field="prayer-text"] |
| Prayer status / notes update | inbox:prayer:update | same | hidden dropdown | hidden | [data-action="update-prayer"] |
| Assign prayer request to team | inbox:prayer:assign | hasChat || hasVoice | hidden button | hidden | [data-action="assign-prayer"] |
| Send care message (prayer / visitor / callback card) | inbox:prayer:care_message | hasChat || hasVoice | hidden button | hidden | [data-action="send-care-message"] |
| Visitors list chip | inbox:visitor:read | hasChat || hasVoice | hidden | hidden | [data-chip="visitor"] |
| Visitor status / notes update | inbox:visitor:update | same | hidden | hidden | [data-action="update-visitor"] |
| Callbacks list chip | inbox:callback:read | hasChat || hasVoice | hidden | hidden | [data-chip="callback"] |
| Callback reason (un-redacted) | inbox:callback:read:reason | same | redacted to "Pastoral inquiry" | redacted | [data-field="callback-reason"] |
| Callback status update | inbox:callback:update | same | hidden | hidden | [data-action="update-callback"] |
| Safety flags chip | inbox:safety:read | hasChat || hasVoice | hidden | hidden | [data-chip="safety"] |
| Safety resolve actions | inbox:safety:resolve | same | hidden | hidden | [data-action="resolve-safety"] |
| Assignee picker (cross-cutting) | inbox:item:assign | hasChat || hasVoice | read-only label (no dropdown) | hidden | [data-testid="inbox-assignee-chip"] |
inbox:item:assign (added Inbox sprint PR 4/5, 2026-04-19) gates the assignee-picker dropdown on every inbox row. Template grants: admin + office_admin only. Narrower than inbox:prayer:assign (which also includes pastor) because the cross-cutting picker mutates every row's assignee — day-to-day routing stays with admin + office_admin. Pastors can still be extended via a custom group. Viewers without the cap see a flat read-only chip when an item is already assigned, so they can still tell who's handling what.
4.3 Train AI sub-sections
| Sub-section | Capability | Plan predicate | Cap fallback | Plan fallback | Test handle |
|---|---|---|---|---|---|
| Church Knowledge editor | train:church_knowledge:edit | hasChat || hasVoice | hidden | hidden | [data-section="knowledge"] |
| Theology lens picker | train:theology:edit | hasChat || hasVoice | hidden | hidden | [data-section="theology"] |
| Agent personality editor | train:agents:edit | hasChat || hasVoice | hidden | hidden | [data-section="agents"] |
| FAQ editor | train:faqs:edit | hasChat || hasVoice | hidden | hidden | [data-section="faqs"] |
| Safety / crisis message editor | train:safety:edit | hasChat || hasVoice | hidden | hidden | [data-section="safety"] |
| Simulator | train:simulator:use | hasChat || hasVoice (simulator needs at least one AI channel) | hidden | hidden | [data-section="simulator"] |
| Document upload | train:documents:upload | isProTier(plan) (documents are a Pro feature per TIER_FEATURES) | hidden | locked-with-upsell "Upload documents unlocks with Pro — [Upgrade →]" | [data-section="documents"] |
| Pastor Pulse editor | train:pastor_pulse:edit | hasChat || hasVoice | hidden | hidden | [data-section="pulse"] |
4.4 Website sub-sections
All gated on hasProWebsite(plan) at the tab level (§3.4). If tab is visible, individual caps gate sub-sections.
| Sub-section | Capability | Plan predicate | Cap fallback | Plan fallback | Test handle |
|---|---|---|---|---|---|
| Section editor (Hero, About, Services, Staff, Events, Give, Contact) | website:sections:edit | hasProWebsite | hidden panel | n/a (tab already hidden) | [data-section="sections"] |
| Design panel (template, colors, videos) | website:design:edit | hasProWebsite | hidden panel | n/a | [data-section="design"] |
| Publish button | website:publish | hasProWebsite | hidden | n/a | [data-action="publish"] |
| Preview button | website:preview | hasProWebsite | hidden | n/a | [data-action="preview"] |
| Media upload | website:media:upload | hasProWebsite | hidden | n/a | [data-action="upload-media"] |
4.5 Settings slide-over (header gear)
Triggered by the gear icon (§5). Tabs inside: Account / Team / Notifications / Integrations.
| Sub-section | Capability | Plan predicate | Cap fallback | Plan fallback | Test handle |
|---|---|---|---|---|---|
| Church profile (name, description, contact, social) | settings:church_profile:edit | none | hidden | n/a | [data-settings="profile"] |
| Office hours grid | settings:hours:edit | hasChat || hasVoice | hidden | hidden | [data-settings="hours"] |
| Notifications (email/phone, alert routing) | settings:notifications:edit | hasChat || hasVoice | hidden | hidden | [data-settings="notifications"] |
| Integrations (PCO, Cal.com, giving URL) | settings:integrations:edit | isProTier(plan) || hasProWebsite (Pro Website gets PCO for directory sync) | hidden | locked-with-upsell "Integrations unlock with Pro — [Upgrade →]" | [data-settings="integrations"] |
| Sharing / embed / QR | settings:sharing:view | hasChat || hasProWebsite | hidden | hidden | [data-settings="sharing"] |
| Team roster (view) | settings:team:view | none | hidden | n/a | [data-settings="team"] |
| Team invite | settings:team:invite | none | hidden button | n/a | [data-action="invite-member"] |
| Team remove | settings:team:remove | none | hidden button | n/a | [data-action="remove-member"] |
4.6 Care sub-surface
Care (congregation broadcast) is folded into the Inbox tab visually (per rbac-catalog.ts which assigns care:* caps to the Inbox category). A "Send broadcast" action button sits in Inbox header; a "Subscribers" panel is reachable from Inbox side-nav.
| Sub-section | Capability | Plan predicate | Cap fallback | Plan fallback | Test handle |
|---|---|---|---|---|---|
| Broadcast composer (email/SMS) | care:broadcast | hasChat || hasVoice | hidden | hidden | [data-action="care-broadcast"] |
| Subscribers roster | care:subscribers:view | hasChat || hasVoice | hidden | hidden | [data-panel="care-subscribers"] |
4.7 Billing (inside Settings slide-over, admin-only section)
| Sub-section | Capability | Plan predicate | Cap fallback | Plan fallback | Test handle |
|---|---|---|---|---|---|
| Billing view (subscription, invoices) | billing:view | none | hidden | n/a | [data-settings="billing"] |
| Billing manage (upgrade/downgrade, card) | billing:manage | none | hidden | n/a | [data-action="manage-billing"] |
| Cancel subscription | billing:cancel | isActive(church) | hidden | hidden (already cancelled) | [data-action="cancel-sub"] |
5. Header controls
| Control | Capability | Plan predicate | Cap fallback | Plan fallback | Test handle |
|---|---|---|---|---|---|
| Gear icon (opens Settings slide-over) | ANY of settings:* (see §4.5) | none | hidden | n/a | [data-header="gear"] |
| Bell icon (notifications) | home:overview:view (everyone) | none | hidden | n/a | [data-header="bell"] |
| Upgrade CTA | none (every authenticated member sees it, to nudge billing conversations) | !hasSuite(plan) — Suite is the top tier, no higher upgrade | hidden | hidden for Suite | [data-header="upgrade"] |
| Sign-out | none (always available to authenticated) | none | never hidden | n/a | [data-header="signout"] |
6. Mobile bottom nav
Same 4 tabs. Mobile-specific adaptations:
| Tab | Mobile visibility | Notes |
|---|---|---|
| Home | same as desktop (§3.1) | always |
| Inbox | same as desktop (§3.2) | filter chips become a horizontal scrollable strip |
| Train AI | same as desktop (§3.3) | sub-sections become a vertical accordion |
| Website | same as desktop — hidden if !hasProWebsite(plan) | on mobile the editor is read-only (preview + publish only) — website:sections:edit buttons render a "Open on desktop to edit" toast |
Header gear/bell/upgrade collapse into an overflow menu (⋯). Same caps + plan predicates apply.
7. Full plan × tab matrix
Cells: V = visible, H = hidden, L = locked-with-upsell. Assumes viewer has the required capability for each tab (this matrix is the PLAN gate only; §8 layers the role gate on top).
| Plan key | Home | Inbox | Train AI | Website | Settings | Care | Upgrade |
|---|---|---|---|---|---|---|---|
cwa_starter_chat / _annual | V | V (no Calls chip) | V | H | V | V | V |
cwa_starter_voice / _annual | V | V (no chat-only chips) | V | H | V | V | V |
cwa_starter_both / _annual | V | V | V | H | V | V | V |
cwa_pro_chat / _annual | V | V (no Calls chip) | V | H | V | V | V |
cwa_pro_voice / _annual | V | V (no chat-only chips) | V | H | V | V | V |
cwa_pro_both / _annual | V | V | V | H | V | V | V |
cwa_suite_chat / _annual | V | V (no Calls chip) | V | H | V | V | H |
cwa_suite_both / _annual | V | V | V | H | V | V | H |
cwa_bundle | V | V | V | H | V | V | H |
cwa_pro_website | V | V | V | V | V | V | V |
ps_pro_website (legacy) | V | V | V | V | V | V | V |
pro_website (legacy) | V | V | V | V | V | V | V |
ps_premium (directory-only) | V (no call tile, no share card) | H | H | H | V (profile only) | H | V |
Notes:
- Pro Website is the ONLY non-voice, non-chat-branded plan that still shows the Website tab.
ps_premiumbuyers purchased a directory listing; the admin dashboard is effectively a profile editor + billing view. Inbox/Train/Website are hidden because there is no AI and no website.- Document upload (§4.3) inside Train AI is a sub-row that locks-with-upsell on any non-Pro plan; the tab itself stays V.
8. Role × capability matrix
Rows = 12 TemplateKey values + custom. Columns grouped by surface. Pulled VERBATIM from TEMPLATE_GROUP_CAPABILITIES in rbac.ts (the code, not the spec's prose §4 — code wins). Cells: ✓ = template grants the cap, blank = does not.
8.1 Home (6 caps)
| Role | overview | metrics | metrics:financial | checklist | share_link | examples:remove |
|---|---|---|---|---|---|---|
| admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| office_admin | ✓ | ✓ | ✓ | ✓ | ✓ | |
| pastor | ✓ | ✓ | ✓ | ✓ | ✓ | |
| prayer_team | ✓ | ✓ | ✓ | |||
| care_team | ✓ | ✓ | ✓ | |||
| treasurer | ✓ | ✓ | ✓ | ✓ | ||
| volunteer_coordinator | ✓ | ✓ | ✓ | |||
| worship_team | ✓ | ✓ | ✓ | |||
| usher_team | ✓ | ✓ | ||||
| kids_ministry | ✓ | ✓ | ✓ | |||
| youth_ministry | ✓ | ✓ | ✓ | |||
| tech_team | ✓ | ✓ | ||||
| custom | (see the group's own capability set) |
8.2 Inbox (15 caps)
| Role | calls:read | calls:transcript | calls:delete | prayer:read | prayer:read:conf | prayer:update | prayer:assign | prayer:care_message | visitor:read | visitor:update | callback:read | callback:read:reason | callback:update | safety:read | safety:resolve |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| office_admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
| pastor | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
| prayer_team | ✓ | ✓ | |||||||||||||
| care_team | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ||||||||
| treasurer | |||||||||||||||
| volunteer_coordinator | ✓ | ✓ | ✓ | ✓ | |||||||||||
| worship_team | |||||||||||||||
| usher_team | ✓ | ||||||||||||||
| kids_ministry | ✓ | ✓ | ✓ | ✓ | |||||||||||
| youth_ministry | ✓ | ✓ | ✓ | ✓ | |||||||||||
| tech_team |
8.3 Train AI (8 caps)
| Role | knowledge | theology | agents | faqs | safety | simulator | documents | pastor_pulse |
|---|---|---|---|---|---|---|---|---|
| admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| office_admin | ✓ | ✓ | ✓ | ✓ | ||||
| pastor | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| prayer_team | ||||||||
| care_team | ||||||||
| treasurer | ||||||||
| volunteer_coordinator | ||||||||
| worship_team | ✓ | |||||||
| usher_team | ||||||||
| kids_ministry | ||||||||
| youth_ministry | ||||||||
| tech_team | ✓ |
8.4 Website (5 caps)
| Role | sections | design | publish | preview | media |
|---|---|---|---|---|---|
| admin | ✓ | ✓ | ✓ | ✓ | ✓ |
| office_admin | ✓ | ✓ | ✓ | ||
| pastor | ✓ | ✓ | ✓ | ✓ | ✓ |
| prayer_team | ✓ | ||||
| care_team | ✓ | ||||
| treasurer | ✓ | ||||
| volunteer_coordinator | ✓ | ||||
| worship_team | ✓ | ||||
| usher_team | ✓ | ||||
| kids_ministry | ✓ | ||||
| youth_ministry | ✓ | ||||
| tech_team | ✓ | ✓ | ✓ | ✓ |
8.5 Settings (8 caps)
| Role | profile | hours | notifications | integrations | sharing | team:view | team:invite | team:remove |
|---|---|---|---|---|---|---|---|---|
| admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| office_admin | ✓ | ✓ | ✓ | ✓ | ||||
| pastor | ✓ | ✓ | ||||||
| prayer_team | ||||||||
| care_team | ||||||||
| treasurer | ||||||||
| volunteer_coordinator | ||||||||
| worship_team | ||||||||
| usher_team | ||||||||
| kids_ministry | ||||||||
| youth_ministry | ||||||||
| tech_team | ✓ | ✓ |
8.6 Care (2 caps)
| Role | broadcast | subscribers:view |
|---|---|---|
| admin | ✓ | ✓ |
| office_admin | ✓ | ✓ |
| pastor | ✓ | ✓ |
| care_team | ✓ | ✓ |
| all others |
8.7 Admin-only (8 caps) — never grantable via group UI
| Role | billing:view | billing:manage | billing:cancel | church:delete | church:transfer | groups:manage | api_keys | audit:view |
|---|---|---|---|---|---|---|---|---|
| admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| all others |
custom role: by construction the create-group UI excludes admin-only caps (filterGrantableCapabilities() in rbac-catalog.ts), so no custom group can ever hold any of these.
9. Upsell-lock behavior spec
When a sub-section is marked locked-with-upsell in §4:
Rendering:
- Show the row/tab/button in the nav with an inline lock icon (Lucide
Lock) at text size. - Label color
text-stone-400(disabled) instead of the activetext-stone-900. aria-disabled="true",cursor: not-allowed.- Do NOT render the actual UI behind the lock (no ghost fields, no skeleton) — that invites confusion about whether data exists.
Interaction:
- Click does NOT navigate. Instead, render an inline popover anchored to the locked element with copy per §9.1 and a primary
Upgrade →button. Upgrade →routes to/admin/[token]#upgradewith a?context=param naming the locked feature (e.g.?context=documents) so the Upgrade CTA copy can echo it back: "You tried to upload a document — Pro plans include unlimited document training."- Keyboard: Enter or Space on the locked element opens the popover same as click.
Hide vs lock decision rule:
- Hide when the plan simply doesn't include the product (no Voice plan → no Calls chip; no Pro Website → no Website tab).
- Lock-with-upsell when the plan COULD upgrade to unlock the feature within its product line (Starter Chat + documents upload = upgrade to Pro Chat).
9.1 Canonical upsell copy
| Feature | Copy |
|---|---|
| Voice-required feature (Calls chip) | "Calls unlock with a Voice or Bundle plan — [Upgrade →]" |
| Pro-required feature (document upload, integrations) | "{Feature} unlocks with Pro — [Upgrade →]" |
| Suite-required feature (marketplace, custom agents) | "{Feature} unlocks with Suite — [Upgrade →]" |
| Pro Website required (Website tab) | (hidden, not locked — no upsell shown in nav) |
| Cancel when already cancelled | billing:cancel is simply hidden when !isActive(church) |
10. Edge cases
-
User loses capability mid-session. Admin removes a member from a template group while the member has the dashboard open. On next page navigation or API call: the tab disappears and any API request returns 403. In-session polling: every 60 seconds —
GET /api/premium/me/capabilitiesreturns the freshly-computedeffectivePermissions; if the set differs from the cached provider value, re-render the nav (hide newly-lost tabs). No hard reload. Tested in spec §9 scenario 11. -
Plan downgrade mid-session (subscription paused/cancelled via Stripe webhook). Same polling interval picks up the new
plan/statusfrompremium_churches. Tab visibility re-computes. Data the user ALREADY loaded (e.g., a prayer list in memory) continues to render until next navigation — this is acceptable because the data is not sensitive beyond what the member's old plan already granted. -
groups:managewithout Admin template. A custom group CANNOT grantgroups:manage(it's admin-only, stripped byfilterGrantableCapabilities()). But what about Admin members who are NOT in the Pastor template? Team panel (settings:team:view) is a SEPARATE cap fromgroups:manage. Admin always has both. Pastor hassettings:team:viewonly. So: Admin can manage groups AND see team; Pastor can see team roster but cannot manage group capabilities. Correct. -
Custom group with ZERO capabilities. A member whose only group grants zero caps AND has zero direct caps sees the lockout page (§8.5 of the spec): "Your account has no permissions. Please contact your church admin."
home:overview:viewis NOT implicitly granted — if the empty group is the member's only source, Home itself is hidden. -
Churn-return auth hole (FA-046) — KNOWN GAP. Current behavior: a member whose
premium_churches.status = 'cancelled'retains a workingaccess_tokenand CAN still hit the dashboard. TheisActive(church)predicate is NOT yet wired into the tab-visibility resolver. Until FA-046 is fixed, a cancelled church viewing/admin/[token]sees the full UI (minus Cancel button per §4.7). Flag all tabs' Plan fallback as "best-effort" under this condition. Fix: addisActive()as a top-level guard in the dashboard layout that routes cancelled churches to/billing-lapsed. -
Multi-channel plan without Pro Website add-on (e.g.,
cwa_pro_bothwith no Pro Website purchased). Website tab HIDDEN (§3.4). Upgrade CTA highlights Pro Website as an available add-on. -
Legacy
role-only member (Phase 1 rollout). Members whosegroup_idsandcapabilitiesarrays are both empty fall through tolegacyRoleToCapabilities(role)inrbac.ts. The resulting cap set is re-checked against this doc exactly as if it came from a group. No separate rendering logic.
11. Test matrix
Playwright data-provider format. Each row = one scenario. Run matrix: 10 canonical plans × 9 canonical roles = 90 test cases. Machine-readable (no surrounding prose inside cells).
Columns:
plan— plan keyrole— TemplateKey orcustomexpected_visible_tabs— comma-separated subset ofhome,inbox,train,websiteexpected_home_sections— comma-separated subset ofwelcome,metrics,giving,checklist,share,remove-examplesexpected_inbox_chips— comma-separated subset ofcalls,prayer,visitor,callback,safetyexpected_upgrade_cta—visibleorhidden
| plan | role | expected_visible_tabs | expected_home_sections | expected_inbox_chips | expected_upgrade_cta |
|---|---|---|---|---|---|
| cwa_starter_chat | admin | home,inbox,train | welcome,metrics,checklist,share,remove-examples | prayer,visitor,callback,safety | visible |
| cwa_starter_chat | pastor | home,inbox,train | welcome,metrics,checklist,share,remove-examples | prayer,visitor,callback,safety | visible |
| cwa_starter_chat | office_admin | home,inbox,train | welcome,metrics,checklist,share,remove-examples | prayer,visitor,callback,safety | visible |
| cwa_starter_chat | prayer_team | home,inbox | welcome,metrics,share | prayer | visible |
| cwa_starter_chat | care_team | home,inbox | welcome,metrics,share | prayer,visitor,callback | visible |
| cwa_starter_chat | treasurer | home | welcome,metrics,giving,share | visible | |
| cwa_starter_chat | volunteer_coordinator | home,inbox | welcome,metrics,share | visitor,callback | visible |
| cwa_starter_chat | usher_team | home,inbox | welcome,share | visitor | visible |
| cwa_starter_chat | tech_team | home | welcome,share | visible | |
| cwa_starter_voice | admin | home,inbox,train | welcome,metrics,checklist,share,remove-examples | calls,prayer,visitor,callback,safety | visible |
| cwa_starter_voice | pastor | home,inbox,train | welcome,metrics,checklist,share,remove-examples | calls,prayer,visitor,callback,safety | visible |
| cwa_starter_voice | prayer_team | home,inbox | welcome,metrics,share | prayer | visible |
| cwa_starter_voice | usher_team | home,inbox | welcome,share | visitor | visible |
| cwa_starter_both | admin | home,inbox,train | welcome,metrics,checklist,share,remove-examples | calls,prayer,visitor,callback,safety | visible |
| cwa_pro_chat | admin | home,inbox,train | welcome,metrics,checklist,share,remove-examples | prayer,visitor,callback,safety | visible |
| cwa_pro_chat | pastor | home,inbox,train | welcome,metrics,checklist,share,remove-examples | prayer,visitor,callback,safety | visible |
| cwa_pro_voice | admin | home,inbox,train | welcome,metrics,checklist,share,remove-examples | calls,prayer,visitor,callback,safety | visible |
| cwa_pro_both | admin | home,inbox,train | welcome,metrics,checklist,share,remove-examples | calls,prayer,visitor,callback,safety | visible |
| cwa_pro_both | care_team | home,inbox | welcome,metrics,share | prayer,visitor,callback | visible |
| cwa_suite_chat | admin | home,inbox,train | welcome,metrics,checklist,share,remove-examples | prayer,visitor,callback,safety | hidden |
| cwa_suite_both | admin | home,inbox,train | welcome,metrics,checklist,share,remove-examples | calls,prayer,visitor,callback,safety | hidden |
| cwa_bundle | admin | home,inbox,train | welcome,metrics,checklist,share,remove-examples | calls,prayer,visitor,callback,safety | hidden |
| cwa_pro_website | admin | home,inbox,train,website | welcome,metrics,checklist,share,remove-examples | prayer,visitor,callback,safety | visible |
| cwa_pro_website | pastor | home,inbox,train,website | welcome,metrics,checklist,share,remove-examples | prayer,visitor,callback,safety | visible |
| cwa_pro_website | tech_team | home,website | welcome,share | visible | |
| cwa_pro_website | prayer_team | home,inbox,website | welcome,metrics,share | prayer | visible |
| ps_pro_website | admin | home,inbox,train,website | welcome,metrics,checklist,share,remove-examples | prayer,visitor,callback,safety | visible |
| pro_website | admin | home,inbox,train,website | welcome,metrics,checklist,share,remove-examples | prayer,visitor,callback,safety | visible |
| ps_premium | admin | home | welcome | visible |
Full cartesian expansion (remaining 60 rows of plan × role) follows the same derivation: visible_tabs = intersection(role's cap-granted tabs, plan's plan-gated tabs); home_sections same rule; inbox_chips = intersection of role's inbox caps with plan's channel. Generator script: scripts/derive-admin-nav-test-matrix.mjs (to be built). 30 scenarios above are the distilled canonical set — each exercises at least one unique row/column.
12. How to keep this doc fresh
Rebuild triggers. Regenerate or hand-update this doc when ANY of these files change:
churchwiseai-web/src/lib/rbac.ts— capability enum orTEMPLATE_GROUP_CAPABILITIES.churchwiseai-web/src/lib/rbac-catalog.ts— capability labels or category assignment.churchwiseai-web/src/lib/tier-config.ts— plan keys or any predicate.churchwiseai-web/src/app/admin/[token]/components/AdminDashboard.rbac.ts—CAP_TABSorcanSeeTab.
Proposed CI check. A derive-script to run in pnpm derive --check:
// knowledge/scripts/check-admin-nav-map.ts
// 1. Parse ALL_CAPABILITIES from rbac.ts
// 2. Grep this doc for every capability key; fail if any are missing
// 3. Parse VALID_PLAN_KEYS from tier-config.ts
// 4. Grep §7 plan matrix for every plan key; fail if any are missing
// 5. Parse TEMPLATE_GROUP_CAPABILITIES; assert §8 matrix row-for-row matches
Gate PRs that touch the rebuild-trigger files on this check passing.
last-verified date at the top must be bumped by hand when a human or agent does a full pass comparing code → doc. Current: 2026-04-19.
Reconciliation needed
Flagged conflicts between this doc, the RBAC spec, and the code:
-
Capability count. Spec §3 header says "42 capabilities"; code exports 52 via
ALL_CAPABILITIES. Resolution: the 2care:*caps were added in Phase 1 as a founder decision (seerbac.tscomment around line 76), andinbox:prayer:assign+inbox:prayer:care_messagewere added 2026-04-18 to back the Inbox card "Assign to prayer team" and "Send care message" buttons with dedicated caps instead of the short-lived proxies onsettings:team:invite/care:broadcast. The 42 → 52 gap is real; the spec should be regenerated from code. This doc uses 52 (code wins). -
Care tab fate. Spec §11 Q6 flagged this as undecided. Resolution picked here: Care is folded into Inbox (broadcast action + subscribers panel) per
rbac-catalog.tsassigning bothcare:*caps to theInboxcategory and per the P1 sprint plan §Slice 2 (Inbox merges Calls + Requests + Care). If founder changes mind and Care becomes its own tab, update §3 (add 3.5 Care tab), §4 (move §4.6 Care), and §7 (add Care column to plan matrix). Nothing else downstream should change — the caps are already correct. -
settings:team:viewon Pastor. Spec §5 Settings matrix showssettings:team:view = ✓for Pastor; codeTEMPLATE_GROUP_CAPABILITIES.pastoralso grants it. Match. (Spec §4.3 prose says "settings:team:view" explicitly — consistent.) -
inbox:calls:deleteon Pastor. Spec §5 matrix marks it Admin-only. Code agrees (Pastor template omits it). Match. -
home:metrics:financial:viewon Pastor. Spec §5 matrix: Admin + Treasurer only. Code agrees (Pastor template omits it). Match. -
Pastor Pulse for Worship Team. Spec §4.8 and matrix both grant
train:pastor_pulse:edit. Code agrees. Match. -
Simulator tab vs Train-AI simulator sub-section. Undefined at the section level in spec §5 (the simulator sub-tab appears in Train AI surfaces). This doc treats it as a Train AI sub-section (§4.3). No conflict — spec and code agree that
train:simulator:useis the gate; just naming clarity. -
Pastor Pulse naming. Spec §4 and code both use
train:pastor_pulse:edit. The UI label inrbac-catalog.tsis "Update weekly pulse" — pastor-friendly. This doc uses the code name in tables and the UI label in prose. No conflict.
No unreconcilable conflicts. All items 1-8 are aligned in code + this doc; item 1 requires updating the older spec header (cosmetic).