Appearance
PR 2 — Customer Focus Mode
A session-level "lens" that narrows the entire portal to a single customer. Used by internal staff during screen-shares, customer meetings, or bug investigations to eliminate the risk of accidentally exposing another customer's data on screen.
Depends on PR 1 (account-manager-and-scope-foundation.md). Reuses the same CustomerScope plumbing — Focus Mode is just a second source of scope, layered on the request context.
1. Goal
- Any internal user (including
platform_admin) can enter Focus Mode on one customer at a time. - While focused: every customer-owned read/write is scoped to that customer. Cross-customer aggregates are hidden (not scoped down).
- Out-of-scope single resource fetches return
404/ empty (soft mode), not403— toggling off is friction-free. - Persistent banner on every page makes the active mode unmissable.
- Audit-logged on enter and exit.
- Auto-expires after 4 hours.
Non-goals
- Multi-customer focus (use account_manager scope instead).
- Permanent / sticky-across-sessions focus (would defeat the safety story).
- Field-level redaction (whole-customer scoping only).
2. UX
Entry points
- Customer detail page — "Focus on this customer" button in header.
- Command palette —
> focus <customer>. - Keyboard —
Cmd+Shift+Fopens the customer picker.
Active state
- A persistent banner across the top of ConsoleLayout, high-contrast accent color, text:
Focus mode: <customer name> · Exit (Esc). - Sidebar customer/instance/tenant pickers pre-filtered to the focused customer.
- Cross-customer aggregate widgets (dashboard "Total ARR", "All instances by status", etc.) replaced with an info card:
Focus mode hides cross-customer aggregates. Exit focus to view. - All
/customers/<other-id>/...URLs return404 Not foundwhile focused. - Direct API calls without the focus header still see the user's full permissions — focus is per-session, not per-account.
Exit
- Clicking the banner's
Exit. - Pressing
Escwhile banner has focus. - Auto-expires after 4 hours from last activity (sliding TTL refreshed by any authenticated request that includes the focus cookie).
- On exit: full sidebar + aggregates restored; no page reload required (TanStack Query caches keyed by focus state — see Section 6).
3. Backend
Transport
A signed cookie, not a header.
- Header-based: every fetch in the frontend must opt in. One forgotten
fetch()and the request leaks across. - Cookie-based: browser attaches automatically. Server-side rendering, file downloads (PDF invoices), and embedded iframes all carry the focus by default.
Cookie: console_focus=v1.<base64(customerID|expiresAt|hmac)>
Set-Cookie: console_focus=...; HttpOnly; Secure; SameSite=Strict; Path=/api
Max-Age: 14400 (4h)The HMAC binds the cookie to the user's sub claim — stealing the cookie without the JWT does nothing.
Middleware change — internal/auth/middleware.go
After PR 1 sets Scope on the claims, add a post-step:
go
// Layer focus mode on top of any existing scope.
if cookie := readFocusCookie(c.Request, claims.Sub); cookie != nil {
focused := []uuid.UUID{cookie.CustomerID}
switch {
case claims.Scope == nil || !claims.Scope.Bounded():
// Unscoped user (platform_admin etc.) — focus narrows them.
claims.Scope = &CustomerScope{
CustomerIDs: focused,
Strict: false,
Source: "focus_mode",
}
default:
// Already scoped (account_manager). Intersect.
if !claims.Scope.Allows(cookie.CustomerID) {
// AM tried to focus on a customer outside their assignment.
// The frontend should never offer this, but guard anyway.
c.AbortWithStatusJSON(403, gin.H{"error": "cannot focus on unassigned customer"})
return
}
claims.Scope = &CustomerScope{
CustomerIDs: focused,
Strict: true, // AM strictness wins
Source: "intersection",
}
}
}Repository helper — no change
ApplyCustomerScope from PR 1 just works. It doesn't care which source populated the scope.
CustomerScopedMiddleware — soft mode
internal/auth/customer_scope.go currently 403s on out-of-scope :id. Update for soft scope:
go
if rc.Scope().Bounded() && id != "" {
if !rc.Scope().Allows(oid) {
if rc.Scope().Strict {
c.AbortWithStatusJSON(403, gin.H{"error": "out of scope"})
} else {
c.AbortWithStatusJSON(404, gin.H{"error": "not found"})
}
return
}
}Strict (account_manager) → 403 = security boundary. Soft (focus_mode) → 404 = "this resource is invisible while focused" — the resource still exists, the user just isn't currently looking at it.
Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/v1/me/focus | any internal user | Enter focus on {customerId}. Sets cookie. |
| DELETE | /api/v1/me/focus | any internal user | Exit focus. Clears cookie. |
| GET | /api/v1/me/focus | any internal user | Returns current focus (for UI rehydration on page load). |
A platform_admin entering focus on a non-existent or churned customer → 400. An account_manager entering focus on a customer outside their assignment → 403 (should be impossible from the UI; defensive guard).
4. Aggregate handling
The same dashboard component renders for both AM and Focus Mode, but the policy differs:
| Mode | Cross-customer aggregate widgets |
|---|---|
| Account manager | Computed across assigned customers (server query already scoped). |
| Focus mode | Hidden. Replaced with an info card. |
| Both (AM + focus) | Hidden — focus mode wins on aggregates because user explicitly chose to look at one customer. |
Implementation: the /me payload exposes scopeSource ("account_manager", "focus_mode", "intersection", or absent). Aggregate widgets check scopeSource === 'focus_mode' || scopeSource === 'intersection' and render the info card instead.
5. Frontend
Store
New: web/src/auth/focus.ts
ts
type FocusState = {
customerId?: string
customerName?: string
expiresAt?: number // epoch ms
}
export const useFocus = create<FocusState & { enter, exit, hydrate }>(...)hydrate()runs once at app boot fromGET /api/v1/me/focus.enter(customerId)POSTs/api/v1/me/focus, then invalidates the entire TanStack Query cache so every list reissues with the new cookie.exit()DELETEs and invalidates again.- A 30s ticker checks
expiresAt; if past, callsexit()and toasts "Focus mode expired".
Banner — ConsoleLayout.tsx
Above the existing top bar, render when useFocus().customerId is set:
tsx
<FocusBanner
customerName={focus.customerName}
expiresAt={focus.expiresAt}
onExit={focus.exit}
/>Sticky, h-9, accent color from --color-warning-bg or a new --color-focus-bg token. Includes a countdown so the user sees the TTL.
Cache key separation
TanStack Query keys must include focus state to prevent cache poisoning across toggle:
ts
const focusId = useFocus(s => s.customerId) ?? 'none'
useQuery({ queryKey: ['customers', 'list', focusId], ... })A wrapper hook useScopedQuery standardizes this so individual call sites don't forget. Lint rule (custom) flags useQuery calls without the focus key in the affected pages — defer to a follow-up if too invasive.
Aggregate widgets
web/src/pages/DashboardPage.tsx and any cross-customer widget:
tsx
const focused = useFocus(s => !!s.customerId)
return focused
? <Empty title="Focus mode hides aggregates" />
: <DashboardAggregates />Customer picker (entry)
Cmd+Shift+F opens <FocusCustomerPicker>, a Combobox prefilled with recent customers. For an account_manager, the list is already filtered to assigned customers by the existing /customers scoped endpoint — no special-case code.
6. Audit log
| Action | Subject (user) | Target (customer) | Notes |
|---|---|---|---|
console.focus.entered | the focusing user | focused customer | Includes user agent + IP |
console.focus.exited | the focusing user | focused customer | reason: manual / expired / switched |
console.focus.switched | the focusing user | new customer | Single event when switching directly without exit |
Repository: reuse the existing audit_log writer in internal/handler/auditlog/repository.go. New action constants in internal/model/audit_log.go.
The 4h auto-expiry writes a console.focus.exited row with reason: "expired" from the next request that observes the timeout — not from a background job, to keep this PR scope contained to the request path.
7. Composition with PR 1
| Caller | Pre-focus scope | Focus active? | Effective scope |
|---|---|---|---|
| platform_admin | none | no | unscoped (sees all) |
| platform_admin | none | yes (cust X) | {X}, soft, hides aggregates |
| account_manager (assigned A,B) | {A,B}, strict | no | {A,B}, strict |
| account_manager | {A,B}, strict | yes (cust A) | {A}, strict |
| account_manager | {A,B}, strict | yes (cust C) | 403 at focus-set time |
| customer portal user | (handled by existing portal middleware) | n/a | unchanged |
Customer portal users cannot enter Focus Mode — the POST /me/focus endpoint is gated to IsInternal().
8. Tests
| Layer | Test |
|---|---|
| Middleware | Cookie HMAC bound to JWT sub — swapping subs invalidates |
| Middleware | Focus on platform_admin → soft scope; focus on AM → strict + intersection |
| Middleware | AM focusing outside assignment → 403 |
| Repository | List query with focus scope returns only focused customer rows |
| Repository | Single resource fetch out-of-scope returns 404 (soft) for focus, 403 for AM |
| Frontend | Focus enter invalidates query cache |
| Frontend | Banner countdown updates and triggers exit at TTL |
| Frontend | Aggregate widgets hidden when scopeSource === 'focus_mode' |
| E2E (manual) | Screen-share simulation: enter focus, navigate every page, confirm no other-customer data renders |
9. Implementation order
| Step | Layer | Description |
|---|---|---|
| 1 | Backend | Cookie codec + HMAC binding to user sub |
| 2 | Backend | internal/auth/middleware.go — layer focus on Scope |
| 3 | Backend | customer_scope.go — strict vs soft branching |
| 4 | Backend | /api/v1/me/focus endpoints (GET/POST/DELETE) |
| 5 | Backend | Audit log actions |
| 6 | Backend | /me payload includes scopeSource |
| 7 | Frontend | web/src/auth/focus.ts store + hydration |
| 8 | Frontend | <FocusBanner> + countdown + Esc-exit |
| 9 | Frontend | useScopedQuery wrapper / cache key migration on impacted pages |
| 10 | Frontend | Aggregate widget hide-when-focused |
| 11 | Frontend | <FocusCustomerPicker> + Cmd+Shift+F global keybind |
| 12 | Frontend | Customer detail "Focus on this customer" button |
| 13 | Docs | Update docs/roles-and-permissions.md with focus mode section |
Steps 1–6 land first (backend gated; frontend doesn't expose UI yet but can be exercised via curl). Steps 7–12 light the feature up.
10. Risk register
| Risk | Mitigation |
|---|---|
| Cookie sent with the wrong session (cached / shared device) | HMAC bound to JWT sub — invalidates on user change |
| Frontend cache shows pre-focus data after entering focus | Query keys include focusId; enter() invalidates cache |
| File download (invoice PDF) ignores cookie | Cookie is Path=/api and credentials default — covers <a href="/api/...">. Manually exercise during QA. |
| Focus active during a long pageload spans multiple customer requests | Banner pinned + Esc-to-exit; backend re-reads cookie every request — no in-memory drift |
| New aggregate widget added without focus-mode hide | Same drift class as PR 1 list endpoints. Add to QA checklist; consider a future ESLint rule on useFocus() for any <Aggregate*> component. |
| Auto-expiry writes audit event from request path, not background | Acceptable — if user is idle for >4h they generate no requests, so the next request after idle correctly logs the expiry. Cron-based expiry is a follow-up if needed. |

