feat: multiple profiles (named configuration slots) - #1092
Conversation
The login password was stored in plaintext in connectionConfig and re-POSTed on token renewal. Remove that persistence: - loginPassword is now transient (in-memory only); buildConnectionStorageObject omits it and loadConnectionConfig idempotently strips any legacy persisted value (no connectionConfig version bump). - Option A: the constructor keeps an unexpired user-session JWT on startup (mirroring device tokens) so the expiring JWT is the cross-reload credential. - Token renewal no longer re-POSTs stored credentials (auth/validate is unimplemented on the server); on expiry it deletes the token to surface re-login. - connectToServer establishes the session in-memory before reload. Adds a localStorage test helper for the unit-test runner (jsdom opaque origin has no Web Storage). Service-level specs cover the persistence and renewal behavior; the connectToServer flow is covered by the deploy-time acceptance test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ession (Unit 2)
Add a synchronous `authMode` ('cookie' | 'token') derived from the connection
config (proxy-aware, origin-compared), available before connection discovery so
the interceptor/bootstrap can branch from the first request. Cookie mode = KIP
served same-origin as the SK server.
In cookie mode the constructor ignores a stored user token (the SSO/session
cookie is authoritative) but keeps a stored device token as the unattended
same-origin fallback (not stranded). Token mode is unchanged (Option A: keep the
unexpired user JWT). Carriage changes land in Unit 4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add refreshLoginStatus() — a cookie-mode-only credentialed GET to /skServer/loginStatus that derives session state fail-closed (logged in only when status === 'loggedIn'); token mode does not consult it. Expose loginStatus$ (OIDC/auth descriptors the bootstrap redirect needs) plus two derived signals: isUserSession$ (a real per-user identity) and canWriteUserData$ (a user session that is not server-side read-only), both combineLatest over authToken$ + loginStatus$ branching on authMode. Unit 3 of the Signal K standard-auth plan (R2, R3, R13). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rame Branch all three credential carriers on auth mode first: - HTTP interceptor: cookie mode sends withCredentials and no JWT header (even if a stale token is stored); token mode keeps the header. - WebSocket: extract buildWebSocketUrl and omit &token= in cookie mode; drive the (re)connect off the isLoggedIn$ transition (the authToken$ path is dead in cookie mode), reusing the isFullyConnected guard to avoid a double-connect with the bootstrap; re-check loginStatus on a non-clean drop so an expired cookie surfaces as a logout. - Freeboard iframe: build the src from window.location.origin with no ?token= in cookie mode (proxy leaves signalKUrl cross-origin), via a pure buildFreeboardSkUrl helper. Extend the global AuthenticationService test stub with the Unit 3 session surface (authMode, loginStatus$, isUserSession$, canWriteUserData$, refreshLoginStatus). Unit 4 of the Signal K standard-auth plan (R4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onfig Add a useServerStorage decision (cookie mode OR useSharedConfig) and re-key every SettingsService storage-routing branch and the startup() load to it, plus the app-init remote-bootstrap gate. In cookie mode config now persists to the server applicationData slot regardless of the stored useSharedConfig flag — the same decoupling applied to auth carriage — closing the cookie-mode localStorage split-brain. Unit 5 of the Signal K standard-auth plan (R12). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drive the cookie-mode bootstrap from loginStatus: logged in proceeds to the storage bootstrap; not-logged-in + authRequired auto-redirects to the SK/SSO login (oidcLoginUrl, else the admin login) honoring oidcAutoLogin; auth-not-required is anonymous read with no redirect. A reload-surviving sessionStorage budget (SsoRedirectService) caps auto-redirect attempts so a kiosk with oidcAutoLogin cannot loop: - the budget resets only on a genuinely completed, authorized bootstrap, so a loggedIn -> applicationData-401 -> reauth path stays bounded; - an auth-blocked outcome (budget exhausted, oidcAutoLogin:false, or a null/unreachable loginStatus) finishes the bootstrap degraded, preserving the auth-blocked recovery state (and its Sign-in toast) without resetting the budget; - it fails closed (no auto-redirect) when sessionStorage is unavailable or silently discards writes, rather than fail open into a loop; - the mid-bootstrap 401 path reuses the same oidcAutoLogin/budget-guarded decision. An explicit Sign in bypasses the budget and sets noAutoLogin. returnTo is validated relative-only (reject //, backslash, control chars, cross-origin, self-route) to prevent an open redirect. The bootstrap starts the WebSocket only from a fresh HTTPConnected state, so it does not double-connect with the cookie isLoggedIn$ reconnect. Convert the bootstrap /login navigations to the mode-aware path and surface an auth-blocked recovery toast (Sign in) in cookie mode. Unit 6 of the Signal K standard-auth plan (R1, R3, R9, R11). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In cookie mode the /login route no longer opens KIP's credential dialog — it redirects to the SK/SSO login and shows a transitional state. The Connectivity tab hides the credential controls and shows a session identity block (signed-in/provider, read-only note, anonymous, sign-in, loading); connectToServer skips the credential dialog/login and clears a stale user token when the new config resolves to cookie mode. Refactor effectiveOriginIsSameAsApp into a public authModeForConfig so the Connectivity tab can resolve the mode of a config being edited. Unit 7 of the Signal K standard-auth plan (R1, R5, R11, R13). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the dual-mode auth feature (same-origin SSO, password-storage removal, cross-origin re-login on expiry). Correct the plan's stale "re-login on each reload" note — Option A persists the session JWT across reloads, so token-mode re-login is on expiry only. Unit 9 of the Signal K standard-auth plan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…yAccess flag canWriteUserData was derived from loginStatus.readOnlyAccess, but that field is the server's allow_readonly (anonymous read) config — true on a server that permits anonymous read — not the signed-in user's permission. A signed-in admin was therefore shown "Read-only access". Derive write capability from userLevel instead (admin/readwrite can write; readonly cannot), matching Signal K's own permission check. Resolves the userLevel/readOnlyAccess write-gating question the plan deferred to deploy-time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lyAccess Review #1: refreshLoginStatus() now pipes timeout(5000) so a hung /skServer/loginStatus cannot block the APP_INITIALIZER from rendering the app (every sibling bootstrap GET already does this); a TimeoutError lands in the existing fail-closed catch. Review mxtommy#8: add canWriteUserData tests for userLevel 'readwrite' (write), missing userLevel (fail closed), plus the timeout fail-closed case. Review mxtommy#11/mxtommy#16: remove the dead readOnlyAccess field, renewToken() and the auth/validate URL (404, no caller); record in the plan that the write gate keys off userLevel (admin/readwrite), not the server allow_readonly flag, and add the deferred review follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review #1: getConfig() pipes timeout(5000) so a hung applicationData fetch cannot stall the bootstrap after waitUntilReady() passes. Review #2b: wrap the patchQueue concatMap in catchError -> EMPTY so one failed patch (e.g. a read-only session's 401) no longer terminates the queue and silently drops every later config save. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review mxtommy#6: in cookie mode the interceptor attached withCredentials to every request, including the cross-origin discovery GET under proxyEnabled + cross-origin signalKUrl (which depends on the foreign server's CORS allow-credentials and can fail the connection). Send credentials only on same-origin requests; a cross-origin request gets neither credentials nor a token header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d path Review mxtommy#7: the bootstrap finally started the WebSocket whenever HTTP was connected, even on the cookie auth-blocked path (HTTP connected but no session) -> an anonymous WS that churns behind the recovery toast. Start the WS only on a clean (non-degraded, non-redirecting) bootstrap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review #4b: a missing server config returns {} (not 404), which passed
the === null guard and made pushSettings() dereference activeConfig.app
-> TypeError. Guard on the presence of app config instead.
Review mxtommy#17: collapse the asymmetric useDeviceToken if/else to
useDeviceToken = !useSharedConfig.
Review mxtommy#15: seed the local config keys in the storage-routing tests so the
localStorage startup() branch loads cleanly instead of throwing an
unhandled, suite-masking JSON parse error.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ceeds Review mxtommy#12: connectToServer persisted the new config before the in-memory login could fail, leaving a rejected config persisted (applied on the next reload) with no rollback. Run the login first, then persist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review mxtommy#13: isSafeReturnTo validated the raw string, so '/a/..//evil' (which resolves to '//evil') was accepted. Reject when the resolved pathname is protocol-relative, holding the open-redirect invariant on the normalized path. Not reachable today (the only caller passes the browser-normalized location.pathname); hardens against future callers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review mxtommy#14: the global AuthenticationServiceStub lacked authModeForConfig, loginStatusValue and deleteToken, and AppNetworkInitServiceStub's bootstrapIssue$ type omitted the new 'auth-blocked' reason and cause, so a future component spec exercising those paths would drift undetected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (Unit 2) Makes the active config slot (sharedConfigName) settable at runtime, the seam for profile switching: - SettingsService: getActiveProfileName / setActiveProfile (persist per-device + reload) / getActiveConfigSnapshot (guarded clone source, null when not loaded); gate loadDemoConfig on storage readiness. - StorageService: refuse any mutation targeting an empty/undefined slot name (closes the degraded-boot /undefined-write path); add awaitQueueDrain() with a bounded timeout for switch-time queue draining; make the patch queue resilient so one failed write no longer wedges it. Adds a localStorage test helper and module-provides the services under test so their deps resolve to the global stubs. 15 new tests; full suite shows +15 passing, -2 failing vs baseline (no regressions). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New service owning profile (named config slot) lifecycle, all hardcoded to the 'user' scope: - list (user scope, active flagged), switch (drain then setActiveProfile), create (clone current / blank seed, no auto-switch), duplicate, rename, delete. - Guard rails: refuse deleting the active / reserved 'default' / last profile; refuse renaming 'default'; rename-of-active deletes the old slot (awaited) before switching so no orphan is left. - Name validation as a security invariant: allow-list [A-Za-z0-9 _-], reject empty / 'default' / duplicates; the Unit 1 probe showed '.' truncates and '/' splits the applicationData path even when encoded, so both are rejected outright. 23 tests covering happy paths, guard rails, validation, and ordering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nit 5, R8) Move isRemoteControl + instanceName from the profile (IAppConfig) to the per-device IConnectionConfig so switching profiles no longer changes a display's remote-control role or advertised name, and two displays on one profile can be named distinctly. - IConnectionConfig gains the two fields; IAppConfig drops them (forward-compatible: old slots keep ignored fields). Default/demo consts updated. - connectionConfig schema version bumped 12->13 (decoupled from app configVersion 12); loadConnectionConfig + the localStorage version gate accept 13. - SettingsService reads them from connectionConfig at boot and its setters persist there; pushSettings no longer sources them from the profile. - app-init runs a one-time migration AFTER the profile loads (lifting from the active profile in shared mode or local appConfig in local mode), deferring on a degraded boot so the setting is never lost. remote-dashboards/display.component need no change (same getter/setter API). Full suite: +28 passing vs prior, no new failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Evolve the Configurations tab into a Profiles experience built on ProfileService:
- list profiles with the active one marked ('Active on this device'), per-row
Switch / Rename / Duplicate / Delete (delete disabled for active / default /
last), and New-from-current / New-blank creation.
- switch and delete go through a confirmation dialog; switch warns about the reload.
- remove the user/default hide+block special-casing; profiles are shown when logged
in with a user account (device tokens map to global scope -> a clear notice).
- Export downloads the active profile; Import creates a NEW profile (prompt + shape
validation via ProfileService.importProfile), never overwriting the active one.
- all mutations surface errors via toast; the active name is never changed on failure.
ProfileService gains importProfile + config-shape validation. 34 component/service
tests; full suite +7 passing, no new failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Profiles UI keyed availability off a JWT token in storage (hasToken && !device). In cookie mode the httpOnly session cookie carries auth and there is no token, so a signed-in admin saw "Profiles require logging in to Signal K with a user account". Gate on the auth service's mode-agnostic isUserSession$ (cookie SSO session or non-device token) instead; the device-token/anonymous exclusion now lives in that signal. Removes the dead hasToken/isTokenTypeDevice plumbing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"New from current" was a strict special case of duplicating the active profile (which is already visible) — an affordance, not a capability. Drop it; relabel "New blank" to "New". The create flow always seeds a blank profile, and Duplicate covers copying an existing one. Also removes the now-dead clone-from-current backing chain: ProfileService's ProfileSeed param + requireSnapshot(), and SettingsService.getActiveConfigSnapshot() (whose only caller was requireSnapshot). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- switchProfile re-verifies the slot still exists before persisting + reloading,
so a profile deleted on another device no longer boots into a dead slot.
- importProfile rejects a shape-valid config whose app.configVersion is
unsupported, so an unbootable import can't become a switchable profile.
- duplicate/rename reject an empty server slot ({} from a missing config),
which would otherwise write an unbootable copy.
- serialize mutations with an in-flight lock (the plan's double-switch guard).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#2) A read-only cookie session (isUserSession true, canWriteUserData false) was shown fully-enabled New/Rename/Duplicate/Delete/Import controls whose writes fail server-side. Disable those on !canWriteUserData (Switch and Download stay — read/local), and show a read-only hint. Also drop the dead jsonData field left from the import rewrite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildConnectionStorageObject hardcoded the latest version, so a write around the deferred v12->v13 migration (e.g. on a degraded boot) locked the schema at 13 before the lift ran — the migration then skipped and the remote-control identity was lost. Emit the stored version instead (only the one-time migration advances it), and preserve the stored identity unless the user changed it this session, so a write can't revert a migrated value. Also: one shared CONNECTION_CONFIG_VERSION + SUPPORTED_CONNECTION_CONFIG_VERSIONS (validate connectionConfig against its own version space, not the app version constant); add migrateRemoteControlToDevice tests for all four branches; strip plan-unit references from shipped comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The patch queue swallows per-item errors (catchError -> of(null)), so awaitQueueDrain resolved true even when a patch failed — callers that treat a drain as a save/delete guarantee silently lost the failure. Track a failure count and resolve false when a patch failed while draining (or on timeout). deleteProfile now surfaces an unconfirmed delete; switch/rename log when the pre-flush did not fully persist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uard (review #2) Replace the hardcoded rgba border (broke in light theme) with the --mat-sys-outline-variant token, and add SettingsService tests for the loadDemoConfig storage-readiness guard (server mode: not-ready skips the write, ready performs it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
While the current method of "configurations are one per user" method works to an extent it is a bit too big a hammer for the job - it is not KIP specific, so switching to another user means that also other application's serverside stored settings are switched. Settings are device and orientation specific, while a user's access level and other settings, such as unit preferences, are per the (human) user. So to me this looks like a welcome addition and at the right granularity. I have also struggled with managing KIP dashboards for portrait and landscape tablet orientations, as logout-login dance when you rotate the device is pretty unconvenient. Afai understand this would solve that, allowing me to have multiple dashboard sets? |
|
Hi Teppo and Matti, I agree that device-tied configuration should be added. FYI I'd rather call those same-origin cookie/SSO authentication feedbackI would like to preserve the following existing use cases. At this stage, it is not yet clear to me that this PR keeps them:
I personally use an old iPhone that auto-logins with an R/W account and loads a single AP widget dashboard. It acts as my AP remote control, and users should not be able to access or change Signal K server settings from it. It is a dedicated remote, and that was one of the original reasons I started using Signal K and contributing to KIP. multiple profiles feedbackBy removing the ability to use Global scope as shared storage, the only remaining way to copy configurations between users is to export to a file, switch user, and then import the file. I understand that exposing Global scope makes the UI more complex. Still, removing it also removes a useful feature. Maybe there is a better way to improve the UX without losing that capability. KIP configuration management already has fairly weak UX as it is. On Teppo’s comments
In most computer networks, when you log in with an account, access level and user preferences such as regional settings, units, and home folder follow the account, not the device. That can be tuned or disabled, but it is the common model. Hardware-specific settings stay with the device. The KIP layout is not black and white. It's not a hardware-specific settings, but it is a configuration that is heavily impacted by it, when aspect ratio changes.
I do not think this PR solves that directly. You would still need to switch The real problem is not the layout itself, but the grid model design and aspect-ratio change. If we want to keep widget aspect ratios stable when orientation changes, the grid must add or remove columns and/or rows. That means dynamically changing widget col/row spans and repositioning widgets, which causes visible layout shifts in the sense that widgets are forced pushed out of rows/col. The end result is some rows or columns can end up partially empty and some widgets change location. I think dynamic orientation switching without visual side effects is impossible. At least so far I have not found a way to do it. You hated the Chartplotter mode layout shift from portrait to landscape but it's a solution that does not destroy legibility and usability, but it shifts FSK/dashboard split from vertical to horizontal. |

Motivation
Let a user keep several full KIP configurations (dashboards, widgets, theme, units) on the Signal K server and switch the active one per device — e.g. a helm layout vs a cockpit layout, or per-crew setups. Profiles are user-scope Signal K applicationData, so they need a real per-user identity.
Relationship to the authentication PR
This PR includes the commits from #1091 (same-origin cookie/SSO authentication). Profiles build on it: user-scope applicationData needs the per-user session that work provides. If #1091 merges first, this branch can be rebased to show only the profiles diff; as submitted it contains both.
Approach
ProfileServiceowns the lifecycle — list / switch / create / rename / duplicate / delete / import — on the user scope, with name validation (URL- and JSON-Patch-safe charset, reserveddefault, no duplicates) and guard rails (can't delete the active, the last, ordefault; switching verifies the slot still exists; import rejects an unsupported config version).Testing
Unit tests for ProfileService CRUD + guard rails, the connection-config migration (all branches), storage write-safety/queue-failure reporting, and the read-only write gating. Same jsdom pre-existing-failures note as #1091.
🤖 Generated with Claude Code