@gcm-cz/dmon-switcher 0.1.0 → 1.0.2
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 +244 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +19 -0
- package/dist/index.js +74 -54
- package/package.json +7 -2
package/README.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# @gcm-cz/dmon-switcher
|
|
2
|
+
|
|
3
|
+
Headless JavaScript library for integrating the DMon user switcher into a relying party (RP).
|
|
4
|
+
|
|
5
|
+
Lets your application discover which users the browser has already authenticated with DMon and
|
|
6
|
+
switch between them — without leaving your page, without re-implementing the picker UI, and
|
|
7
|
+
without touching DMon's cookies directly.
|
|
8
|
+
|
|
9
|
+
Internally the library embeds a hidden `<iframe>` that loads a DMon-hosted bridge page. The
|
|
10
|
+
bridge runs in DMon's first-party context, reads the `dmon_auth` session cookie, and exposes a
|
|
11
|
+
`postMessage` RPC interface. Your application calls `listSessions()`, renders its own picker,
|
|
12
|
+
and calls `switchTo()`. **The library renders no UI of its own.**
|
|
13
|
+
|
|
14
|
+
Zero runtime dependencies. Ships as ESM + CJS with full TypeScript declarations.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Prerequisites
|
|
19
|
+
|
|
20
|
+
- A DMon instance with the embed bridge feature enabled.
|
|
21
|
+
- A registered DMon client with **`embed_origins`** configured to include your application's
|
|
22
|
+
origin (e.g. `https://app.example.com`). Without this, the bridge iframe will be blocked by
|
|
23
|
+
CSP and the library will not function. See the
|
|
24
|
+
[RP Integration Guide](../../docs/integrations/RP_USER_SWITCHER.md#4-client-registration)
|
|
25
|
+
for the admin UI walk-through.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm install @gcm-cz/dmon-switcher
|
|
33
|
+
# or
|
|
34
|
+
pnpm add @gcm-cz/dmon-switcher
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import { DmonSwitcher } from "@gcm-cz/dmon-switcher";
|
|
43
|
+
import type { Session } from "@gcm-cz/dmon-switcher";
|
|
44
|
+
|
|
45
|
+
// Instantiate once at component mount. A hidden iframe is created immediately.
|
|
46
|
+
const switcher = new DmonSwitcher({
|
|
47
|
+
dmonOrigin: "https://accounts.example.com",
|
|
48
|
+
realmSlug: "default",
|
|
49
|
+
clientId: "my-rp",
|
|
50
|
+
mode: "emit", // "emit" (default) or "redirect" — controls switchTo() behaviour
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Register a switch handler before awaiting ready().
|
|
54
|
+
switcher.on("switch", ({ sub }) => {
|
|
55
|
+
if (sub === "__new__") {
|
|
56
|
+
// Force interactive login to add a new session.
|
|
57
|
+
window.location.href = buildAuthorizeUrl({ prompt: "login" });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
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 }); });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Wait for the bridge to signal ready, then fetch the session list.
|
|
66
|
+
await switcher.ready();
|
|
67
|
+
const sessions: Session[] = await switcher.listSessions();
|
|
68
|
+
renderMyUserMenu(sessions);
|
|
69
|
+
|
|
70
|
+
// Switch to a user when selected in your menu.
|
|
71
|
+
await switcher.switchTo(selectedSub);
|
|
72
|
+
|
|
73
|
+
// Build the account-page URL for an anchor — the user decides how to open it.
|
|
74
|
+
const accountHref = switcher.accountPageUrl(currentUserSub);
|
|
75
|
+
// <a href={accountHref}>Account settings</a>
|
|
76
|
+
|
|
77
|
+
// Clean up when the component unmounts.
|
|
78
|
+
switcher.destroy();
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### React example
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
import { useEffect, useRef, useState } from "react";
|
|
85
|
+
import { DmonSwitcher } from "@gcm-cz/dmon-switcher";
|
|
86
|
+
import type { Session } from "@gcm-cz/dmon-switcher";
|
|
87
|
+
|
|
88
|
+
function UserMenu({ currentSub }: { currentSub: string }) {
|
|
89
|
+
const switcherRef = useRef<DmonSwitcher | null>(null);
|
|
90
|
+
const [sessions, setSessions] = useState<Session[]>([]);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const switcher = new DmonSwitcher({
|
|
94
|
+
dmonOrigin: "https://accounts.example.com",
|
|
95
|
+
realmSlug: "default",
|
|
96
|
+
clientId: "my-rp",
|
|
97
|
+
});
|
|
98
|
+
switcherRef.current = switcher;
|
|
99
|
+
|
|
100
|
+
switcher.on("switch", ({ sub }) => {
|
|
101
|
+
// your re-auth logic
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
switcher.ready()
|
|
105
|
+
.then(() => switcher.listSessions())
|
|
106
|
+
.then(setSessions);
|
|
107
|
+
|
|
108
|
+
return () => switcher.destroy();
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<menu>
|
|
113
|
+
{sessions.map(s => (
|
|
114
|
+
<li key={s.sub} onClick={() => switcherRef.current?.switchTo(s.sub)}>
|
|
115
|
+
{s.name}
|
|
116
|
+
</li>
|
|
117
|
+
))}
|
|
118
|
+
<li>
|
|
119
|
+
<a href={switcherRef.current?.accountPageUrl(currentSub)}>
|
|
120
|
+
Account settings
|
|
121
|
+
</a>
|
|
122
|
+
</li>
|
|
123
|
+
</menu>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## API Reference
|
|
131
|
+
|
|
132
|
+
### `Session`
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
interface Session {
|
|
136
|
+
sub: string; // Stable user identifier (OIDC "sub" claim)
|
|
137
|
+
name: string; // Display name
|
|
138
|
+
given_name?: string;
|
|
139
|
+
family_name?: string;
|
|
140
|
+
picture?: string; // Profile picture URL
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`listSessions()` returns only sessions the realm's `can_authorize` policy permits. Users
|
|
145
|
+
authenticated in DMon but blocked by policy are silently omitted.
|
|
146
|
+
|
|
147
|
+
### `DmonSwitcherOptions`
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
interface DmonSwitcherOptions {
|
|
151
|
+
dmonOrigin: string; // Base URL of your DMon instance
|
|
152
|
+
realmSlug: string; // Realm slug the RP is registered in
|
|
153
|
+
clientId: string; // OAuth 2.0 client_id of the RP
|
|
154
|
+
mode?: "emit" | "redirect"; // default "emit"
|
|
155
|
+
debug?: boolean; // Log internal steps via console.debug; default false
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**`debug`** — when `true`, every internal step is logged via `console.debug` under the
|
|
160
|
+
`[DmonSwitcher]` prefix: construction, ready handshake, RPC send/receive, dropped messages,
|
|
161
|
+
switch, account-page URL construction, and teardown. Off by default. Useful during integration
|
|
162
|
+
and troubleshooting; do not enable in production.
|
|
163
|
+
|
|
164
|
+
### `DmonSwitcher`
|
|
165
|
+
|
|
166
|
+
#### `constructor(opts: DmonSwitcherOptions)`
|
|
167
|
+
|
|
168
|
+
Creates a hidden `<iframe>`, appends it to `document.body`, and starts the bridge handshake.
|
|
169
|
+
Does not throw — errors are deferred and surfaced via `ready()` never resolving.
|
|
170
|
+
|
|
171
|
+
#### `ready(): Promise<void>`
|
|
172
|
+
|
|
173
|
+
Resolves when the bridge iframe sends its `ready` message. Also resolves immediately if
|
|
174
|
+
construction failed (e.g. DOM unavailable) or after `destroy()` is called — in both cases the
|
|
175
|
+
instance is inoperative and subsequent calls no-op.
|
|
176
|
+
|
|
177
|
+
`listSessions()` and `switchTo()` in `"redirect"` mode wait for `ready()` internally before
|
|
178
|
+
sending an RPC, so you do not have to await `ready()` before calling them. `switchTo()` in
|
|
179
|
+
`"emit"` mode fires local listeners immediately without waiting for the bridge. Awaiting
|
|
180
|
+
`ready()` explicitly is recommended when you want timeout-based error detection:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
const TIMEOUT = 5000;
|
|
184
|
+
await Promise.race([
|
|
185
|
+
switcher.ready(),
|
|
186
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("bridge timeout")), TIMEOUT)),
|
|
187
|
+
]);
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### `listSessions(): Promise<Session[]>`
|
|
191
|
+
|
|
192
|
+
Sends a `list_sessions` RPC to the bridge. Returns `[]` when there are no authenticated
|
|
193
|
+
sessions, when the cookie is absent, or when all sessions are policy-blocked.
|
|
194
|
+
|
|
195
|
+
#### `switchTo(sub: string, opts?: { returnTo?: string }): Promise<void>`
|
|
196
|
+
|
|
197
|
+
Initiates a user switch.
|
|
198
|
+
|
|
199
|
+
- **`"emit"` mode** — fires all `switch` listeners with `{ sub }` and resolves immediately.
|
|
200
|
+
The RP is responsible for triggering OIDC re-authentication.
|
|
201
|
+
- **`"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
|
|
203
|
+
`redirect_uri=opts.returnTo ?? window.location.href`.
|
|
204
|
+
|
|
205
|
+
**`__new__` sentinel:** `switchTo("__new__")` uses `prompt=login` to force fresh credential
|
|
206
|
+
entry and append a new session to the cookie.
|
|
207
|
+
|
|
208
|
+
#### `accountPageUrl(sub: string): string`
|
|
209
|
+
|
|
210
|
+
Returns (does **not** open) the DMon account-page URL for `sub`:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
{dmonOrigin}/account?login_hint={encodeURIComponent(sub)}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Pure and side-effect free — safe to call during render. Put the result in an anchor `href` so
|
|
217
|
+
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.
|
|
219
|
+
|
|
220
|
+
#### `destroy(): void`
|
|
221
|
+
|
|
222
|
+
Removes the iframe, deregisters `window` message listeners, rejects all pending RPC promises
|
|
223
|
+
with `AbortError`, and resolves any pending `ready()` waiters so they do not hang. Idempotent —
|
|
224
|
+
safe to call multiple times, subsequent calls are no-ops. Call at component unmount.
|
|
225
|
+
|
|
226
|
+
#### `on(event: "switch", handler: (payload: { sub: string }) => void): void`
|
|
227
|
+
|
|
228
|
+
Registers a handler fired by `switchTo()` in `"emit"` mode.
|
|
229
|
+
|
|
230
|
+
#### `on(event: "ready", handler: () => void): void`
|
|
231
|
+
|
|
232
|
+
Registers a handler called when the bridge sends its `ready` message.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## `switchTo` modes
|
|
237
|
+
|
|
238
|
+
| Mode | What `switchTo()` does | Who triggers re-auth |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| `"emit"` (default) | Fires the `switch` event | Your code |
|
|
241
|
+
| `"redirect"` | Bridge navigates the page to DMon `/authorize` | DMon → OIDC code callback |
|
|
242
|
+
|
|
243
|
+
`"emit"` is recommended for SPAs — it keeps your application in control of the authorization
|
|
244
|
+
flow and lets you discard tokens before starting the new grant.
|
package/dist/index.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function
|
|
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;
|
package/dist/index.d.ts
CHANGED
|
@@ -17,11 +17,20 @@ export interface DmonSwitcherOptions {
|
|
|
17
17
|
* Off by default.
|
|
18
18
|
*/
|
|
19
19
|
debug?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Grace period (ms) to wait for the bridge "ready" message after the iframe `load`
|
|
22
|
+
* event fires. The iframe `load` event fires even when CSP `frame-ancestors` blocks
|
|
23
|
+
* rendering — the bridge JS never runs, so no handshake arrives. After this window
|
|
24
|
+
* expires without a "ready" the promise is rejected. Defaults to 200 ms.
|
|
25
|
+
* Set to 0 to reject synchronously on load (only safe in tests).
|
|
26
|
+
*/
|
|
27
|
+
postLoadGraceMs?: number;
|
|
20
28
|
}
|
|
21
29
|
type SwitchHandler = (payload: {
|
|
22
30
|
sub: string;
|
|
23
31
|
}) => void;
|
|
24
32
|
type ReadyHandler = () => void;
|
|
33
|
+
type ErrorHandler = (err: Error) => void;
|
|
25
34
|
export declare class DmonSwitcher {
|
|
26
35
|
private readonly origin;
|
|
27
36
|
private readonly mode;
|
|
@@ -29,12 +38,18 @@ export declare class DmonSwitcher {
|
|
|
29
38
|
private readonly iframe;
|
|
30
39
|
private readonly readyPromise;
|
|
31
40
|
private resolveReady;
|
|
41
|
+
private rejectReady;
|
|
32
42
|
private nextRequestId;
|
|
33
43
|
private destroyed;
|
|
34
44
|
private failed;
|
|
45
|
+
private bridgeFailed;
|
|
46
|
+
private readyReceived;
|
|
47
|
+
private loadGraceTimer;
|
|
48
|
+
private readonly postLoadGraceMs;
|
|
35
49
|
private readonly pending;
|
|
36
50
|
private readonly switchListeners;
|
|
37
51
|
private readonly readyListeners;
|
|
52
|
+
private readonly errorListeners;
|
|
38
53
|
constructor(opts: DmonSwitcherOptions);
|
|
39
54
|
ready(): Promise<void>;
|
|
40
55
|
listSessions(): Promise<Session[]>;
|
|
@@ -53,6 +68,10 @@ export declare class DmonSwitcher {
|
|
|
53
68
|
destroy(): void;
|
|
54
69
|
on(event: "switch", handler: SwitchHandler): void;
|
|
55
70
|
on(event: "ready", handler: ReadyHandler): void;
|
|
71
|
+
on(event: "error", handler: ErrorHandler): void;
|
|
72
|
+
private rejectBridge;
|
|
73
|
+
private readonly handleIframeLoad;
|
|
74
|
+
private readonly handleIframeError;
|
|
56
75
|
private log;
|
|
57
76
|
private logError;
|
|
58
77
|
private emitSwitch;
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
function
|
|
1
|
+
function d(s) {
|
|
2
2
|
if (typeof s != "object" || s === null) return !1;
|
|
3
|
-
const
|
|
4
|
-
return !(
|
|
3
|
+
const r = s;
|
|
4
|
+
return !(r.dmon_bridge !== !0 || typeof r.type != "string");
|
|
5
5
|
}
|
|
6
|
-
function
|
|
6
|
+
function h(s) {
|
|
7
7
|
return typeof s.request_id == "string";
|
|
8
8
|
}
|
|
9
|
-
class
|
|
10
|
-
constructor(
|
|
11
|
-
this.iframe = null, this.nextRequestId = 1, this.destroyed = !1, this.failed = !1, this.pending = /* @__PURE__ */ new Map(), this.switchListeners = [], this.readyListeners = [], this.
|
|
12
|
-
|
|
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 = () => {
|
|
12
|
+
this.log("iframe load event"), !(this.readyReceived || this.destroyed || this.failed || this.bridgeFailed) && (this.loadGraceTimer = setTimeout(() => {
|
|
13
|
+
this.rejectBridge("DmonSwitcher: bridge iframe loaded but sent no ready message (CSP frame-ancestors block?)");
|
|
14
|
+
}, this.postLoadGraceMs));
|
|
15
|
+
}, this.handleIframeError = () => {
|
|
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;
|
|
13
19
|
if (e.source !== this.iframe.contentWindow) {
|
|
14
20
|
this.log("message dropped: wrong source", {
|
|
15
21
|
eventOrigin: e.origin,
|
|
@@ -23,9 +29,9 @@ class l {
|
|
|
23
29
|
this.log("message dropped: wrong origin", { eventOrigin: e.origin, expectedOrigin: this.origin });
|
|
24
30
|
return;
|
|
25
31
|
}
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
this.log("ready received"), this.resolveReady();
|
|
32
|
+
const i = e.data;
|
|
33
|
+
if (i.type === "ready") {
|
|
34
|
+
this.log("ready received"), this.readyReceived = !0, this.loadGraceTimer != null && (clearTimeout(this.loadGraceTimer), this.loadGraceTimer = null), this.resolveReady();
|
|
29
35
|
for (const o of this.readyListeners)
|
|
30
36
|
try {
|
|
31
37
|
o();
|
|
@@ -34,18 +40,18 @@ class l {
|
|
|
34
40
|
}
|
|
35
41
|
return;
|
|
36
42
|
}
|
|
37
|
-
if (!
|
|
43
|
+
if (!h(i)) {
|
|
38
44
|
this.log("message dropped: no request_id");
|
|
39
45
|
return;
|
|
40
46
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
}, this.origin =
|
|
44
|
-
this.resolveReady = e;
|
|
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;
|
|
45
51
|
});
|
|
46
52
|
try {
|
|
47
|
-
const e = `${
|
|
48
|
-
|
|
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 });
|
|
49
55
|
} catch (e) {
|
|
50
56
|
this.failed = !0, this.resolveReady(), this.logError("construction failed — switcher disabled", e);
|
|
51
57
|
}
|
|
@@ -59,29 +65,29 @@ class l {
|
|
|
59
65
|
try {
|
|
60
66
|
const e = (await this.rpc({ type: "list_sessions" })).sessions;
|
|
61
67
|
return Array.isArray(e) ? (this.log("listSessions resolved", { count: e.length }), e) : (this.log("listSessions resolved with no sessions array"), []);
|
|
62
|
-
} catch
|
|
63
|
-
return
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
|
-
async switchTo(
|
|
72
|
+
async switchTo(r, e) {
|
|
67
73
|
if (this.failed || this.destroyed || this.iframe == null) {
|
|
68
|
-
this.log("switchTo skipped — switcher unavailable", { sub:
|
|
74
|
+
this.log("switchTo skipped — switcher unavailable", { sub: r });
|
|
69
75
|
return;
|
|
70
76
|
}
|
|
71
77
|
try {
|
|
72
78
|
if (this.mode === "emit") {
|
|
73
|
-
this.log("switchTo emit", { sub:
|
|
79
|
+
this.log("switchTo emit", { sub: r, listeners: this.switchListeners.length }), this.emitSwitch(r);
|
|
74
80
|
return;
|
|
75
81
|
}
|
|
76
|
-
const
|
|
77
|
-
this.log("switchTo redirect", { sub:
|
|
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({
|
|
78
84
|
type: "redirect_authorize",
|
|
79
|
-
login_hint:
|
|
80
|
-
prompt:
|
|
81
|
-
return_to:
|
|
85
|
+
login_hint: r,
|
|
86
|
+
prompt: i,
|
|
87
|
+
return_to: t
|
|
82
88
|
});
|
|
83
|
-
} catch (
|
|
84
|
-
this.logError("switchTo failed",
|
|
89
|
+
} catch (i) {
|
|
90
|
+
this.logError("switchTo failed", i);
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
93
|
/**
|
|
@@ -92,9 +98,9 @@ class l {
|
|
|
92
98
|
* so the user can choose how to open it (left click = same tab, middle/ctrl click =
|
|
93
99
|
* new tab). Pure and side-effect free; safe to call at any time.
|
|
94
100
|
*/
|
|
95
|
-
accountPageUrl(
|
|
96
|
-
const e = `${this.origin}/account?login_hint=${encodeURIComponent(
|
|
97
|
-
return this.log("accountPageUrl", { sub:
|
|
101
|
+
accountPageUrl(r) {
|
|
102
|
+
const e = `${this.origin}/account?login_hint=${encodeURIComponent(r)}`;
|
|
103
|
+
return this.log("accountPageUrl", { sub: r, url: e }), e;
|
|
98
104
|
}
|
|
99
105
|
destroy() {
|
|
100
106
|
if (this.destroyed) {
|
|
@@ -103,47 +109,61 @@ class l {
|
|
|
103
109
|
}
|
|
104
110
|
this.destroyed = !0;
|
|
105
111
|
try {
|
|
106
|
-
window.removeEventListener("message", this.handleMessage);
|
|
107
|
-
const
|
|
108
|
-
this.log("destroy", { rejectingPending: this.pending.size }), this.pending.forEach(({ reject: e }) => e(
|
|
109
|
-
} catch (
|
|
110
|
-
this.logError("destroy encountered an error",
|
|
112
|
+
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);
|
|
111
117
|
}
|
|
112
118
|
}
|
|
113
|
-
on(
|
|
114
|
-
|
|
119
|
+
on(r, e) {
|
|
120
|
+
r === "switch" ? this.switchListeners.push(e) : r === "ready" ? this.readyListeners.push(e) : this.errorListeners.push(e);
|
|
121
|
+
}
|
|
122
|
+
rejectBridge(r) {
|
|
123
|
+
if (this.bridgeFailed || this.destroyed || this.failed) return;
|
|
124
|
+
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);
|
|
128
|
+
}), this.pending.clear();
|
|
129
|
+
for (const i of this.errorListeners)
|
|
130
|
+
try {
|
|
131
|
+
i(e);
|
|
132
|
+
} catch (t) {
|
|
133
|
+
this.logError("error handler threw", t);
|
|
134
|
+
}
|
|
115
135
|
}
|
|
116
|
-
log(
|
|
117
|
-
this.debug && (e === void 0 ? console.debug(`[DmonSwitcher] ${
|
|
136
|
+
log(r, e) {
|
|
137
|
+
this.debug && (e === void 0 ? console.debug(`[DmonSwitcher] ${r}`) : console.debug(`[DmonSwitcher] ${r}`, e));
|
|
118
138
|
}
|
|
119
139
|
// Errors are always surfaced (not gated by `debug`) but never thrown — the library
|
|
120
140
|
// must not crash the consuming application.
|
|
121
|
-
logError(
|
|
122
|
-
e === void 0 ? console.error(`[DmonSwitcher] ${
|
|
141
|
+
logError(r, e) {
|
|
142
|
+
e === void 0 ? console.error(`[DmonSwitcher] ${r}`) : console.error(`[DmonSwitcher] ${r}`, e);
|
|
123
143
|
}
|
|
124
144
|
// Invoke each consumer "switch" handler in isolation: a throwing handler is logged and
|
|
125
145
|
// skipped, never allowed to break the loop or propagate out of the library.
|
|
126
|
-
emitSwitch(
|
|
146
|
+
emitSwitch(r) {
|
|
127
147
|
for (const e of this.switchListeners)
|
|
128
148
|
try {
|
|
129
|
-
e({ sub:
|
|
130
|
-
} catch (
|
|
131
|
-
this.logError("switch handler threw",
|
|
149
|
+
e({ sub: r });
|
|
150
|
+
} catch (i) {
|
|
151
|
+
this.logError("switch handler threw", i);
|
|
132
152
|
}
|
|
133
153
|
}
|
|
134
|
-
async rpc(
|
|
154
|
+
async rpc(r) {
|
|
135
155
|
if (this.destroyed || this.iframe == null)
|
|
136
156
|
throw new DOMException("DmonSwitcher destroyed", "AbortError");
|
|
137
157
|
if (await this.readyPromise, this.destroyed || this.iframe == null)
|
|
138
158
|
throw new DOMException("DmonSwitcher destroyed", "AbortError");
|
|
139
159
|
const e = String(this.nextRequestId++);
|
|
140
|
-
this.log("rpc send", { request_id: e, ...
|
|
141
|
-
const
|
|
142
|
-
return new Promise((
|
|
143
|
-
this.pending.set(e, { resolve:
|
|
160
|
+
this.log("rpc send", { request_id: e, ...r });
|
|
161
|
+
const i = this.iframe;
|
|
162
|
+
return new Promise((t, o) => {
|
|
163
|
+
this.pending.set(e, { resolve: t, reject: o }), i.contentWindow?.postMessage({ ...r, request_id: e }, this.origin);
|
|
144
164
|
});
|
|
145
165
|
}
|
|
146
166
|
}
|
|
147
167
|
export {
|
|
148
|
-
|
|
168
|
+
a as DmonSwitcher
|
|
149
169
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gcm-cz/dmon-switcher",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"repository": "gitlab:gcm-cz/dmon",
|
|
5
|
+
"license": "MIT",
|
|
4
6
|
"type": "module",
|
|
5
7
|
"main": "./dist/index.cjs",
|
|
6
8
|
"module": "./dist/index.js",
|
|
@@ -12,7 +14,10 @@
|
|
|
12
14
|
"require": "./dist/index.cjs"
|
|
13
15
|
}
|
|
14
16
|
},
|
|
15
|
-
"files": [
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
16
21
|
"scripts": {
|
|
17
22
|
"build": "vite build && tsc --emitDeclarationOnly",
|
|
18
23
|
"prepare": "vite build && tsc --emitDeclarationOnly",
|