Skip to content

feat: multiple profiles (named configuration slots) - #1092

Open
mairas wants to merge 28 commits into
mxtommy:masterfrom
mairas:profiles-upstream
Open

feat: multiple profiles (named configuration slots)#1092
mairas wants to merge 28 commits into
mxtommy:masterfrom
mairas:profiles-upstream

Conversation

@mairas

@mairas mairas commented Jun 26, 2026

Copy link
Copy Markdown

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

  • A ProfileService owns the lifecycle — list / switch / create / rename / duplicate / delete / import — on the user scope, with name validation (URL- and JSON-Patch-safe charset, reserved default, no duplicates) and guard rails (can't delete the active, the last, or default; switching verifies the slot still exists; import rejects an unsupported config version).
  • The active profile name and the per-device "remote control" identity live in a per-device connection config (its own schema version, migrated once from the previous in-profile location), so switching a profile never changes which display a screen is or whether it participates in remote control.
  • A Profiles section in the Configurations tab. Write actions are gated on write capability — a read-only session can view and switch but not mutate.

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

mairas and others added 28 commits June 24, 2026 09:56
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>
@mairas

mairas commented Jun 30, 2026

Copy link
Copy Markdown
Author

This is what multi-profile settings look like. You always use the same session-provided login. The profiles are per-user and you can switch between them and modify them as needed via Settings.

Screenshot 2026-06-30 at 14 33 59

@tkurki

tkurki commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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?

@godind

godind commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Hi Teppo and Matti,

I agree that device-tied configuration should be added. FYI I'd rather call those Layouts as Profiles generally refers to users. I have not looked at the implementation details yet, as I am working through another batch of PRs first, and this one is a large change. Since the PRs were opened before we had a chance to discuss the plan, this is only initial, high-level feedback based on the PR description and the single screenshot.

same-origin cookie/SSO authentication feedback

I would like to preserve the following existing use cases. At this stage, it is not yet clear to me that this PR keeps them:

  1. For boat owners or companies renting boats, there may be an admin account for the owner or fleet manager, and a separate lessee account with R/W or read-only rights to protect the Signal K setup. A permanent nav station device should allow easy switching between those accounts. At the same time, lessees and their passengers should be able to load KIP on their own devices, such as phones and tablets, all using the lessee account without issues.
  2. For boats with racing crews, there may be an admin account for the owner and multiple separate R/W accounts for crew members, so each person can manage their own KIP iPhone layout. All users, on their own devices and with their own accounts, should be able to log in to the same server at the same time without issues.
  3. For boats with fixed KIP dedicated devices, such as a mast display or a helm KIP MFD, it should be possible to auto-login with a user that has only read-only or limited R/W access. That way, if someone on that device reaches the Signal K admin page, they still do not get admin access and cannot alter the Signal K server setup, while the nav station below can remain logged in as admin at the same time.

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 feedback

By 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

Settings are device and orientation specific, while a user's access level and other settings, such as unit preferences, are per the (human) user.

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 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?

I do not think this PR solves that directly. You would still need to switch Layout if when the device orientation changes you want a different layout, or you would manually go in settings/options/configuration and load another Layout - which is as much a headache in my opinion.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants