instar 1.3.804 → 1.3.805
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/dashboard/index.html +19 -1
- package/dashboard/subscriptions.js +513 -67
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +36 -0
- package/dist/commands/server.js.map +1 -1
- package/dist/core/EnrollmentWizard.d.ts +23 -0
- package/dist/core/EnrollmentWizard.d.ts.map +1 -1
- package/dist/core/EnrollmentWizard.js +42 -1
- package/dist/core/EnrollmentWizard.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +128 -30
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +46 -46
- package/upgrades/1.3.805.md +41 -0
- package/upgrades/side-effects/subs-setup-flow-ux.md +79 -0
- package/upgrades/subs-setup-flow-ux.eli16.md +31 -0
|
@@ -90,6 +90,63 @@ export function countdown(iso, now = Date.now(), { expiredWord = 'expired' } = {
|
|
|
90
90
|
return `${sec}s`;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// ── Interaction-hold (Dashboard UX Standard F9) ─────────────────────────────
|
|
94
|
+
// THE RULE (topic 29836 case study D1): a background refresh must NEVER replace a
|
|
95
|
+
// surface with an open interaction. An open interaction is (a) an element marked
|
|
96
|
+
// data-interaction-open (an enrollment episode the controller is driving), (b) a
|
|
97
|
+
// focused text-entry element, or (c) a dirty (partially-typed) field. While one is
|
|
98
|
+
// open, the periodic poll MERGES server state into the view (countdowns, status
|
|
99
|
+
// lines) instead of rebuilding the DOM out from under the operator's fingers. The
|
|
100
|
+
// hold releases when the flow reaches a terminal state (verified / failed /
|
|
101
|
+
// cancelled / expired) or the operator backs out.
|
|
102
|
+
export function hasOpenInteraction(doc, root) {
|
|
103
|
+
if (!root || typeof root.querySelector !== 'function') return false;
|
|
104
|
+
if (root.querySelector('[data-interaction-open]')) return true;
|
|
105
|
+
const active = doc && doc.activeElement;
|
|
106
|
+
if (active && root.contains(active) && /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName || '')) return true;
|
|
107
|
+
for (const field of root.querySelectorAll('input, textarea')) {
|
|
108
|
+
if (typeof field.value === 'string' && field.value !== '') return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The F9 merge arm: patch every countdown element ([data-ttl-expires]) under `root`
|
|
114
|
+
* from the live clock WITHOUT rebuilding anything. Safe on held and unheld DOM.
|
|
115
|
+
* Returns the number of elements patched. */
|
|
116
|
+
export function updateCountdowns(doc, root, now = Date.now()) {
|
|
117
|
+
if (!root || typeof root.querySelectorAll !== 'function') return 0;
|
|
118
|
+
let patched = 0;
|
|
119
|
+
for (const node of root.querySelectorAll('[data-ttl-expires]')) {
|
|
120
|
+
const left = countdown(node.getAttribute('data-ttl-expires'), now);
|
|
121
|
+
node.textContent = left === 'expired'
|
|
122
|
+
? 'Sign-in link expired — start again'
|
|
123
|
+
: (left ? `Link expires in ${left}` : '');
|
|
124
|
+
patched++;
|
|
125
|
+
}
|
|
126
|
+
return patched;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Plain-language explanation for a HELD (identity-refused) enrollment, naming BOTH
|
|
130
|
+
* accounts when the gate verdict carries them (topic 29836 D3). Fail-closed reasons
|
|
131
|
+
* (oracle unavailable / no expected email) get honest "couldn't confirm" copy. */
|
|
132
|
+
export function heldExplanation(expected, got, reason, { short = false } = {}) {
|
|
133
|
+
const exp = sanitizeForDisplay(expected, 'label');
|
|
134
|
+
const g = sanitizeForDisplay(got, 'label');
|
|
135
|
+
if (exp && g) {
|
|
136
|
+
return short
|
|
137
|
+
? `That sign-in was ${g} — this slot needs ${exp}.`
|
|
138
|
+
: `That code signed in ${g} — this slot needs ${exp}. The account was NOT enrolled; sign in again with the right account.`;
|
|
139
|
+
}
|
|
140
|
+
if (reason === 'missing-completed-email') {
|
|
141
|
+
return short
|
|
142
|
+
? 'Couldn’t confirm which account that was — not enrolled.'
|
|
143
|
+
: 'Signed in, but I couldn’t confirm which account that sign-in belongs to — so it was NOT enrolled (fail-closed). Try again, or check the attention queue.';
|
|
144
|
+
}
|
|
145
|
+
return short
|
|
146
|
+
? 'The account didn’t verify — not enrolled.'
|
|
147
|
+
: 'Signed in, but the account couldn’t be verified against what you approved — so it was NOT enrolled.';
|
|
148
|
+
}
|
|
149
|
+
|
|
93
150
|
/** A coarse "N ago" for a PAST ISO timestamp (token-refresh recency). '' if invalid. */
|
|
94
151
|
export function relativeAge(iso, now = Date.now()) {
|
|
95
152
|
const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
|
|
@@ -262,11 +319,38 @@ function trustedLoginUrl(raw) {
|
|
|
262
319
|
} catch { return null; }
|
|
263
320
|
}
|
|
264
321
|
|
|
265
|
-
|
|
322
|
+
/** Explicit terminal-outcome card for the pending panel (topic 29836 D4 — completion
|
|
323
|
+
* must never be a vanishing line). `o` is a client-recorded outcome:
|
|
324
|
+
* { kind:'validated'|'held'|'expired', accountId, machineId, machineNickname?, email?,
|
|
325
|
+
* expected?, got?, reason? }. Static glyphs are literals (never dynamic data). */
|
|
326
|
+
export function renderOutcomeCard(doc, o) {
|
|
327
|
+
const isDone = o && o.kind === 'validated';
|
|
328
|
+
const row = el(doc, 'div', isDone ? 'sub-pending sub-pending-done' : 'sub-pending sub-pending-failed');
|
|
329
|
+
const who = sanitizeForDisplay((o && (o.email || o.accountId)) || 'The account', 'label');
|
|
330
|
+
const machine = sanitizeForDisplay((o && (o.machineNickname || o.machineId)) || 'the machine', 'label');
|
|
331
|
+
if (isDone) {
|
|
332
|
+
row.appendChild(el(doc, 'div', 'sub-pending-done-head', '✓ Done'));
|
|
333
|
+
row.appendChild(el(doc, 'div', 'sub-pending-outcome-body', `${who} is now set up on ${machine}.`));
|
|
334
|
+
} else if (o && o.kind === 'held') {
|
|
335
|
+
row.appendChild(el(doc, 'div', 'sub-pending-failed-head', '✗ Not enrolled — the account didn’t match'));
|
|
336
|
+
row.appendChild(el(doc, 'div', 'sub-pending-outcome-body', heldExplanation(o.expected, o.got, o.reason)));
|
|
337
|
+
} else {
|
|
338
|
+
row.appendChild(el(doc, 'div', 'sub-pending-failed-head', '✗ Sign-in link expired'));
|
|
339
|
+
row.appendChild(el(doc, 'div', 'sub-pending-outcome-body',
|
|
340
|
+
`The sign-in for ${who} on ${machine} expired before it finished — start it again from the grid above.`));
|
|
341
|
+
}
|
|
342
|
+
return row;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function renderPendingLogins(doc, target, logins, now = Date.now(), outcomes = []) {
|
|
266
346
|
if (!target) return;
|
|
267
347
|
target.replaceChildren();
|
|
348
|
+
// Client-observed terminal outcomes lead the panel (explicit completed/failed cards —
|
|
349
|
+
// never a vanishing line; topic 29836 D4). Newest first, capped by the controller.
|
|
350
|
+
const cards = Array.isArray(outcomes) ? outcomes : [];
|
|
351
|
+
for (const o of cards) target.appendChild(renderOutcomeCard(doc, o));
|
|
268
352
|
if (!Array.isArray(logins) || logins.length === 0) {
|
|
269
|
-
target.appendChild(el(doc, 'div', 'sub-empty', 'No logins waiting for approval.'));
|
|
353
|
+
if (cards.length === 0) target.appendChild(el(doc, 'div', 'sub-empty', 'No logins waiting for approval.'));
|
|
270
354
|
return;
|
|
271
355
|
}
|
|
272
356
|
for (const l of logins) {
|
|
@@ -275,14 +359,38 @@ export function renderPendingLogins(doc, target, logins, now = Date.now()) {
|
|
|
275
359
|
row.setAttribute('data-login-id', sanitizeForDisplay(l && l.id, 'label'));
|
|
276
360
|
if (l && (l.machineId || l.machineNickname)) row.setAttribute('data-machine-id', sanitizeForDisplay(l.machineId, 'label'));
|
|
277
361
|
|
|
278
|
-
// Lead with a plain-language headline naming what to do + where (
|
|
279
|
-
|
|
280
|
-
|
|
362
|
+
// Lead with a plain-language headline naming what to do + where. Wording floors (D5):
|
|
363
|
+
// the ACCOUNT is shown by its email when known (the label can be an internal nickname
|
|
364
|
+
// like "Justin"); the MACHINE by nickname, NEVER a raw m_<hex> machine id.
|
|
365
|
+
const machine = friendlyMachine(l && l.machineNickname, l && l.machineId);
|
|
366
|
+
const who = sanitizeForDisplay(l && (l.expectedEmail || l.email || l.label), 'label');
|
|
367
|
+
|
|
368
|
+
// D5 record ⟂ pane liveness: a login whose sign-in window is GONE (paneAlive === false)
|
|
369
|
+
// must not present as submittable — no link, no code input; an explicit needs-restart
|
|
370
|
+
// card pointing at the working restart affordance (the grid's Retry) instead.
|
|
371
|
+
if (l && l.paneAlive === false) {
|
|
372
|
+
row.setAttribute('class', 'sub-pending sub-pending-failed');
|
|
373
|
+
row.appendChild(el(doc, 'div', 'sub-pending-failed-head',
|
|
374
|
+
machine ? `✗ This sign-in can’t finish — its window on ${machine} closed` : '✗ This sign-in can’t finish — its window closed'));
|
|
375
|
+
row.appendChild(el(doc, 'div', 'sub-pending-outcome-body',
|
|
376
|
+
`Start ${who || 'it'} again from the “Accounts on each machine” grid above — tapping Retry starts a fresh sign-in.`));
|
|
377
|
+
target.appendChild(row);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
281
381
|
const headline = machine
|
|
282
382
|
? `Sign in to finish setting up ${who} on ${machine}`
|
|
283
383
|
: `Sign in to finish setting up ${who}`;
|
|
284
384
|
row.appendChild(el(doc, 'div', 'sub-pending-headline', headline));
|
|
285
385
|
|
|
386
|
+
// Wrong-account hazard (topic 29836 D3): state prominently, BEFORE the link, which
|
|
387
|
+
// account the provider's OAuth page MUST show — the page opens in whatever login
|
|
388
|
+
// state the browser already has, and nothing else warns.
|
|
389
|
+
if (l && l.expectedEmail) {
|
|
390
|
+
row.appendChild(el(doc, 'div', 'sub-pending-expected',
|
|
391
|
+
`The sign-in page must show ${sanitizeForDisplay(l.expectedEmail, 'label')} — if it shows a different account, tap “Switch account” first.`));
|
|
392
|
+
}
|
|
393
|
+
|
|
286
394
|
// The PRIMARY action: one tappable "Sign in" link to the provider's own OAuth URL.
|
|
287
395
|
// Falls back to copy-text only if the URL isn't a trusted provider sign-in host.
|
|
288
396
|
const href = trustedLoginUrl(l && l.verificationUrl);
|
|
@@ -319,9 +427,14 @@ export function renderPendingLogins(doc, target, logins, now = Date.now()) {
|
|
|
319
427
|
row.appendChild(submit);
|
|
320
428
|
}
|
|
321
429
|
|
|
322
|
-
// One short secondary line: the TTL
|
|
430
|
+
// One short secondary line: the TTL (patched live by updateCountdowns between
|
|
431
|
+
// rebuilds — the F9 merge arm), and the flow notice only if present (trimmed).
|
|
323
432
|
const ttl = l && l.ttlExpiresAt ? countdown(l.ttlExpiresAt, now) : '';
|
|
324
|
-
if (ttl)
|
|
433
|
+
if (ttl) {
|
|
434
|
+
const ttlEl = el(doc, 'div', 'sub-pending-ttl', ttl === 'expired' ? 'Sign-in link expired — start again' : `Link expires in ${ttl}`);
|
|
435
|
+
ttlEl.setAttribute('data-ttl-expires', sanitizeForDisplay(l.ttlExpiresAt, 'label'));
|
|
436
|
+
row.appendChild(ttlEl);
|
|
437
|
+
}
|
|
325
438
|
if (l && l.notice) {
|
|
326
439
|
row.appendChild(el(doc, 'div', 'sub-pending-notice', sanitizeForDisplay(l.notice, 'summary')));
|
|
327
440
|
}
|
|
@@ -392,9 +505,13 @@ export function buildMatrixModel(poolScope, pendingScope, transient = {}) {
|
|
|
392
505
|
cellStatus.set(`${a.id}::${mid}`, a.status === 'needs-reauth' ? 'needs-reauth' : (a.status === 'active' ? 'active' : 'other'));
|
|
393
506
|
}
|
|
394
507
|
// (accountId, machineId) in-progress, correlated on (login.id === accountId, machineId) (FD6 r3 #2).
|
|
395
|
-
|
|
508
|
+
// The MAP carries the pending-login RECORD so the in-progress cell can render the COMPLETE
|
|
509
|
+
// sign-in flow (link, expected email, code input, TTL, notice) from SERVER state — the flow
|
|
510
|
+
// must never exist only in the bottom panel (topic 29836 D2), and a rebuild mid-flow renders
|
|
511
|
+
// the same step back instead of losing it (D1 defense-in-depth).
|
|
512
|
+
const inProgress = new Map();
|
|
396
513
|
for (const l of pendingRows) {
|
|
397
|
-
if (l && l.id && l.machineId) inProgress.
|
|
514
|
+
if (l && l.id && l.machineId) inProgress.set(`${l.id}::${l.machineId}`, l);
|
|
398
515
|
}
|
|
399
516
|
|
|
400
517
|
const machineList = Array.from(machines.values());
|
|
@@ -404,15 +521,32 @@ export function buildMatrixModel(poolScope, pendingScope, transient = {}) {
|
|
|
404
521
|
const rowCells = [];
|
|
405
522
|
for (const m of machineList) {
|
|
406
523
|
const key = `${acct.accountId}::${m.machineId}`;
|
|
524
|
+
const t = transient[key] || null;
|
|
525
|
+
const pendingLogin = inProgress.get(key) || null;
|
|
407
526
|
let state;
|
|
408
527
|
if (m.offline) state = 'offline'; // whole column offline (FD6)
|
|
409
|
-
else if (
|
|
410
|
-
else if (
|
|
411
|
-
else if (inProgress.has(key)) state = 'in-progress';
|
|
528
|
+
else if (t && t.state === 'held') state = 'held';
|
|
529
|
+
else if (t && t.state === 'cant-resolve') state = 'cant-resolve';
|
|
412
530
|
else if (cellStatus.get(key) === 'active') state = 'active';
|
|
531
|
+
// just-verified BRIDGES the gap between a client-observed successful enrollment and the
|
|
532
|
+
// next pool read that shows the account active — the cell must flip to an unmistakable
|
|
533
|
+
// verified presentation the moment the enrollment verifies (topic 29836 D4), never blink
|
|
534
|
+
// back to a "Set up" button while the server catches up.
|
|
535
|
+
else if (t && t.state === 'just-verified') state = 'just-verified';
|
|
536
|
+
// broken (D5): the server says this attempt's sign-in pane is DEAD (record ⟂ pane
|
|
537
|
+
// reconciliation) — or the client just watched a code-submit refuse with pane-dead.
|
|
538
|
+
// Presenting it as submittable would be a lie; it gets an explicit needs-restart
|
|
539
|
+
// presentation with a working Retry (start-cell supersedes the zombie atomically).
|
|
540
|
+
else if ((pendingLogin && pendingLogin.paneAlive === false) || (t && t.state === 'broken')) state = 'broken';
|
|
541
|
+
else if (pendingLogin) state = 'in-progress';
|
|
542
|
+
else if (t && t.state === 'expired') state = 'expired';
|
|
413
543
|
else if (cellStatus.get(key) === 'needs-reauth') state = 'needs-reauth';
|
|
414
544
|
else state = 'empty'; // → "Set up" button
|
|
415
|
-
rowCells.push({
|
|
545
|
+
rowCells.push({
|
|
546
|
+
accountId: acct.accountId, machineId: m.machineId, state,
|
|
547
|
+
login: state === 'in-progress' ? pendingLogin : null,
|
|
548
|
+
detail: t,
|
|
549
|
+
});
|
|
416
550
|
}
|
|
417
551
|
cells.push({ account: acct, cells: rowCells });
|
|
418
552
|
}
|
|
@@ -421,12 +555,85 @@ export function buildMatrixModel(poolScope, pendingScope, transient = {}) {
|
|
|
421
555
|
|
|
422
556
|
const MATRIX_CELL_GLYPH = {
|
|
423
557
|
active: '✓', 'needs-reauth': '⟳', 'in-progress': '◷', offline: '—', held: '⚠', 'cant-resolve': '✗',
|
|
558
|
+
expired: '✗', 'just-verified': '✓', broken: '✗',
|
|
424
559
|
};
|
|
425
560
|
const MATRIX_CELL_WORD = {
|
|
426
561
|
active: 'Active', 'needs-reauth': 'Needs sign-in', 'in-progress': 'Signing in…',
|
|
427
562
|
offline: 'Machine offline', held: 'Didn’t match — re-try', 'cant-resolve': 'Can’t set up', other: 'Set up',
|
|
563
|
+
expired: 'Sign-in link expired', 'just-verified': 'Set up complete', broken: 'Sign-in needs a restart',
|
|
428
564
|
};
|
|
429
565
|
|
|
566
|
+
/** D5 wording floor: never show a raw internal machine id (m_<hex>) to the operator —
|
|
567
|
+
* a nickname, or nothing. Exported for the floor test. */
|
|
568
|
+
export function friendlyMachine(nickname, machineId) {
|
|
569
|
+
const nick = sanitizeForDisplay(nickname, 'label');
|
|
570
|
+
if (nick) return nick;
|
|
571
|
+
const id = typeof machineId === 'string' ? machineId : '';
|
|
572
|
+
if (/^m_[0-9a-f]{8,}$/i.test(id)) return '';
|
|
573
|
+
return sanitizeForDisplay(id, 'label');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Append the COMPLETE in-cell sign-in flow (topic 29836 D2): the sign-in link, the
|
|
578
|
+
* expected-account warning (D3 UI layer), the paste-back code input, the live TTL
|
|
579
|
+
* countdown, the flow notice (two-codes heads-up), and a Cancel. Shared by the
|
|
580
|
+
* in-progress matrix cell (rendered from SERVER pending-login state, so the step
|
|
581
|
+
* survives reloads and rebuilds) and the controller's immediate post-start render.
|
|
582
|
+
* `flow` = { accountId, machineId, loginId, verificationUrl, expectedEmail,
|
|
583
|
+
* ttlExpiresAt, notice, kind, userCode }.
|
|
584
|
+
*/
|
|
585
|
+
export function appendCellSignInFlow(doc, cell, flow, now = Date.now()) {
|
|
586
|
+
cell.appendChild(el(doc, 'div', 'sub-matrix-flow-step', 'Open the sign-in link, then paste the code it gives you below.'));
|
|
587
|
+
if (flow && flow.expectedEmail) {
|
|
588
|
+
cell.appendChild(el(doc, 'div', 'sub-matrix-expected',
|
|
589
|
+
`The sign-in page must show ${sanitizeForDisplay(flow.expectedEmail, 'label')} — if it shows a different account, tap “Switch account” first.`));
|
|
590
|
+
}
|
|
591
|
+
const href = trustedLoginUrl(flow && flow.verificationUrl);
|
|
592
|
+
if (href) {
|
|
593
|
+
const a = doc.createElement('a');
|
|
594
|
+
a.setAttribute('href', href);
|
|
595
|
+
a.setAttribute('target', '_blank');
|
|
596
|
+
a.setAttribute('rel', 'noopener noreferrer');
|
|
597
|
+
a.setAttribute('class', 'sub-matrix-signin');
|
|
598
|
+
a.textContent = 'Sign in';
|
|
599
|
+
cell.appendChild(a);
|
|
600
|
+
} else if (flow && flow.verificationUrl) {
|
|
601
|
+
cell.appendChild(el(doc, 'div', 'sub-matrix-url', sanitizeForDisplay(flow.verificationUrl, 'url')));
|
|
602
|
+
}
|
|
603
|
+
if (flow && flow.userCode) {
|
|
604
|
+
cell.appendChild(el(doc, 'div', 'sub-pending-code', `Code: ${sanitizeForDisplay(flow.userCode, 'code')}`));
|
|
605
|
+
}
|
|
606
|
+
if (!flow || flow.kind !== 'device-code') {
|
|
607
|
+
const code = doc.createElement('input');
|
|
608
|
+
code.setAttribute('type', 'text');
|
|
609
|
+
code.setAttribute('class', 'sub-matrix-code-input');
|
|
610
|
+
code.setAttribute('placeholder', 'Paste your sign-in code');
|
|
611
|
+
code.setAttribute('autocomplete', 'off');
|
|
612
|
+
cell.appendChild(code);
|
|
613
|
+
const submit = el(doc, 'button', 'sub-matrix-code-submit', 'Submit');
|
|
614
|
+
submit.setAttribute('data-matrix-code-submit', '1');
|
|
615
|
+
submit.setAttribute('data-account-id', sanitizeForDisplay(flow && flow.accountId, 'label'));
|
|
616
|
+
submit.setAttribute('data-machine-id', sanitizeForDisplay(flow && flow.machineId, 'label'));
|
|
617
|
+
submit.setAttribute('data-login-id', sanitizeForDisplay((flow && (flow.loginId || flow.accountId)) || '', 'label'));
|
|
618
|
+
cell.appendChild(submit);
|
|
619
|
+
}
|
|
620
|
+
if (flow && flow.notice) {
|
|
621
|
+
cell.appendChild(el(doc, 'div', 'sub-matrix-notice', sanitizeForDisplay(flow.notice, 'summary')));
|
|
622
|
+
}
|
|
623
|
+
if (flow && flow.ttlExpiresAt) {
|
|
624
|
+
const left = countdown(flow.ttlExpiresAt, now);
|
|
625
|
+
const ttl = el(doc, 'div', 'sub-matrix-ttl',
|
|
626
|
+
left === 'expired' ? 'Sign-in link expired — start again' : (left ? `Link expires in ${left}` : ''));
|
|
627
|
+
ttl.setAttribute('data-ttl-expires', sanitizeForDisplay(flow.ttlExpiresAt, 'label'));
|
|
628
|
+
cell.appendChild(ttl);
|
|
629
|
+
}
|
|
630
|
+
const cancelBtn = el(doc, 'button', 'sub-matrix-cancel', 'Cancel');
|
|
631
|
+
cancelBtn.setAttribute('data-matrix-cancel', '1');
|
|
632
|
+
cancelBtn.setAttribute('data-account-id', sanitizeForDisplay(flow && flow.accountId, 'label'));
|
|
633
|
+
cancelBtn.setAttribute('data-machine-id', sanitizeForDisplay(flow && flow.machineId, 'label'));
|
|
634
|
+
cell.appendChild(cancelBtn);
|
|
635
|
+
}
|
|
636
|
+
|
|
430
637
|
/** Render the account × machine grid. `target` is replaced. Each empty (reachable) cell
|
|
431
638
|
* gets a "Set up" button carrying its (accountId, machineId) as data-* attributes for the
|
|
432
639
|
* controller's delegated tap handler. Offline columns are disabled; no state is fabricated. */
|
|
@@ -452,16 +659,22 @@ export function renderAccountMatrix(doc, target, poolScope, pendingScope, transi
|
|
|
452
659
|
table.appendChild(thead);
|
|
453
660
|
|
|
454
661
|
const tbody = doc.createElement('tbody');
|
|
662
|
+
const now = Date.now();
|
|
455
663
|
for (const row of model.rows) {
|
|
456
664
|
const tr = doc.createElement('tr');
|
|
457
665
|
tr.appendChild(el(doc, 'th', 'sub-matrix-acct', sanitizeForDisplay(row.account.email, 'label')));
|
|
458
666
|
for (const c of row.cells) {
|
|
459
|
-
|
|
460
|
-
|
|
667
|
+
// just-verified rides a transient highlight on an otherwise-active cell (D4 ceremony).
|
|
668
|
+
const justVerified = c.state === 'just-verified' || (c.state === 'active' && c.detail && c.detail.state === 'just-verified');
|
|
669
|
+
const td = el(doc, 'td', `sub-matrix-cell sub-matrix-${c.state}${justVerified && c.state !== 'just-verified' ? ' sub-matrix-just-verified' : ''}`);
|
|
670
|
+
// Stable cell identity for the interaction-hold rule + targeted merge updates (F9).
|
|
671
|
+
td.setAttribute('data-cell-key', sanitizeForDisplay(`${c.accountId}::${c.machineId}`, 'url'));
|
|
672
|
+
if (c.state === 'empty' || c.state === 'needs-reauth' || c.state === 'held' || c.state === 'cant-resolve' || c.state === 'expired' || c.state === 'broken') {
|
|
461
673
|
// An actionable cell → a button that runs the SAME in-dashboard sign-in flow (PIN → link →
|
|
462
674
|
// paste code). empty → "Set up"; needs-reauth (an existing account whose login expired) →
|
|
463
|
-
// "Sign in"; held/cant-resolve → "Retry". A needs-reauth account already
|
|
464
|
-
// email, so the start-cell orchestrator drives a real re-auth — never a
|
|
675
|
+
// "Sign in"; held/cant-resolve/expired/broken → "Retry". A needs-reauth account already
|
|
676
|
+
// resolves to its email, so the start-cell orchestrator drives a real re-auth — never a
|
|
677
|
+
// cosmetic button, and a broken (dead-pane) attempt is superseded server-side on Retry.
|
|
465
678
|
const label = c.state === 'empty' ? 'Set up' : (c.state === 'needs-reauth' ? 'Sign in' : 'Retry');
|
|
466
679
|
const btn = el(doc, 'button', 'sub-matrix-setup', label);
|
|
467
680
|
btn.setAttribute('data-matrix-setup', '1');
|
|
@@ -471,9 +684,32 @@ export function renderAccountMatrix(doc, target, poolScope, pendingScope, transi
|
|
|
471
684
|
// Show the status word ("⟳ Needs sign-in" / "⚠ Didn't match…") ABOVE the button.
|
|
472
685
|
td.appendChild(el(doc, 'div', 'sub-matrix-glyph', `${MATRIX_CELL_GLYPH[c.state]} ${MATRIX_CELL_WORD[c.state]}`));
|
|
473
686
|
}
|
|
687
|
+
// A held cell with the gate verdict's both-account detail names BOTH accounts in
|
|
688
|
+
// plain language (topic 29836 D3) — never just a bare "didn't match".
|
|
689
|
+
if (c.state === 'held' && c.detail) {
|
|
690
|
+
td.appendChild(el(doc, 'div', 'sub-matrix-held-detail',
|
|
691
|
+
heldExplanation(c.detail.expected, c.detail.got, c.detail.reason, { short: true })));
|
|
692
|
+
}
|
|
693
|
+
// A broken cell says WHY in plain words (D5): the attempt's sign-in window is gone.
|
|
694
|
+
if (c.state === 'broken') {
|
|
695
|
+
td.appendChild(el(doc, 'div', 'sub-matrix-held-detail',
|
|
696
|
+
'Its sign-in window closed before finishing — tap Retry to start a fresh sign-in.'));
|
|
697
|
+
}
|
|
474
698
|
td.appendChild(btn);
|
|
699
|
+
} else if (c.state === 'in-progress' && c.login && c.login.verificationUrl) {
|
|
700
|
+
// The cell carries the COMPLETE flow, rebuilt from SERVER pending-login state
|
|
701
|
+
// (topic 29836 D2): a poll rebuild mid-flow renders the same step back — the
|
|
702
|
+
// "flips to ◷ before the code can be pasted" defect is structurally impossible.
|
|
703
|
+
td.appendChild(el(doc, 'div', 'sub-matrix-glyph', '◷ Signing in'));
|
|
704
|
+
appendCellSignInFlow(doc, td, {
|
|
705
|
+
accountId: c.accountId, machineId: c.machineId, loginId: c.login.id,
|
|
706
|
+
verificationUrl: c.login.verificationUrl, expectedEmail: c.login.expectedEmail,
|
|
707
|
+
ttlExpiresAt: c.login.ttlExpiresAt, notice: c.login.notice, kind: c.login.kind,
|
|
708
|
+
userCode: c.login.userCode,
|
|
709
|
+
}, now);
|
|
475
710
|
} else {
|
|
476
|
-
const word = c.state === 'offline' ? 'unknown'
|
|
711
|
+
const word = c.state === 'offline' ? 'unknown'
|
|
712
|
+
: (justVerified && c.state === 'active' ? 'Active — just set up' : MATRIX_CELL_WORD[c.state]);
|
|
477
713
|
const glyph = MATRIX_CELL_GLYPH[c.state] || '';
|
|
478
714
|
td.appendChild(el(doc, 'span', 'sub-matrix-glyph', `${glyph} ${word}`.trim()));
|
|
479
715
|
// An in-progress (◷) cell gets a tappable Cancel so a mis-tapped setup can be
|
|
@@ -534,9 +770,20 @@ export function createController(opts) {
|
|
|
534
770
|
} = opts;
|
|
535
771
|
|
|
536
772
|
// matrixTransient: client-side last-attempt state per `${accountId}::${machineId}` cell (FD6 —
|
|
537
|
-
// held / cant-resolve are known only to the client from the
|
|
773
|
+
// held / cant-resolve / expired / just-verified are known only to the client from the
|
|
774
|
+
// response it just got or from an episode it drove).
|
|
775
|
+
// matrixEpisodes: enrollment episodes THIS client started (start-cell succeeded) — used to
|
|
776
|
+
// detect a server-side expiry (pending login vanished without a terminal outcome) and to
|
|
777
|
+
// add the D4 ceremony when the episode lands. recentOutcomes: client-observed terminal
|
|
778
|
+
// outcomes rendered as explicit cards in the pending panel (D4 — never a vanishing line).
|
|
538
779
|
const state = { timerId: null, active: false, inFlight: null, offers: [], approveWired: false,
|
|
539
|
-
matrixWired: false, matrixTransient: {}, lastPoolBody: null, lastPendingBody: null
|
|
780
|
+
matrixWired: false, matrixTransient: {}, lastPoolBody: null, lastPendingBody: null,
|
|
781
|
+
matrixEpisodes: {}, recentOutcomes: [] };
|
|
782
|
+
|
|
783
|
+
const JUST_VERIFIED_TTL_MS = 5 * 60_000; // highlight ceremony window
|
|
784
|
+
const EXPIRED_TTL_MS = 60 * 60_000; // explicit expired presentation window
|
|
785
|
+
const OUTCOME_TTL_MS = 15 * 60_000; // pending-panel outcome cards window
|
|
786
|
+
const MAX_OUTCOME_CARDS = 5;
|
|
540
787
|
|
|
541
788
|
async function fetchJson(url, controller) {
|
|
542
789
|
const resp = await fetchImpl(url, { signal: controller.signal });
|
|
@@ -594,26 +841,147 @@ export function createController(opts) {
|
|
|
594
841
|
reschedule();
|
|
595
842
|
}
|
|
596
843
|
|
|
844
|
+
// ── Episode + outcome bookkeeping (D1/D4/D5) ───────────────────────────────
|
|
845
|
+
function purgeTransients() {
|
|
846
|
+
const t = now();
|
|
847
|
+
for (const key of Object.keys(state.matrixTransient)) {
|
|
848
|
+
const entry = state.matrixTransient[key];
|
|
849
|
+
if (!entry || typeof entry.at !== 'number') continue;
|
|
850
|
+
if (entry.state === 'just-verified' && t - entry.at > JUST_VERIFIED_TTL_MS) delete state.matrixTransient[key];
|
|
851
|
+
if ((entry.state === 'expired' || entry.state === 'broken') && t - entry.at > EXPIRED_TTL_MS) delete state.matrixTransient[key];
|
|
852
|
+
}
|
|
853
|
+
state.recentOutcomes = state.recentOutcomes
|
|
854
|
+
.filter((o) => o && typeof o.at === 'number' && now() - o.at <= OUTCOME_TTL_MS)
|
|
855
|
+
.slice(0, MAX_OUTCOME_CARDS);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Resolve a machine's operator-facing name from the cached bodies ('' if unknown —
|
|
859
|
+
* never a raw m_<hex> id; D5 wording floor). */
|
|
860
|
+
function machineNick(machineId) {
|
|
861
|
+
const accounts = state.lastPoolBody && Array.isArray(state.lastPoolBody.accounts) ? state.lastPoolBody.accounts : [];
|
|
862
|
+
const hit = accounts.find((a) => a && a.machineId === machineId && a.machineNickname);
|
|
863
|
+
if (hit) return hit.machineNickname;
|
|
864
|
+
const logins = state.lastPendingBody && Array.isArray(state.lastPendingBody.logins) ? state.lastPendingBody.logins : [];
|
|
865
|
+
const l = logins.find((x) => x && x.machineId === machineId && x.machineNickname);
|
|
866
|
+
return l ? l.machineNickname : '';
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** One chokepoint for a client-observed terminal submit outcome — records the panel
|
|
870
|
+
* card (D4), the matrix transient (so BOTH surfaces flip together), and ends the
|
|
871
|
+
* episode. Used by the in-cell submit AND the pending-panel submit. */
|
|
872
|
+
function recordSubmitOutcome(kind, ids, body) {
|
|
873
|
+
const { accountId, machineId } = ids;
|
|
874
|
+
const key = accountId && machineId ? `${accountId}::${machineId}` : null;
|
|
875
|
+
if (key) delete state.matrixEpisodes[key];
|
|
876
|
+
const base = { accountId, machineId, machineNickname: machineNick(machineId), at: now() };
|
|
877
|
+
if (kind === 'validated') {
|
|
878
|
+
if (key) state.matrixTransient[key] = { state: 'just-verified', at: now() };
|
|
879
|
+
state.recentOutcomes.unshift({ ...base, kind, email: (body && body.email) || null });
|
|
880
|
+
} else if (kind === 'held') {
|
|
881
|
+
if (key) {
|
|
882
|
+
state.matrixTransient[key] = {
|
|
883
|
+
state: 'held', at: now(),
|
|
884
|
+
expected: (body && body.expected) || null, got: (body && body.got) || null, reason: (body && body.reason) || null,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
state.recentOutcomes.unshift({ ...base, kind, expected: (body && body.expected) || null, got: (body && body.got) || null, reason: (body && body.reason) || null });
|
|
888
|
+
} else if (kind === 'broken') {
|
|
889
|
+
if (key) state.matrixTransient[key] = { state: 'broken', at: now() };
|
|
890
|
+
}
|
|
891
|
+
state.recentOutcomes = state.recentOutcomes.slice(0, MAX_OUTCOME_CARDS);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Episode reconciliation (D5/D4): an episode this client started whose pending login
|
|
895
|
+
* VANISHED server-side resolves to a terminal state — active pool row → just-verified
|
|
896
|
+
* ceremony; gone without any outcome → explicit expired (never a silent revert to a
|
|
897
|
+
* bare "Set up" button). Runs on every tick with the fresh bodies. */
|
|
898
|
+
function reconcileEpisodes(poolBody, pendingBody) {
|
|
899
|
+
const accounts = poolBody && Array.isArray(poolBody.accounts) ? poolBody.accounts : [];
|
|
900
|
+
const logins = pendingBody && Array.isArray(pendingBody.logins) ? pendingBody.logins : [];
|
|
901
|
+
for (const key of Object.keys(state.matrixEpisodes)) {
|
|
902
|
+
const sep = key.indexOf('::');
|
|
903
|
+
const accountId = key.slice(0, sep);
|
|
904
|
+
const machineId = key.slice(sep + 2);
|
|
905
|
+
const active = accounts.some((a) => a && a.id === accountId && a.machineId === machineId && a.status === 'active');
|
|
906
|
+
const stillPending = logins.some((l) => l && l.id === accountId && l.machineId === machineId);
|
|
907
|
+
if (active) {
|
|
908
|
+
delete state.matrixEpisodes[key];
|
|
909
|
+
const t = state.matrixTransient[key];
|
|
910
|
+
if (!t || (t.state !== 'held' && t.state !== 'just-verified')) {
|
|
911
|
+
state.matrixTransient[key] = { state: 'just-verified', at: now() };
|
|
912
|
+
}
|
|
913
|
+
releaseCellHold(key);
|
|
914
|
+
} else if (!stillPending) {
|
|
915
|
+
delete state.matrixEpisodes[key];
|
|
916
|
+
const t = state.matrixTransient[key];
|
|
917
|
+
if (!t || (t.state !== 'held' && t.state !== 'cant-resolve' && t.state !== 'just-verified' && t.state !== 'broken')) {
|
|
918
|
+
state.matrixTransient[key] = { state: 'expired', at: now() };
|
|
919
|
+
state.recentOutcomes.unshift({ kind: 'expired', accountId, machineId, machineNickname: machineNick(machineId), at: now() });
|
|
920
|
+
}
|
|
921
|
+
releaseCellHold(key);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** The episode is over server-side — release the cell's open-interaction marker so
|
|
927
|
+
* the guarded rebuild may replace it with the terminal presentation. */
|
|
928
|
+
function releaseCellHold(key) {
|
|
929
|
+
if (!els.matrix || typeof els.matrix.querySelectorAll !== 'function') return;
|
|
930
|
+
for (const cell of els.matrix.querySelectorAll('[data-interaction-open]')) {
|
|
931
|
+
if (cell.getAttribute && cell.getAttribute('data-cell-key') === key) cell.removeAttribute('data-interaction-open');
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Guarded matrix rebuild from the cached bodies — used by handlers that just flipped a
|
|
936
|
+
* cell terminal so the explicit presentation shows immediately (not 30s later). Respects
|
|
937
|
+
* the F9 hold: skipped while any OTHER interaction is open (the next tick catches up). */
|
|
938
|
+
function rerenderMatrixFromCache() {
|
|
939
|
+
if (!els.matrix || !state.lastPoolBody) return;
|
|
940
|
+
if (hasOpenInteraction(doc, els.matrix)) return;
|
|
941
|
+
renderAccountMatrix(doc, els.matrix, state.lastPoolBody, state.lastPendingBody, state.matrixTransient);
|
|
942
|
+
}
|
|
943
|
+
|
|
597
944
|
function render(accountsBody, pendingBody, inUseBody, poolBody) {
|
|
598
945
|
const accounts = accountsBody && Array.isArray(accountsBody.accounts) ? accountsBody.accounts : [];
|
|
599
946
|
const logins = pendingBody && Array.isArray(pendingBody.logins) ? pendingBody.logins : [];
|
|
600
947
|
const inUseAccountId = inUseBody && inUseBody.activeAccountId ? inUseBody.activeAccountId : null;
|
|
601
948
|
renderAccounts(doc, els.accounts, accounts, now(), inUseAccountId);
|
|
602
|
-
|
|
603
|
-
|
|
949
|
+
purgeTransients();
|
|
950
|
+
// Episode reconciliation runs FIRST so a terminal transition it derives (expired /
|
|
951
|
+
// landed) is visible to BOTH surfaces on this same tick (the panel card + the cell).
|
|
952
|
+
state.lastPoolBody = poolBody || null;
|
|
953
|
+
state.lastPendingBody = pendingBody || null;
|
|
954
|
+
if (els.matrix) reconcileEpisodes(poolBody, pendingBody);
|
|
955
|
+
// F9 (Dashboard UX Standard): every surface that can hold an operator interaction is
|
|
956
|
+
// rebuilt ONLY while no interaction is open; a held surface gets targeted merge updates
|
|
957
|
+
// (live countdowns) instead. This is the structural fix for the topic 29836 D1 defect
|
|
958
|
+
// (the poll reverting a PIN input / swapping the code step out mid-paste).
|
|
959
|
+
if (els.pending) {
|
|
960
|
+
if (!hasOpenInteraction(doc, els.pending)) {
|
|
961
|
+
renderPendingLogins(doc, els.pending, logins, now(), state.recentOutcomes);
|
|
962
|
+
} else {
|
|
963
|
+
updateCountdowns(doc, els.pending, now());
|
|
964
|
+
}
|
|
965
|
+
wireCodeSubmit();
|
|
966
|
+
}
|
|
604
967
|
// The account × machine matrix (account-machine-matrix) — built from the pool-scope read +
|
|
605
968
|
// the (already pool-scope) pending logins. Hidden when the pool-scope read is unavailable.
|
|
606
969
|
if (els.matrix) {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
970
|
+
if (!hasOpenInteraction(doc, els.matrix)) {
|
|
971
|
+
renderAccountMatrix(doc, els.matrix, poolBody, pendingBody, state.matrixTransient);
|
|
972
|
+
} else {
|
|
973
|
+
updateCountdowns(doc, els.matrix, now());
|
|
974
|
+
}
|
|
610
975
|
wireMatrixSetup();
|
|
611
976
|
}
|
|
612
977
|
// The one-tap follow-me Approve card(s) — rendered into els.followMe from the scan offers
|
|
613
978
|
// (ws52-operator-tap-not-text Part A). Silent when there are none. The Approve click is wired
|
|
614
|
-
// once (delegated) so re-renders never stack listeners.
|
|
979
|
+
// once (delegated) so re-renders never stack listeners. Same F9 hold: a half-typed PIN on an
|
|
980
|
+
// Approve card is an open interaction the poll must not clobber.
|
|
615
981
|
if (els.followMe) {
|
|
616
|
-
|
|
982
|
+
if (!hasOpenInteraction(doc, els.followMe)) {
|
|
983
|
+
renderFollowMeOffers(doc, els.followMe, state.offers);
|
|
984
|
+
}
|
|
617
985
|
wireApprove();
|
|
618
986
|
}
|
|
619
987
|
}
|
|
@@ -681,25 +1049,50 @@ export function createController(opts) {
|
|
|
681
1049
|
if (!id) { setRowStatus(row, 'Couldn’t identify this login — please refresh.'); return; }
|
|
682
1050
|
setRowStatus(row, 'Sending your code…');
|
|
683
1051
|
btn.setAttribute('disabled', '1');
|
|
1052
|
+
// F9: the row is an open interaction while the submit is in flight — a poll rebuild
|
|
1053
|
+
// would detach the very node the outcome is about to be written into.
|
|
1054
|
+
row.setAttribute('data-interaction-open', 'submitting');
|
|
684
1055
|
void (async () => {
|
|
685
1056
|
try {
|
|
686
1057
|
const r = await postJson(URLS.submitCode, { machineId, id, code });
|
|
687
1058
|
if (r.ok && r.json && r.json.outcome === 'validated') {
|
|
688
|
-
setRowStatus(row, 'Done — this machine is set up with the account.');
|
|
689
1059
|
if (input) input.value = '';
|
|
1060
|
+
row.removeAttribute('data-interaction-open');
|
|
1061
|
+
recordSubmitOutcome('validated', { accountId: id, machineId }, r.json);
|
|
1062
|
+
// Explicit terminal presentation in place (D4) — the next rebuild shows the
|
|
1063
|
+
// durable ✓ Done card from recentOutcomes.
|
|
1064
|
+
row.setAttribute('class', 'sub-pending sub-pending-done');
|
|
1065
|
+
setRowStatus(row, `✓ Done — ${r.json.email ? r.json.email + ' is' : 'this account is'} set up on this machine.`);
|
|
1066
|
+
rerenderMatrixFromCache();
|
|
690
1067
|
} else if (r.ok && r.json && r.json.outcome === 'submitted') {
|
|
691
1068
|
setRowStatus(row, 'Code sent — finishing sign-in…');
|
|
692
1069
|
if (input) input.value = '';
|
|
1070
|
+
row.removeAttribute('data-interaction-open');
|
|
693
1071
|
} else if (r.ok && r.json && r.json.outcome === 'held') {
|
|
694
|
-
|
|
1072
|
+
if (input) input.value = '';
|
|
1073
|
+
row.removeAttribute('data-interaction-open');
|
|
1074
|
+
recordSubmitOutcome('held', { accountId: id, machineId }, r.json);
|
|
1075
|
+
row.setAttribute('class', 'sub-pending sub-pending-failed');
|
|
1076
|
+
// Plain language naming BOTH accounts (D3) — never a bare "didn't match".
|
|
1077
|
+
setRowStatus(row, `✗ ${heldExplanation(r.json.expected, r.json.got, r.json.reason)}`);
|
|
1078
|
+
rerenderMatrixFromCache();
|
|
695
1079
|
btn.removeAttribute('disabled');
|
|
1080
|
+
} else if (r.json && r.json.code === 'pane-dead') {
|
|
1081
|
+
// D5: the attempt's window is gone — flip to the explicit needs-restart state.
|
|
1082
|
+
row.removeAttribute('data-interaction-open');
|
|
1083
|
+
recordSubmitOutcome('broken', { accountId: id, machineId }, r.json);
|
|
1084
|
+
row.setAttribute('class', 'sub-pending sub-pending-failed');
|
|
1085
|
+
setRowStatus(row, `✗ ${r.json.error || 'This sign-in’s window is gone — start it again from the grid above.'}`);
|
|
1086
|
+
rerenderMatrixFromCache();
|
|
696
1087
|
} else {
|
|
697
1088
|
const msg = (r.json && (r.json.error || r.json.reason)) ? (r.json.error || r.json.reason) : `failed (${r.status})`;
|
|
698
1089
|
setRowStatus(row, `Couldn’t submit the code: ${msg}`);
|
|
1090
|
+
row.removeAttribute('data-interaction-open');
|
|
699
1091
|
btn.removeAttribute('disabled');
|
|
700
1092
|
}
|
|
701
1093
|
} catch (e) {
|
|
702
1094
|
setRowStatus(row, 'Couldn’t reach the server — try again.');
|
|
1095
|
+
row.removeAttribute('data-interaction-open');
|
|
703
1096
|
btn.removeAttribute('disabled');
|
|
704
1097
|
}
|
|
705
1098
|
})();
|
|
@@ -730,6 +1123,8 @@ export function createController(opts) {
|
|
|
730
1123
|
if (codeBtn && els.matrix.contains(codeBtn)) { onCodeTap(codeBtn); return; }
|
|
731
1124
|
const cancelBtn = t.closest('[data-matrix-cancel]');
|
|
732
1125
|
if (cancelBtn && els.matrix.contains(cancelBtn)) { onCancelTap(cancelBtn); return; }
|
|
1126
|
+
const collapseBtn = t.closest('[data-matrix-collapse]');
|
|
1127
|
+
if (collapseBtn && els.matrix.contains(collapseBtn)) { onCollapseTap(collapseBtn); return; }
|
|
733
1128
|
});
|
|
734
1129
|
}
|
|
735
1130
|
|
|
@@ -743,12 +1138,17 @@ export function createController(opts) {
|
|
|
743
1138
|
}
|
|
744
1139
|
|
|
745
1140
|
// Expand the cell into a PIN input + Confirm (replacing the "Set up"/"Retry" button).
|
|
1141
|
+
// The cell becomes an OPEN INTERACTION the moment it expands (F9): the marker holds it
|
|
1142
|
+
// through the poll until the flow reaches a terminal state or the operator backs out.
|
|
746
1143
|
function onSetupTap(btn) {
|
|
747
1144
|
const cell = matrixCellOf(btn);
|
|
748
1145
|
if (!cell) return;
|
|
749
1146
|
const accountId = btn.getAttribute('data-account-id');
|
|
750
1147
|
const machineId = btn.getAttribute('data-machine-id');
|
|
751
1148
|
if (!accountId || !machineId) return;
|
|
1149
|
+
// A retry clears the previous attempt's terminal presentation for this cell.
|
|
1150
|
+
delete state.matrixTransient[`${accountId}::${machineId}`];
|
|
1151
|
+
cell.setAttribute('data-interaction-open', 'pin-entry');
|
|
752
1152
|
btn.remove();
|
|
753
1153
|
const pin = doc.createElement('input');
|
|
754
1154
|
pin.setAttribute('type', 'password');
|
|
@@ -761,9 +1161,25 @@ export function createController(opts) {
|
|
|
761
1161
|
confirm.setAttribute('data-account-id', accountId);
|
|
762
1162
|
confirm.setAttribute('data-machine-id', machineId);
|
|
763
1163
|
cell.appendChild(confirm);
|
|
1164
|
+
// An explicit way OUT of the interaction (the hold would otherwise pin the cell
|
|
1165
|
+
// forever if the operator changes their mind) — client-side only, nothing started yet.
|
|
1166
|
+
const back = el(doc, 'button', 'sub-matrix-collapse', 'Back');
|
|
1167
|
+
back.setAttribute('data-matrix-collapse', '1');
|
|
1168
|
+
cell.appendChild(back);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Back out of an un-started PIN entry: release the hold and restore the cell from cache.
|
|
1172
|
+
function onCollapseTap(btn) {
|
|
1173
|
+
const cell = matrixCellOf(btn);
|
|
1174
|
+
if (!cell) return;
|
|
1175
|
+
cell.removeAttribute('data-interaction-open');
|
|
1176
|
+
const pin = cell.querySelector('.sub-matrix-pin');
|
|
1177
|
+
if (pin) pin.value = '';
|
|
1178
|
+
rerenderMatrixFromCache();
|
|
764
1179
|
}
|
|
765
1180
|
|
|
766
|
-
// Confirm → POST the PIN-gated start-cell; render the
|
|
1181
|
+
// Confirm → POST the PIN-gated start-cell; render the COMPLETE in-cell sign-in flow on
|
|
1182
|
+
// success (link + expected-account warning + code input + TTL + notice + Cancel — D2).
|
|
767
1183
|
function onConfirmTap(btn) {
|
|
768
1184
|
const cell = matrixCellOf(btn);
|
|
769
1185
|
if (!cell) return;
|
|
@@ -780,9 +1196,16 @@ export function createController(opts) {
|
|
|
780
1196
|
const r = await postJson(URLS.startCell, { accountId, machineId, pin });
|
|
781
1197
|
if (pinInput) pinInput.value = ''; // PIN is memory-only — clear it immediately
|
|
782
1198
|
if (r.ok && r.json && r.json.verificationUrl) {
|
|
783
|
-
|
|
1199
|
+
// Episode opens: the enrollment is live server-side; the reconciler now owns
|
|
1200
|
+
// detecting its expiry / landing when the client misses the terminal response.
|
|
1201
|
+
state.matrixEpisodes[`${accountId}::${machineId}`] = { loginId: r.json.loginId || accountId, at: now() };
|
|
1202
|
+
renderCellSignIn(cell, {
|
|
1203
|
+
accountId, machineId, loginId: r.json.loginId || accountId,
|
|
1204
|
+
verificationUrl: r.json.verificationUrl, expectedEmail: r.json.expectedEmail,
|
|
1205
|
+
ttlExpiresAt: r.json.ttlExpiresAt, notice: r.json.notice, kind: r.json.kind,
|
|
1206
|
+
});
|
|
784
1207
|
} else if (r.status === 409) {
|
|
785
|
-
state.matrixTransient[`${accountId}::${machineId}`] = { state: 'cant-resolve' };
|
|
1208
|
+
state.matrixTransient[`${accountId}::${machineId}`] = { state: 'cant-resolve', at: now() };
|
|
786
1209
|
setCellStatus(cell, 'Can’t set this account up here — its details couldn’t be resolved.');
|
|
787
1210
|
btn.removeAttribute('disabled');
|
|
788
1211
|
} else {
|
|
@@ -797,36 +1220,16 @@ export function createController(opts) {
|
|
|
797
1220
|
})();
|
|
798
1221
|
}
|
|
799
1222
|
|
|
800
|
-
// After start-cell:
|
|
801
|
-
|
|
802
|
-
|
|
1223
|
+
// After start-cell: the immediate in-cell render of the complete flow (the SAME structure
|
|
1224
|
+
// the poll rebuilds from server state, via the shared appendCellSignInFlow — D2 coherence:
|
|
1225
|
+
// one live attempt, one URL, on every surface).
|
|
1226
|
+
function renderCellSignIn(cell, flow) {
|
|
803
1227
|
const pin = cell.querySelector('.sub-matrix-pin'); if (pin) pin.remove();
|
|
804
1228
|
const confirm = cell.querySelector('[data-matrix-confirm]'); if (confirm) confirm.remove();
|
|
805
|
-
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
a.setAttribute('href', href);
|
|
810
|
-
a.setAttribute('target', '_blank');
|
|
811
|
-
a.setAttribute('rel', 'noopener noreferrer');
|
|
812
|
-
a.setAttribute('class', 'sub-matrix-signin');
|
|
813
|
-
a.textContent = 'Sign in';
|
|
814
|
-
cell.appendChild(a);
|
|
815
|
-
} else {
|
|
816
|
-
cell.appendChild(el(doc, 'div', 'sub-matrix-url', sanitizeForDisplay(verificationUrl, 'url')));
|
|
817
|
-
}
|
|
818
|
-
const code = doc.createElement('input');
|
|
819
|
-
code.setAttribute('type', 'text');
|
|
820
|
-
code.setAttribute('class', 'sub-matrix-code-input');
|
|
821
|
-
code.setAttribute('placeholder', 'Paste your sign-in code');
|
|
822
|
-
code.setAttribute('autocomplete', 'off');
|
|
823
|
-
cell.appendChild(code);
|
|
824
|
-
const submit = el(doc, 'button', 'sub-matrix-code-submit', 'Submit');
|
|
825
|
-
submit.setAttribute('data-matrix-code-submit', '1');
|
|
826
|
-
submit.setAttribute('data-account-id', accountId);
|
|
827
|
-
submit.setAttribute('data-machine-id', machineId);
|
|
828
|
-
submit.setAttribute('data-login-id', loginId);
|
|
829
|
-
cell.appendChild(submit);
|
|
1229
|
+
const back = cell.querySelector('[data-matrix-collapse]'); if (back) back.remove();
|
|
1230
|
+
const status = cell.querySelector('.sub-matrix-status'); if (status) status.remove();
|
|
1231
|
+
cell.setAttribute('data-interaction-open', 'signing-in');
|
|
1232
|
+
appendCellSignInFlow(doc, cell, flow, now());
|
|
830
1233
|
}
|
|
831
1234
|
|
|
832
1235
|
// Submit the pasted code via the SHIPPED submit-code relay (unchanged contract).
|
|
@@ -841,32 +1244,68 @@ export function createController(opts) {
|
|
|
841
1244
|
if (!code) { setCellStatus(cell, 'Paste the code the sign-in page gave you, then tap Submit.'); return; }
|
|
842
1245
|
setCellStatus(cell, 'Sending your code…');
|
|
843
1246
|
btn.setAttribute('disabled', '1');
|
|
1247
|
+
cell.setAttribute('data-interaction-open', 'submitting');
|
|
844
1248
|
void (async () => {
|
|
845
1249
|
try {
|
|
846
1250
|
const r = await postJson(URLS.submitCode, { machineId, id: loginId, code });
|
|
847
1251
|
if (input) input.value = ''; // code is memory-only — cleared after use
|
|
848
|
-
const key = `${accountId}::${machineId}`;
|
|
849
1252
|
if (r.ok && r.json && r.json.outcome === 'validated') {
|
|
850
|
-
|
|
851
|
-
|
|
1253
|
+
// TERMINAL SUCCESS (D4): unmistakable in-cell presentation the moment the
|
|
1254
|
+
// enrollment verifies; the transient keeps the ceremony on the next rebuilds.
|
|
1255
|
+
cell.removeAttribute('data-interaction-open');
|
|
1256
|
+
recordSubmitOutcome('validated', { accountId, machineId }, r.json);
|
|
1257
|
+
renderCellDone(cell, r.json.email || null);
|
|
852
1258
|
} else if (r.ok && r.json && r.json.outcome === 'submitted') {
|
|
853
1259
|
setCellStatus(cell, 'Code sent — finishing sign-in…');
|
|
1260
|
+
cell.removeAttribute('data-interaction-open');
|
|
854
1261
|
} else if (r.ok && r.json && r.json.outcome === 'held') {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1262
|
+
// TERMINAL REFUSAL (D3): the identity gate held the enrollment — name BOTH
|
|
1263
|
+
// accounts in plain language; the wrong account was NOT enrolled.
|
|
1264
|
+
cell.removeAttribute('data-interaction-open');
|
|
1265
|
+
recordSubmitOutcome('held', { accountId, machineId }, r.json);
|
|
1266
|
+
renderCellHeld(cell, accountId, machineId, r.json);
|
|
1267
|
+
} else if (r.json && r.json.code === 'pane-dead') {
|
|
1268
|
+
// TERMINAL (D5): the sign-in window is gone — explicit needs-restart state.
|
|
1269
|
+
cell.removeAttribute('data-interaction-open');
|
|
1270
|
+
recordSubmitOutcome('broken', { accountId, machineId }, r.json);
|
|
1271
|
+
rerenderMatrixFromCache();
|
|
858
1272
|
} else {
|
|
859
1273
|
const msg = (r.json && (r.json.error || r.json.reason)) ? (r.json.error || r.json.reason) : `failed (${r.status})`;
|
|
860
1274
|
setCellStatus(cell, `Couldn’t submit the code: ${msg}`);
|
|
1275
|
+
cell.setAttribute('data-interaction-open', 'signing-in');
|
|
861
1276
|
btn.removeAttribute('disabled');
|
|
862
1277
|
}
|
|
863
1278
|
} catch (e) {
|
|
864
1279
|
setCellStatus(cell, 'Couldn’t reach the server — try again.');
|
|
1280
|
+
cell.setAttribute('data-interaction-open', 'signing-in');
|
|
865
1281
|
btn.removeAttribute('disabled');
|
|
866
1282
|
}
|
|
867
1283
|
})();
|
|
868
1284
|
}
|
|
869
1285
|
|
|
1286
|
+
// The in-cell terminal SUCCESS presentation (D4): unmistakable, named, highlighted.
|
|
1287
|
+
function renderCellDone(cell, email) {
|
|
1288
|
+
cell.replaceChildren();
|
|
1289
|
+
cell.setAttribute('class', 'sub-matrix-cell sub-matrix-just-verified sub-matrix-done');
|
|
1290
|
+
cell.appendChild(el(doc, 'div', 'sub-matrix-glyph', '✓ All set'));
|
|
1291
|
+
cell.appendChild(el(doc, 'div', 'sub-matrix-done-detail',
|
|
1292
|
+
email ? `${sanitizeForDisplay(email, 'label')} is signed in on this machine.` : 'The account is signed in on this machine.'));
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// The in-cell terminal HELD presentation (D3): both accounts named + a working Retry.
|
|
1296
|
+
function renderCellHeld(cell, accountId, machineId, body) {
|
|
1297
|
+
cell.replaceChildren();
|
|
1298
|
+
cell.setAttribute('class', 'sub-matrix-cell sub-matrix-held');
|
|
1299
|
+
cell.appendChild(el(doc, 'div', 'sub-matrix-glyph', `${MATRIX_CELL_GLYPH.held} ${MATRIX_CELL_WORD.held}`));
|
|
1300
|
+
cell.appendChild(el(doc, 'div', 'sub-matrix-held-detail',
|
|
1301
|
+
heldExplanation(body && body.expected, body && body.got, body && body.reason, { short: true })));
|
|
1302
|
+
const btn = el(doc, 'button', 'sub-matrix-setup', 'Retry');
|
|
1303
|
+
btn.setAttribute('data-matrix-setup', '1');
|
|
1304
|
+
btn.setAttribute('data-account-id', sanitizeForDisplay(accountId, 'label'));
|
|
1305
|
+
btn.setAttribute('data-machine-id', sanitizeForDisplay(machineId, 'label'));
|
|
1306
|
+
cell.appendChild(btn);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
870
1309
|
// Cancel an in-flight cell: POST the Bearer-only cancel relay (self/peer), abandoning
|
|
871
1310
|
// the login + tearing down its pane. No PIN (mirrors the code-submit step — a PIN can't
|
|
872
1311
|
// cross the mesh). Reversible: the cell frees up to re-tap "Set up". Guarded by a native
|
|
@@ -889,7 +1328,12 @@ export function createController(opts) {
|
|
|
889
1328
|
try {
|
|
890
1329
|
const r = await postJson(URLS.cancel, { machineId, id: accountId });
|
|
891
1330
|
if (r.ok && r.json && (r.json.cancelled || r.json.alreadyTerminal)) {
|
|
892
|
-
|
|
1331
|
+
// TERMINAL (cancelled): the episode is over — release the hold so the next
|
|
1332
|
+
// rebuild replaces the flow with a fresh "Set up" from server state.
|
|
1333
|
+
const key = `${accountId}::${machineId}`;
|
|
1334
|
+
delete state.matrixTransient[key];
|
|
1335
|
+
delete state.matrixEpisodes[key];
|
|
1336
|
+
cell.removeAttribute('data-interaction-open');
|
|
893
1337
|
setCellStatus(cell, 'Cancelled — you can set this up again.');
|
|
894
1338
|
} else {
|
|
895
1339
|
const msg = (r.json && (r.json.error || r.json.reason)) ? (r.json.error || r.json.reason) : `failed (${r.status})`;
|
|
@@ -926,5 +1370,7 @@ if (typeof window !== 'undefined') {
|
|
|
926
1370
|
createController, sanitizeForDisplay, renderAccounts, renderPendingLogins,
|
|
927
1371
|
renderFollowMeOffers, renderFollowMeApproveCard, buildFollowMeIssuePayload,
|
|
928
1372
|
renderAccountMatrix, buildMatrixModel,
|
|
1373
|
+
hasOpenInteraction, updateCountdowns, heldExplanation, appendCellSignInFlow,
|
|
1374
|
+
renderOutcomeCard, friendlyMachine,
|
|
929
1375
|
};
|
|
930
1376
|
}
|