Related: Frontend Architecture · User Roles & Permissions
State is managed with NgRx Signals (@ngrx/signals). Four global signal stores are provided at root level and initialized at app startup. All stores use patchState for updates — never mutate signal state directly.
| Store | Key | Persisted | Purpose |
|---|---|---|---|
UserStore |
LL-USER-STATE |
isAuthenticated only |
Firebase Auth state, role, permissions |
SpaceStore |
LL-SPACE-STATE |
selectedSpaceId, selectedEnvironmentBySpaceId |
Active workspace, content path, schemas |
AppSettingsStore |
— | No | Global UI settings from Firestore |
LocalSettingsStore |
LL-SETTINGS-STATE |
All fields | User preferences (theme, layouts, editor) |
File: src/app/shared/stores/user.store.ts
Subscribes to user(auth) observable and then fetches the Firebase ID token to extract custom claims.
// Key state
{
id, displayName, initials, email, emailVerified, photoURL,
role: 'admin' | 'custom' | undefined,
permissions: string[] | undefined,
lock: boolean | undefined,
isAuthenticated: boolean,
isPasswordProvider, isGoogleProvider, isMicrosoftProvider, numberProviders
}
// Key computed
isRoleAdmin: computed(() => role() === 'admin')
isLocked: computed(() => lock() === true)
// Key methods
setAuthenticated(isAuthenticated) // patches isAuthenticated and persists it to localStorageAuth providers supported: Email/Password, Google, Microsoft.
Custom claims (role, permissions, lock) are set server-side via Firebase Admin SDK and read from the ID token result. Claims drive all route-level permission guards.
File: src/app/shared/stores/space.store.ts
Loads all spaces the user has access to. Tracks the currently selected space and environment.
// Key state
{
spaces: Space[],
loaded: boolean, // true once load() has resolved (success, empty, or error)
selectedSpaceId: string | undefined,
selectedEnvironmentBySpaceId: Record<string, string>,
environment: SpaceEnvironment | undefined,
contentPath: PathItem[], // breadcrumb trail for content browser
assetPath: PathItem[], // breadcrumb trail for asset browser
schemas: Schema[], // schemas for the selected space
documents: ContentDocument[] // documents for the selected space
}
// Key computed
selectedSpace: computed(() => spaces().find(s => s.id === selectedSpaceId()))
hasNoSpaces: computed(() => loaded() && spaces().length === 0) // gated on loaded so it is not true before the first responseKey methods:
load()—rxMethodthat streamsSpaceService.findAll(); called fromonInit. Setsloaded, and clears the selection when the list is emptyspaceById(id)— returns acomputedsignal of the space with that id (orundefined)changeSpace(space)— switch active workspace, reset paths, resolve environmentchangeContentPath(path)/changeAssetPath(path)— update breadcrumb navigationchangeEnvironment(env)— switch preview environment (persisted per space)_syncSpaceData(spaceId)— privaterxMethod, fed fromonInitwithselectedSpaceId(orundefinedwhile signed out). Owns theschemas/documentsFirestore listeners: on every id change it clears both, thenswitchMaps to the new space's listeners, closing the old ones. Each listener retries with exponential backoff (1s → 30s cap) and an error toast. Gated onUserStore.isAuthenticatedbecause this root store outlives the signed-in shell
The URL selects the space, not the store. Every space route lives under a componentless spaces/:spaceId parent in features-routing.module.ts, guarded by spaceSelectionGuard (src/app/shared/guards/space-selection.guard.ts). The guard waits for loaded$ (a withProps observable of loaded, created once so guards don't leave an effect behind per navigation), then:
:spaceIdis inspaces→changeSpace()if it differs from the stored selection, allow.- otherwise (deleted, mistyped, another install) → warning toast, redirect to the stored space's dashboard, or the first space if that one is gone too (never back to an id that would fail again), or
/features/welcomewhen there are none.
This is what makes shared links work: the persisted selectedSpaceId is only a default for when the URL names no space. Don't call changeSpace() before navigating — navigate and let the guard select, so an unsaved-changes canDeactivate can still cancel the switch. Selecting the space is all it takes: the store then moves its own documents/schemas listeners to it (see _syncSpaceData).
Space Environments — a Space can have multiple environments (e.g. staging, production URLs). The selected environment is persisted per space in selectedEnvironmentBySpaceId.
File: src/app/shared/stores/app-settings.store.ts
Loads global application settings from Firestore (settings collection). Contains UI configuration set by admins (e.g. custom branding).
{ ui: AppUi | undefined }Not persisted to localStorage — always fetched fresh on startup.
File: src/app/shared/stores/local-settings.store.ts
Fully client-side user preferences, persisted to localStorage. No Firestore interaction.
{
theme: 'light' | 'dark' | 'auto',
debugEnabled: boolean,
editorEnabled: boolean,
editorSize: '' | 'sm' | 'md' | 'lg' | 'xl',
editorFormWidth: number, // % width of the form panel in the editor
assetLayout: 'list' | 'grid',
assetDialogLayout: 'list' | 'grid',
translationLayout: 'list' | 'tree',
markdownMode: 'source' | 'wysiwyg', // how MARKDOWN fields are edited
lastSeenWhatsNewVersion: string // newest What's New entry the user has opened
}Theme switching applies CSS classes + data-theme attribute to <html> immediately. 'auto' is resolved once: load() (run in onInit) checks the OS prefers-color-scheme media query at startup and patches theme to 'light' or 'dark'. It does not listen for later OS changes.
markdownMode is the author's chosen way of editing MARKDOWN content fields — raw markdown or the visual editor — remembered across reloads and shared by every markdown field at once. See Contents → Field Editors.
Inject stores using inject() in components or services:
// In a component
readonly spaceStore = inject(SpaceStore);
readonly userStore = inject(UserStore);
// Read state as signals
const spaceId = this.spaceStore.selectedSpace()?.id;
const isAdmin = this.userStore.isRoleAdmin();
// Update state
this.spaceStore.changeEnvironment(env);
this.localSettingsStore.setTheme('dark');Never call patchState from outside the store — use the store's exposed methods.
- Create
src/app/shared/stores/<name>.store.ts - Use
signalStore({ providedIn: 'root' }, withState<T>(...), withMethods(...), withComputed(...), withHooks(...)) - Initialize in
withHooks.onInitif it needs to load data - Persist to
localStorageonly what's needed for app shell restore (not full Firestore data)