Skip to content

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), not 403 — 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

  1. Customer detail page — "Focus on this customer" button in header.
  2. Command palette> focus <customer>.
  3. KeyboardCmd+Shift+F opens 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 return 404 Not found while 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 Esc while 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

MethodPathAuthPurpose
POST/api/v1/me/focusany internal userEnter focus on {customerId}. Sets cookie.
DELETE/api/v1/me/focusany internal userExit focus. Clears cookie.
GET/api/v1/me/focusany internal userReturns 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:

ModeCross-customer aggregate widgets
Account managerComputed across assigned customers (server query already scoped).
Focus modeHidden. 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 from GET /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, calls exit() and toasts "Focus mode expired".

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

ActionSubject (user)Target (customer)Notes
console.focus.enteredthe focusing userfocused customerIncludes user agent + IP
console.focus.exitedthe focusing userfocused customerreason: manual / expired / switched
console.focus.switchedthe focusing usernew customerSingle 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

CallerPre-focus scopeFocus active?Effective scope
platform_adminnonenounscoped (sees all)
platform_adminnoneyes (cust X){X}, soft, hides aggregates
account_manager (assigned A,B){A,B}, strictno{A,B}, strict
account_manager{A,B}, strictyes (cust A){A}, strict
account_manager{A,B}, strictyes (cust C)403 at focus-set time
customer portal user(handled by existing portal middleware)n/aunchanged

Customer portal users cannot enter Focus Mode — the POST /me/focus endpoint is gated to IsInternal().


8. Tests

LayerTest
MiddlewareCookie HMAC bound to JWT sub — swapping subs invalidates
MiddlewareFocus on platform_admin → soft scope; focus on AM → strict + intersection
MiddlewareAM focusing outside assignment → 403
RepositoryList query with focus scope returns only focused customer rows
RepositorySingle resource fetch out-of-scope returns 404 (soft) for focus, 403 for AM
FrontendFocus enter invalidates query cache
FrontendBanner countdown updates and triggers exit at TTL
FrontendAggregate 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

StepLayerDescription
1BackendCookie codec + HMAC binding to user sub
2Backendinternal/auth/middleware.go — layer focus on Scope
3Backendcustomer_scope.go — strict vs soft branching
4Backend/api/v1/me/focus endpoints (GET/POST/DELETE)
5BackendAudit log actions
6Backend/me payload includes scopeSource
7Frontendweb/src/auth/focus.ts store + hydration
8Frontend<FocusBanner> + countdown + Esc-exit
9FrontenduseScopedQuery wrapper / cache key migration on impacted pages
10FrontendAggregate widget hide-when-focused
11Frontend<FocusCustomerPicker> + Cmd+Shift+F global keybind
12FrontendCustomer detail "Focus on this customer" button
13DocsUpdate 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

RiskMitigation
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 focusQuery keys include focusId; enter() invalidates cache
File download (invoice PDF) ignores cookieCookie is Path=/api and credentials default — covers <a href="/api/...">. Manually exercise during QA.
Focus active during a long pageload spans multiple customer requestsBanner pinned + Esc-to-exit; backend re-reads cookie every request — no in-memory drift
New aggregate widget added without focus-mode hideSame 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 backgroundAcceptable — 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.