@matanetwork/sovereign-id 0.1.0
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/LICENSE +21 -0
- package/README.md +251 -0
- package/package.json +49 -0
- package/src/index.d.ts +216 -0
- package/src/index.js +616 -0
- package/src/install-upsell.js +592 -0
- package/src/resume.js +115 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install upsell — the SDK's "no wallet installed" fallback UI.
|
|
3
|
+
*
|
|
4
|
+
* Phase 4 of the mID Mission. When `signIn()` can't reach the extension
|
|
5
|
+
* (no `window.__mata_mid__` global) AND the native-app deep link
|
|
6
|
+
* doesn't get answered within ~1.5s, the SDK shows this overlay
|
|
7
|
+
* instead of throwing `no_wallet_installed` to the RP. The overlay:
|
|
8
|
+
*
|
|
9
|
+
* 1. Tells the user MATA needs to be installed.
|
|
10
|
+
* 2. Surfaces the right install link for the user's browser / OS.
|
|
11
|
+
* 3. Polls `hasExtension()` once a second — when the user installs
|
|
12
|
+
* the extension in a new tab and comes back, the overlay detects
|
|
13
|
+
* the new `window.__mata_mid__` and auto-resumes the sign-in.
|
|
14
|
+
* 4. Lets the user click "Continue" to manually retry, or "Cancel"
|
|
15
|
+
* to reject the sign-in.
|
|
16
|
+
*
|
|
17
|
+
* ## Why Shadow DOM
|
|
18
|
+
*
|
|
19
|
+
* The RP's page has unknown CSS. A naked `<div>` would inherit the
|
|
20
|
+
* page's font, get hidden by aggressive `body *` rules, or worse —
|
|
21
|
+
* be skinnable by a malicious RP. Shadow DOM gives us style isolation:
|
|
22
|
+
* the overlay always looks the same regardless of where it mounts.
|
|
23
|
+
*
|
|
24
|
+
* ## What we do NOT do
|
|
25
|
+
*
|
|
26
|
+
* - **No fingerprinting.** Browser detection uses `navigator.userAgent`
|
|
27
|
+
* only for choosing the right install CTA — we never report it.
|
|
28
|
+
* - **No tracking pixels.** The overlay is purely client-side.
|
|
29
|
+
* - **No redirect.** v1 stays inline. A future "hosted upsell page"
|
|
30
|
+
* is possible (would let us A/B install conversion) but the inline
|
|
31
|
+
* modal keeps the RP page in control and avoids a third-party
|
|
32
|
+
* redirect on the critical sign-in path.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const CHROME_WEB_STORE_URL =
|
|
36
|
+
'https://chromewebstore.google.com/detail/mata-digital-freedom-tool/fdpehkhlkfdmodfdjfjfaikaalabgimg';
|
|
37
|
+
|
|
38
|
+
/** Where the upsell sends the user when they click "create your account". */
|
|
39
|
+
const MATA_SIGNUP_URL = 'https://my.mata.network/signup';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract a sensible default referral code from an RP origin. Returns
|
|
43
|
+
* the bare hostname (no scheme, no port, no path) — e.g.
|
|
44
|
+
* `"https://acme.com"` → `"acme.com"`, `"http://localhost:3000"` →
|
|
45
|
+
* `"localhost"`. The MATA signup flow reads `?ref=` verbatim and
|
|
46
|
+
* stores it under the `mata_ref` cookie + `referral_code` Supabase
|
|
47
|
+
* field, so downstream attribution sees the RP's domain directly.
|
|
48
|
+
*
|
|
49
|
+
* Returns `null` when the origin can't be parsed — caller may want
|
|
50
|
+
* to omit the ref entirely in that case.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} rpOrigin
|
|
53
|
+
* @returns {string | null}
|
|
54
|
+
*/
|
|
55
|
+
export function defaultRefFromOrigin(rpOrigin) {
|
|
56
|
+
if (typeof rpOrigin !== 'string' || rpOrigin.length === 0) return null;
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(rpOrigin);
|
|
59
|
+
return url.hostname || null;
|
|
60
|
+
} catch (_) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Append `?ref=<code>` (or `&ref=<code>`) to a URL, preserving any
|
|
67
|
+
* existing query string. Used to attach the referral on both the
|
|
68
|
+
* install CTA and the signup link in the upsell modal.
|
|
69
|
+
*
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
function appendRef(url, ref) {
|
|
73
|
+
if (!ref) return url;
|
|
74
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
75
|
+
return `${url}${sep}ref=${encodeURIComponent(ref)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One of the CTAs the upsell can present. Pick by browser/OS.
|
|
80
|
+
*
|
|
81
|
+
* @typedef {object} InstallCta
|
|
82
|
+
* @property {string} label - Button text (e.g. "Install MATA for Chrome")
|
|
83
|
+
* @property {string} url - Where the button navigates to
|
|
84
|
+
* @property {string} hint - Sub-text under the CTA (e.g. "Opens the Chrome Web Store in a new tab")
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Detect the browser/OS and pick the right install CTA.
|
|
89
|
+
*
|
|
90
|
+
* @returns {InstallCta}
|
|
91
|
+
*/
|
|
92
|
+
export function pickInstallCta() {
|
|
93
|
+
if (typeof navigator === 'undefined') {
|
|
94
|
+
return chromeExtensionCta();
|
|
95
|
+
}
|
|
96
|
+
const ua = navigator.userAgent || '';
|
|
97
|
+
|
|
98
|
+
// Mobile: Chrome on Android, Safari on iOS. The Chrome extension
|
|
99
|
+
// doesn't run on mobile Chrome; deep-link to the native app once
|
|
100
|
+
// those listings exist. Until then, mobile users see a clear
|
|
101
|
+
// "MATA isn't on mobile yet" message rather than a broken link.
|
|
102
|
+
if (/Android/i.test(ua)) {
|
|
103
|
+
return {
|
|
104
|
+
label: 'MATA is coming to Android',
|
|
105
|
+
url: 'https://my.mata.network/',
|
|
106
|
+
hint: 'Open mata.network for updates.',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (/iPhone|iPad|iPod/i.test(ua)) {
|
|
110
|
+
return {
|
|
111
|
+
label: 'MATA is coming to iOS',
|
|
112
|
+
url: 'https://my.mata.network/',
|
|
113
|
+
hint: 'Open mata.network for updates.',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Desktop: pick a CTA per browser. Chrome / Edge / Brave / Arc /
|
|
118
|
+
// Opera all support Chrome Web Store extensions.
|
|
119
|
+
if (/Chrome|CriOS|Edg|OPR|Brave/i.test(ua) && !/Firefox/i.test(ua)) {
|
|
120
|
+
return chromeExtensionCta();
|
|
121
|
+
}
|
|
122
|
+
if (/Firefox/i.test(ua)) {
|
|
123
|
+
return {
|
|
124
|
+
label: 'MATA is coming to Firefox',
|
|
125
|
+
url: 'https://my.mata.network/',
|
|
126
|
+
hint: 'For now, use Chrome / Edge / Brave / Arc / Opera, or install the desktop app.',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
|
|
130
|
+
return {
|
|
131
|
+
label: 'MATA is coming to Safari',
|
|
132
|
+
url: 'https://my.mata.network/',
|
|
133
|
+
hint: 'For now, use Chrome / Edge / Brave / Arc / Opera, or install the desktop app.',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// Fallback — assume Chromium-flavored. The Web Store fails gracefully
|
|
137
|
+
// for genuinely-unsupported browsers (they show their own "this won't
|
|
138
|
+
// work here" page).
|
|
139
|
+
return chromeExtensionCta();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function chromeExtensionCta() {
|
|
143
|
+
return {
|
|
144
|
+
label: 'Install MATA from the Chrome Web Store',
|
|
145
|
+
url: CHROME_WEB_STORE_URL,
|
|
146
|
+
hint: 'Opens in a new tab. After installing, this page will detect MATA automatically.',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Render the install upsell overlay. Returns a Promise that resolves
|
|
152
|
+
* with one of:
|
|
153
|
+
*
|
|
154
|
+
* - `"installed"` — the upsell detected (via polling) that the
|
|
155
|
+
* extension is now installed. The caller should retry `signIn()`.
|
|
156
|
+
* - `"canceled"` — the user clicked Cancel or pressed Escape.
|
|
157
|
+
*
|
|
158
|
+
* The overlay polls `hasExtensionFn()` (defaults to a check of
|
|
159
|
+
* `window.__mata_mid__`) once a second. The Promise lives until one
|
|
160
|
+
* of those terminal states fires.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} options
|
|
163
|
+
* @param {string} options.rpOrigin - shown prominently as the
|
|
164
|
+
* requesting RP.
|
|
165
|
+
* @param {() => boolean} [options.hasExtensionFn] - inversion-of-control
|
|
166
|
+
* hook so the SDK's `hasExtension()` is used in production +
|
|
167
|
+
* tests can stub it.
|
|
168
|
+
* @param {InstallCta} [options.cta] - override the auto-picked CTA.
|
|
169
|
+
* @param {number} [options.pollIntervalMs] - default 1000.
|
|
170
|
+
* @param {string | null} [options.ref] - referral code stamped onto
|
|
171
|
+
* the install CTA and the signup link. Defaults to the hostname
|
|
172
|
+
* extracted from `rpOrigin` (e.g., `"acme.com"`), so RPs get
|
|
173
|
+
* attribution by default. Pass `null` to disable, or a custom
|
|
174
|
+
* string to override (e.g., a configured MATA referral code).
|
|
175
|
+
* Follows the existing `?ref=` convention captured by
|
|
176
|
+
* [my.mata.network](https://my.mata.network)'s welcome view.
|
|
177
|
+
* @returns {Promise<"installed" | "canceled">}
|
|
178
|
+
*/
|
|
179
|
+
export function showInstallUpsell(options) {
|
|
180
|
+
const rpOrigin = options.rpOrigin || 'this site';
|
|
181
|
+
const hasExtensionFn = options.hasExtensionFn || defaultHasExtension;
|
|
182
|
+
const cta = options.cta || pickInstallCta();
|
|
183
|
+
const pollIntervalMs = options.pollIntervalMs ?? 1000;
|
|
184
|
+
// Default: derive the ref code from the RP's domain so RPs get
|
|
185
|
+
// attribution without any extra wiring. Pass `null` to disable.
|
|
186
|
+
const ref =
|
|
187
|
+
options.ref === undefined
|
|
188
|
+
? defaultRefFromOrigin(options.rpOrigin)
|
|
189
|
+
: options.ref;
|
|
190
|
+
// Apply ref to both outbound URLs. Chrome Web Store strips unknown
|
|
191
|
+
// params on its own redirect, but stamping it costs nothing and
|
|
192
|
+
// keeps the future option open if the store starts honoring it.
|
|
193
|
+
const installUrl = appendRef(cta.url, ref);
|
|
194
|
+
const signupUrl = appendRef(MATA_SIGNUP_URL, ref);
|
|
195
|
+
const resolvedCta = { ...cta, url: installUrl };
|
|
196
|
+
|
|
197
|
+
// Already detected? Skip the modal entirely. Saves a flash.
|
|
198
|
+
// Check this BEFORE the document-existence guard so server-side
|
|
199
|
+
// tests with a stubbed `hasExtensionFn` still see the right
|
|
200
|
+
// short-circuit.
|
|
201
|
+
if (hasExtensionFn()) {
|
|
202
|
+
return Promise.resolve('installed');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof document === 'undefined') {
|
|
206
|
+
// Server-context safety — there's nothing to render.
|
|
207
|
+
return Promise.resolve('canceled');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return new Promise((resolve) => {
|
|
211
|
+
const host = document.createElement('div');
|
|
212
|
+
host.setAttribute('data-mata-mid-upsell', '');
|
|
213
|
+
// Maximum z — RPs can layer their own modals, this should win.
|
|
214
|
+
host.style.cssText = 'position: fixed; inset: 0; z-index: 2147483647;';
|
|
215
|
+
document.body.appendChild(host);
|
|
216
|
+
|
|
217
|
+
const shadow = host.attachShadow({ mode: 'closed' });
|
|
218
|
+
shadow.innerHTML = upsellMarkup({ rpOrigin, cta: resolvedCta, signupUrl });
|
|
219
|
+
|
|
220
|
+
let pollHandle = null;
|
|
221
|
+
let escHandler = null;
|
|
222
|
+
let trapHandler = null;
|
|
223
|
+
let resolved = false;
|
|
224
|
+
|
|
225
|
+
// Save the element that had focus before the modal opened so
|
|
226
|
+
// we can restore it on close — standard accessible-modal
|
|
227
|
+
// behavior. Without this, dismissing the modal leaves focus on
|
|
228
|
+
// <body>, which is disorienting for keyboard + screen-reader
|
|
229
|
+
// users.
|
|
230
|
+
const previouslyFocused =
|
|
231
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
232
|
+
|
|
233
|
+
const finish = (result) => {
|
|
234
|
+
if (resolved) return;
|
|
235
|
+
resolved = true;
|
|
236
|
+
if (pollHandle !== null) clearInterval(pollHandle);
|
|
237
|
+
if (escHandler !== null) document.removeEventListener('keydown', escHandler);
|
|
238
|
+
if (trapHandler !== null) host.removeEventListener('keydown', trapHandler, true);
|
|
239
|
+
try {
|
|
240
|
+
host.remove();
|
|
241
|
+
} catch (_) {
|
|
242
|
+
/* best-effort */
|
|
243
|
+
}
|
|
244
|
+
// Restore focus to wherever it was before we opened. Skip if
|
|
245
|
+
// the element is no longer in the document (e.g. RP nav-ed
|
|
246
|
+
// away and back).
|
|
247
|
+
if (previouslyFocused && document.contains(previouslyFocused)) {
|
|
248
|
+
try {
|
|
249
|
+
previouslyFocused.focus();
|
|
250
|
+
} catch (_) {
|
|
251
|
+
/* best-effort */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
resolve(result);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Wire button handlers.
|
|
258
|
+
const installBtn = shadow.querySelector('[data-action="install"]');
|
|
259
|
+
const continueBtn = shadow.querySelector('[data-action="continue"]');
|
|
260
|
+
const cancelBtn = shadow.querySelector('[data-action="cancel"]');
|
|
261
|
+
const backdrop = shadow.querySelector('[data-role="backdrop"]');
|
|
262
|
+
|
|
263
|
+
if (installBtn) {
|
|
264
|
+
installBtn.addEventListener('click', () => {
|
|
265
|
+
// Open the install URL in a new tab so this page stays open
|
|
266
|
+
// (the polling needs the current tab alive to detect the
|
|
267
|
+
// extension once the user comes back).
|
|
268
|
+
window.open(resolvedCta.url, '_blank', 'noopener,noreferrer');
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (continueBtn) {
|
|
272
|
+
continueBtn.addEventListener('click', () => {
|
|
273
|
+
if (hasExtensionFn()) {
|
|
274
|
+
finish('installed');
|
|
275
|
+
} else {
|
|
276
|
+
flashContinueHint(shadow);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (cancelBtn) {
|
|
281
|
+
cancelBtn.addEventListener('click', () => finish('canceled'));
|
|
282
|
+
}
|
|
283
|
+
if (backdrop) {
|
|
284
|
+
backdrop.addEventListener('click', (e) => {
|
|
285
|
+
// Only fire on direct backdrop clicks, not bubbled clicks
|
|
286
|
+
// from the dialog content.
|
|
287
|
+
if (e.target === backdrop) finish('canceled');
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Escape key cancels.
|
|
292
|
+
escHandler = (e) => {
|
|
293
|
+
if (e.key === 'Escape') finish('canceled');
|
|
294
|
+
};
|
|
295
|
+
document.addEventListener('keydown', escHandler);
|
|
296
|
+
|
|
297
|
+
// Focus trap. Modals MUST keep keyboard focus inside themselves
|
|
298
|
+
// for accessibility compliance — without this, Tab eventually
|
|
299
|
+
// escapes into the RP's underlying page (which is now
|
|
300
|
+
// inert-but-not-actually-inert behind the backdrop). We listen
|
|
301
|
+
// on `host` in capture phase so Tab is intercepted before the
|
|
302
|
+
// browser's native focus move runs.
|
|
303
|
+
trapHandler = (e) => {
|
|
304
|
+
if (e.key !== 'Tab') return;
|
|
305
|
+
const focusables = collectFocusables(shadow);
|
|
306
|
+
if (focusables.length === 0) {
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const first = focusables[0];
|
|
311
|
+
const last = focusables[focusables.length - 1];
|
|
312
|
+
// `shadow.activeElement` is the focused element inside the
|
|
313
|
+
// shadow tree (the document's activeElement is the host).
|
|
314
|
+
const active = shadow.activeElement;
|
|
315
|
+
if (e.shiftKey) {
|
|
316
|
+
// Shift+Tab on first → wrap to last.
|
|
317
|
+
if (active === first || !focusables.includes(active)) {
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
last.focus();
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
// Tab on last → wrap to first.
|
|
323
|
+
if (active === last) {
|
|
324
|
+
e.preventDefault();
|
|
325
|
+
first.focus();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
host.addEventListener('keydown', trapHandler, true);
|
|
330
|
+
|
|
331
|
+
// Move initial focus into the dialog. Default to the install
|
|
332
|
+
// button (primary action) — that matches the visual hierarchy.
|
|
333
|
+
// Wrapped in `requestAnimationFrame` so the dialog's open
|
|
334
|
+
// animation has a frame to settle; otherwise some browsers
|
|
335
|
+
// refuse to focus elements that just became visible.
|
|
336
|
+
requestAnimationFrame(() => {
|
|
337
|
+
const focusables = collectFocusables(shadow);
|
|
338
|
+
if (focusables.length > 0) {
|
|
339
|
+
try {
|
|
340
|
+
focusables[0].focus();
|
|
341
|
+
} catch (_) {
|
|
342
|
+
/* best-effort */
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Poll for the extension. Once the user installs in a new tab and
|
|
348
|
+
// returns to this tab, the next poll detects `window.__mata_mid__`
|
|
349
|
+
// and auto-resumes the sign-in.
|
|
350
|
+
pollHandle = setInterval(() => {
|
|
351
|
+
if (hasExtensionFn()) {
|
|
352
|
+
finish('installed');
|
|
353
|
+
}
|
|
354
|
+
}, pollIntervalMs);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Find the focusable elements inside the dialog's shadow tree, in
|
|
360
|
+
* tab order. Buttons + anchors with `href` cover everything the
|
|
361
|
+
* upsell ships today; the broader selector list is here so future
|
|
362
|
+
* additions (text inputs, custom-tabindex elements) work without a
|
|
363
|
+
* change to this helper.
|
|
364
|
+
*
|
|
365
|
+
* @internal
|
|
366
|
+
*/
|
|
367
|
+
function collectFocusables(shadow) {
|
|
368
|
+
const dialog = shadow.querySelector('.dialog');
|
|
369
|
+
if (!dialog) return [];
|
|
370
|
+
const selector = [
|
|
371
|
+
'button:not([disabled])',
|
|
372
|
+
'[href]',
|
|
373
|
+
'input:not([disabled])',
|
|
374
|
+
'select:not([disabled])',
|
|
375
|
+
'textarea:not([disabled])',
|
|
376
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
377
|
+
].join(',');
|
|
378
|
+
return Array.from(dialog.querySelectorAll(selector)).filter((el) => {
|
|
379
|
+
// Skip elements that are visually hidden (display:none) or
|
|
380
|
+
// explicitly inert. We use offsetParent === null as a fast
|
|
381
|
+
// proxy for "not laid out / not visible." Doesn't catch
|
|
382
|
+
// visibility:hidden, but the upsell never renders such
|
|
383
|
+
// elements.
|
|
384
|
+
return el.offsetParent !== null;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The runtime's default extension probe. Mirrors `hasExtension()` from
|
|
390
|
+
* the SDK index — kept inline so install-upsell.js can be imported
|
|
391
|
+
* standalone (e.g. for visual review).
|
|
392
|
+
*
|
|
393
|
+
* @internal
|
|
394
|
+
*/
|
|
395
|
+
function defaultHasExtension() {
|
|
396
|
+
return (
|
|
397
|
+
typeof window !== 'undefined' &&
|
|
398
|
+
typeof window['__mata_mid__'] === 'object' &&
|
|
399
|
+
window['__mata_mid__'] !== null &&
|
|
400
|
+
window['__mata_mid__'].version === 1
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Briefly highlight the polling hint when the user clicks Continue
|
|
406
|
+
* but the extension isn't detected yet. Helps them understand why
|
|
407
|
+
* nothing happened.
|
|
408
|
+
*
|
|
409
|
+
* @internal
|
|
410
|
+
*/
|
|
411
|
+
function flashContinueHint(shadow) {
|
|
412
|
+
const hint = shadow.querySelector('[data-role="continue-hint"]');
|
|
413
|
+
if (!hint) return;
|
|
414
|
+
hint.classList.remove('flash');
|
|
415
|
+
// Restart the CSS animation by forcing a reflow.
|
|
416
|
+
void hint.offsetWidth;
|
|
417
|
+
hint.classList.add('flash');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The actual HTML the Shadow DOM renders. Inlined CSS keeps the
|
|
422
|
+
* package single-file and avoids loader configuration on the RP side.
|
|
423
|
+
*
|
|
424
|
+
* @internal
|
|
425
|
+
*/
|
|
426
|
+
function upsellMarkup({ rpOrigin, cta, signupUrl }) {
|
|
427
|
+
// Strip any HTML that might appear in rpOrigin / cta. They're
|
|
428
|
+
// already validated upstream but defense-in-depth is cheap.
|
|
429
|
+
const safe = (s) =>
|
|
430
|
+
String(s)
|
|
431
|
+
.replace(/&/g, '&')
|
|
432
|
+
.replace(/</g, '<')
|
|
433
|
+
.replace(/>/g, '>')
|
|
434
|
+
.replace(/"/g, '"');
|
|
435
|
+
|
|
436
|
+
return `
|
|
437
|
+
<style>
|
|
438
|
+
:host { all: initial; }
|
|
439
|
+
* { box-sizing: border-box; }
|
|
440
|
+
[data-role="backdrop"] {
|
|
441
|
+
position: fixed; inset: 0;
|
|
442
|
+
background: rgba(0, 0, 0, 0.55);
|
|
443
|
+
display: flex; align-items: center; justify-content: center;
|
|
444
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
445
|
+
color: #1a1a1a;
|
|
446
|
+
animation: fade-in 0.18s ease-out;
|
|
447
|
+
}
|
|
448
|
+
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
|
449
|
+
.dialog {
|
|
450
|
+
background: #fff;
|
|
451
|
+
border-radius: 12px;
|
|
452
|
+
padding: 28px;
|
|
453
|
+
max-width: 460px;
|
|
454
|
+
width: calc(100% - 32px);
|
|
455
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25);
|
|
456
|
+
animation: slide-up 0.22s ease-out;
|
|
457
|
+
}
|
|
458
|
+
@keyframes slide-up {
|
|
459
|
+
from { transform: translateY(12px); opacity: 0; }
|
|
460
|
+
to { transform: translateY(0); opacity: 1; }
|
|
461
|
+
}
|
|
462
|
+
h2 {
|
|
463
|
+
margin: 0 0 4px;
|
|
464
|
+
font-size: 1.25rem;
|
|
465
|
+
font-weight: 600;
|
|
466
|
+
letter-spacing: -0.01em;
|
|
467
|
+
}
|
|
468
|
+
.rp {
|
|
469
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
470
|
+
font-size: 0.92rem;
|
|
471
|
+
color: #555;
|
|
472
|
+
word-break: break-all;
|
|
473
|
+
}
|
|
474
|
+
p {
|
|
475
|
+
margin: 14px 0 0;
|
|
476
|
+
line-height: 1.5;
|
|
477
|
+
font-size: 0.95rem;
|
|
478
|
+
color: #333;
|
|
479
|
+
}
|
|
480
|
+
.actions {
|
|
481
|
+
margin-top: 22px;
|
|
482
|
+
display: flex;
|
|
483
|
+
flex-direction: column;
|
|
484
|
+
gap: 10px;
|
|
485
|
+
}
|
|
486
|
+
.btn {
|
|
487
|
+
font: inherit;
|
|
488
|
+
font-weight: 500;
|
|
489
|
+
padding: 11px 18px;
|
|
490
|
+
border-radius: 6px;
|
|
491
|
+
border: none;
|
|
492
|
+
cursor: pointer;
|
|
493
|
+
transition: background 0.12s, opacity 0.12s;
|
|
494
|
+
text-align: center;
|
|
495
|
+
text-decoration: none;
|
|
496
|
+
display: inline-block;
|
|
497
|
+
}
|
|
498
|
+
.btn-primary { background: #2563eb; color: #fff; }
|
|
499
|
+
.btn-primary:hover { background: #1d4ed8; }
|
|
500
|
+
.btn-secondary {
|
|
501
|
+
background: #fff;
|
|
502
|
+
color: #1a1a1a;
|
|
503
|
+
border: 1px solid #d0d0d0;
|
|
504
|
+
}
|
|
505
|
+
.btn-secondary:hover { background: #f6f6f6; }
|
|
506
|
+
.btn-tertiary {
|
|
507
|
+
background: transparent;
|
|
508
|
+
color: #555;
|
|
509
|
+
padding: 8px;
|
|
510
|
+
font-size: 0.88rem;
|
|
511
|
+
}
|
|
512
|
+
.btn-tertiary:hover { color: #1a1a1a; }
|
|
513
|
+
/* Keyboard focus ring. :focus-visible so mouse users don't
|
|
514
|
+
see a ring on click but keyboard / screen-reader users do —
|
|
515
|
+
standard accessible-modal practice. */
|
|
516
|
+
.btn:focus-visible,
|
|
517
|
+
.signup-line a:focus-visible {
|
|
518
|
+
outline: 2px solid #2563eb;
|
|
519
|
+
outline-offset: 2px;
|
|
520
|
+
}
|
|
521
|
+
.cta-hint {
|
|
522
|
+
margin: 6px 0 0;
|
|
523
|
+
font-size: 0.82rem;
|
|
524
|
+
color: #777;
|
|
525
|
+
text-align: center;
|
|
526
|
+
}
|
|
527
|
+
.row {
|
|
528
|
+
display: flex;
|
|
529
|
+
gap: 8px;
|
|
530
|
+
}
|
|
531
|
+
.row .btn { flex: 1; }
|
|
532
|
+
.continue-hint {
|
|
533
|
+
margin: 12px 0 0;
|
|
534
|
+
text-align: center;
|
|
535
|
+
font-size: 0.84rem;
|
|
536
|
+
color: #888;
|
|
537
|
+
}
|
|
538
|
+
.continue-hint.flash {
|
|
539
|
+
animation: flash-warn 0.6s ease-out;
|
|
540
|
+
}
|
|
541
|
+
@keyframes flash-warn {
|
|
542
|
+
0% { color: #888; }
|
|
543
|
+
40% { color: #b45309; }
|
|
544
|
+
100% { color: #888; }
|
|
545
|
+
}
|
|
546
|
+
.signup-line {
|
|
547
|
+
margin: 14px 0 0;
|
|
548
|
+
padding-top: 14px;
|
|
549
|
+
border-top: 1px solid #eee;
|
|
550
|
+
font-size: 0.88rem;
|
|
551
|
+
color: #555;
|
|
552
|
+
text-align: center;
|
|
553
|
+
}
|
|
554
|
+
.signup-line a {
|
|
555
|
+
color: #2563eb;
|
|
556
|
+
text-decoration: none;
|
|
557
|
+
}
|
|
558
|
+
.signup-line a:hover { text-decoration: underline; }
|
|
559
|
+
</style>
|
|
560
|
+
<div data-role="backdrop" role="dialog" aria-modal="true" aria-labelledby="mata-mid-title">
|
|
561
|
+
<div class="dialog">
|
|
562
|
+
<h2 id="mata-mid-title">Sign in with Sovereign ID</h2>
|
|
563
|
+
<div class="rp">${safe(rpOrigin)}</div>
|
|
564
|
+
|
|
565
|
+
<p>
|
|
566
|
+
To sign in here, install <strong>MATA</strong>. Your wallet stays
|
|
567
|
+
on your device — MATA never sees who you are or where you sign in.
|
|
568
|
+
</p>
|
|
569
|
+
|
|
570
|
+
<div class="actions">
|
|
571
|
+
<button class="btn btn-primary" data-action="install">${safe(cta.label)}</button>
|
|
572
|
+
<p class="cta-hint">${safe(cta.hint)}</p>
|
|
573
|
+
|
|
574
|
+
<div class="row">
|
|
575
|
+
<button class="btn btn-secondary" data-action="continue">I've installed it</button>
|
|
576
|
+
<button class="btn btn-secondary" data-action="cancel">Cancel</button>
|
|
577
|
+
</div>
|
|
578
|
+
<p class="continue-hint" data-role="continue-hint">
|
|
579
|
+
Watching for MATA — this will continue automatically once installed.
|
|
580
|
+
</p>
|
|
581
|
+
</div>
|
|
582
|
+
|
|
583
|
+
<p class="signup-line">
|
|
584
|
+
New to MATA? After installing, the extension will walk you through
|
|
585
|
+
<a href="${safe(signupUrl)}" target="_blank" rel="noopener noreferrer">
|
|
586
|
+
creating your account
|
|
587
|
+
</a>.
|
|
588
|
+
</p>
|
|
589
|
+
</div>
|
|
590
|
+
</div>
|
|
591
|
+
`;
|
|
592
|
+
}
|
package/src/resume.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume-after-reload — sessionStorage-backed pending sign-in handoff.
|
|
3
|
+
*
|
|
4
|
+
* The install upsell modal handles the common case where the user
|
|
5
|
+
* installs MATA in a new tab and returns to the RP tab — the modal's
|
|
6
|
+
* polling detects `window.__mata_mid__` and the in-flight `signIn()`
|
|
7
|
+
* promise resolves. But there's a second path: the user reloads the
|
|
8
|
+
* RP tab manually (or Chrome's "Extension installed" toast nudges them
|
|
9
|
+
* to). In that path the modal is gone and the awaiting promise dies.
|
|
10
|
+
*
|
|
11
|
+
* This module persists enough state across the reload that
|
|
12
|
+
* `resumePendingSignIn()` can pick the flow back up the moment the
|
|
13
|
+
* page boots.
|
|
14
|
+
*
|
|
15
|
+
* ## Storage model
|
|
16
|
+
*
|
|
17
|
+
* - **sessionStorage**, not localStorage. Per-tab, cleared on tab
|
|
18
|
+
* close. Matches the natural lifespan of a sign-in request — we
|
|
19
|
+
* don't want a stale resume to fire 3 days later in a different
|
|
20
|
+
* browsing session.
|
|
21
|
+
* - **One pending entry per tab.** If `signIn()` is called twice
|
|
22
|
+
* before the first resolves, the second overwrites — same UX as the
|
|
23
|
+
* modal-replacement behavior in the consent handoff.
|
|
24
|
+
* - **TTL = the original request's `timeoutMs`**. If the user reloads
|
|
25
|
+
* after the request would have timed out anyway, the resume entry
|
|
26
|
+
* is treated as stale and dropped.
|
|
27
|
+
*
|
|
28
|
+
* ## When the entry is cleared
|
|
29
|
+
*
|
|
30
|
+
* - On successful resolution (JWT received).
|
|
31
|
+
* - On user cancel from the upsell modal.
|
|
32
|
+
* - On TTL expiry at the next `resumePendingSignIn()` call.
|
|
33
|
+
* - On any non-`ERR_NO_WALLET_INSTALLED` error from `signIn()` (so
|
|
34
|
+
* resume doesn't keep retrying a malformed request).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Fixed sessionStorage key. One pending sign-in per tab. */
|
|
38
|
+
const STORAGE_KEY = 'mata_mid_pending_signin';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Stash a pending sign-in for possible resume after page reload.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} entry
|
|
44
|
+
* @param {object} entry.request - the original `SignInRequest` shape.
|
|
45
|
+
* @param {object} entry.options - the original `SignInOptions` shape.
|
|
46
|
+
* @param {number} entry.expiresAt - Unix ms; entries past this time
|
|
47
|
+
* are treated as stale.
|
|
48
|
+
*/
|
|
49
|
+
export function stashPendingSignIn(entry) {
|
|
50
|
+
if (typeof sessionStorage === 'undefined') return;
|
|
51
|
+
try {
|
|
52
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entry));
|
|
53
|
+
} catch (_) {
|
|
54
|
+
// Storage quota / private-mode rejection — silent skip is fine;
|
|
55
|
+
// the only consequence is that reload-resume won't fire.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read the pending sign-in entry. Returns `null` when there isn't one,
|
|
61
|
+
* when it's malformed, or when the TTL has passed (in which case the
|
|
62
|
+
* entry is also cleared).
|
|
63
|
+
*
|
|
64
|
+
* @returns {{request: object, options: object, expiresAt: number} | null}
|
|
65
|
+
*/
|
|
66
|
+
export function readPendingSignIn() {
|
|
67
|
+
if (typeof sessionStorage === 'undefined') return null;
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = sessionStorage.getItem(STORAGE_KEY);
|
|
71
|
+
} catch (_) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (!raw) return null;
|
|
75
|
+
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(raw);
|
|
79
|
+
} catch (_) {
|
|
80
|
+
clearPendingSignIn();
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
typeof parsed !== 'object' ||
|
|
85
|
+
parsed === null ||
|
|
86
|
+
typeof parsed.request !== 'object' ||
|
|
87
|
+
typeof parsed.expiresAt !== 'number'
|
|
88
|
+
) {
|
|
89
|
+
clearPendingSignIn();
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (Date.now() >= parsed.expiresAt) {
|
|
93
|
+
clearPendingSignIn();
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
// `options` is optional — older entries from a partial-deploy may
|
|
97
|
+
// omit it. Treat missing as `{}` so the resume call still composes.
|
|
98
|
+
if (typeof parsed.options !== 'object' || parsed.options === null) {
|
|
99
|
+
parsed.options = {};
|
|
100
|
+
}
|
|
101
|
+
return parsed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Clear any pending sign-in entry. Safe to call when nothing is
|
|
106
|
+
* pending.
|
|
107
|
+
*/
|
|
108
|
+
export function clearPendingSignIn() {
|
|
109
|
+
if (typeof sessionStorage === 'undefined') return;
|
|
110
|
+
try {
|
|
111
|
+
sessionStorage.removeItem(STORAGE_KEY);
|
|
112
|
+
} catch (_) {
|
|
113
|
+
/* best-effort */
|
|
114
|
+
}
|
|
115
|
+
}
|