@bkazemi/verily 0.5.0 → 0.6.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/README.md +4 -2
- package/dist/browser/badge.d.ts +4 -1
- package/dist/browser/badge.js +7 -4
- package/dist/browser/connect-dialog.js +10 -6
- package/dist/browser/evidence-dialog.d.ts +17 -3
- package/dist/browser/evidence-dialog.js +278 -28
- package/dist/browser/index.d.ts +7 -1
- package/dist/browser/index.js +24 -42
- package/dist/core/index.d.ts +28 -0
- package/dist/core/index.js +48 -0
- package/dist/server/index.js +83 -7
- package/dist/server/openpgp.d.ts +4 -0
- package/dist/server/openpgp.js +10 -1
- package/dist/server/service.d.ts +15 -1
- package/dist/server/service.js +15 -3
- package/dist/server/style.d.ts +1 -1
- package/dist/server/style.js +40 -0
- package/dist/verily.js +286 -64
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { attestationLabel, externalId, externalLink, externalName, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
|
|
1
|
+
import { attestationLabel, externalId, externalIdGroups, externalLink, externalName, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
|
|
2
2
|
import { markStyles, verificationMark } from './mark.js';
|
|
3
3
|
import { providerMark } from './provider-mark.js';
|
|
4
4
|
import { onSignatureResult, signature, standing } from './signed.js';
|
|
@@ -19,8 +19,16 @@ export const styles = `
|
|
|
19
19
|
h2 { margin: 0; font-size: 18px; font-weight: 650; letter-spacing: -.3px; }
|
|
20
20
|
button { width: 32px; height: 32px; border: 1px solid #dce2de; border-radius: 8px; background: #fff; color: #52645a; font: 20px system-ui, sans-serif; cursor: pointer; }
|
|
21
21
|
button:hover { background: #f2f5f1; }
|
|
22
|
+
.icon { display: grid; place-items: center; padding: 0; }
|
|
23
|
+
.icon svg { width: 16px; height: 16px; }
|
|
24
|
+
.title { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
25
|
+
.all { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #52645a; }
|
|
26
|
+
.all:hover { background: #f2f5f1; }
|
|
27
|
+
.all svg { width: 16px; height: 16px; }
|
|
22
28
|
a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }
|
|
23
|
-
a:focus-visible, button:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }
|
|
29
|
+
a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }
|
|
30
|
+
/* An element given its own display would otherwise go on showing while hidden. */
|
|
31
|
+
[hidden] { display: none !important; }
|
|
24
32
|
.summary { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
|
25
33
|
.mark { width: 36px; height: 36px; flex-shrink: 0; }
|
|
26
34
|
.provider { width: 14px; height: 14px; }
|
|
@@ -32,7 +40,7 @@ export const styles = `
|
|
|
32
40
|
.spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }
|
|
33
41
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
34
42
|
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
|
35
|
-
.caution { display: inline-block; margin-left: 4px; color: #a15c00; cursor: help; vertical-align:
|
|
43
|
+
.caution { display: inline-block; margin-left: 4px; color: #a15c00; cursor: help; vertical-align: -1.5px; }
|
|
36
44
|
.caution svg { display: block; width: 12px; height: 12px; }
|
|
37
45
|
.bad { color: #b3261e; font-weight: 650; text-decoration: underline dotted; text-underline-offset: 3px; cursor: help; }
|
|
38
46
|
.account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }
|
|
@@ -41,6 +49,9 @@ export const styles = `
|
|
|
41
49
|
/* The account's own name carries that weight; a link inside a line of prose does not. */
|
|
42
50
|
.summary a, .method a, .additional a { font: inherit; }
|
|
43
51
|
.reference { margin-top: 2px; }
|
|
52
|
+
/* A fingerprint in fours: it breaks between groups, and copies as one unbroken word. */
|
|
53
|
+
.fingerprint { font-family: ui-monospace, monospace; text-wrap: balance; }
|
|
54
|
+
.fingerprint span { display: inline-block; margin-right: .5ch; }
|
|
44
55
|
.method { margin-top: 8px; }
|
|
45
56
|
/* In the corner the heading leaves empty, apart from everything that was checked. */
|
|
46
57
|
.listing { position: absolute; top: 12px; right: 14px; padding: 1px 8px; border: 1px solid #e0e6df; border-radius: 999px; color: #6b786f; font-size: 11px; font-weight: 550; }
|
|
@@ -50,6 +61,15 @@ export const styles = `
|
|
|
50
61
|
.account.retired > a, .account.retired > strong { color: #6b786f; }
|
|
51
62
|
.account.retired .mark { opacity: .45; }
|
|
52
63
|
.group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }
|
|
64
|
+
summary.group { display: flex; align-items: center; gap: 4px; width: fit-content; border-radius: 4px; list-style: none; cursor: pointer; }
|
|
65
|
+
summary::-webkit-details-marker { display: none; }
|
|
66
|
+
.fold { display: flex; align-items: center; gap: 4px; width: auto; height: auto; padding: 2px 8px 2px 4px; margin-left: -4px; border: 0; background: none; font: 550 12px/1.6 system-ui, sans-serif; }
|
|
67
|
+
.fold svg, summary svg { width: 12px; height: 12px; }
|
|
68
|
+
.fold[aria-expanded="true"] svg, details[open] > summary svg { transform: rotate(90deg); }
|
|
69
|
+
@media (prefers-reduced-motion: no-preference) { .fold svg, summary svg { transition: transform .15s; } }
|
|
70
|
+
/* The fold and what the holder may do share a line, until a question needs the whole of it. */
|
|
71
|
+
.bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
|
72
|
+
.account .bar .row { margin: 0 0 0 auto; }
|
|
53
73
|
.additional { margin-top: 2px; padding-left: 12px; }
|
|
54
74
|
.joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }
|
|
55
75
|
dl { margin: 16px 0 0; padding-top: 12px; border-top: 1px solid #e5e9e3; display: grid; grid-template-columns: auto 1fr; gap: 5px 16px; font-size: 11px; }
|
|
@@ -64,6 +84,14 @@ export const styles = `
|
|
|
64
84
|
.row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }
|
|
65
85
|
.account .row { align-items: center; justify-content: start; flex-wrap: wrap; margin-top: 12px; }
|
|
66
86
|
.account .action { padding: 5px 10px; font-size: 12px; }
|
|
87
|
+
.account .action.dots { display: grid; place-items: center; align-self: stretch; width: 30px; padding: 0; }
|
|
88
|
+
.dots svg { width: 16px; height: 16px; }
|
|
89
|
+
/* Placed under its button each time it opens, above the dialog and clipped by none of it. */
|
|
90
|
+
.menu { position: fixed; inset: auto; margin: 0; min-width: 170px; padding: 6px; border: 1px solid #dce2de; border-radius: 10px; background: #fff; color: #23312b; box-shadow: 0 12px 40px #10201930; }
|
|
91
|
+
.menu:popover-open { display: grid; }
|
|
92
|
+
.account .menu .action { border: 0; border-radius: 6px; padding: 6px 10px; font-weight: 550; }
|
|
93
|
+
.menu .danger { color: #b3261e; }
|
|
94
|
+
.menu hr { width: 100%; margin: 4px 0; border: 0; border-top: 1px solid #e5e9e3; }
|
|
67
95
|
footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin: 20px 0 -8px; color: #9aa9a0; font-size: 11px; }
|
|
68
96
|
footer a { display: flex; color: inherit; }
|
|
69
97
|
.logo { display: block; height: 13px; }
|
|
@@ -86,6 +114,69 @@ export function outward(anchor, url) {
|
|
|
86
114
|
anchor.target = '_blank';
|
|
87
115
|
return anchor;
|
|
88
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Where the verifier lists every public record of a record's subject. A record whose
|
|
119
|
+
* address is not the usual one is left linking to itself.
|
|
120
|
+
*/
|
|
121
|
+
export function subjectUrl(e) {
|
|
122
|
+
const url = new URL(e.evidenceUrl);
|
|
123
|
+
const path = url.pathname.replace(/\/connections\/[^/]+$/, '/published');
|
|
124
|
+
if (path === url.pathname)
|
|
125
|
+
return e.evidenceUrl;
|
|
126
|
+
url.pathname = path;
|
|
127
|
+
url.search = `site=${encodeURIComponent(e.siteName)}&reference=${encodeURIComponent(e.local.reference)}`;
|
|
128
|
+
return url.href;
|
|
129
|
+
}
|
|
130
|
+
/** Opens somewhere else, in a tab of its own: a box with an arrow leaving it. */
|
|
131
|
+
function awayMark() {
|
|
132
|
+
const namespace = 'http://www.w3.org/2000/svg';
|
|
133
|
+
const svg = document.createElementNS(namespace, 'svg');
|
|
134
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
135
|
+
svg.setAttribute('fill', 'none');
|
|
136
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
137
|
+
svg.setAttribute('focusable', 'false');
|
|
138
|
+
for (const d of [
|
|
139
|
+
'M14 5h5v5',
|
|
140
|
+
'M19 5l-8 8',
|
|
141
|
+
'M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4',
|
|
142
|
+
]) {
|
|
143
|
+
const path = document.createElementNS(namespace, 'path');
|
|
144
|
+
path.setAttribute('d', d);
|
|
145
|
+
path.setAttribute('stroke', 'currentColor');
|
|
146
|
+
path.setAttribute('stroke-width', '2');
|
|
147
|
+
path.setAttribute('stroke-linecap', 'round');
|
|
148
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
149
|
+
svg.append(path);
|
|
150
|
+
}
|
|
151
|
+
return svg;
|
|
152
|
+
}
|
|
153
|
+
/** A small mark of plain strokes, in the surrounding colour. */
|
|
154
|
+
function strokes(box, width, paths) {
|
|
155
|
+
const namespace = 'http://www.w3.org/2000/svg';
|
|
156
|
+
const svg = document.createElementNS(namespace, 'svg');
|
|
157
|
+
svg.setAttribute('viewBox', `0 0 ${box} ${box}`);
|
|
158
|
+
svg.setAttribute('fill', 'none');
|
|
159
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
160
|
+
svg.setAttribute('focusable', 'false');
|
|
161
|
+
for (const d of paths) {
|
|
162
|
+
const path = document.createElementNS(namespace, 'path');
|
|
163
|
+
path.setAttribute('d', d);
|
|
164
|
+
path.setAttribute('stroke', 'currentColor');
|
|
165
|
+
path.setAttribute('stroke-width', String(width));
|
|
166
|
+
path.setAttribute('stroke-linecap', 'round');
|
|
167
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
168
|
+
svg.append(path);
|
|
169
|
+
}
|
|
170
|
+
return svg;
|
|
171
|
+
}
|
|
172
|
+
/** Points at what is folded away, and turns to point down once it is open. */
|
|
173
|
+
const foldMark = () => strokes(16, 1.8, ['M6 3.5 10.5 8 6 12.5']);
|
|
174
|
+
/** Three dots: there is more to do here than the buttons beside it. */
|
|
175
|
+
const moreMark = () => strokes(16, 2.4, ['M3 8h.01', 'M8 8h.01', 'M13 8h.01']);
|
|
176
|
+
/** A cross drawn in a square, so it sits in the middle of its button in any font. */
|
|
177
|
+
export const closeMark = () => strokes(16, 1.8, ['M4 4l8 8', 'M12 4l-8 8']);
|
|
178
|
+
/** Points back the way the reader came, drawn for the same reason the cross is. */
|
|
179
|
+
export const backMark = () => strokes(16, 1.8, ['M10 3.5 5.5 8 10 12.5']);
|
|
89
180
|
/** Joins the two cards: the link itself, drawn rather than described. */
|
|
90
181
|
export function linkMark() {
|
|
91
182
|
const namespace = 'http://www.w3.org/2000/svg';
|
|
@@ -148,8 +239,13 @@ export function accountCard(heading, name, reference, url, ...extra) {
|
|
|
148
239
|
if (value instanceof HTMLAnchorElement)
|
|
149
240
|
outward(value, url);
|
|
150
241
|
card.append(title, value);
|
|
151
|
-
if (reference)
|
|
242
|
+
if (typeof reference === 'string')
|
|
152
243
|
card.append(node('div', reference, 'muted reference'));
|
|
244
|
+
else if (reference) {
|
|
245
|
+
const groups = node('div', '', 'muted reference fingerprint');
|
|
246
|
+
groups.append(...reference.map((group) => node('span', group)));
|
|
247
|
+
card.append(groups);
|
|
248
|
+
}
|
|
153
249
|
card.append(...extra);
|
|
154
250
|
return card;
|
|
155
251
|
}
|
|
@@ -161,12 +257,77 @@ const retiringNote = 'Retiring keeps this record as history. It will no longer r
|
|
|
161
257
|
* from the status on the card.
|
|
162
258
|
*/
|
|
163
259
|
const removable = (evidence) => evidence.links ?? (evidence.status === 'revoked' ? [] : [evidence.id]);
|
|
260
|
+
/**
|
|
261
|
+
* Everything a holder may do to an account besides renewing it, behind one button. The
|
|
262
|
+
* menu is a popover, so it is drawn above the dialog and the dialog's scrolling cannot
|
|
263
|
+
* clip it, the browser shuts it on Escape or a press elsewhere, and its button opens and
|
|
264
|
+
* shuts it. Nothing where the browser has no popovers, and the choices are then laid out.
|
|
265
|
+
*/
|
|
266
|
+
function actionsMenu(more, choices) {
|
|
267
|
+
const menu = node('div', '', 'menu');
|
|
268
|
+
if (typeof menu.showPopover !== 'function')
|
|
269
|
+
return undefined;
|
|
270
|
+
const label = 'More actions';
|
|
271
|
+
more.className = 'action dots';
|
|
272
|
+
more.title = label;
|
|
273
|
+
more.setAttribute('aria-label', label);
|
|
274
|
+
more.setAttribute('aria-haspopup', 'menu');
|
|
275
|
+
more.append(moreMark());
|
|
276
|
+
more.popoverTargetElement = menu;
|
|
277
|
+
menu.setAttribute('popover', 'auto');
|
|
278
|
+
menu.setAttribute('role', 'menu');
|
|
279
|
+
for (const choice of choices)
|
|
280
|
+
choice.setAttribute('role', 'menuitem');
|
|
281
|
+
// Whatever removes the account sits last and apart from the rest.
|
|
282
|
+
menu.append(...choices.slice(0, -1), node('hr'), choices.at(-1));
|
|
283
|
+
const shut = () => {
|
|
284
|
+
try {
|
|
285
|
+
if (menu.matches(':popover-open'))
|
|
286
|
+
menu.hidePopover();
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// Gone from the page with its card.
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const gap = 4;
|
|
293
|
+
const edge = 8;
|
|
294
|
+
// Under the button and ending where it ends, before it is first painted.
|
|
295
|
+
menu.addEventListener('beforetoggle', (event) => {
|
|
296
|
+
if (event.newState !== 'open')
|
|
297
|
+
return;
|
|
298
|
+
const at = more.getBoundingClientRect();
|
|
299
|
+
menu.style.top = `${at.bottom + gap}px`;
|
|
300
|
+
menu.style.right = `${document.documentElement.clientWidth - at.right}px`;
|
|
301
|
+
});
|
|
302
|
+
menu.addEventListener('toggle', (event) => {
|
|
303
|
+
if (event.newState !== 'open')
|
|
304
|
+
return;
|
|
305
|
+
// Over the button where the window ends before the menu does.
|
|
306
|
+
const { height, bottom } = menu.getBoundingClientRect();
|
|
307
|
+
if (bottom > innerHeight - edge)
|
|
308
|
+
menu.style.top = `${Math.max(edge, more.getBoundingClientRect().top - gap - height)}px`;
|
|
309
|
+
// It does not follow its button, so it does not stay open once the button moves.
|
|
310
|
+
menu.closest('dialog')?.addEventListener('scroll', shut, { once: true });
|
|
311
|
+
choices[0].focus();
|
|
312
|
+
});
|
|
313
|
+
menu.addEventListener('click', shut);
|
|
314
|
+
menu.addEventListener('keydown', (event) => {
|
|
315
|
+
const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];
|
|
316
|
+
if (!step)
|
|
317
|
+
return;
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
const at = choices.indexOf(event.target);
|
|
320
|
+
choices[(at + step + choices.length) % choices.length].focus();
|
|
321
|
+
});
|
|
322
|
+
return menu;
|
|
323
|
+
}
|
|
164
324
|
/**
|
|
165
325
|
* Under a holder's own account: renewing it, which is showing the same account again, and
|
|
166
326
|
* removing it, which asks once more before it does. A removed link has nothing left to do.
|
|
167
327
|
*
|
|
168
328
|
* An account still in use can also be listed another way, or retired, which asks first as
|
|
169
|
-
* removing does.
|
|
329
|
+
* removing does. Those and removing it are then kept in a menu beside Renew, so the row
|
|
330
|
+
* stays one line. A retired one is renewed or removed and nothing else.
|
|
170
331
|
*/
|
|
171
332
|
function manageRow(evidence, manage) {
|
|
172
333
|
const row = node('div', '', 'row');
|
|
@@ -210,7 +371,14 @@ function manageRow(evidence, manage) {
|
|
|
210
371
|
retire.onclick = retiring;
|
|
211
372
|
listing.push(...(evidence.mark === 'preferred' ? [] : [list('Mark as preferred', 'preferred')]), ...(evidence.mark === 'unused' ? [] : [list('Mark as unused', 'unused')]), ...(evidence.mark === undefined ? [] : [list('Mark as current', 'current')]), retire);
|
|
212
373
|
}
|
|
213
|
-
|
|
374
|
+
const said = note ? [node('span', note, 'muted')] : [];
|
|
375
|
+
const more = act('');
|
|
376
|
+
// Two buttons need no menu between them.
|
|
377
|
+
const menu = listing.length ? actionsMenu(more, [...listing, remove]) : undefined;
|
|
378
|
+
if (menu)
|
|
379
|
+
row.replaceChildren(renew, more, menu, ...said);
|
|
380
|
+
else
|
|
381
|
+
row.replaceChildren(renew, ...listing, remove, ...said);
|
|
214
382
|
};
|
|
215
383
|
const retiring = () => {
|
|
216
384
|
const cancel = act('Cancel');
|
|
@@ -256,9 +424,10 @@ function signatures(evidence) {
|
|
|
256
424
|
* own page for the record at what it says of the signature, where the signed record is to be
|
|
257
425
|
* had: nothing is downloaded from the card. The signed record is checked here against the
|
|
258
426
|
* verifier's keys: a tick once it has checked, and a warning in the word's place if it
|
|
259
|
-
* fails, the one thing on a card that is a warning.
|
|
427
|
+
* fails, the one thing on a card that is a warning. `warned` is told each time that warning
|
|
428
|
+
* is drawn, on the first check or on a later one, so whatever folds the card away opens.
|
|
260
429
|
*/
|
|
261
|
-
function signedMark(evidence, later) {
|
|
430
|
+
function signedMark(evidence, later, warned) {
|
|
262
431
|
const mark = node('span');
|
|
263
432
|
const link = outward(node('a', 'signed'), `${evidence.evidenceUrl}#signed`);
|
|
264
433
|
const see = 'See the signature (opens in a new tab)';
|
|
@@ -270,6 +439,7 @@ function signedMark(evidence, later) {
|
|
|
270
439
|
// What it means, for a reader who has never met a signature, kept off the card.
|
|
271
440
|
warning.title = `This record does not match the signature ${evidence.verifierName} put on it, so it may have been altered. Do not rely on it.`;
|
|
272
441
|
mark.replaceChildren(document.createTextNode(' · '), warning);
|
|
442
|
+
warned();
|
|
273
443
|
return;
|
|
274
444
|
}
|
|
275
445
|
if (first?.state === 'valid' && results.every((result) => result.state === 'valid')) {
|
|
@@ -320,12 +490,39 @@ function cautionMark() {
|
|
|
320
490
|
}
|
|
321
491
|
return svg;
|
|
322
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* Opens and shuts what a card folds away: how its account was shown, every method of it
|
|
495
|
+
* together so that none is put ahead of another, and its dates.
|
|
496
|
+
*/
|
|
497
|
+
function foldToggle(folded, fold) {
|
|
498
|
+
const toggle = node('button', '', 'fold');
|
|
499
|
+
let shown = fold.open;
|
|
500
|
+
const set = (open) => {
|
|
501
|
+
shown = open;
|
|
502
|
+
for (const part of folded)
|
|
503
|
+
part.hidden = !open;
|
|
504
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
505
|
+
};
|
|
506
|
+
toggle.type = 'button';
|
|
507
|
+
toggle.append(foldMark(), document.createTextNode('Details'));
|
|
508
|
+
set(fold.open);
|
|
509
|
+
toggle.onclick = () => {
|
|
510
|
+
set(!shown);
|
|
511
|
+
fold.toggle(shown);
|
|
512
|
+
};
|
|
513
|
+
return toggle;
|
|
514
|
+
}
|
|
323
515
|
/**
|
|
324
516
|
* One linked account: its state, how it was shown and when, on a card of its own. A
|
|
325
517
|
* subject with several accounts gets one of these each, since a provider authenticates and
|
|
326
518
|
* expires on its own terms and none of that may be read across to another's card.
|
|
519
|
+
*
|
|
520
|
+
* Given a `fold`, the card is one of several, read first as a list of accounts: it keeps
|
|
521
|
+
* how it was shown and its dates behind a toggle, which shares a line with what the holder
|
|
522
|
+
* may do. Its state, its verifier and what is said of its signature are never folded.
|
|
523
|
+
* Alone, it shows everything.
|
|
327
524
|
*/
|
|
328
|
-
function externalCard(evidence, manage, later = () => { }) {
|
|
525
|
+
function externalCard(evidence, manage, later = () => { }, fold, warned = () => { }) {
|
|
329
526
|
const current = evidence.status === 'verified' && evidence.expiresAt > Date.now();
|
|
330
527
|
const status = statusLabel(evidence, Date.now());
|
|
331
528
|
const provider = evidence.providerName;
|
|
@@ -345,7 +542,7 @@ function externalCard(evidence, manage, later = () => { }) {
|
|
|
345
542
|
else
|
|
346
543
|
attribution.append(document.createTextNode(evidence.verifierName));
|
|
347
544
|
if (evidence.signedUrl)
|
|
348
|
-
attribution.append(signedMark(evidence, later));
|
|
545
|
+
attribution.append(signedMark(evidence, later, warned));
|
|
349
546
|
copy.append(node('div', status, 'state'), attribution);
|
|
350
547
|
const retired = evidence.status === 'retired';
|
|
351
548
|
// A retired account claims nothing now, which is not the same as a claim that failed.
|
|
@@ -379,16 +576,24 @@ function externalCard(evidence, manage, later = () => { }) {
|
|
|
379
576
|
dates.append(node('dt', label), node('dd', moment(time)));
|
|
380
577
|
}
|
|
381
578
|
const names = { site: evidence.siteName, provider };
|
|
579
|
+
const row = manage && removable(evidence).length ? [manageRow(evidence, manage)] : [];
|
|
580
|
+
const bar = node('div', '', 'bar');
|
|
581
|
+
const methods = [
|
|
582
|
+
...attestationNote(main, names),
|
|
583
|
+
...evidence.attestations.external.slice(1).flatMap((a) => attestationNote(a, names, true)),
|
|
584
|
+
];
|
|
585
|
+
if (fold)
|
|
586
|
+
bar.append(foldToggle([...methods, dates], fold), ...row);
|
|
382
587
|
const logo = providerMark(evidence.provider, evidence.attestations.external[0].method);
|
|
383
588
|
const card = accountCard(
|
|
384
589
|
// The mark and the name, or just the name. A mark that falls back to writing the name
|
|
385
590
|
// would print it twice here, since this heading writes it either way.
|
|
386
591
|
logo ? [logo, document.createTextNode(provider)] : [document.createTextNode(provider)], externalName(evidence.external),
|
|
387
592
|
// A mailbox's address or a domain is its name already, so there is no second identifier.
|
|
388
|
-
externalId(evidence.external), externalLink(evidence.external),
|
|
593
|
+
externalIdGroups(evidence.external) ?? externalId(evidence.external), externalLink(evidence.external),
|
|
389
594
|
// Status first, then how it was shown, then when. The proof explains the state
|
|
390
595
|
// above it, so it cannot sit before that state has been given.
|
|
391
|
-
summary, ...
|
|
596
|
+
summary, ...(fold ? [] : methods),
|
|
392
597
|
// How the holder lists the account, in their words and apart from how it was shown:
|
|
393
598
|
// nothing checked it, so it is never among the methods above it.
|
|
394
599
|
...(evidence.status === 'revoked' || retired
|
|
@@ -397,7 +602,7 @@ function externalCard(evidence, manage, later = () => { }) {
|
|
|
397
602
|
? [node('div', 'Preferred', 'listing preferred')]
|
|
398
603
|
: evidence.mark === 'unused'
|
|
399
604
|
? [node('div', 'No longer used', 'listing')]
|
|
400
|
-
: []),
|
|
605
|
+
: []), ...(fold ? [bar, ...methods, dates] : [dates, ...row]));
|
|
401
606
|
const surface = retired ? 'account retired' : 'account';
|
|
402
607
|
card.className = surface;
|
|
403
608
|
// A signed record is not read, or acted on, before its signature has been checked: until
|
|
@@ -432,10 +637,11 @@ function addRow(manage) {
|
|
|
432
637
|
}
|
|
433
638
|
/**
|
|
434
639
|
* The subject once, then each account linked to it on its own card, in the order given.
|
|
435
|
-
* Retired accounts sit in a group of their own at the foot, under what says they are
|
|
436
|
-
*
|
|
640
|
+
* Retired accounts sit in a group of their own at the foot, under what says they are,
|
|
641
|
+
* shut until it is opened unless no other account is left to read. Every record given is
|
|
642
|
+
* of the one subject, which the caller has checked.
|
|
437
643
|
*/
|
|
438
|
-
function render(content, records, manage, later) {
|
|
644
|
+
function render(content, records, manage, later, folds = { open: false, cards: new Set() }) {
|
|
439
645
|
const [first] = records;
|
|
440
646
|
// Each card says what it is. Without that the pair is two unlabelled boxes.
|
|
441
647
|
const local = localSide(first.local, first.siteName);
|
|
@@ -448,14 +654,33 @@ function render(content, records, manage, later) {
|
|
|
448
654
|
provider: first.providerName,
|
|
449
655
|
}));
|
|
450
656
|
const retired = records.filter((record) => record.status === 'retired');
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
657
|
+
const listed = records.filter((record) => record.status !== 'retired');
|
|
658
|
+
// A card alone in its group has room for its dates. Among others it folds them.
|
|
659
|
+
const cards = (among, warned) => among.map((record) => {
|
|
660
|
+
const key = `${record.provider}\n${record.external.id}`;
|
|
661
|
+
return externalCard(record, manage, later, among.length > 1
|
|
662
|
+
? {
|
|
663
|
+
open: folds.cards.has(key) !== folds.open,
|
|
664
|
+
toggle: (open) => void (open === folds.open ? folds.cards.delete(key) : folds.cards.add(key)),
|
|
665
|
+
}
|
|
666
|
+
: undefined, warned);
|
|
667
|
+
});
|
|
668
|
+
const history = node('details', '', 'history');
|
|
669
|
+
const count = node('summary', '', 'group');
|
|
670
|
+
count.append(foldMark(), document.createTextNode(`Retired accounts (${retired.length})`));
|
|
671
|
+
// A signature that fails is a warning, and a warning is not left behind a fold.
|
|
672
|
+
history.append(count, ...cards(retired, () => {
|
|
673
|
+
history.open = true;
|
|
674
|
+
}));
|
|
675
|
+
// Shut to keep history out of the way of the accounts in use. With none of those there
|
|
676
|
+
// is nothing else to read, so it starts open.
|
|
677
|
+
history.open = folds.retired ?? !listed.length;
|
|
678
|
+
// Pressing the heading is the reader's choice, and is kept. It is heard before the
|
|
679
|
+
// group turns, so what it will be is the opposite of what it is.
|
|
680
|
+
count.addEventListener('click', () => {
|
|
681
|
+
folds.retired = !history.open;
|
|
682
|
+
});
|
|
683
|
+
content.replaceChildren(localCard, linkMark(), ...cards(listed), ...(manage ? [addRow(manage)] : []), ...(retired.length ? [history] : []),
|
|
459
684
|
// Nothing below the cards may name a provider. Approval, method and dates belong to
|
|
460
685
|
// the card they came from, and a second provider on this subject gets its own card.
|
|
461
686
|
node('p', 'Verification does not guarantee legal identity, trustworthiness, or permanent ownership.', 'muted explanation'));
|
|
@@ -478,7 +703,12 @@ arrange = (records) => records,
|
|
|
478
703
|
* Given where whoever is looking holds these accounts: each then has its renewal and
|
|
479
704
|
* removal under it, and the dialog offers to connect another.
|
|
480
705
|
*/
|
|
481
|
-
manage
|
|
706
|
+
manage,
|
|
707
|
+
/**
|
|
708
|
+
* Whether the cards of several accounts start with their details open. They start shut
|
|
709
|
+
* unless the page asked otherwise, and a reader may turn each either way from there.
|
|
710
|
+
*/
|
|
711
|
+
detailsOpen = false) {
|
|
482
712
|
const existing = openDialogs.get(opener);
|
|
483
713
|
if (existing?.open) {
|
|
484
714
|
existing.focus();
|
|
@@ -491,7 +721,11 @@ manage) {
|
|
|
491
721
|
root.adoptedStyleSheets = [sheet];
|
|
492
722
|
const dialog = node('dialog');
|
|
493
723
|
const heading = node('h2', 'Verification details');
|
|
494
|
-
const close = node('button', '
|
|
724
|
+
const close = node('button', '', 'icon');
|
|
725
|
+
// Every account on a page of its own, on the verifier. It sits by the heading it widens,
|
|
726
|
+
// and only where there is more than one account to list.
|
|
727
|
+
const all = node('a', '', 'all');
|
|
728
|
+
const title = node('div', '', 'title');
|
|
495
729
|
const header = node('header');
|
|
496
730
|
// What drew the record, under it. The record is about the pair, not about us.
|
|
497
731
|
const stamp = node('footer');
|
|
@@ -502,7 +736,12 @@ manage) {
|
|
|
502
736
|
close.setAttribute('aria-label', 'Close verification details');
|
|
503
737
|
content.setAttribute('aria-live', 'polite');
|
|
504
738
|
stamp.append(stampLink(), node('span', version));
|
|
505
|
-
|
|
739
|
+
all.append(awayMark());
|
|
740
|
+
close.append(closeMark());
|
|
741
|
+
// The close was the first thing in the dialog to take the focus, and still is.
|
|
742
|
+
close.autofocus = true;
|
|
743
|
+
title.append(heading);
|
|
744
|
+
header.append(title, close);
|
|
506
745
|
dialog.append(header, content, stamp);
|
|
507
746
|
/** Links removed from this dialog, shown as removed before the page hands over new records. */
|
|
508
747
|
const removed = new Map();
|
|
@@ -519,6 +758,8 @@ manage) {
|
|
|
519
758
|
/** How each record given reads now, by its id. */
|
|
520
759
|
const listings = (given) => new Map([given].flat().map((record) => [record.id, listing(record)]));
|
|
521
760
|
let held;
|
|
761
|
+
/** What has been opened in this dialog, which outlasts every redraw of its cards. */
|
|
762
|
+
const folds = { open: detailsOpen, cards: new Set() };
|
|
522
763
|
/** A record as the listings made here leave it, each applied as the backend applies it. */
|
|
523
764
|
const relisted = (evidence) => {
|
|
524
765
|
let record = evidence;
|
|
@@ -599,13 +840,22 @@ manage) {
|
|
|
599
840
|
.map((e) => removed.has(e.id) && e.status !== 'revoked'
|
|
600
841
|
? { ...e, status: 'revoked', revokedAt: removed.get(e.id) }
|
|
601
842
|
: relisted(standing(e))));
|
|
843
|
+
const listable = records.filter((e) => e.visibility === 'public' && e.status !== 'revoked');
|
|
844
|
+
// In the header only while it leads somewhere: an anchor with no address is not a link.
|
|
845
|
+
title.replaceChildren(heading, ...(listable.length > 1 ? [all] : []));
|
|
846
|
+
if (listable.length > 1) {
|
|
847
|
+
const label = `View all on ${listable[0].verifierName} (opens in a new tab)`;
|
|
848
|
+
outward(all, subjectUrl(listable[0]));
|
|
849
|
+
all.title = label;
|
|
850
|
+
all.setAttribute('aria-label', label);
|
|
851
|
+
}
|
|
602
852
|
const key = JSON.stringify(records.map((e) => [e, statusLabel(e, Date.now())]));
|
|
603
853
|
if (key === drawn)
|
|
604
854
|
return;
|
|
605
855
|
drawn = key;
|
|
606
856
|
// The cards are new, and what the old ones were waiting to try again went with them.
|
|
607
857
|
retries.clear();
|
|
608
|
-
render(content, records, actions, (retry) => retries.add(retry));
|
|
858
|
+
render(content, records, actions, (retry) => retries.add(retry), folds);
|
|
609
859
|
};
|
|
610
860
|
/**
|
|
611
861
|
* Redraws at the moment the next record runs out, where that comes before the next check
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Evidence } from '../core/index.js';
|
|
2
2
|
import { type Listing } from './evidence-dialog.js';
|
|
3
3
|
export type { Evidence } from '../core/index.js';
|
|
4
4
|
export interface Result {
|
|
@@ -14,6 +14,12 @@ export interface Arrangement {
|
|
|
14
14
|
* the keyboard is on it. On unless this is false. A stacked pill has nothing to open.
|
|
15
15
|
*/
|
|
16
16
|
peek?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the dialog's cards start with their details open: how each account was shown,
|
|
19
|
+
* and its dates. With several accounts they start shut unless this is true. A reader may
|
|
20
|
+
* open or shut each either way.
|
|
21
|
+
*/
|
|
22
|
+
detailsOpen?: boolean;
|
|
17
23
|
}
|
|
18
24
|
/**
|
|
19
25
|
* Presents records the page already holds, without asking any backend for them. This is
|
package/dist/browser/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { accountsOf } from '../core/index.js';
|
|
1
2
|
import { routePath } from '../core/routes.js';
|
|
2
3
|
import { badgeShown, quietBadge, renderBadge, renderBadgeMessage, renderBadgePending, renderConnectPill, } from './badge.js';
|
|
3
4
|
import { openConnectDialog, } from './connect-dialog.js';
|
|
4
|
-
import { openEvidenceDialog } from './evidence-dialog.js';
|
|
5
|
+
import { openEvidenceDialog, subjectUrl, } from './evidence-dialog.js';
|
|
5
6
|
import { drawSignedWith, standing, watchSigned } from './signed.js';
|
|
6
7
|
/**
|
|
7
8
|
* What each host's dialog opens on and how it reads again, kept apart from the pill: the
|
|
@@ -91,15 +92,17 @@ function details(element) {
|
|
|
91
92
|
throw error;
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
|
-
}, latestGroup.get(element), accounts, managers.get(element));
|
|
95
|
+
}, latestGroup.get(element), accounts, managers.get(element), openHosts.has(element));
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
/**
|
|
98
|
-
* Hosts whose pill is stacked,
|
|
99
|
-
*
|
|
99
|
+
* Hosts whose pill is stacked, hosts whose pill opens no panel, and hosts whose dialog
|
|
100
|
+
* starts with its details open, where the page asked for any of them. Kept here so every
|
|
101
|
+
* later refresh of the host draws it the same way.
|
|
100
102
|
*/
|
|
101
103
|
const stackedHosts = new WeakSet();
|
|
102
104
|
const quietHosts = new WeakSet();
|
|
105
|
+
const openHosts = new WeakSet();
|
|
103
106
|
/**
|
|
104
107
|
* How many accounts a pill names before it counts the rest. A stacked pill pays for each
|
|
105
108
|
* row in the page's height. A panel floats over the page and pays nothing, but it cannot
|
|
@@ -108,7 +111,11 @@ const quietHosts = new WeakSet();
|
|
|
108
111
|
const stackedAccounts = 4;
|
|
109
112
|
const peekedAccounts = 8;
|
|
110
113
|
/** Records how a host's pill is arranged, from what the page last said. */
|
|
111
|
-
function arrange(element, { stacked, peek }) {
|
|
114
|
+
function arrange(element, { stacked, peek, detailsOpen }) {
|
|
115
|
+
if (detailsOpen)
|
|
116
|
+
openHosts.add(element);
|
|
117
|
+
else
|
|
118
|
+
openHosts.delete(element);
|
|
112
119
|
if (stacked)
|
|
113
120
|
stackedHosts.add(element);
|
|
114
121
|
else
|
|
@@ -133,45 +140,16 @@ const live = (evidence) => evidence.status === 'verified' && evidence.expiresAt
|
|
|
133
140
|
* Each entry names every record of the account not yet revoked, shown or not, so removing
|
|
134
141
|
* the account removes them all. One left behind, lapsed or unconfirmed, could be confirmed
|
|
135
142
|
* again later and bring the account back without its holder approving anything.
|
|
136
|
-
*
|
|
137
|
-
* A record from a backend older than `connectedAt` is placed by when it was approved.
|
|
138
143
|
*/
|
|
139
144
|
function accounts(records) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const sorted = [...held].sort((a, b) => when(a) - when(b));
|
|
149
|
-
const verified = sorted.filter(live);
|
|
150
|
-
const base = verified[0] ?? sorted.reduce((a, b) => (b.approvedAt > a.approvedAt ? b : a));
|
|
151
|
-
const external = [...base.attestations.external];
|
|
152
|
-
for (const other of verified.slice(1))
|
|
153
|
-
for (const attestation of other.attestations.external)
|
|
154
|
-
if (!external.some((shown) => shown.method === attestation.method))
|
|
155
|
-
external.push(attestation);
|
|
156
|
-
return {
|
|
157
|
-
...base,
|
|
158
|
-
connectedAt: when(sorted[0]),
|
|
159
|
-
attestations: { ...base.attestations, external },
|
|
160
|
-
links: sorted.filter((e) => e.status !== 'revoked').map((e) => e.id),
|
|
161
|
-
// Every record the card speaks for, so its signature mark answers for all of them.
|
|
162
|
-
sources: verified.length ? verified : [base],
|
|
163
|
-
};
|
|
164
|
-
})
|
|
165
|
-
.sort((a, b) => place(a) - place(b) || a.connectedAt - b.connectedAt);
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* Where an account is listed among a subject's others, before the order they were
|
|
169
|
-
* connected in. This is all there is to preferring an account: there is no separate pin.
|
|
170
|
-
*/
|
|
171
|
-
function place(account) {
|
|
172
|
-
if (account.status === 'retired')
|
|
173
|
-
return 3;
|
|
174
|
-
return account.mark === 'preferred' ? 0 : account.mark === 'unused' ? 2 : 1;
|
|
145
|
+
return accountsOf(records, Date.now()).map(({ lead, held, verified, external, connectedAt }) => ({
|
|
146
|
+
...lead,
|
|
147
|
+
connectedAt,
|
|
148
|
+
attestations: { ...lead.attestations, external },
|
|
149
|
+
links: held.filter((e) => e.status !== 'revoked').map((e) => e.id),
|
|
150
|
+
// Every record the card speaks for, so its signature mark answers for all of them.
|
|
151
|
+
sources: verified.length ? verified : [lead],
|
|
152
|
+
}));
|
|
175
153
|
}
|
|
176
154
|
/**
|
|
177
155
|
* One subject's accounts as a single pill: the first of them, then how many more stand
|
|
@@ -198,6 +176,8 @@ function presentGroup(element, records, load) {
|
|
|
198
176
|
const badge = renderBadge(element, lead, {
|
|
199
177
|
more: Math.max(verified.length - 1, 0),
|
|
200
178
|
linked: lead.visibility === 'public',
|
|
179
|
+
// A modified click follows the link, and must reach what a plain one shows: every account.
|
|
180
|
+
...(ordered.length > 1 ? { href: subjectUrl(lead) } : {}),
|
|
201
181
|
rows: stacked
|
|
202
182
|
? verified.slice(1, stackedAccounts)
|
|
203
183
|
: peek
|
|
@@ -729,6 +709,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
|
|
|
729
709
|
'connect',
|
|
730
710
|
'stacked',
|
|
731
711
|
'peek',
|
|
712
|
+
'details-open',
|
|
732
713
|
];
|
|
733
714
|
handed;
|
|
734
715
|
/**
|
|
@@ -788,6 +769,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
|
|
|
788
769
|
const arrangement = {
|
|
789
770
|
stacked: this.getAttribute('stacked') !== null,
|
|
790
771
|
peek: this.getAttribute('peek') !== 'off',
|
|
772
|
+
detailsOpen: this.getAttribute('details-open') !== null,
|
|
791
773
|
};
|
|
792
774
|
// The holder's own badge, where the page says so: its dialog connects another
|
|
793
775
|
// account, renews one and removes one, through the backend named. The host hears
|