@bkazemi/verily 0.5.0 → 0.7.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 +25 -6
- package/dist/browser/badge.d.ts +4 -1
- package/dist/browser/badge.js +7 -4
- package/dist/browser/connect-dialog.js +10 -7
- package/dist/browser/evidence-dialog.d.ts +23 -4
- package/dist/browser/evidence-dialog.js +335 -49
- package/dist/browser/index.d.ts +7 -1
- package/dist/browser/index.js +27 -42
- package/dist/core/index.d.ts +106 -2
- package/dist/core/index.js +74 -2
- package/dist/server/discord.js +3 -0
- package/dist/server/domain.js +37 -10
- package/dist/server/github-gist.js +16 -4
- package/dist/server/github.js +3 -0
- package/dist/server/index.js +108 -11
- package/dist/server/link.js +9 -4
- package/dist/server/openpgp.d.ts +4 -0
- package/dist/server/openpgp.js +10 -1
- package/dist/server/service.d.ts +59 -2
- package/dist/server/service.js +240 -22
- package/dist/server/style.d.ts +1 -1
- package/dist/server/style.js +50 -1
- package/dist/server/youtube.js +5 -0
- package/dist/verily.js +345 -84
- 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, externalSince, 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,24 +40,45 @@ 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
|
-
|
|
36
|
-
.
|
|
37
|
-
.
|
|
43
|
+
/* The "signed" pill after the verifier's name, one size smaller than the line it sits on. */
|
|
44
|
+
.summary .signed { display: inline-block; margin-left: 5px; padding: 0 6px; border: 1px solid #cfdcd3; border-radius: 999px; font-size: 10px; font-weight: 550; line-height: 1.3; text-decoration: none; vertical-align: 1px; }
|
|
45
|
+
.summary a.signed:hover { background: #f2f5f1; }
|
|
46
|
+
.summary .signed.bad { border-color: #b3261e; color: #b3261e; font-weight: 650; cursor: help; }
|
|
47
|
+
.caution { display: inline-block; margin-left: 3px; color: #a15c00; vertical-align: -1.5px; }
|
|
48
|
+
.caution svg { display: block; width: 10px; height: 10px; }
|
|
38
49
|
.account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }
|
|
39
50
|
.account h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 3px; color: #6b786f; font-size: 11px; font-weight: 550; }
|
|
40
51
|
.account a, .account strong { font-weight: 650; font-size: 14px; }
|
|
41
52
|
/* The account's own name carries that weight; a link inside a line of prose does not. */
|
|
42
53
|
.summary a, .method a, .additional a { font: inherit; }
|
|
43
54
|
.reference { margin-top: 2px; }
|
|
55
|
+
/* A fingerprint in fours: it breaks between groups, and copies as one unbroken word. */
|
|
56
|
+
.fingerprint { font-family: ui-monospace, monospace; text-wrap: balance; }
|
|
57
|
+
.fingerprint span { display: inline-block; margin-right: .5ch; }
|
|
44
58
|
.method { margin-top: 8px; }
|
|
45
59
|
/* In the corner the heading leaves empty, apart from everything that was checked. */
|
|
46
60
|
.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; }
|
|
47
61
|
.listing.preferred { border-color: #245f43; color: #245f43; }
|
|
48
62
|
/* Quiet, as its pill is: the surface and the name step back, and the controls do not. */
|
|
49
63
|
.account.retired { background: #f6f8f5; }
|
|
50
|
-
.account.retired > a, .account.retired > strong { color: #6b786f; }
|
|
64
|
+
.account.retired > a, .account.retired > strong, .account.retired > .named > a, .account.retired > .named > strong { color: #6b786f; }
|
|
65
|
+
/* The name, then a dot and the account's creation date. The date wraps under a long name. */
|
|
66
|
+
/* Clipped 4px left of the text, which hides the dot when the date wraps to a new line. */
|
|
67
|
+
.named { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 14px; margin-left: -4px; padding-left: 4px; overflow-x: clip; }
|
|
68
|
+
.since { position: relative; white-space: nowrap; }
|
|
69
|
+
/* The dot sits in the gap before the date. */
|
|
70
|
+
.since::before { content: "·"; position: absolute; left: -7px; transform: translateX(-50%); }
|
|
51
71
|
.account.retired .mark { opacity: .45; }
|
|
52
72
|
.group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }
|
|
73
|
+
summary.group { display: flex; align-items: center; gap: 4px; width: fit-content; border-radius: 4px; list-style: none; cursor: pointer; }
|
|
74
|
+
summary::-webkit-details-marker { display: none; }
|
|
75
|
+
.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; }
|
|
76
|
+
.fold svg, summary svg { width: 12px; height: 12px; }
|
|
77
|
+
.fold[aria-expanded="true"] svg, details[open] > summary svg { transform: rotate(90deg); }
|
|
78
|
+
@media (prefers-reduced-motion: no-preference) { .fold svg, summary svg { transition: transform .15s; } }
|
|
79
|
+
/* The fold and what the holder may do share a line, until a question needs the whole of it. */
|
|
80
|
+
.bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
|
81
|
+
.account .bar .row { margin: 0 0 0 auto; }
|
|
53
82
|
.additional { margin-top: 2px; padding-left: 12px; }
|
|
54
83
|
.joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }
|
|
55
84
|
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 +93,14 @@ export const styles = `
|
|
|
64
93
|
.row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }
|
|
65
94
|
.account .row { align-items: center; justify-content: start; flex-wrap: wrap; margin-top: 12px; }
|
|
66
95
|
.account .action { padding: 5px 10px; font-size: 12px; }
|
|
96
|
+
.account .action.dots { display: grid; place-items: center; align-self: stretch; width: 30px; padding: 0; }
|
|
97
|
+
.dots svg { width: 16px; height: 16px; }
|
|
98
|
+
/* Placed under its button each time it opens, above the dialog and clipped by none of it. */
|
|
99
|
+
.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; }
|
|
100
|
+
.menu:popover-open { display: grid; }
|
|
101
|
+
.account .menu .action { border: 0; border-radius: 6px; padding: 6px 10px; font-weight: 550; }
|
|
102
|
+
.menu .danger { color: #b3261e; }
|
|
103
|
+
.menu hr { width: 100%; margin: 4px 0; border: 0; border-top: 1px solid #e5e9e3; }
|
|
67
104
|
footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin: 20px 0 -8px; color: #9aa9a0; font-size: 11px; }
|
|
68
105
|
footer a { display: flex; color: inherit; }
|
|
69
106
|
.logo { display: block; height: 13px; }
|
|
@@ -86,6 +123,69 @@ export function outward(anchor, url) {
|
|
|
86
123
|
anchor.target = '_blank';
|
|
87
124
|
return anchor;
|
|
88
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Where the verifier lists every public record of a record's subject. A record whose
|
|
128
|
+
* address is not the usual one is left linking to itself.
|
|
129
|
+
*/
|
|
130
|
+
export function subjectUrl(e) {
|
|
131
|
+
const url = new URL(e.evidenceUrl);
|
|
132
|
+
const path = url.pathname.replace(/\/connections\/[^/]+$/, '/published');
|
|
133
|
+
if (path === url.pathname)
|
|
134
|
+
return e.evidenceUrl;
|
|
135
|
+
url.pathname = path;
|
|
136
|
+
url.search = `site=${encodeURIComponent(e.siteName)}&reference=${encodeURIComponent(e.local.reference)}`;
|
|
137
|
+
return url.href;
|
|
138
|
+
}
|
|
139
|
+
/** Opens somewhere else, in a tab of its own: a box with an arrow leaving it. */
|
|
140
|
+
function awayMark() {
|
|
141
|
+
const namespace = 'http://www.w3.org/2000/svg';
|
|
142
|
+
const svg = document.createElementNS(namespace, 'svg');
|
|
143
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
144
|
+
svg.setAttribute('fill', 'none');
|
|
145
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
146
|
+
svg.setAttribute('focusable', 'false');
|
|
147
|
+
for (const d of [
|
|
148
|
+
'M14 5h5v5',
|
|
149
|
+
'M19 5l-8 8',
|
|
150
|
+
'M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4',
|
|
151
|
+
]) {
|
|
152
|
+
const path = document.createElementNS(namespace, 'path');
|
|
153
|
+
path.setAttribute('d', d);
|
|
154
|
+
path.setAttribute('stroke', 'currentColor');
|
|
155
|
+
path.setAttribute('stroke-width', '2');
|
|
156
|
+
path.setAttribute('stroke-linecap', 'round');
|
|
157
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
158
|
+
svg.append(path);
|
|
159
|
+
}
|
|
160
|
+
return svg;
|
|
161
|
+
}
|
|
162
|
+
/** A small mark of plain strokes, in the surrounding colour. */
|
|
163
|
+
function strokes(box, width, paths) {
|
|
164
|
+
const namespace = 'http://www.w3.org/2000/svg';
|
|
165
|
+
const svg = document.createElementNS(namespace, 'svg');
|
|
166
|
+
svg.setAttribute('viewBox', `0 0 ${box} ${box}`);
|
|
167
|
+
svg.setAttribute('fill', 'none');
|
|
168
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
169
|
+
svg.setAttribute('focusable', 'false');
|
|
170
|
+
for (const d of paths) {
|
|
171
|
+
const path = document.createElementNS(namespace, 'path');
|
|
172
|
+
path.setAttribute('d', d);
|
|
173
|
+
path.setAttribute('stroke', 'currentColor');
|
|
174
|
+
path.setAttribute('stroke-width', String(width));
|
|
175
|
+
path.setAttribute('stroke-linecap', 'round');
|
|
176
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
177
|
+
svg.append(path);
|
|
178
|
+
}
|
|
179
|
+
return svg;
|
|
180
|
+
}
|
|
181
|
+
/** Points at what is folded away, and turns to point down once it is open. */
|
|
182
|
+
const foldMark = () => strokes(16, 1.8, ['M6 3.5 10.5 8 6 12.5']);
|
|
183
|
+
/** Three dots: there is more to do here than the buttons beside it. */
|
|
184
|
+
const moreMark = () => strokes(16, 2.4, ['M3 8h.01', 'M8 8h.01', 'M13 8h.01']);
|
|
185
|
+
/** A cross drawn in a square, so it sits in the middle of its button in any font. */
|
|
186
|
+
export const closeMark = () => strokes(16, 1.8, ['M4 4l8 8', 'M12 4l-8 8']);
|
|
187
|
+
/** Points back the way the reader came, drawn for the same reason the cross is. */
|
|
188
|
+
export const backMark = () => strokes(16, 1.8, ['M10 3.5 5.5 8 10 12.5']);
|
|
89
189
|
/** Joins the two cards: the link itself, drawn rather than described. */
|
|
90
190
|
export function linkMark() {
|
|
91
191
|
const namespace = 'http://www.w3.org/2000/svg';
|
|
@@ -141,15 +241,41 @@ function moment(time) {
|
|
|
141
241
|
* expires on its own terms, and a second provider on the same subject would differ.
|
|
142
242
|
*/
|
|
143
243
|
export function accountCard(heading, name, reference, url, ...extra) {
|
|
244
|
+
return side(heading, name, reference, url, undefined, extra);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The card for an external account: its name, link and id, with the "Joined <date>" text
|
|
248
|
+
* on the name's line when the creation date is known.
|
|
249
|
+
*/
|
|
250
|
+
export function namedCard(heading, external, ...extra) {
|
|
251
|
+
return side(heading, externalName(external),
|
|
252
|
+
// A mailbox's address or a domain is its name already, so there is no second identifier.
|
|
253
|
+
externalIdGroups(external) ?? externalId(external), externalLink(external),
|
|
254
|
+
// In UTC, so the day matches the one the verifier's own pages print.
|
|
255
|
+
externalSince(external, (time) => new Date(time).toLocaleDateString(undefined, { dateStyle: 'medium', timeZone: 'UTC' })), extra);
|
|
256
|
+
}
|
|
257
|
+
function side(heading, name, reference, url, since, extra) {
|
|
144
258
|
const card = node('section', '', 'account');
|
|
145
259
|
const title = node('h3');
|
|
146
260
|
const value = node(url ? 'a' : 'strong', name);
|
|
147
261
|
title.append(...heading);
|
|
148
262
|
if (value instanceof HTMLAnchorElement)
|
|
149
263
|
outward(value, url);
|
|
150
|
-
|
|
151
|
-
|
|
264
|
+
if (since) {
|
|
265
|
+
// The name and the date share a line, and the date wraps under a long name.
|
|
266
|
+
const named = node('div', '', 'named');
|
|
267
|
+
named.append(value, node('span', since, 'muted since'));
|
|
268
|
+
card.append(title, named);
|
|
269
|
+
}
|
|
270
|
+
else
|
|
271
|
+
card.append(title, value);
|
|
272
|
+
if (typeof reference === 'string')
|
|
152
273
|
card.append(node('div', reference, 'muted reference'));
|
|
274
|
+
else if (reference) {
|
|
275
|
+
const groups = node('div', '', 'muted reference fingerprint');
|
|
276
|
+
groups.append(...reference.map((group) => node('span', group)));
|
|
277
|
+
card.append(groups);
|
|
278
|
+
}
|
|
153
279
|
card.append(...extra);
|
|
154
280
|
return card;
|
|
155
281
|
}
|
|
@@ -161,12 +287,77 @@ const retiringNote = 'Retiring keeps this record as history. It will no longer r
|
|
|
161
287
|
* from the status on the card.
|
|
162
288
|
*/
|
|
163
289
|
const removable = (evidence) => evidence.links ?? (evidence.status === 'revoked' ? [] : [evidence.id]);
|
|
290
|
+
/**
|
|
291
|
+
* Everything a holder may do to an account besides renewing it, behind one button. The
|
|
292
|
+
* menu is a popover, so it is drawn above the dialog and the dialog's scrolling cannot
|
|
293
|
+
* clip it, the browser shuts it on Escape or a press elsewhere, and its button opens and
|
|
294
|
+
* shuts it. Nothing where the browser has no popovers, and the choices are then laid out.
|
|
295
|
+
*/
|
|
296
|
+
function actionsMenu(more, choices) {
|
|
297
|
+
const menu = node('div', '', 'menu');
|
|
298
|
+
if (typeof menu.showPopover !== 'function')
|
|
299
|
+
return undefined;
|
|
300
|
+
const label = 'More actions';
|
|
301
|
+
more.className = 'action dots';
|
|
302
|
+
more.title = label;
|
|
303
|
+
more.setAttribute('aria-label', label);
|
|
304
|
+
more.setAttribute('aria-haspopup', 'menu');
|
|
305
|
+
more.append(moreMark());
|
|
306
|
+
more.popoverTargetElement = menu;
|
|
307
|
+
menu.setAttribute('popover', 'auto');
|
|
308
|
+
menu.setAttribute('role', 'menu');
|
|
309
|
+
for (const choice of choices)
|
|
310
|
+
choice.setAttribute('role', 'menuitem');
|
|
311
|
+
// Whatever removes the account sits last and apart from the rest.
|
|
312
|
+
menu.append(...choices.slice(0, -1), node('hr'), choices.at(-1));
|
|
313
|
+
const shut = () => {
|
|
314
|
+
try {
|
|
315
|
+
if (menu.matches(':popover-open'))
|
|
316
|
+
menu.hidePopover();
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// Gone from the page with its card.
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
const gap = 4;
|
|
323
|
+
const edge = 8;
|
|
324
|
+
// Under the button and ending where it ends, before it is first painted.
|
|
325
|
+
menu.addEventListener('beforetoggle', (event) => {
|
|
326
|
+
if (event.newState !== 'open')
|
|
327
|
+
return;
|
|
328
|
+
const at = more.getBoundingClientRect();
|
|
329
|
+
menu.style.top = `${at.bottom + gap}px`;
|
|
330
|
+
menu.style.right = `${document.documentElement.clientWidth - at.right}px`;
|
|
331
|
+
});
|
|
332
|
+
menu.addEventListener('toggle', (event) => {
|
|
333
|
+
if (event.newState !== 'open')
|
|
334
|
+
return;
|
|
335
|
+
// Over the button where the window ends before the menu does.
|
|
336
|
+
const { height, bottom } = menu.getBoundingClientRect();
|
|
337
|
+
if (bottom > innerHeight - edge)
|
|
338
|
+
menu.style.top = `${Math.max(edge, more.getBoundingClientRect().top - gap - height)}px`;
|
|
339
|
+
// It does not follow its button, so it does not stay open once the button moves.
|
|
340
|
+
menu.closest('dialog')?.addEventListener('scroll', shut, { once: true });
|
|
341
|
+
choices[0].focus();
|
|
342
|
+
});
|
|
343
|
+
menu.addEventListener('click', shut);
|
|
344
|
+
menu.addEventListener('keydown', (event) => {
|
|
345
|
+
const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];
|
|
346
|
+
if (!step)
|
|
347
|
+
return;
|
|
348
|
+
event.preventDefault();
|
|
349
|
+
const at = choices.indexOf(event.target);
|
|
350
|
+
choices[(at + step + choices.length) % choices.length].focus();
|
|
351
|
+
});
|
|
352
|
+
return menu;
|
|
353
|
+
}
|
|
164
354
|
/**
|
|
165
355
|
* Under a holder's own account: renewing it, which is showing the same account again, and
|
|
166
356
|
* removing it, which asks once more before it does. A removed link has nothing left to do.
|
|
167
357
|
*
|
|
168
358
|
* An account still in use can also be listed another way, or retired, which asks first as
|
|
169
|
-
* removing does.
|
|
359
|
+
* removing does. Those and removing it are then kept in a menu beside Renew, so the row
|
|
360
|
+
* stays one line. A retired one is renewed or removed and nothing else.
|
|
170
361
|
*/
|
|
171
362
|
function manageRow(evidence, manage) {
|
|
172
363
|
const row = node('div', '', 'row');
|
|
@@ -210,7 +401,14 @@ function manageRow(evidence, manage) {
|
|
|
210
401
|
retire.onclick = retiring;
|
|
211
402
|
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
403
|
}
|
|
213
|
-
|
|
404
|
+
const said = note ? [node('span', note, 'muted')] : [];
|
|
405
|
+
const more = act('');
|
|
406
|
+
// Two buttons need no menu between them.
|
|
407
|
+
const menu = listing.length ? actionsMenu(more, [...listing, remove]) : undefined;
|
|
408
|
+
if (menu)
|
|
409
|
+
row.replaceChildren(renew, more, menu, ...said);
|
|
410
|
+
else
|
|
411
|
+
row.replaceChildren(renew, ...listing, remove, ...said);
|
|
214
412
|
};
|
|
215
413
|
const retiring = () => {
|
|
216
414
|
const cancel = act('Cancel');
|
|
@@ -252,36 +450,45 @@ function signatures(evidence) {
|
|
|
252
450
|
: signature(source)));
|
|
253
451
|
}
|
|
254
452
|
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
453
|
+
* The "signed" pill shown after the verifier's name. It links to the signature section of
|
|
454
|
+
* the verifier's evidence page, where the signed record can be downloaded.
|
|
455
|
+
*
|
|
456
|
+
* The signature is checked here against the verifier's keys:
|
|
457
|
+
* - valid: the pill says "signed"
|
|
458
|
+
* - not yet checked: the pill carries a warning sign
|
|
459
|
+
* - invalid: a warning replaces the pill
|
|
460
|
+
*
|
|
461
|
+
* `warned` is called every time the invalid warning is drawn, so a folded card can open.
|
|
260
462
|
*/
|
|
261
|
-
function signedMark(evidence, later) {
|
|
463
|
+
function signedMark(evidence, later, warned) {
|
|
262
464
|
const mark = node('span');
|
|
263
|
-
const link = outward(node('a', 'signed'), `${evidence.evidenceUrl}#signed`);
|
|
465
|
+
const link = outward(node('a', 'signed', 'signed'), `${evidence.evidenceUrl}#signed`);
|
|
264
466
|
const see = 'See the signature (opens in a new tab)';
|
|
265
467
|
const settle = (results) => {
|
|
266
468
|
const first = results[0];
|
|
267
469
|
if (results.some((result) => result.state === 'invalid')) {
|
|
268
|
-
|
|
470
|
+
// Not a link: the page it would open is the one this record failed to match.
|
|
471
|
+
const warning = node('span', 'bad signature', 'signed bad');
|
|
269
472
|
warning.setAttribute('role', 'alert');
|
|
270
473
|
// What it means, for a reader who has never met a signature, kept off the card.
|
|
271
474
|
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
|
-
mark.replaceChildren(
|
|
475
|
+
mark.replaceChildren(warning);
|
|
476
|
+
warned();
|
|
273
477
|
return;
|
|
274
478
|
}
|
|
275
479
|
if (first?.state === 'valid' && results.every((result) => result.state === 'valid')) {
|
|
276
|
-
|
|
277
|
-
//
|
|
480
|
+
// No tick: a signature that checks out is what a reader expects, and the pill is
|
|
481
|
+
// marked only when it did not. Setting the word again clears a warning sign left in it.
|
|
482
|
+
link.textContent = 'signed';
|
|
483
|
+
// Names where the keys came from, which is what the check answers for.
|
|
278
484
|
link.title = `Signature checked in this browser against the keys ${new URL(evidence.evidenceUrl).host} publishes (key ${first.keyId}). ${see}`;
|
|
279
|
-
mark.replaceChildren(
|
|
485
|
+
mark.replaceChildren(link);
|
|
280
486
|
return;
|
|
281
487
|
}
|
|
282
|
-
// No verdict was reached. The record is signed and says so, with a warning
|
|
283
|
-
//
|
|
284
|
-
// or could not read what it needed is made again when
|
|
488
|
+
// No verdict was reached. The record is signed and says so, with a warning sign in the
|
|
489
|
+
// pill, which is all that tells it from one that checked out, and why is said on hover.
|
|
490
|
+
// A check that only ran out of time or could not read what it needed is made again when
|
|
491
|
+
// the dialog next reads.
|
|
285
492
|
const why = results.find((result) => result.state === 'unchecked');
|
|
286
493
|
const reason = why?.state === 'unchecked' && why.why === 'timeout'
|
|
287
494
|
? 'Signature check timed out, retrying.'
|
|
@@ -292,8 +499,9 @@ function signedMark(evidence, later) {
|
|
|
292
499
|
caution.setAttribute('aria-label', reason);
|
|
293
500
|
caution.append(cautionMark());
|
|
294
501
|
link.textContent = 'signed';
|
|
502
|
+
link.append(caution);
|
|
295
503
|
link.title = `${reason} ${see}`;
|
|
296
|
-
mark.replaceChildren(
|
|
504
|
+
mark.replaceChildren(link);
|
|
297
505
|
if (why)
|
|
298
506
|
later(() => void signatures(evidence).then(settle));
|
|
299
507
|
};
|
|
@@ -320,12 +528,39 @@ function cautionMark() {
|
|
|
320
528
|
}
|
|
321
529
|
return svg;
|
|
322
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* Opens and shuts what a card folds away: how its account was shown, every method of it
|
|
533
|
+
* together so that none is put ahead of another, and its dates.
|
|
534
|
+
*/
|
|
535
|
+
function foldToggle(folded, fold) {
|
|
536
|
+
const toggle = node('button', '', 'fold');
|
|
537
|
+
let shown = fold.open;
|
|
538
|
+
const set = (open) => {
|
|
539
|
+
shown = open;
|
|
540
|
+
for (const part of folded)
|
|
541
|
+
part.hidden = !open;
|
|
542
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
543
|
+
};
|
|
544
|
+
toggle.type = 'button';
|
|
545
|
+
toggle.append(foldMark(), document.createTextNode('Details'));
|
|
546
|
+
set(fold.open);
|
|
547
|
+
toggle.onclick = () => {
|
|
548
|
+
set(!shown);
|
|
549
|
+
fold.toggle(shown);
|
|
550
|
+
};
|
|
551
|
+
return toggle;
|
|
552
|
+
}
|
|
323
553
|
/**
|
|
324
554
|
* One linked account: its state, how it was shown and when, on a card of its own. A
|
|
325
555
|
* subject with several accounts gets one of these each, since a provider authenticates and
|
|
326
556
|
* expires on its own terms and none of that may be read across to another's card.
|
|
557
|
+
*
|
|
558
|
+
* Given a `fold`, the card is one of several, read first as a list of accounts: it keeps
|
|
559
|
+
* how it was shown and its dates behind a toggle, which shares a line with what the holder
|
|
560
|
+
* may do. Its state, its verifier and what is said of its signature are never folded.
|
|
561
|
+
* Alone, it shows everything.
|
|
327
562
|
*/
|
|
328
|
-
function externalCard(evidence, manage, later = () => { }) {
|
|
563
|
+
function externalCard(evidence, manage, later = () => { }, fold, warned = () => { }) {
|
|
329
564
|
const current = evidence.status === 'verified' && evidence.expiresAt > Date.now();
|
|
330
565
|
const status = statusLabel(evidence, Date.now());
|
|
331
566
|
const provider = evidence.providerName;
|
|
@@ -345,7 +580,7 @@ function externalCard(evidence, manage, later = () => { }) {
|
|
|
345
580
|
else
|
|
346
581
|
attribution.append(document.createTextNode(evidence.verifierName));
|
|
347
582
|
if (evidence.signedUrl)
|
|
348
|
-
attribution.append(signedMark(evidence, later));
|
|
583
|
+
attribution.append(signedMark(evidence, later, warned));
|
|
349
584
|
copy.append(node('div', status, 'state'), attribution);
|
|
350
585
|
const retired = evidence.status === 'retired';
|
|
351
586
|
// A retired account claims nothing now, which is not the same as a claim that failed.
|
|
@@ -379,16 +614,22 @@ function externalCard(evidence, manage, later = () => { }) {
|
|
|
379
614
|
dates.append(node('dt', label), node('dd', moment(time)));
|
|
380
615
|
}
|
|
381
616
|
const names = { site: evidence.siteName, provider };
|
|
617
|
+
const row = manage && removable(evidence).length ? [manageRow(evidence, manage)] : [];
|
|
618
|
+
const bar = node('div', '', 'bar');
|
|
619
|
+
const methods = [
|
|
620
|
+
...attestationNote(main, names),
|
|
621
|
+
...evidence.attestations.external.slice(1).flatMap((a) => attestationNote(a, names, true)),
|
|
622
|
+
];
|
|
623
|
+
if (fold)
|
|
624
|
+
bar.append(foldToggle([...methods, dates], fold), ...row);
|
|
382
625
|
const logo = providerMark(evidence.provider, evidence.attestations.external[0].method);
|
|
383
|
-
const card =
|
|
626
|
+
const card = namedCard(
|
|
384
627
|
// The mark and the name, or just the name. A mark that falls back to writing the name
|
|
385
628
|
// would print it twice here, since this heading writes it either way.
|
|
386
|
-
logo ? [logo, document.createTextNode(provider)] : [document.createTextNode(provider)],
|
|
387
|
-
// A mailbox's address or a domain is its name already, so there is no second identifier.
|
|
388
|
-
externalId(evidence.external), externalLink(evidence.external),
|
|
629
|
+
logo ? [logo, document.createTextNode(provider)] : [document.createTextNode(provider)], evidence.external,
|
|
389
630
|
// Status first, then how it was shown, then when. The proof explains the state
|
|
390
631
|
// above it, so it cannot sit before that state has been given.
|
|
391
|
-
summary, ...
|
|
632
|
+
summary, ...(fold ? [] : methods),
|
|
392
633
|
// How the holder lists the account, in their words and apart from how it was shown:
|
|
393
634
|
// nothing checked it, so it is never among the methods above it.
|
|
394
635
|
...(evidence.status === 'revoked' || retired
|
|
@@ -397,7 +638,7 @@ function externalCard(evidence, manage, later = () => { }) {
|
|
|
397
638
|
? [node('div', 'Preferred', 'listing preferred')]
|
|
398
639
|
: evidence.mark === 'unused'
|
|
399
640
|
? [node('div', 'No longer used', 'listing')]
|
|
400
|
-
: []),
|
|
641
|
+
: []), ...(fold ? [bar, ...methods, dates] : [dates, ...row]));
|
|
401
642
|
const surface = retired ? 'account retired' : 'account';
|
|
402
643
|
card.className = surface;
|
|
403
644
|
// A signed record is not read, or acted on, before its signature has been checked: until
|
|
@@ -432,10 +673,11 @@ function addRow(manage) {
|
|
|
432
673
|
}
|
|
433
674
|
/**
|
|
434
675
|
* 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
|
-
*
|
|
676
|
+
* Retired accounts sit in a group of their own at the foot, under what says they are,
|
|
677
|
+
* shut until it is opened unless no other account is left to read. Every record given is
|
|
678
|
+
* of the one subject, which the caller has checked.
|
|
437
679
|
*/
|
|
438
|
-
function render(content, records, manage, later) {
|
|
680
|
+
function render(content, records, manage, later, folds = { open: false, cards: new Set() }) {
|
|
439
681
|
const [first] = records;
|
|
440
682
|
// Each card says what it is. Without that the pair is two unlabelled boxes.
|
|
441
683
|
const local = localSide(first.local, first.siteName);
|
|
@@ -448,14 +690,33 @@ function render(content, records, manage, later) {
|
|
|
448
690
|
provider: first.providerName,
|
|
449
691
|
}));
|
|
450
692
|
const retired = records.filter((record) => record.status === 'retired');
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
693
|
+
const listed = records.filter((record) => record.status !== 'retired');
|
|
694
|
+
// A card alone in its group has room for its dates. Among others it folds them.
|
|
695
|
+
const cards = (among, warned) => among.map((record) => {
|
|
696
|
+
const key = `${record.provider}\n${record.external.id}`;
|
|
697
|
+
return externalCard(record, manage, later, among.length > 1
|
|
698
|
+
? {
|
|
699
|
+
open: folds.cards.has(key) !== folds.open,
|
|
700
|
+
toggle: (open) => void (open === folds.open ? folds.cards.delete(key) : folds.cards.add(key)),
|
|
701
|
+
}
|
|
702
|
+
: undefined, warned);
|
|
703
|
+
});
|
|
704
|
+
const history = node('details', '', 'history');
|
|
705
|
+
const count = node('summary', '', 'group');
|
|
706
|
+
count.append(foldMark(), document.createTextNode(`Retired accounts (${retired.length})`));
|
|
707
|
+
// A signature that fails is a warning, and a warning is not left behind a fold.
|
|
708
|
+
history.append(count, ...cards(retired, () => {
|
|
709
|
+
history.open = true;
|
|
710
|
+
}));
|
|
711
|
+
// Shut to keep history out of the way of the accounts in use. With none of those there
|
|
712
|
+
// is nothing else to read, so it starts open.
|
|
713
|
+
history.open = folds.retired ?? !listed.length;
|
|
714
|
+
// Pressing the heading is the reader's choice, and is kept. It is heard before the
|
|
715
|
+
// group turns, so what it will be is the opposite of what it is.
|
|
716
|
+
count.addEventListener('click', () => {
|
|
717
|
+
folds.retired = !history.open;
|
|
718
|
+
});
|
|
719
|
+
content.replaceChildren(localCard, linkMark(), ...cards(listed), ...(manage ? [addRow(manage)] : []), ...(retired.length ? [history] : []),
|
|
459
720
|
// Nothing below the cards may name a provider. Approval, method and dates belong to
|
|
460
721
|
// the card they came from, and a second provider on this subject gets its own card.
|
|
461
722
|
node('p', 'Verification does not guarantee legal identity, trustworthiness, or permanent ownership.', 'muted explanation'));
|
|
@@ -478,7 +739,12 @@ arrange = (records) => records,
|
|
|
478
739
|
* Given where whoever is looking holds these accounts: each then has its renewal and
|
|
479
740
|
* removal under it, and the dialog offers to connect another.
|
|
480
741
|
*/
|
|
481
|
-
manage
|
|
742
|
+
manage,
|
|
743
|
+
/**
|
|
744
|
+
* Whether the cards of several accounts start with their details open. They start shut
|
|
745
|
+
* unless the page asked otherwise, and a reader may turn each either way from there.
|
|
746
|
+
*/
|
|
747
|
+
detailsOpen = false) {
|
|
482
748
|
const existing = openDialogs.get(opener);
|
|
483
749
|
if (existing?.open) {
|
|
484
750
|
existing.focus();
|
|
@@ -491,7 +757,11 @@ manage) {
|
|
|
491
757
|
root.adoptedStyleSheets = [sheet];
|
|
492
758
|
const dialog = node('dialog');
|
|
493
759
|
const heading = node('h2', 'Verification details');
|
|
494
|
-
const close = node('button', '
|
|
760
|
+
const close = node('button', '', 'icon');
|
|
761
|
+
// Every account on a page of its own, on the verifier. It sits by the heading it widens,
|
|
762
|
+
// and only where there is more than one account to list.
|
|
763
|
+
const all = node('a', '', 'all');
|
|
764
|
+
const title = node('div', '', 'title');
|
|
495
765
|
const header = node('header');
|
|
496
766
|
// What drew the record, under it. The record is about the pair, not about us.
|
|
497
767
|
const stamp = node('footer');
|
|
@@ -502,7 +772,12 @@ manage) {
|
|
|
502
772
|
close.setAttribute('aria-label', 'Close verification details');
|
|
503
773
|
content.setAttribute('aria-live', 'polite');
|
|
504
774
|
stamp.append(stampLink(), node('span', version));
|
|
505
|
-
|
|
775
|
+
all.append(awayMark());
|
|
776
|
+
close.append(closeMark());
|
|
777
|
+
// The close was the first thing in the dialog to take the focus, and still is.
|
|
778
|
+
close.autofocus = true;
|
|
779
|
+
title.append(heading);
|
|
780
|
+
header.append(title, close);
|
|
506
781
|
dialog.append(header, content, stamp);
|
|
507
782
|
/** Links removed from this dialog, shown as removed before the page hands over new records. */
|
|
508
783
|
const removed = new Map();
|
|
@@ -519,6 +794,8 @@ manage) {
|
|
|
519
794
|
/** How each record given reads now, by its id. */
|
|
520
795
|
const listings = (given) => new Map([given].flat().map((record) => [record.id, listing(record)]));
|
|
521
796
|
let held;
|
|
797
|
+
/** What has been opened in this dialog, which outlasts every redraw of its cards. */
|
|
798
|
+
const folds = { open: detailsOpen, cards: new Set() };
|
|
522
799
|
/** A record as the listings made here leave it, each applied as the backend applies it. */
|
|
523
800
|
const relisted = (evidence) => {
|
|
524
801
|
let record = evidence;
|
|
@@ -599,13 +876,22 @@ manage) {
|
|
|
599
876
|
.map((e) => removed.has(e.id) && e.status !== 'revoked'
|
|
600
877
|
? { ...e, status: 'revoked', revokedAt: removed.get(e.id) }
|
|
601
878
|
: relisted(standing(e))));
|
|
879
|
+
const listable = records.filter((e) => e.visibility === 'public' && e.status !== 'revoked');
|
|
880
|
+
// In the header only while it leads somewhere: an anchor with no address is not a link.
|
|
881
|
+
title.replaceChildren(heading, ...(listable.length > 1 ? [all] : []));
|
|
882
|
+
if (listable.length > 1) {
|
|
883
|
+
const label = `View all on ${listable[0].verifierName} (opens in a new tab)`;
|
|
884
|
+
outward(all, subjectUrl(listable[0]));
|
|
885
|
+
all.title = label;
|
|
886
|
+
all.setAttribute('aria-label', label);
|
|
887
|
+
}
|
|
602
888
|
const key = JSON.stringify(records.map((e) => [e, statusLabel(e, Date.now())]));
|
|
603
889
|
if (key === drawn)
|
|
604
890
|
return;
|
|
605
891
|
drawn = key;
|
|
606
892
|
// The cards are new, and what the old ones were waiting to try again went with them.
|
|
607
893
|
retries.clear();
|
|
608
|
-
render(content, records, actions, (retry) => retries.add(retry));
|
|
894
|
+
render(content, records, actions, (retry) => retries.add(retry), folds);
|
|
609
895
|
};
|
|
610
896
|
/**
|
|
611
897
|
* 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
|