The LFX One UI adapts to who the user is (persona) and what perspective they're viewing from (lens). Together, these two concepts drive sidebar navigation, dashboard content, filter visibility, project context, and route-level authorization. This doc covers how the pieces fit together.
| Concept | Owner | Values |
|---|---|---|
| Lens | LensService |
'me' | 'foundation' | 'project' | 'org' |
| Persona | PersonaService + backend |
'contributor' | 'maintainer' | 'board-member' | 'executive-director' |
| Project context | ProjectContextService |
ProjectContext | null (foundation- or project-scoped) |
- A lens is a viewing perspective the user actively chooses (persisted in a cookie).
- A persona is derived server-side from the user's committee memberships and governs which lenses they're allowed to use.
- A project context is the scope inside a lens (e.g. which foundation or project is selected).
type Lens = 'me' | 'foundation' | 'project' | 'org';
class LensService {
activeLens: Signal<Lens>; // currently-active lens, clamped to persona-allowed set
availableLenses: Signal<LensOption[]>; // lenses the current persona can switch to
setLens(lens: Lens): void; // no-op if the lens isn't allowed for this persona
}Key behaviors:
activeLensis a computed signal — it reads the user's selected lens from a 30-day cookie and clamps it to the set allowed by their persona. If the persisted lens is disallowed, it falls back toDEFAULT_LENS.setLens()rejects disallowed lenses silently (no throw), so unprivileged callers can't escalate scope.availableLensesis driven by role-based access rules: root writers see all four lenses;foundationis available whenhasBoardRole || isRootWriter;projectis available whenhasProjectRole || isRootWriter. A user can carry both roles and see both lenses.
Every top-level route under MainLayoutComponent that is lens-aware declares its lens via data.lens:
// apps/lfx-one/src/app/app.routes.ts (excerpt)
{ path: '', pathMatch: 'full', data: { lens: 'me' }, loadComponent: ... },
{ path: 'foundation/overview', data: { lens: 'foundation' }, loadComponent: ... },
{ path: 'foundation/health-metrics', data: { lens: 'foundation' }, canActivate: [executiveDirectorGuard], loadComponent: ... },
{ path: 'project/overview', data: { lens: 'project' }, loadComponent: ... },
{ path: 'org', data: { lens: 'org' }, loadComponent: ... },Feature routes (/meetings, /votes, /surveys, etc.) typically don't declare data.lens — instead, feature pages read activeLens from LensService to decide whether to show a "My …" view (me lens) or a scoped view.
type PersonaType = 'contributor' | 'maintainer' | 'board-member' | 'executive-director';Personas are detected from committee memberships. A user can carry multiple personas simultaneously; the "primary" persona is the highest-priority one (executive-director > board-member > maintainer > contributor).
Two server services own persona resolution:
PersonaDetectionService(apps/lfx-one/src/server/services/persona-detection.service.ts)getPersonas(req)— RPC call to the NATS subjectNatsSubjects.PERSONAS_GETreturning the raw persona payload.checkRootWriter(req)— independent NATS lookup (NatsSubjects.PROJECT_SLUG_TO_UID+ access check) to confirm root-writer status.
PersonaEnrichmentService(apps/lfx-one/src/server/services/persona-enrichment.service.ts)getEnrichedPersonas(req)— batches project metadata fetches so the frontend gets project names/slugs/parent UIDs alongside raw persona UIDs.
Both are exported as singletons from apps/lfx-one/src/server/utils/persona-helper.ts. SSR uses resolvePersonaForSsr(req, res) — a hybrid that reads the persona cookie first and falls back to NATS on cache miss.
Non-obvious behavior:
- Root writers are injected with the
executive-directorpersona server-side even if they don't natively hold it (for consistent lens-gating). - Impersonation overrides are honored only if the target persona is in the detected list — users can't "upgrade" themselves through impersonation.
- The ROOT (tenant root) project is stripped from the detection response before consumers see it;
checkRootWriteruses an independent NATS lookup to avoid leaking access. - The persona cookie holds only personas + organizations, not projects. Project enrichment always refreshes from
/api/user/personas?enriched=trueafter page hydration.
PersonaService (apps/lfx-one/src/app/shared/services/persona.service.ts) surfaces signals derived from the hydrated AuthContext:
class PersonaService {
currentPersona: WritableSignal<PersonaType>;
allPersonas: WritableSignal<PersonaType[]>;
personaProjects: WritableSignal<Record<PersonaType, PersonaProject[]>>;
hasBoardRole: Signal<boolean>; // 'board-member' or 'executive-director' in allPersonas
hasProjectRole: Signal<boolean>; // 'maintainer' or 'contributor' in allPersonas
isRootWriter: WritableSignal<boolean>;
enrichedPersonasLoaded: WritableSignal<boolean>;
refreshEnrichedPersonas(force?: boolean): Observable<PersonaApiResponse>;
}Typical consumers: lens-switcher.component.ts (hides lenses the persona can't use), sidebar.component.ts (decides which nav items to render), and ProjectContextService (picks the right context based on hasBoardRole).
Carries the "what project/foundation am I currently scoped to?" state across feature pages. Exposes computed signals for the active context inferred from activeLens + persona:
class ProjectContextService {
activeContext: Signal<ProjectContext | null>; // foundation- or project-scoped, computed
isFoundationContext: Signal<boolean>; // true when the active context is a foundation
canWrite: Signal<boolean>; // resolved via ProjectService.getProject()
setFoundation(ctx: ProjectContext): void;
setProject(ctx: ProjectContext): void;
clearFoundation(): void;
clearProject(): void;
}Resolution rules for activeContext:
activeLens === 'foundation'→ returns the foundation selection.activeLens === 'project'→ returns the project selection.activeLens === 'me' | 'org'→ returns the foundation selection if the persona is board-scoped (hasBoardRole), otherwise the project selection.
Example — a feature page reading the context:
// apps/lfx-one/src/app/modules/votes/votes-dashboard/votes-dashboard.component.ts (sketch)
private readonly projectContextService = inject(ProjectContextService);
protected readonly activeContext = this.projectContextService.activeContext;
protected readonly canWrite = this.projectContextService.canWrite;The lens system is designed so that route depth never reflects context — every feature lives at a flat top-level route (/meetings, /votes, etc.) and reads its context at runtime from LensService + ProjectContextService. This keeps routing simple and lets lens switches change the whole dashboard without a re-route.
Sequence for a typical page load:
- SSR:
resolvePersonaForSsrfetches personas + organizations from cookie/NATS, populatesAuthContext. - Hydration:
PersonaServicesignals populate fromAuthContextvia Angular TransferState. - Enrichment:
refreshEnrichedPersonas()fires in the background to hydratepersonaProjects. - Lens gating:
LensService.activeLensclamps to the allowed set;availableLensesdrives the lens-switcher UI. - Feature page: reads
activeLens+ProjectContextService.activeContextto decide which data to fetch and which UI variants to render.
- Impersonation — how the server-side effective identity interacts with persona detection.
- Component Architecture — layout components (
MainLayoutComponent,ProfileLayoutComponent) that host lens-aware pages. - Drawer Pattern — several drawers use
buildLensAwareInsightsUrlto deep-link into Insights with the right scope.