@gcm-cz/dmon-switcher 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,15 +51,15 @@ const switcher = new DmonSwitcher({
51
51
  });
52
52
 
53
53
  // Register a switch handler before awaiting ready().
54
- switcher.on("switch", ({ sub }) => {
55
- if (sub === "__new__") {
54
+ switcher.on("switch", ({ loginHint }) => {
55
+ if (loginHint === "__new__") {
56
56
  // Force interactive login to add a new session.
57
57
  window.location.href = buildAuthorizeUrl({ prompt: "login" });
58
58
  return;
59
59
  }
60
60
  // Attempt silent re-auth; fall back to interactive on login_required.
61
- performSilentAuth({ loginHint: sub, prompt: "none" })
62
- .catch(() => { window.location.href = buildAuthorizeUrl({ loginHint: sub }); });
61
+ performSilentAuth({ loginHint, prompt: "none" })
62
+ .catch(() => { window.location.href = buildAuthorizeUrl({ loginHint }); });
63
63
  });
64
64
 
65
65
  // Wait for the bridge to signal ready, then fetch the session list.
@@ -67,11 +67,12 @@ await switcher.ready();
67
67
  const sessions: Session[] = await switcher.listSessions();
68
68
  renderMyUserMenu(sessions);
69
69
 
70
- // Switch to a user when selected in your menu.
71
- await switcher.switchTo(selectedSub);
70
+ // Switch to a user when selected in your menu. Pass `session.sub` (the user id); the
71
+ // switcher resolves it to the login_hint (username) via the cached session list.
72
+ await switcher.switchTo(selectedSession.sub);
72
73
 
73
74
  // Build the account-page URL for an anchor — the user decides how to open it.
74
- const accountHref = switcher.accountPageUrl(currentUserSub);
75
+ const accountHref = switcher.accountPageUrl(currentSub);
75
76
  // <a href={accountHref}>Account settings</a>
76
77
 
77
78
  // Clean up when the component unmounts.
@@ -97,8 +98,8 @@ function UserMenu({ currentSub }: { currentSub: string }) {
97
98
  });
98
99
  switcherRef.current = switcher;
99
100
 
100
- switcher.on("switch", ({ sub }) => {
101
- // your re-auth logic
101
+ switcher.on("switch", ({ loginHint }) => {
102
+ // your re-auth logic — loginHint is the username (or "__new__")
102
103
  });
103
104
 
104
105
  switcher.ready()
@@ -133,8 +134,8 @@ function UserMenu({ currentSub }: { currentSub: string }) {
133
134
 
134
135
  ```typescript
135
136
  interface Session {
136
- sub: string; // Stable user identifier (OIDC "sub" claim)
137
- name: string; // Display name
137
+ sub: string; // OIDC subject — the user id; pass to switchTo() / accountPageUrl()
138
+ name: string; // Username — used by the switcher as the login_hint (resolved from sub)
138
139
  given_name?: string;
139
140
  family_name?: string;
140
141
  picture?: string; // Profile picture URL
@@ -194,12 +195,15 @@ sessions, when the cookie is absent, or when all sessions are policy-blocked.
194
195
 
195
196
  #### `switchTo(sub: string, opts?: { returnTo?: string }): Promise<void>`
196
197
 
197
- Initiates a user switch.
198
+ Initiates a user switch. `sub` is the target account's `Session.sub` (the user id), or the
199
+ `"__new__"` sentinel. The switcher resolves `sub` to the OIDC `login_hint` (the username,
200
+ `Session.name`) via the most recent `listSessions()` result; unresolved values are passed
201
+ through unchanged.
198
202
 
199
- - **`"emit"` mode** — fires all `switch` listeners with `{ sub }` and resolves immediately.
200
- The RP is responsible for triggering OIDC re-authentication.
203
+ - **`"emit"` mode** — fires all `switch` listeners with `{ loginHint }` (the resolved
204
+ username) and resolves immediately. The RP is responsible for triggering OIDC re-authentication.
201
205
  - **`"redirect"` mode** — sends `redirect_authorize` to the bridge, which top-level-navigates
202
- to the DMon `/authorize` endpoint with `login_hint=sub`, `prompt=none`, and
206
+ to the DMon `/authorize` endpoint with `login_hint=<resolved username>`, `prompt=none`, and
203
207
  `redirect_uri=opts.returnTo ?? window.location.href`.
204
208
 
205
209
  **`__new__` sentinel:** `switchTo("__new__")` uses `prompt=login` to force fresh credential
@@ -207,15 +211,16 @@ entry and append a new session to the cookie.
207
211
 
208
212
  #### `accountPageUrl(sub: string): string`
209
213
 
210
- Returns (does **not** open) the DMon account-page URL for `sub`:
214
+ Returns (does **not** open) the DMon account-page URL for the given account's `Session.sub`
215
+ (the user id), resolved to a `login_hint` (the username) via the cached `listSessions()` result:
211
216
 
212
217
  ```
213
- {dmonOrigin}/account?login_hint={encodeURIComponent(sub)}
218
+ {dmonOrigin}/account?login_hint={encodeURIComponent(resolvedUsername)}
214
219
  ```
215
220
 
216
221
  Pure and side-effect free — safe to call during render. Put the result in an anchor `href` so
217
222
  the user controls how it opens. DMon will silently mount the account page if the cookie already
218
- contains the session for `sub`; otherwise it falls back to the standard login flow.
223
+ contains a session for that user; otherwise it falls back to the standard login flow.
219
224
 
220
225
  #### `destroy(): void`
221
226
 
@@ -223,7 +228,7 @@ Removes the iframe, deregisters `window` message listeners, rejects all pending
223
228
  with `AbortError`, and resolves any pending `ready()` waiters so they do not hang. Idempotent —
224
229
  safe to call multiple times, subsequent calls are no-ops. Call at component unmount.
225
230
 
226
- #### `on(event: "switch", handler: (payload: { sub: string }) => void): void`
231
+ #### `on(event: "switch", handler: (payload: { loginHint: string }) => void): void`
227
232
 
228
233
  Registers a handler fired by `switchTo()` in `"emit"` mode.
229
234
 
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function d(s){if(typeof s!="object"||s===null)return!1;const r=s;return!(r.dmon_bridge!==!0||typeof r.type!="string")}function h(s){return typeof s.request_id=="string"}class a{constructor(r){this.iframe=null,this.nextRequestId=1,this.destroyed=!1,this.failed=!1,this.bridgeFailed=!1,this.readyReceived=!1,this.loadGraceTimer=null,this.pending=new Map,this.switchListeners=[],this.readyListeners=[],this.errorListeners=[],this.handleIframeLoad=()=>{this.log("iframe load event"),!(this.readyReceived||this.destroyed||this.failed||this.bridgeFailed)&&(this.loadGraceTimer=setTimeout(()=>{this.rejectBridge("DmonSwitcher: bridge iframe loaded but sent no ready message (CSP frame-ancestors block?)")},this.postLoadGraceMs))},this.handleIframeError=()=>{this.log("iframe error event"),this.rejectBridge("DmonSwitcher: bridge iframe failed to load (network error)")},this.handleMessage=e=>{if(this.iframe==null||!d(e.data))return;if(e.source!==this.iframe.contentWindow){this.log("message dropped: wrong source",{eventOrigin:e.origin,expectedOrigin:this.origin,hasSource:e.source!=null,sourceIsExpectedIframe:!1});return}if(e.origin!==this.origin){this.log("message dropped: wrong origin",{eventOrigin:e.origin,expectedOrigin:this.origin});return}const i=e.data;if(i.type==="ready"){this.log("ready received"),this.readyReceived=!0,this.loadGraceTimer!=null&&(clearTimeout(this.loadGraceTimer),this.loadGraceTimer=null),this.resolveReady();for(const o of this.readyListeners)try{o()}catch(n){this.logError("ready handler threw",n)}return}if(!h(i)){this.log("message dropped: no request_id");return}const t=this.pending.get(i.request_id);t!==void 0?(this.log("rpc reply",{request_id:i.request_id,type:i.type}),this.pending.delete(i.request_id),t.resolve(i)):this.log("rpc reply for unknown request_id — ignored",{request_id:i.request_id})},this.origin=r.dmonOrigin,this.mode=r.mode??"emit",this.debug=r.debug??!1,this.postLoadGraceMs=r.postLoadGraceMs??200,this.readyPromise=new Promise((e,i)=>{this.resolveReady=e,this.rejectReady=i});try{const e=`${r.dmonOrigin}/api/v1/authorize/${r.realmSlug}/embed/bridge?client_id=${encodeURIComponent(r.clientId)}`,i=document.createElement("iframe");i.setAttribute("aria-hidden","true"),i.setAttribute("sandbox","allow-scripts allow-same-origin"),i.style.display="none",i.src=e,document.body.appendChild(i),i.onload=this.handleIframeLoad,i.onerror=this.handleIframeError,this.iframe=i,window.addEventListener("message",this.handleMessage),this.log("constructed",{origin:this.origin,mode:this.mode,src:e})}catch(e){this.failed=!0,this.resolveReady(),this.logError("construction failed — switcher disabled",e)}}ready(){return this.readyPromise}async listSessions(){if(this.failed||this.destroyed||this.iframe==null)return this.log("listSessions skipped — switcher unavailable"),[];try{const e=(await this.rpc({type:"list_sessions"})).sessions;return Array.isArray(e)?(this.log("listSessions resolved",{count:e.length}),e):(this.log("listSessions resolved with no sessions array"),[])}catch{return[]}}async switchTo(r,e){if(this.failed||this.destroyed||this.iframe==null){this.log("switchTo skipped — switcher unavailable",{sub:r});return}try{if(this.mode==="emit"){this.log("switchTo emit",{sub:r,listeners:this.switchListeners.length}),this.emitSwitch(r);return}const i=r==="__new__"?"login":"none",t=e?.returnTo??window.location.href;this.log("switchTo redirect",{sub:r,prompt:i,return_to:t}),await this.rpc({type:"redirect_authorize",login_hint:r,prompt:i,return_to:t})}catch(i){this.logError("switchTo failed",i)}}accountPageUrl(r){const e=`${this.origin}/account?login_hint=${encodeURIComponent(r)}`;return this.log("accountPageUrl",{sub:r,url:e}),e}destroy(){if(this.destroyed){this.log("destroy called again — no-op");return}this.destroyed=!0;try{this.loadGraceTimer!=null&&(clearTimeout(this.loadGraceTimer),this.loadGraceTimer=null),window.removeEventListener("message",this.handleMessage);const r=new DOMException("DmonSwitcher destroyed","AbortError");this.log("destroy",{rejectingPending:this.pending.size}),this.pending.forEach(({reject:e})=>e(r)),this.pending.clear(),this.iframe?.remove(),this.resolveReady()}catch(r){this.logError("destroy encountered an error",r)}}on(r,e){r==="switch"?this.switchListeners.push(e):r==="ready"?this.readyListeners.push(e):this.errorListeners.push(e)}rejectBridge(r){if(this.bridgeFailed||this.destroyed||this.failed)return;this.bridgeFailed=!0;const e=new Error(r);this.log(`bridge failure: ${r}`),this.rejectReady(e),this.pending.forEach(({reject:i})=>{i(e)}),this.pending.clear();for(const i of this.errorListeners)try{i(e)}catch(t){this.logError("error handler threw",t)}}log(r,e){this.debug&&(e===void 0?console.debug(`[DmonSwitcher] ${r}`):console.debug(`[DmonSwitcher] ${r}`,e))}logError(r,e){e===void 0?console.error(`[DmonSwitcher] ${r}`):console.error(`[DmonSwitcher] ${r}`,e)}emitSwitch(r){for(const e of this.switchListeners)try{e({sub:r})}catch(i){this.logError("switch handler threw",i)}}async rpc(r){if(this.destroyed||this.iframe==null)throw new DOMException("DmonSwitcher destroyed","AbortError");if(await this.readyPromise,this.destroyed||this.iframe==null)throw new DOMException("DmonSwitcher destroyed","AbortError");const e=String(this.nextRequestId++);this.log("rpc send",{request_id:e,...r});const i=this.iframe;return new Promise((t,o)=>{this.pending.set(e,{resolve:t,reject:o}),i.contentWindow?.postMessage({...r,request_id:e},this.origin)})}}exports.DmonSwitcher=a;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function h(s){if(typeof s!="object"||s===null)return!1;const e=s;return!(e.dmon_bridge!==!0||typeof e.type!="string")}function d(s){return typeof s.request_id=="string"}class a{constructor(e){this.iframe=null,this.nextRequestId=1,this.destroyed=!1,this.failed=!1,this.bridgeFailed=!1,this.readyReceived=!1,this.loadGraceTimer=null,this.pending=new Map,this.switchListeners=[],this.readyListeners=[],this.errorListeners=[],this.lastSessions=[],this.handleIframeLoad=()=>{this.log("iframe load event"),!(this.readyReceived||this.destroyed||this.failed||this.bridgeFailed)&&(this.loadGraceTimer=setTimeout(()=>{this.rejectBridge("DmonSwitcher: bridge iframe loaded but sent no ready message (CSP frame-ancestors block?)")},this.postLoadGraceMs))},this.handleIframeError=()=>{this.log("iframe error event"),this.rejectBridge("DmonSwitcher: bridge iframe failed to load (network error)")},this.handleMessage=i=>{if(this.iframe==null||!h(i.data))return;if(i.source!==this.iframe.contentWindow){this.log("message dropped: wrong source",{eventOrigin:i.origin,expectedOrigin:this.origin,hasSource:i.source!=null,sourceIsExpectedIframe:!1});return}if(i.origin!==this.origin){this.log("message dropped: wrong origin",{eventOrigin:i.origin,expectedOrigin:this.origin});return}const r=i.data;if(r.type==="ready"){this.log("ready received"),this.readyReceived=!0,this.loadGraceTimer!=null&&(clearTimeout(this.loadGraceTimer),this.loadGraceTimer=null),this.resolveReady();for(const o of this.readyListeners)try{o()}catch(n){this.logError("ready handler threw",n)}return}if(!d(r)){this.log("message dropped: no request_id");return}const t=this.pending.get(r.request_id);t!==void 0?(this.log("rpc reply",{request_id:r.request_id,type:r.type}),this.pending.delete(r.request_id),t.resolve(r)):this.log("rpc reply for unknown request_id — ignored",{request_id:r.request_id})},this.origin=e.dmonOrigin,this.mode=e.mode??"emit",this.debug=e.debug??!1,this.postLoadGraceMs=e.postLoadGraceMs??200,this.readyPromise=new Promise((i,r)=>{this.resolveReady=i,this.rejectReady=r});try{const i=`${e.dmonOrigin}/api/v1/authorize/${e.realmSlug}/embed/bridge?client_id=${encodeURIComponent(e.clientId)}`,r=document.createElement("iframe");r.setAttribute("aria-hidden","true"),r.setAttribute("sandbox","allow-scripts allow-same-origin"),r.style.display="none",r.src=i,document.body.appendChild(r),r.onload=this.handleIframeLoad,r.onerror=this.handleIframeError,this.iframe=r,window.addEventListener("message",this.handleMessage),this.log("constructed",{origin:this.origin,mode:this.mode,src:i})}catch(i){this.failed=!0,this.resolveReady(),this.logError("construction failed — switcher disabled",i)}}ready(){return this.readyPromise}async listSessions(){if(this.failed||this.destroyed||this.iframe==null)return this.log("listSessions skipped — switcher unavailable"),[];try{const i=(await this.rpc({type:"list_sessions"})).sessions;return Array.isArray(i)?(this.log("listSessions resolved",{count:i.length}),this.lastSessions=i,this.lastSessions):(this.log("listSessions resolved with no sessions array"),[])}catch{return[]}}async switchTo(e,i){if(this.failed||this.destroyed||this.iframe==null){this.log("switchTo skipped — switcher unavailable",{sub:e});return}try{const r=this.resolveLoginHint(e);if(this.mode==="emit"){this.log("switchTo emit",{sub:e,loginHint:r,listeners:this.switchListeners.length}),this.emitSwitch(r);return}const t=e==="__new__"?"login":"none",o=i?.returnTo??window.location.href;this.log("switchTo redirect",{sub:e,loginHint:r,prompt:t,return_to:o}),await this.rpc({type:"redirect_authorize",login_hint:r,prompt:t,return_to:o})}catch(r){this.logError("switchTo failed",r)}}accountPageUrl(e){const i=this.resolveLoginHint(e),r=`${this.origin}/account?login_hint=${encodeURIComponent(i)}`;return this.log("accountPageUrl",{sub:e,loginHint:i,url:r}),r}resolveLoginHint(e){return e==="__new__"?e:this.lastSessions.find(r=>r.sub===e)?.name??e}destroy(){if(this.destroyed){this.log("destroy called again — no-op");return}this.destroyed=!0;try{this.loadGraceTimer!=null&&(clearTimeout(this.loadGraceTimer),this.loadGraceTimer=null),window.removeEventListener("message",this.handleMessage);const e=new DOMException("DmonSwitcher destroyed","AbortError");this.log("destroy",{rejectingPending:this.pending.size}),this.pending.forEach(({reject:i})=>i(e)),this.pending.clear(),this.iframe?.remove(),this.resolveReady()}catch(e){this.logError("destroy encountered an error",e)}}on(e,i){e==="switch"?this.switchListeners.push(i):e==="ready"?this.readyListeners.push(i):this.errorListeners.push(i)}rejectBridge(e){if(this.bridgeFailed||this.destroyed||this.failed)return;this.bridgeFailed=!0;const i=new Error(e);this.log(`bridge failure: ${e}`),this.rejectReady(i),this.pending.forEach(({reject:r})=>{r(i)}),this.pending.clear();for(const r of this.errorListeners)try{r(i)}catch(t){this.logError("error handler threw",t)}}log(e,i){this.debug&&(i===void 0?console.debug(`[DmonSwitcher] ${e}`):console.debug(`[DmonSwitcher] ${e}`,i))}logError(e,i){i===void 0?console.error(`[DmonSwitcher] ${e}`):console.error(`[DmonSwitcher] ${e}`,i)}emitSwitch(e){for(const i of this.switchListeners)try{i({loginHint:e})}catch(r){this.logError("switch handler threw",r)}}async rpc(e){if(this.destroyed||this.iframe==null)throw new DOMException("DmonSwitcher destroyed","AbortError");if(await this.readyPromise,this.destroyed||this.iframe==null)throw new DOMException("DmonSwitcher destroyed","AbortError");const i=String(this.nextRequestId++);this.log("rpc send",{request_id:i,...e});const r=this.iframe;return new Promise((t,o)=>{this.pending.set(i,{resolve:t,reject:o}),r.contentWindow?.postMessage({...e,request_id:i},this.origin)})}}exports.DmonSwitcher=a;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  export interface Session {
2
+ /**
3
+ * OIDC subject identifier — the DMon user id as a string. Equals the `sub`
4
+ * claim of the access/ID tokens issued for this user, so relying parties can
5
+ * correlate a switcher account with its tokens.
6
+ */
2
7
  sub: string;
8
+ /**
9
+ * Username / login identifier. Pass this to {@link DmonSwitcher.switchTo} and
10
+ * {@link DmonSwitcher.accountPageUrl} as the OIDC `login_hint` when switching
11
+ * to this account.
12
+ */
3
13
  name: string;
4
14
  given_name?: string;
5
15
  family_name?: string;
@@ -27,7 +37,7 @@ export interface DmonSwitcherOptions {
27
37
  postLoadGraceMs?: number;
28
38
  }
29
39
  type SwitchHandler = (payload: {
30
- sub: string;
40
+ loginHint: string;
31
41
  }) => void;
32
42
  type ReadyHandler = () => void;
33
43
  type ErrorHandler = (err: Error) => void;
@@ -50,21 +60,40 @@ export declare class DmonSwitcher {
50
60
  private readonly switchListeners;
51
61
  private readonly readyListeners;
52
62
  private readonly errorListeners;
63
+ /** Most recent listSessions() result, used to resolve a `sub` (user id) to its login_hint. */
64
+ private lastSessions;
53
65
  constructor(opts: DmonSwitcherOptions);
54
66
  ready(): Promise<void>;
55
67
  listSessions(): Promise<Session[]>;
68
+ /**
69
+ * Switch to a previously authenticated account.
70
+ *
71
+ * @param sub - The {@link Session.sub} of the target account (the user id), or the
72
+ * `"__new__"` sentinel to force interactive login. The switcher resolves it to the
73
+ * OIDC `login_hint` (username) via the cached {@link listSessions} result.
74
+ */
56
75
  switchTo(sub: string, opts?: {
57
76
  returnTo?: string;
58
77
  }): Promise<void>;
59
78
  /**
60
- * Build the URL of the DMon account page for the given user, pre-filled with the
79
+ * Build the URL of the DMon account page for the given account, pre-filled with the
61
80
  * `login_hint` so DMon can mount it silently if that user's session is in the cookie.
62
81
  *
82
+ * @param sub - The {@link Session.sub} of the target account (the user id); resolved to
83
+ * the OIDC `login_hint` (username) via the cached {@link listSessions} result.
84
+ *
63
85
  * This only constructs the string — it never navigates. Put it in an anchor's `href`
64
86
  * so the user can choose how to open it (left click = same tab, middle/ctrl click =
65
87
  * new tab). Pure and side-effect free; safe to call at any time.
66
88
  */
67
89
  accountPageUrl(sub: string): string;
90
+ /**
91
+ * Resolve a {@link Session.sub} (the user id) to the OIDC `login_hint` (the username)
92
+ * using the most recent {@link listSessions} result. The `"__new__"` sentinel is passed
93
+ * through unchanged. When no cached session matches (e.g. `listSessions` has not run
94
+ * yet), the input is returned unchanged as a best-effort fallback.
95
+ */
96
+ private resolveLoginHint;
68
97
  destroy(): void;
69
98
  on(event: "switch", handler: SwitchHandler): void;
70
99
  on(event: "ready", handler: ReadyHandler): void;
package/dist/index.js CHANGED
@@ -1,36 +1,36 @@
1
- function d(s) {
1
+ function h(s) {
2
2
  if (typeof s != "object" || s === null) return !1;
3
- const r = s;
4
- return !(r.dmon_bridge !== !0 || typeof r.type != "string");
3
+ const e = s;
4
+ return !(e.dmon_bridge !== !0 || typeof e.type != "string");
5
5
  }
6
- function h(s) {
6
+ function d(s) {
7
7
  return typeof s.request_id == "string";
8
8
  }
9
9
  class a {
10
- constructor(r) {
11
- this.iframe = null, this.nextRequestId = 1, this.destroyed = !1, this.failed = !1, this.bridgeFailed = !1, this.readyReceived = !1, this.loadGraceTimer = null, this.pending = /* @__PURE__ */ new Map(), this.switchListeners = [], this.readyListeners = [], this.errorListeners = [], this.handleIframeLoad = () => {
10
+ constructor(e) {
11
+ this.iframe = null, this.nextRequestId = 1, this.destroyed = !1, this.failed = !1, this.bridgeFailed = !1, this.readyReceived = !1, this.loadGraceTimer = null, this.pending = /* @__PURE__ */ new Map(), this.switchListeners = [], this.readyListeners = [], this.errorListeners = [], this.lastSessions = [], this.handleIframeLoad = () => {
12
12
  this.log("iframe load event"), !(this.readyReceived || this.destroyed || this.failed || this.bridgeFailed) && (this.loadGraceTimer = setTimeout(() => {
13
13
  this.rejectBridge("DmonSwitcher: bridge iframe loaded but sent no ready message (CSP frame-ancestors block?)");
14
14
  }, this.postLoadGraceMs));
15
15
  }, this.handleIframeError = () => {
16
16
  this.log("iframe error event"), this.rejectBridge("DmonSwitcher: bridge iframe failed to load (network error)");
17
- }, this.handleMessage = (e) => {
18
- if (this.iframe == null || !d(e.data)) return;
19
- if (e.source !== this.iframe.contentWindow) {
17
+ }, this.handleMessage = (i) => {
18
+ if (this.iframe == null || !h(i.data)) return;
19
+ if (i.source !== this.iframe.contentWindow) {
20
20
  this.log("message dropped: wrong source", {
21
- eventOrigin: e.origin,
21
+ eventOrigin: i.origin,
22
22
  expectedOrigin: this.origin,
23
- hasSource: e.source != null,
23
+ hasSource: i.source != null,
24
24
  sourceIsExpectedIframe: !1
25
25
  });
26
26
  return;
27
27
  }
28
- if (e.origin !== this.origin) {
29
- this.log("message dropped: wrong origin", { eventOrigin: e.origin, expectedOrigin: this.origin });
28
+ if (i.origin !== this.origin) {
29
+ this.log("message dropped: wrong origin", { eventOrigin: i.origin, expectedOrigin: this.origin });
30
30
  return;
31
31
  }
32
- const i = e.data;
33
- if (i.type === "ready") {
32
+ const r = i.data;
33
+ if (r.type === "ready") {
34
34
  this.log("ready received"), this.readyReceived = !0, this.loadGraceTimer != null && (clearTimeout(this.loadGraceTimer), this.loadGraceTimer = null), this.resolveReady();
35
35
  for (const o of this.readyListeners)
36
36
  try {
@@ -40,20 +40,20 @@ class a {
40
40
  }
41
41
  return;
42
42
  }
43
- if (!h(i)) {
43
+ if (!d(r)) {
44
44
  this.log("message dropped: no request_id");
45
45
  return;
46
46
  }
47
- const t = this.pending.get(i.request_id);
48
- t !== void 0 ? (this.log("rpc reply", { request_id: i.request_id, type: i.type }), this.pending.delete(i.request_id), t.resolve(i)) : this.log("rpc reply for unknown request_id — ignored", { request_id: i.request_id });
49
- }, this.origin = r.dmonOrigin, this.mode = r.mode ?? "emit", this.debug = r.debug ?? !1, this.postLoadGraceMs = r.postLoadGraceMs ?? 200, this.readyPromise = new Promise((e, i) => {
50
- this.resolveReady = e, this.rejectReady = i;
47
+ const t = this.pending.get(r.request_id);
48
+ t !== void 0 ? (this.log("rpc reply", { request_id: r.request_id, type: r.type }), this.pending.delete(r.request_id), t.resolve(r)) : this.log("rpc reply for unknown request_id — ignored", { request_id: r.request_id });
49
+ }, this.origin = e.dmonOrigin, this.mode = e.mode ?? "emit", this.debug = e.debug ?? !1, this.postLoadGraceMs = e.postLoadGraceMs ?? 200, this.readyPromise = new Promise((i, r) => {
50
+ this.resolveReady = i, this.rejectReady = r;
51
51
  });
52
52
  try {
53
- const e = `${r.dmonOrigin}/api/v1/authorize/${r.realmSlug}/embed/bridge?client_id=${encodeURIComponent(r.clientId)}`, i = document.createElement("iframe");
54
- i.setAttribute("aria-hidden", "true"), i.setAttribute("sandbox", "allow-scripts allow-same-origin"), i.style.display = "none", i.src = e, document.body.appendChild(i), i.onload = this.handleIframeLoad, i.onerror = this.handleIframeError, this.iframe = i, window.addEventListener("message", this.handleMessage), this.log("constructed", { origin: this.origin, mode: this.mode, src: e });
55
- } catch (e) {
56
- this.failed = !0, this.resolveReady(), this.logError("construction failed — switcher disabled", e);
53
+ const i = `${e.dmonOrigin}/api/v1/authorize/${e.realmSlug}/embed/bridge?client_id=${encodeURIComponent(e.clientId)}`, r = document.createElement("iframe");
54
+ r.setAttribute("aria-hidden", "true"), r.setAttribute("sandbox", "allow-scripts allow-same-origin"), r.style.display = "none", r.src = i, document.body.appendChild(r), r.onload = this.handleIframeLoad, r.onerror = this.handleIframeError, this.iframe = r, window.addEventListener("message", this.handleMessage), this.log("constructed", { origin: this.origin, mode: this.mode, src: i });
55
+ } catch (i) {
56
+ this.failed = !0, this.resolveReady(), this.logError("construction failed — switcher disabled", i);
57
57
  }
58
58
  }
59
59
  ready() {
@@ -63,44 +63,64 @@ class a {
63
63
  if (this.failed || this.destroyed || this.iframe == null)
64
64
  return this.log("listSessions skipped — switcher unavailable"), [];
65
65
  try {
66
- const e = (await this.rpc({ type: "list_sessions" })).sessions;
67
- return Array.isArray(e) ? (this.log("listSessions resolved", { count: e.length }), e) : (this.log("listSessions resolved with no sessions array"), []);
66
+ const i = (await this.rpc({ type: "list_sessions" })).sessions;
67
+ return Array.isArray(i) ? (this.log("listSessions resolved", { count: i.length }), this.lastSessions = i, this.lastSessions) : (this.log("listSessions resolved with no sessions array"), []);
68
68
  } catch {
69
69
  return [];
70
70
  }
71
71
  }
72
- async switchTo(r, e) {
72
+ /**
73
+ * Switch to a previously authenticated account.
74
+ *
75
+ * @param sub - The {@link Session.sub} of the target account (the user id), or the
76
+ * `"__new__"` sentinel to force interactive login. The switcher resolves it to the
77
+ * OIDC `login_hint` (username) via the cached {@link listSessions} result.
78
+ */
79
+ async switchTo(e, i) {
73
80
  if (this.failed || this.destroyed || this.iframe == null) {
74
- this.log("switchTo skipped — switcher unavailable", { sub: r });
81
+ this.log("switchTo skipped — switcher unavailable", { sub: e });
75
82
  return;
76
83
  }
77
84
  try {
85
+ const r = this.resolveLoginHint(e);
78
86
  if (this.mode === "emit") {
79
- this.log("switchTo emit", { sub: r, listeners: this.switchListeners.length }), this.emitSwitch(r);
87
+ this.log("switchTo emit", { sub: e, loginHint: r, listeners: this.switchListeners.length }), this.emitSwitch(r);
80
88
  return;
81
89
  }
82
- const i = r === "__new__" ? "login" : "none", t = e?.returnTo ?? window.location.href;
83
- this.log("switchTo redirect", { sub: r, prompt: i, return_to: t }), await this.rpc({
90
+ const t = e === "__new__" ? "login" : "none", o = i?.returnTo ?? window.location.href;
91
+ this.log("switchTo redirect", { sub: e, loginHint: r, prompt: t, return_to: o }), await this.rpc({
84
92
  type: "redirect_authorize",
85
93
  login_hint: r,
86
- prompt: i,
87
- return_to: t
94
+ prompt: t,
95
+ return_to: o
88
96
  });
89
- } catch (i) {
90
- this.logError("switchTo failed", i);
97
+ } catch (r) {
98
+ this.logError("switchTo failed", r);
91
99
  }
92
100
  }
93
101
  /**
94
- * Build the URL of the DMon account page for the given user, pre-filled with the
102
+ * Build the URL of the DMon account page for the given account, pre-filled with the
95
103
  * `login_hint` so DMon can mount it silently if that user's session is in the cookie.
96
104
  *
105
+ * @param sub - The {@link Session.sub} of the target account (the user id); resolved to
106
+ * the OIDC `login_hint` (username) via the cached {@link listSessions} result.
107
+ *
97
108
  * This only constructs the string — it never navigates. Put it in an anchor's `href`
98
109
  * so the user can choose how to open it (left click = same tab, middle/ctrl click =
99
110
  * new tab). Pure and side-effect free; safe to call at any time.
100
111
  */
101
- accountPageUrl(r) {
102
- const e = `${this.origin}/account?login_hint=${encodeURIComponent(r)}`;
103
- return this.log("accountPageUrl", { sub: r, url: e }), e;
112
+ accountPageUrl(e) {
113
+ const i = this.resolveLoginHint(e), r = `${this.origin}/account?login_hint=${encodeURIComponent(i)}`;
114
+ return this.log("accountPageUrl", { sub: e, loginHint: i, url: r }), r;
115
+ }
116
+ /**
117
+ * Resolve a {@link Session.sub} (the user id) to the OIDC `login_hint` (the username)
118
+ * using the most recent {@link listSessions} result. The `"__new__"` sentinel is passed
119
+ * through unchanged. When no cached session matches (e.g. `listSessions` has not run
120
+ * yet), the input is returned unchanged as a best-effort fallback.
121
+ */
122
+ resolveLoginHint(e) {
123
+ return e === "__new__" ? e : this.lastSessions.find((r) => r.sub === e)?.name ?? e;
104
124
  }
105
125
  destroy() {
106
126
  if (this.destroyed) {
@@ -110,57 +130,57 @@ class a {
110
130
  this.destroyed = !0;
111
131
  try {
112
132
  this.loadGraceTimer != null && (clearTimeout(this.loadGraceTimer), this.loadGraceTimer = null), window.removeEventListener("message", this.handleMessage);
113
- const r = new DOMException("DmonSwitcher destroyed", "AbortError");
114
- this.log("destroy", { rejectingPending: this.pending.size }), this.pending.forEach(({ reject: e }) => e(r)), this.pending.clear(), this.iframe?.remove(), this.resolveReady();
115
- } catch (r) {
116
- this.logError("destroy encountered an error", r);
133
+ const e = new DOMException("DmonSwitcher destroyed", "AbortError");
134
+ this.log("destroy", { rejectingPending: this.pending.size }), this.pending.forEach(({ reject: i }) => i(e)), this.pending.clear(), this.iframe?.remove(), this.resolveReady();
135
+ } catch (e) {
136
+ this.logError("destroy encountered an error", e);
117
137
  }
118
138
  }
119
- on(r, e) {
120
- r === "switch" ? this.switchListeners.push(e) : r === "ready" ? this.readyListeners.push(e) : this.errorListeners.push(e);
139
+ on(e, i) {
140
+ e === "switch" ? this.switchListeners.push(i) : e === "ready" ? this.readyListeners.push(i) : this.errorListeners.push(i);
121
141
  }
122
- rejectBridge(r) {
142
+ rejectBridge(e) {
123
143
  if (this.bridgeFailed || this.destroyed || this.failed) return;
124
144
  this.bridgeFailed = !0;
125
- const e = new Error(r);
126
- this.log(`bridge failure: ${r}`), this.rejectReady(e), this.pending.forEach(({ reject: i }) => {
127
- i(e);
145
+ const i = new Error(e);
146
+ this.log(`bridge failure: ${e}`), this.rejectReady(i), this.pending.forEach(({ reject: r }) => {
147
+ r(i);
128
148
  }), this.pending.clear();
129
- for (const i of this.errorListeners)
149
+ for (const r of this.errorListeners)
130
150
  try {
131
- i(e);
151
+ r(i);
132
152
  } catch (t) {
133
153
  this.logError("error handler threw", t);
134
154
  }
135
155
  }
136
- log(r, e) {
137
- this.debug && (e === void 0 ? console.debug(`[DmonSwitcher] ${r}`) : console.debug(`[DmonSwitcher] ${r}`, e));
156
+ log(e, i) {
157
+ this.debug && (i === void 0 ? console.debug(`[DmonSwitcher] ${e}`) : console.debug(`[DmonSwitcher] ${e}`, i));
138
158
  }
139
159
  // Errors are always surfaced (not gated by `debug`) but never thrown — the library
140
160
  // must not crash the consuming application.
141
- logError(r, e) {
142
- e === void 0 ? console.error(`[DmonSwitcher] ${r}`) : console.error(`[DmonSwitcher] ${r}`, e);
161
+ logError(e, i) {
162
+ i === void 0 ? console.error(`[DmonSwitcher] ${e}`) : console.error(`[DmonSwitcher] ${e}`, i);
143
163
  }
144
164
  // Invoke each consumer "switch" handler in isolation: a throwing handler is logged and
145
165
  // skipped, never allowed to break the loop or propagate out of the library.
146
- emitSwitch(r) {
147
- for (const e of this.switchListeners)
166
+ emitSwitch(e) {
167
+ for (const i of this.switchListeners)
148
168
  try {
149
- e({ sub: r });
150
- } catch (i) {
151
- this.logError("switch handler threw", i);
169
+ i({ loginHint: e });
170
+ } catch (r) {
171
+ this.logError("switch handler threw", r);
152
172
  }
153
173
  }
154
- async rpc(r) {
174
+ async rpc(e) {
155
175
  if (this.destroyed || this.iframe == null)
156
176
  throw new DOMException("DmonSwitcher destroyed", "AbortError");
157
177
  if (await this.readyPromise, this.destroyed || this.iframe == null)
158
178
  throw new DOMException("DmonSwitcher destroyed", "AbortError");
159
- const e = String(this.nextRequestId++);
160
- this.log("rpc send", { request_id: e, ...r });
161
- const i = this.iframe;
179
+ const i = String(this.nextRequestId++);
180
+ this.log("rpc send", { request_id: i, ...e });
181
+ const r = this.iframe;
162
182
  return new Promise((t, o) => {
163
- this.pending.set(e, { resolve: t, reject: o }), i.contentWindow?.postMessage({ ...r, request_id: e }, this.origin);
183
+ this.pending.set(i, { resolve: t, reject: o }), r.contentWindow?.postMessage({ ...e, request_id: i }, this.origin);
164
184
  });
165
185
  }
166
186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcm-cz/dmon-switcher",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "repository": "gitlab:gcm-cz/dmon",
5
5
  "license": "MIT",
6
6
  "type": "module",