Applet Visibility Control¶
ADR 0005 — SA Pool + Role Filter
Status: Implemented
Date: 2026-04-29
Context¶
The ovApp frontend renders a sidebar menu with multiple applets. Currently, menu
visibility is controlled by a hardcoded static map in src/lib/auth.tsx keyed on
roleName from the JWT token. This means:
- All users with the same role everywhere see the same applets.
- There is no way for SA Kenya to show
[attendant, rider]while SA Togo shows[attendant, activator, orders]. - Adding a new applet requires a code deployment.
The goal is to make applet visibility dynamic, SA-configured, and role-filtered — without removing the safety of the existing role layer.
Decision¶
Implement Option D — Hybrid (SA Pool + Role Filter):
- SA level → an SA admin configures which applets are available for their SA.
- Role level → the actor's membership role further restricts within that SA pool.
- Actor override (future v2) → individual membership records can add or remove applets beyond their role default.
Visibility Resolution Order¶
Global Applet Registry
↓
SA-enabled pool ← ov.sa_applet_config (Odoo model)
↓
Role default filter ← ROLE_DEFAULT_APPLETS (static map, server-owned)
↓
Actor overrides ← ov.membership.applet_overrides (deferred v2)
↓
+ Universal applets ← ALWAYS_ON (keypad + any future always-on slugs)
↓
Final list → embedded in login response → frontend renders
ALWAYS_ON slugs skip the SA pool and role filter entirely — they are merged
into the final list unconditionally.
Applet Registry¶
Canonical slug → display name mapping. These slugs are the agreed identifiers used
in both the backend config table and the frontend navigation.ts.
| Slug | Display Name | Description |
|---|---|---|
assets |
Assets | BLE device browser and fleet view |
mydevices |
My Devices | Customer-owned device portal |
activator |
Activator | New customer activation workflow |
attendant |
Attendant | Battery swap attendant workflow |
rider |
Rider | Rider service plan portal |
customer-management |
Customer Management | Full CRM customer management |
customers |
Customers | Customer portfolio and payments |
orders |
Orders | Sales orders and fulfilment |
products |
Products | Product catalogue management |
ticketing |
Ticketing | Support ticket management |
keypad |
Keypad | Utility keypad screen |
location |
Location | Route and location tracking |
ota |
OTA | Firmware over-the-air updates |
Backend Data Model¶
ov.sa_applet_config (new model)¶
Stores which applets an SA admin has enabled for their SA. This is an opt-in model — if no config record exists for an applet, it is not in that SA's pool.
| Field | Type | Description |
|---|---|---|
sa_id |
Many2one → ov.serviced_account |
The SA that owns this config |
applet_slug |
Char | One of the canonical slugs above |
enabled |
Boolean (default True) |
Whether the applet is active |
note |
Text | Optional SA admin notes |
Unique constraint: (sa_id, applet_slug)
Universal Applets (Always-On)¶
These applets are always injected into every user's applet list — for every SA, every role, whether or not an SA pool is configured. They bypass both the SA pool check and the role filter.
| Slug | Reason |
|---|---|
keypad |
Universal utility — every user needs access regardless of SA setup |
To add more universal applets, edit the
ALWAYS_ONlist inabs_connector/controllers/auth_controller.py → _compute_applets_for_sa.
Role Filter Map (server-side)¶
Defined in Python inside _compute_applets_for_sa. Maps each ov.membership role to
the applets that role is permitted to see, but only if the applet is also present in
the SA's configured pool. This is a filter, not a grant — an applet not in the SA
pool never appears even if the role allows it.
ALWAYS_ONslugs are exempt from this filter — they are appended after the intersection, so they appear regardless of role.
| Role | Permitted Applets (intersected with SA pool) |
|---|---|
admin |
Full SA pool — no role filter applied |
staff |
activator, attendant, customers, customer-management, orders, products, ticketing, assets |
agent |
attendant, rider, keypad, location |
All non-universal applets must be explicitly assigned to the SA pool by an SAA first.
API Endpoint¶
Frontend Integration¶
Applets delivered at login — no extra round-trip¶
Applets are computed server-side and embedded directly inside the login response.
The frontend never needs to make a separate GET /api/me/applets call on startup.
Login response shape¶
Full response returned by POST /api/employee/login:
{
"success": true,
"message": "Login successful!",
"session": {
"token": "<jwt>",
"expires_at": "2026-04-30T07:31:12.230684",
"employee": {
"id": 87,
"name": "Evans Musamia",
"email": "evans_musamia@omnivoltaic.com",
"company_id": 14,
"user_type": "abs.employee"
},
"partner_id": 11571,
"service_accounts": [
{
"id": 26,
"name": "OV Global Root SA",
"account_code": "OVGLOBAL-ROOT",
"account_class": "OVAC",
"state": "active",
"is_root": true,
"note": null,
"member_count": 2,
"child_count": 0,
"company_id": false,
"company_name": false,
"parent": null,
"partner": {
"id": 11625,
"name": "OV Global Root",
"email": null,
"phone": null
},
"created_at": "2026-04-24 14:12:43.276471",
"updated_at": "2026-04-24 14:31:44.987279",
"my_role": "admin",
"my_scope_policy": false,
"applets": [
"activator",
"assets",
"attendant",
"customer-management",
"customers",
"keypad",
"location",
"mydevices",
"orders",
"ota",
"products",
"rider",
"ticketing"
]
},
{
"id": 11,
"name": "Test Company",
"account_code": null,
"account_class": "OVAC",
"state": "active",
"is_root": true,
"note": "Root SA — auto-created from res.company \"Test Company\".",
"member_count": 14,
"child_count": 3,
"company_id": 14,
"company_name": "Test Company",
"parent": null,
"partner": {
"id": 77,
"name": "Test Company",
"email": "beryl_huo@omnivoltaic.com",
"phone": "0755-84563026"
},
"created_at": "2026-03-10 08:34:04.876578",
"updated_at": "2026-04-13 17:42:17.485588",
"my_role": "agent",
"my_scope_policy": false,
"applets": [
"attendant",
"rider",
"keypad",
"location"
]
}
],
"total": 2,
"auto_selected": false
}
}
Key points:
appletson each SA entry is the already-computed list — no extra round-trip needed.- SA 26 (Global Root,
adminrole) → returns the full SA pool (all 13 slugs seeded). - SA 11 (Test Company,
agentrole) → no SA pool configured yet, so role defaults kick in:[attendant, rider, keypad, location]. auto_selected: trueis set when the employee belongs to exactly one SA — the frontend can auto-apply that SA's applets without asking the user to pick.
The applets field on each SA is the already-computed intersection of:
- that SA's enabled pool (
ov.sa_applet_config) - the employee's role defaults (
ROLE_DEFAULT_APPLETS)
If an SA has no config rows yet, the role defaults are returned directly (graceful degradation).
Recommended frontend storage pattern¶
After login succeeds, store each SA's applets keyed by SA id:
// on login success
session.service_accounts.forEach(sa => {
localStorage.setItem(`ov_sa_applets_${sa.id}`, JSON.stringify(sa.applets ?? []));
});
When the user selects / switches SA (X-SA-ID = 4):
const applets = JSON.parse(localStorage.getItem('ov_sa_applets_4') ?? '[]');
// pass to useMenuVisibility / canViewMenu
On logout, clear all ov_sa_applets_* keys.
useMenuVisibility hook update (ovApp — deferred)¶
The existing hook in src/lib/auth.tsx uses a hardcoded menuPermissions map.
When the frontend work is prioritised:
- Read
ov_sa_applets_<active_sa_id>from localStorage. - If present →
canViewMenu(id)checks that list. - If absent (cached session / first load before SA is selected) → fall back to existing static role map.
sidebar.tsx already calls canViewMenu(item.id) and needs no structural changes.
Scenario Walkthroughs¶
Scenario 1 — SA Kenya: field agents only¶
SA Kenya configures: [attendant, rider, keypad, location]
| Actor | Role | Final Applets |
|---|---|---|
| Alice | agent |
attendant, rider, keypad, location |
| Bob | staff |
attendant, keypad, location (rider not in staff defaults) |
| Carol | admin |
attendant, rider, keypad, location (admin = full SA pool) |
Scenario 2 — SA Togo: full operations team¶
SA Togo configures: [activator, attendant, customer-management, orders, products, ticketing, assets]
| Actor | Role | Final Applets |
|---|---|---|
| Denis | agent |
attendant (only attendant overlaps agent defaults) |
| Eve | staff |
activator, attendant, customer-management, orders, products, ticketing, assets |
| Frank | admin |
activator, attendant, customer-management, orders, products, ticketing, assets |
Scenario 3 — One actor in two SAs¶
Grace has an active membership in both SA Kenya (X-SA-ID: 4) and SA Togo (X-SA-ID: 6).
GET /api/me/appletswithX-SA-ID: 4→[attendant, rider, keypad, location]GET /api/me/appletswithX-SA-ID: 6→[activator, attendant, customer-management, ...]
The frontend re-fetches when Grace switches her active SA.
Scenario 4 — No SA config yet (graceful degradation)¶
An SA has not yet had any applet records configured by an SAA. Only keypad is
returned — regardless of role. All other applets must be explicitly assigned to the
SA pool by an SAA before they appear.
{
"id": 9,
"name": "SA New",
"my_role": "staff",
"my_scope_policy": false,
"applets": ["keypad"]
}
Design intent: no applet is granted by default.
keypadis the universal applet every logged-in user always has — it is injected into every response automatically, even if the SA pool is empty orkeypadwas never explicitly added to the pool. Everything else is opt-in, configured by the SAA per SA.
Governance Boundary — Who Can Configure Applets¶
This is a platform-level decision, not an operational one.
| Role | Can do |
|---|---|
SAA (SA Admin — role_code='admin' in Global Root SA) |
Create, update, delete applet config for any SA |
SAM (SA Manager — staff in a specific SA) |
Read their SA's pool only. Cannot change it |
Agent (agent in a specific SA) |
Cannot read or write pool config. Uses GET /api/me/applets |
Rationale: A field SA manager onboards people and manages customers, but they cannot grant themselves access to applets they were not given. Only SAA, sitting in the Global Root SA, has the authority to shape what each SA's team can see.
API Endpoints¶
GET /api/me/applets — actor reads own computed list¶
GET /api/me/applets
Authorization: Bearer <jwt>
X-SA-ID: <sa_id>
{ "sa_id": 4, "sa_name": "SA Kenya", "role": "agent", "applets": ["attendant", "rider"] }
GET /api/sa/<sa_id>/applets — view SA pool (SAA or that SA's members)¶
GET /api/sa/4/applets
Authorization: Bearer <jwt>
{
"sa_id": 4, "sa_name": "SA Kenya",
"pool": [
{ "id": 1, "applet_slug": "attendant", "enabled": true, "note": "" },
{ "id": 2, "applet_slug": "rider", "enabled": true, "note": "" }
]
}
POST /api/sa/<sa_id>/applets — assign applet to SA (SAA only)¶
POST /api/sa/4/applets
Authorization: Bearer <jwt>
Content-Type: application/json
{ "applet_slug": "ticketing", "enabled": true, "note": "added for support team" }
| Scenario | HTTP Status | action in body |
|---|---|---|
| New slug added | 201 Created |
"created" |
| Slug existed but was disabled | 200 OK |
"re-enabled" |
| Slug already active | 409 Conflict |
(error message) |
409 response example:
{
"success": false,
"error": "Applet \"ticketing\" is already assigned to SA 4. Use PATCH /api/sa/4/applets/ticketing to update it.",
"applet_slug": "ticketing",
"id": 7
}
PATCH /api/sa/<sa_id>/applets/<slug> — enable/disable (SAA only)¶
PATCH /api/sa/4/applets/ticketing
Authorization: Bearer <jwt>
Content-Type: application/json
{ "enabled": false, "note": "disabled pending training" }
DELETE /api/sa/<sa_id>/applets/<slug> — remove from pool (SAA only)¶
DELETE /api/sa/4/applets/ticketing
Authorization: Bearer <jwt>
Security Notes¶
- JWT + active membership required — a valid token alone is not sufficient.
- SAA enforcement — write endpoints (
POST,PATCH,DELETE) check that the caller hasrole_code='admin'in the Global Root SA. Any other role receives403. - Visibility only — this feature controls what appears in the sidebar menu. It does not grant or revoke backend API permissions. Each controller continues to enforce JWT + SA membership independently.
- Route-level access control in
ovApp(PUBLIC_ROUTES) is unaffected.
Visibility vs. Security — Important Distinction¶
Applet visibility is a UI/menu concern only. It controls what the frontend renders in the sidebar. It is not a security boundary.
| Layer | Question answered | Enforced by |
|---|---|---|
| Applet visibility | "What menu items should this user see?" | Frontend (useMenuVisibility) reading applets from login response |
| Controller auth | "Is this user allowed to touch this data?" | Every backend controller, independently |
What applet visibility does¶
- Tells the frontend which menu items to render for a given user in a given SA.
- Delivered at login so the sidebar knows what to show immediately, with no extra round-trip.
What applet visibility does NOT do¶
- It does not protect any backend API route.
- It does not grant or revoke data access.
- It does not prevent a determined client from calling an endpoint directly.
What actually protects the data¶
Every controller independently enforces its own access stack regardless of what the frontend shows:
- JWT validation — a valid, non-expired token is required on every request.
- SA membership check — the caller must be an active member of the SA in
X-SA-ID. - Role check — some endpoints require
stafforadminrole (403otherwise). - Scope policy —
assigned_onlyvssa_widefilters which records are returned.
So if a frontend bypasses the menu and calls GET /api/orders directly, the controller
still enforces that the user has a valid JWT, is a member of the requested SA, and has
the right role. The applet config has no bearing on that outcome.
Applet visibility → "what can you see in the menu?" (frontend UX only)
Controller auth → "are you allowed to touch this data?" (backend enforcement)
The two layers are completely independent. Visibility is a convenience and UX decision; security is never delegated to it.
Suggested Default Pools on First Deployment¶
| SA | Suggested Pool |
|---|---|
| SA Togo | activator, attendant, customer-management, orders, products, ticketing, assets |
| SA Kenya | attendant, rider, keypad, location, ticketing |
| Test SA | All applets |
Implementation Plan¶
Phase 1 — Backend (Odoo) ✅ Complete¶
| Step | File | Status | Task |
|---|---|---|---|
| 1.1 | abs_connector/models/ov_sa_applet_config.py |
✅ Done | New model — SA applet pool config |
| 1.2 | abs_connector/models/__init__.py |
✅ Done | Import new model |
| 1.3 | abs_connector/controllers/governance_api.py |
✅ Done | _is_saa() helper + GET /api/me/applets + SA pool CRUD endpoints |
| 1.4 | abs_connector/controllers/auth_controller.py |
✅ Done | _compute_applets_for_sa() + applets embedded in login response |
| 1.5 | abs_connector/__manifest__.py |
✅ Done | Version bumped to 18.0.1.1.9 |
| 1.6 | scripts/seed_sa_applet_config.py |
✅ Done | Standalone seed script for initial SA pools |
Phase 2 — Frontend (ovApp) — Deferred¶
| Step | File | Task |
|---|---|---|
| 2.1 | src/lib/auth.tsx |
After login, store ov_sa_applets_<id> per SA in localStorage |
| 2.2 | src/lib/auth.tsx |
Update useMenuVisibility to read from ov_sa_applets_<active_sa_id> with static fallback |
| 2.3 | src/components/sidebar/navigation.ts |
Add missing slugs: activator, orders, products, customer-management |
| 2.4 | Logout handler | Clear all ov_sa_applets_* keys on logout |
Phase 3 — SA Admin UI (future)¶
SAA-facing screen inside ovApp to configure SA applet pools without requiring
a developer or Odoo backend access. Uses POST/PATCH/DELETE /api/sa/<id>/applets
endpoints already built in Phase 1.
Open Questions¶
| # | Question | Owner |
|---|---|---|
| 1 | Should SA admin configure applets from ovApp itself, or only from the Odoo backend? |
Product |
| 2 | Should ota, keypad, and location be protected to specific roles only? |
Product |
| 3 | On SA switch — re-fetch applets or clear cache and redirect? | Frontend |
| 4 | Should actor-level overrides (v2) use applet_add / applet_remove fields? |
Architecture |