@bkazemi/verily 0.2.0 → 0.3.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 +23 -16
- package/dist/browser/badge.js +16 -5
- package/dist/browser/connect-dialog.d.ts +13 -2
- package/dist/browser/connect-dialog.js +17 -7
- package/dist/browser/evidence-dialog.d.ts +19 -1
- package/dist/browser/evidence-dialog.js +163 -16
- package/dist/browser/index.d.ts +10 -1
- package/dist/browser/index.js +57 -13
- package/dist/browser/logo.d.ts +2 -0
- package/dist/browser/logo.js +2 -0
- package/dist/browser/mark.d.ts +2 -0
- package/dist/browser/mark.js +6 -0
- package/dist/browser/signed.d.ts +4 -0
- package/dist/browser/signed.js +23 -6
- package/dist/core/index.d.ts +29 -3
- package/dist/core/index.js +14 -0
- package/dist/core/signed.d.ts +20 -5
- package/dist/core/signed.js +5 -2
- package/dist/server/index.js +69 -31
- package/dist/server/service.d.ts +42 -3
- package/dist/server/service.js +156 -10
- package/dist/site/index.d.ts +2 -1
- package/dist/verily.js +203 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<img src="docs/images/badge.png" alt="A profile card with a Verily badge showing a GitHub account" width="480" />
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
|
-
Verily is a self-hosted library for Node.js. A signed-in user of your site proves they control an external account, by signing in with GitHub, publishing a gist, linking back with `rel="me"`,
|
|
19
|
+
Verily is a self-hosted library for Node.js. A signed-in user of your site proves they control an external account, by signing in with GitHub, Discord or YouTube, publishing a gist, linking back with `rel="me"`, signing with an OpenPGP key, or answering a mailed link. They approve the connection, and Verily publishes it as a record anyone can inspect, with a badge for their profile.
|
|
20
20
|
|
|
21
21
|
It doesn't sign anyone into your site, and it doesn't establish legal identity.
|
|
22
22
|
|
|
@@ -86,15 +86,15 @@ const verily = createVerily({
|
|
|
86
86
|
});
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
| Option | Meaning
|
|
90
|
-
| ---------------- |
|
|
91
|
-
| `
|
|
92
|
-
| `baseUrl` | The public URL you mount Verily at. Every route it serves is under this path.
|
|
93
|
-
| `siteName` | Your site's name, shown on evidence.
|
|
94
|
-
| `verifierName` | Who vouches for the record, shown on evidence. Usually the backend's domain.
|
|
95
|
-
| `profileOrigins` | The origins a local `profileUrl` may be on.
|
|
96
|
-
| `reportUrl` | Where readers report a bad record (`https:` or `mailto:`).
|
|
97
|
-
| `authenticate` | Returns the signed-in local account for a request, or `undefined` if nobody is signed in.
|
|
89
|
+
| Option | Meaning |
|
|
90
|
+
| ---------------- | ----------------------------------------------------------------------------------------- |
|
|
91
|
+
| `providers` | How users prove an external account, as a list; see [Proof methods](#proof-methods). |
|
|
92
|
+
| `baseUrl` | The public URL you mount Verily at. Every route it serves is under this path. |
|
|
93
|
+
| `siteName` | Your site's name, shown on evidence. |
|
|
94
|
+
| `verifierName` | Who vouches for the record, shown on evidence. Usually the backend's domain. |
|
|
95
|
+
| `profileOrigins` | The origins a local `profileUrl` may be on. |
|
|
96
|
+
| `reportUrl` | Where readers report a bad record (`https:` or `mailto:`). |
|
|
97
|
+
| `authenticate` | Returns the signed-in local account for a request, or `undefined` if nobody is signed in. |
|
|
98
98
|
|
|
99
99
|
Sign-in methods need an app registered with the provider, and each site running Verily registers its own. The callback URL is always `<baseUrl>/callback`, so for `baseUrl: 'https://example.com/api/verily'` it is `https://example.com/api/verily/callback`. The provider redirects only to URLs registered in advance, so each place you run Verily, a local one for development included, needs its callback registered.
|
|
100
100
|
|
|
@@ -145,7 +145,7 @@ import { init } from '@bkazemi/verily'; // also registers <verily-badge>
|
|
|
145
145
|
const client = init({ backendUrl: '/api/verily' });
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
-
Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.
|
|
148
|
+
Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.3.0/dist/verily.js`. The script defines a global `Verily`.
|
|
149
149
|
|
|
150
150
|
Then place the badge wherever the account appears:
|
|
151
151
|
|
|
@@ -168,6 +168,8 @@ A user with several accounts gets one badge for all of them. Give it every conne
|
|
|
168
168
|
|
|
169
169
|
It shows the account that was connected first, then how many more there are, such as `+2`. Clicking it opens the same details, with each account on its own card in the order they were connected. Only accounts that are verified now are counted, and one of those leads if the first has lapsed. An account that appears on more than one record, because it was shown a second way or removed and connected again, is shown once. The ids must all belong to the same user.
|
|
170
170
|
|
|
171
|
+
A holder can change that order. The account they mark as preferred comes first and leads the badge, and the ones they mark as unused come after the rest; an unused account is still verified and still counted. An account they retire is kept as history: it is never counted, sits in a group of its own at the foot of the details, and reads as retired with the date it was last verified. A badge with nothing but retired accounts is a quiet pill that names the first and opens the same details. If you would rather show nothing, leave retired records out of what you give the badge.
|
|
172
|
+
|
|
171
173
|
On a desktop, resting the pointer on that badge, or reaching it with the keyboard, opens a small panel beside it naming the accounts the number stands for: up to eight, then the rest as a count. The panel floats over the page, so nothing moves, and clicking still opens the details. It needs a browser with the Popover API; without one the badge simply has no panel. Add `peek="off"` to the badge to leave it out.
|
|
172
174
|
|
|
173
175
|
Add `stacked` to name the accounts in the badge itself, one to a row:
|
|
@@ -189,8 +191,11 @@ await client.openConnect(); // the connect dialog; call from a click
|
|
|
189
191
|
await client.connect({ provider: 'github' }); // opens <baseUrl>/verify in a popup; call from a click
|
|
190
192
|
await client.mountBadge(element, { connectionId }); // draws once; call again to refresh
|
|
191
193
|
await client.disconnect(connectionId);
|
|
194
|
+
await client.mark(connectionId, 'preferred'); // or 'unused', 'current', 'retired'
|
|
192
195
|
```
|
|
193
196
|
|
|
197
|
+
`mark` names one record and acts on the account, on every record of it the user has. Preferring one account takes the mark from whichever had it. Retiring freezes the records at their last proof: they are never rechecked, never read as verified, cannot change visibility, and are kept until removed. Only a renewal by the method a record was first shown by brings it back.
|
|
198
|
+
|
|
194
199
|
To match your site's look, set any of these CSS variables on an ancestor: `--verily-surface`, `--verily-text`, `--verily-border`, `--verily-muted`, `--verily-hover`, `--verily-font-family`, `--verily-font-size`.
|
|
195
200
|
|
|
196
201
|
## Proof methods
|
|
@@ -216,7 +221,7 @@ import {
|
|
|
216
221
|
| `linkProvider()` | adding a `rel="me"` link to their profile on a page they control. | The local account needs a `profileUrl`. |
|
|
217
222
|
| `githubLinkProvider()` | putting their profile URL in the website field of their GitHub profile, then entering their GitHub username. | The local account needs a `profileUrl`. |
|
|
218
223
|
| `pgpProvider()` | signing a line Verily gives them with their OpenPGP key, then pasting it and the key. | None. |
|
|
219
|
-
| `emailProvider()` | entering a code Verily mails to their address.
|
|
224
|
+
| `emailProvider()` | pressing a button, or entering a code, that Verily mails to their address. | A function that sends one message. |
|
|
220
225
|
|
|
221
226
|
All methods except the sign-ins and the mailed code let the user publish the proof in their own time and come back. A proof that does not check out can be put right and handed back on the same step, up to five times, so a mistyped address never means publishing or signing again. Gists and link-backs can be taken down later, so Verily re-reads them on a schedule. A PGP signature is kept by Verily and published at `<baseUrl>/connections/<id>/proof`.
|
|
222
227
|
|
|
@@ -288,7 +293,7 @@ A static site only needs the script and the badge markup; the backend runs somew
|
|
|
288
293
|
4. Add the script and the badge to your site:
|
|
289
294
|
|
|
290
295
|
```html
|
|
291
|
-
<script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.
|
|
296
|
+
<script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.3.0/dist/verily.js" defer></script>
|
|
292
297
|
<verily-badge
|
|
293
298
|
backend-url="https://your-backend.example/api/verily"
|
|
294
299
|
connection-id="CONNECTION_ID"
|
|
@@ -361,7 +366,7 @@ Once a user has a link, show their badge in place of the pill, and give it the s
|
|
|
361
366
|
></verily-badge>
|
|
362
367
|
```
|
|
363
368
|
|
|
364
|
-
The badge's dialog then has Renew and Remove under each account, and Add account below them, which opens the verify flow in the dialog's place with a way back. It fires `verily-result` on each change. `verily.beginUrl('manage')` still opens a user's links on the instance, for a site that would rather link there.
|
|
369
|
+
The badge's dialog then has Renew and Remove under each account, the controls that mark it as preferred or unused or retire it, and Add account below them, which opens the verify flow in the dialog's place with a way back. Renew shows the same account again and extends that record under its id, so an embed that names it keeps working. A retired account is renewed by the method it was first shown by. It fires `verily-result` on each change. `verily.beginUrl('manage')` still opens a user's links on the instance, for a site that would rather link there.
|
|
365
370
|
|
|
366
371
|
**3. Read connections where you show them.**
|
|
367
372
|
|
|
@@ -370,6 +375,8 @@ const { [user.id]: connections } = await verily.connections([user.id]);
|
|
|
370
375
|
const verified = connections.filter((c) => c.status === 'verified');
|
|
371
376
|
```
|
|
372
377
|
|
|
378
|
+
Show only `verified` records as verified. A `retired` record is an account the user said is finished: it carries `retiredAt` and can be shown as history, never as a current account. `mark` is `preferred` or `unused` where the user set one, which is their word on how to order their accounts and nothing that was checked.
|
|
379
|
+
|
|
373
380
|
To show them, hand the records to the badge. It draws them as given and fetches nothing, so it works for unlisted links, which a browser cannot read for itself:
|
|
374
381
|
|
|
375
382
|
```html
|
|
@@ -427,7 +434,7 @@ createVerily({
|
|
|
427
434
|
|
|
428
435
|
With a key set:
|
|
429
436
|
|
|
430
|
-
- **`GET <baseUrl>/connections/<id>?format=signed`** returns the signed file for a public record that is currently verified: the record as JSON under an OpenPGP cleartext signature. It is signed when asked for, so it says the record stood at that moment. An unlisted, revoked or expired record has no signed form.
|
|
437
|
+
- **`GET <baseUrl>/connections/<id>?format=signed`** returns the signed file for a public record that is currently verified: the record as JSON under an OpenPGP cleartext signature. It is signed when asked for, so it says the record stood at that moment. An unlisted, revoked or expired record has no signed form. A public retired record has one, signed as `version: 2` with `status: "retired"` and `retiredAt`, so a reader that knows only version 1 refuses it and never takes it for a record that stood.
|
|
431
438
|
- **`GET <baseUrl>/keys.asc`** is the public key as `gpg --import` reads it. **`GET <baseUrl>/keys`** lists the same keys as JSON, each with its fingerprint. The Cloudflare Worker also serves that list at `/.well-known/verily-keys.json`.
|
|
432
439
|
- **`<baseUrl>/check`** is a page where a saved file can be pasted and read back.
|
|
433
440
|
- The evidence page says the record is signed and links to the signed record. The badge's dialog checks the signature in the browser with OpenPGP.js, which it starts only when a record is signed, marks the record `signed ✓`, and links to that page. A record whose signature fails is shown as unconfirmed.
|
|
@@ -475,7 +482,7 @@ setInterval(
|
|
|
475
482
|
);
|
|
476
483
|
```
|
|
477
484
|
|
|
478
|
-
- **`prune()`** deletes expired flows, and deletes expired or revoked records after 90 days.
|
|
485
|
+
- **`prune()`** deletes expired flows, and deletes expired or revoked records after 90 days. A retired record is kept until it is removed.
|
|
479
486
|
- **`recheck()`** re-reads gists and link-backs that are due, up to 5 per run (its `budget` argument). Each proof is re-read every 24 hours (`recheckMs`) and stays current for 7 days after its last successful read (`freshnessMs`); after that it shows as unconfirmed until a read succeeds. A failed read changes nothing. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
|
|
480
487
|
|
|
481
488
|
**If you never call `recheck()`, set `freshnessMs: Infinity`.** Otherwise connections proved by gist, link-back or PGP lapse after a week.
|
package/dist/browser/badge.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { externalName, statusLabel } from '../core/index.js';
|
|
2
|
-
import { clockMark, dashMark, markStyles, paintMark, verificationMark } from './mark.js';
|
|
2
|
+
import { boxMark, clockMark, dashMark, markStyles, paintMark, verificationMark } from './mark.js';
|
|
3
3
|
import { providerMark } from './provider-mark.js';
|
|
4
4
|
const styles = `
|
|
5
5
|
:host { display: inline-block; max-width: 100%; vertical-align: middle; }
|
|
@@ -53,7 +53,9 @@ const styles = `
|
|
|
53
53
|
.peek .via { display: block; margin-top: 6px; font-size: 11px; color: var(--verily-muted, #65726c); }
|
|
54
54
|
.provider { display: block; flex-shrink: 0; width: 14px; height: 14px; }
|
|
55
55
|
.expired .icon { color: #815a12; background: #fbefce; }
|
|
56
|
-
.revoked .icon, .message .icon { color: #626d69; background: #edf0ee; }
|
|
56
|
+
.revoked .icon, .retired .icon, .message .icon { color: #626d69; background: #edf0ee; }
|
|
57
|
+
/* Nothing ran out and nothing wants attention: the holder chose this, so it is only quiet. */
|
|
58
|
+
.retired .name { color: var(--verily-muted, #65726c); }
|
|
57
59
|
.message { color: var(--verily-muted, #65726c); }
|
|
58
60
|
.add { font-weight: 600; }
|
|
59
61
|
@media (prefers-reduced-motion: no-preference) {
|
|
@@ -227,7 +229,14 @@ export function renderBadgeMessage(element, message) {
|
|
|
227
229
|
export function renderBadge(element, evidence, { more = 0, linked = true, rows = [], peek = false, } = {}) {
|
|
228
230
|
const provider = evidence.providerName;
|
|
229
231
|
const current = evidence.status === 'verified' && evidence.expiresAt > Date.now();
|
|
230
|
-
const
|
|
232
|
+
const retired = evidence.status === 'retired';
|
|
233
|
+
const state = current
|
|
234
|
+
? 'verified'
|
|
235
|
+
: retired
|
|
236
|
+
? 'retired'
|
|
237
|
+
: evidence.status === 'revoked'
|
|
238
|
+
? 'revoked'
|
|
239
|
+
: 'expired';
|
|
231
240
|
const label = statusLabel(evidence, Date.now());
|
|
232
241
|
const key = renderKey(evidence, current, label, more, linked, rows, peek);
|
|
233
242
|
if (intact(element) && shown.get(element) === key)
|
|
@@ -237,7 +246,9 @@ export function renderBadge(element, evidence, { more = 0, linked = true, rows =
|
|
|
237
246
|
const unnamed = Math.max(more - rows.length, 0);
|
|
238
247
|
const named = rows.map((row) => `, ${row.providerName} ${externalName(row.external)}`).join('');
|
|
239
248
|
const others = `${named}${unnamed ? ` and ${unnamed} more` : ''}`;
|
|
240
|
-
|
|
249
|
+
// A retired account claims nothing now, so its mark is drawn as one waiting to be read
|
|
250
|
+
// is: in the page's text colour, and not as a verification that failed.
|
|
251
|
+
paintMark(mark, current ? 'current' : retired ? 'pending' : 'inactive');
|
|
241
252
|
if (linked) {
|
|
242
253
|
badge.href = evidence.evidenceUrl;
|
|
243
254
|
badge.rel = 'noreferrer';
|
|
@@ -282,7 +293,7 @@ export function renderBadge(element, evidence, { more = 0, linked = true, rows =
|
|
|
282
293
|
}
|
|
283
294
|
if (!current) {
|
|
284
295
|
const icon = span('icon', '');
|
|
285
|
-
icon.append(state === 'expired' ? clockMark() : dashMark());
|
|
296
|
+
icon.append(retired ? boxMark() : state === 'expired' ? clockMark() : dashMark());
|
|
286
297
|
icon.setAttribute('aria-hidden', 'true');
|
|
287
298
|
badge.append(span('label', label), icon);
|
|
288
299
|
}
|
|
@@ -64,7 +64,8 @@ export interface FlowView {
|
|
|
64
64
|
}
|
|
65
65
|
export interface ConnectApi {
|
|
66
66
|
methods(): Promise<Methods>;
|
|
67
|
-
|
|
67
|
+
/** Starts a flow by one method: a new link, or a renewal of the record `renew` names. */
|
|
68
|
+
start(provider: string, method: string, renew?: string): Promise<FlowView>;
|
|
68
69
|
read(id: string): Promise<FlowView>;
|
|
69
70
|
submit(id: string, artifact: string): Promise<FlowView>;
|
|
70
71
|
approve(id: string, visibility: 'public' | 'unlisted', cancel: boolean): Promise<Result>;
|
|
@@ -87,5 +88,15 @@ export declare function openConnectDialog(opener: HTMLElement, api: ConnectApi,
|
|
|
87
88
|
*/
|
|
88
89
|
back?: () => void,
|
|
89
90
|
/** The provider to start on, where the holder already said which account they mean. */
|
|
90
|
-
provider?: string
|
|
91
|
+
provider?: string,
|
|
92
|
+
/**
|
|
93
|
+
* The record to renew, where the holder asked to show one of their accounts again: each
|
|
94
|
+
* flow started then extends that record under its id. `method` is the one method that
|
|
95
|
+
* may, for a retired record. Where the holder is not offered it, a new link is the way
|
|
96
|
+
* back, and the dialog connects as it would with no record named.
|
|
97
|
+
*/
|
|
98
|
+
renew?: {
|
|
99
|
+
id: string;
|
|
100
|
+
method?: string;
|
|
101
|
+
}): Promise<Result>;
|
|
91
102
|
export {};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { externalLink, externalName } from '../core/index.js';
|
|
2
|
-
import { accountCard, linkMark, node, outward, styles } from './evidence-dialog.js';
|
|
2
|
+
import { accountCard, linkMark, node, outward, stampLink, styles } from './evidence-dialog.js';
|
|
3
3
|
import { providerMark } from './provider-mark.js';
|
|
4
|
-
import { verilyLogo } from './logo.js';
|
|
5
4
|
import { version } from '../version.js';
|
|
6
5
|
const connectStyles = `
|
|
7
6
|
${styles}
|
|
@@ -91,7 +90,14 @@ export function openConnectDialog(opener, api,
|
|
|
91
90
|
*/
|
|
92
91
|
back,
|
|
93
92
|
/** The provider to start on, where the holder already said which account they mean. */
|
|
94
|
-
provider
|
|
93
|
+
provider,
|
|
94
|
+
/**
|
|
95
|
+
* The record to renew, where the holder asked to show one of their accounts again: each
|
|
96
|
+
* flow started then extends that record under its id. `method` is the one method that
|
|
97
|
+
* may, for a retired record. Where the holder is not offered it, a new link is the way
|
|
98
|
+
* back, and the dialog connects as it would with no record named.
|
|
99
|
+
*/
|
|
100
|
+
renew) {
|
|
95
101
|
const existing = openDialogs.get(opener);
|
|
96
102
|
if (existing?.open) {
|
|
97
103
|
existing.focus();
|
|
@@ -113,7 +119,7 @@ provider) {
|
|
|
113
119
|
close.type = 'button';
|
|
114
120
|
close.setAttribute('aria-label', 'Close');
|
|
115
121
|
content.setAttribute('aria-live', 'polite');
|
|
116
|
-
stamp.append(
|
|
122
|
+
stamp.append(stampLink(), node('span', version));
|
|
117
123
|
header.append(heading, close);
|
|
118
124
|
dialog.append(header, content, stamp);
|
|
119
125
|
root.append(dialog);
|
|
@@ -208,7 +214,11 @@ provider) {
|
|
|
208
214
|
}
|
|
209
215
|
if (!current(asked))
|
|
210
216
|
return;
|
|
211
|
-
|
|
217
|
+
// A record's renewal stays on its provider, by the one method named where one is.
|
|
218
|
+
const renewing = methods.methods.filter((m) => m.provider === provider && (renew?.method === undefined || m.method === renew.method));
|
|
219
|
+
if (renew && !renewing.length)
|
|
220
|
+
renew = undefined;
|
|
221
|
+
const all = renew ? renewing : methods.methods;
|
|
212
222
|
// A provider asked for that this holder is not offered leaves every option open.
|
|
213
223
|
if (group !== undefined && !all.some((m) => m.provider === group))
|
|
214
224
|
group = undefined;
|
|
@@ -243,7 +253,7 @@ provider) {
|
|
|
243
253
|
return;
|
|
244
254
|
}
|
|
245
255
|
}
|
|
246
|
-
void busy(control, () => api.start(choice.provider, choice.method), show);
|
|
256
|
+
void busy(control, () => api.start(choice.provider, choice.method, renew?.id), show);
|
|
247
257
|
};
|
|
248
258
|
list.append(control);
|
|
249
259
|
}
|
|
@@ -255,7 +265,7 @@ provider) {
|
|
|
255
265
|
: 'Choose how to show you control the other account.', 'explanation'),
|
|
256
266
|
list,
|
|
257
267
|
];
|
|
258
|
-
if (group !== undefined) {
|
|
268
|
+
if (group !== undefined && !renew) {
|
|
259
269
|
const back = button('‹ All options');
|
|
260
270
|
back.onclick = () => {
|
|
261
271
|
group = undefined;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type Evidence } from '../core/index.js';
|
|
2
|
-
|
|
2
|
+
/** The logotype as a link to the project, as both dialogs carry it in their footer. */
|
|
3
|
+
export declare function stampLink(): HTMLAnchorElement;
|
|
4
|
+
export declare const styles = "\n * { box-sizing: border-box; }\n dialog { width: min(460px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); margin: auto; padding: 24px; border: 1px solid #dce2de; border-radius: 16px; background: #fff; color: #23312b; box-shadow: 0 24px 90px #10201935; font: 13px/1.6 system-ui, sans-serif; }\n dialog::backdrop { background: #15271f66; }\n header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }\n h2 { margin: 0; font-size: 18px; font-weight: 650; letter-spacing: -.3px; }\n button { width: 32px; height: 32px; border: 1px solid #dce2de; border-radius: 8px; background: #fff; color: #52645a; font: 20px system-ui, sans-serif; cursor: pointer; }\n button:hover { background: #f2f5f1; }\n a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }\n a:focus-visible, button:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }\n .summary { display: flex; align-items: center; gap: 8px; margin-top: 16px; }\n .mark { width: 36px; height: 36px; flex-shrink: 0; }\n .provider { width: 14px; height: 14px; }\n .state { font-size: 14px; font-weight: 650; line-height: 1.4; }\n .muted { color: #6b786f; font-size: 12px; }\n .account { position: relative; }\n .veiled > :not(.checking) { filter: blur(4px); opacity: .55; user-select: none; }\n .checking { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: #23312b; font-size: 13px; font-weight: 600; }\n .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }\n @keyframes spin { to { transform: rotate(360deg); } }\n @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }\n .caution { display: inline-block; margin-left: 4px; color: #a15c00; cursor: help; vertical-align: middle; }\n .caution svg { display: block; width: 12px; height: 12px; }\n .bad { color: #b3261e; font-weight: 650; text-decoration: underline dotted; text-underline-offset: 3px; cursor: help; }\n .account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }\n .account h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 3px; color: #6b786f; font-size: 11px; font-weight: 550; }\n .account a, .account strong { font-weight: 650; font-size: 14px; }\n /* The account's own name carries that weight; a link inside a line of prose does not. */\n .summary a, .method a, .additional a { font: inherit; }\n .reference { margin-top: 2px; }\n .method { margin-top: 8px; }\n /* In the corner the heading leaves empty, apart from everything that was checked. */\n .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; }\n .listing.preferred { border-color: #245f43; color: #245f43; }\n /* Quiet, as its pill is: the surface and the name step back, and the controls do not. */\n .account.retired { background: #f6f8f5; }\n .account.retired > a, .account.retired > strong { color: #6b786f; }\n .account.retired .mark { opacity: .45; }\n .group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }\n .additional { margin-top: 2px; padding-left: 12px; }\n .joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }\n 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; }\n dt { color: #6b786f; }\n dd { margin: 0; text-align: right; overflow-wrap: anywhere; }\n .explanation { margin-top: 16px; }\n .action { width: auto; height: auto; padding: 9px 14px; font: 600 13px/1.3 system-ui, sans-serif; color: #23312b; text-align: left; }\n .action.primary { border-color: #245f43; background: #245f43; color: #fff; }\n .action.primary:hover { background: #1d4f37; }\n .action:disabled { opacity: .6; cursor: progress; }\n .action.danger:hover { border-color: #b3261e; background: #fdecea; color: #b3261e; }\n .row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }\n .account .row { align-items: center; justify-content: start; flex-wrap: wrap; margin-top: 12px; }\n .account .action { padding: 5px 10px; font-size: 12px; }\n footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin: 20px 0 -8px; color: #9aa9a0; font-size: 11px; }\n footer a { display: flex; color: inherit; }\n .logo { display: block; height: 13px; }\n \n @media (prefers-reduced-motion: no-preference) {\n .mark.current path:last-child { animation: verily-draw .8s cubic-bezier(.65, 0, .35, 1) .1s backwards; }\n }\n @keyframes verily-draw { from { stroke-dasharray: 0 176; } to { stroke-dasharray: 108 176; } }\n\n";
|
|
3
5
|
export declare function node<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string): HTMLElementTagNameMap[K];
|
|
4
6
|
/**
|
|
5
7
|
* Points an anchor out of the dialog. Every link here opens in a new tab: the dialog is
|
|
@@ -28,6 +30,11 @@ export type Account = Evidence & {
|
|
|
28
30
|
*/
|
|
29
31
|
sources?: Evidence[];
|
|
30
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* How a holder may have an account listed: the one to lead with, an ordinary one, one they
|
|
35
|
+
* no longer use, or one that is finished and kept only as history.
|
|
36
|
+
*/
|
|
37
|
+
export type Listing = 'preferred' | 'current' | 'unused' | 'retired';
|
|
31
38
|
/**
|
|
32
39
|
* What the holder of the accounts shown may do from the dialog, where the page that drew
|
|
33
40
|
* the badge said its reader is that holder.
|
|
@@ -35,8 +42,19 @@ export type Account = Evidence & {
|
|
|
35
42
|
export interface Manage {
|
|
36
43
|
/** Opens what connects an account, in this dialog's place. Given a provider, starts on it. */
|
|
37
44
|
connect(provider?: string): void;
|
|
45
|
+
/**
|
|
46
|
+
* Opens what renews the record named, in this dialog's place: the same account shown
|
|
47
|
+
* again, which extends that record under its id. `method` is the one method that may.
|
|
48
|
+
* Absent where the page gave no way to, and Renew then connects on the provider.
|
|
49
|
+
*/
|
|
50
|
+
renew?(id: string, provider: string, method?: string): void;
|
|
38
51
|
/** Removes the links named. Rejects if any could not be removed. */
|
|
39
52
|
remove(ids: string[]): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Lists the account one link names as asked, every record of it at once. Rejects if it
|
|
55
|
+
* could not be. Absent where the page gave no way to, and the card then offers none.
|
|
56
|
+
*/
|
|
57
|
+
mark?(id: string, as: Listing): Promise<void>;
|
|
40
58
|
}
|
|
41
59
|
/**
|
|
42
60
|
* Fetch fresh, permitted evidence; native dialog supplies focus containment and Escape
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { attestationLabel, externalLink, externalName, localSide, proofTitle, statusLabel, } from '../core/index.js';
|
|
1
|
+
import { attestationLabel, 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';
|
|
5
|
-
import { verilyLogo } from './logo.js';
|
|
5
|
+
import { projectUrl, verilyLogo } from './logo.js';
|
|
6
6
|
import { version } from '../version.js';
|
|
7
|
+
/** The logotype as a link to the project, as both dialogs carry it in their footer. */
|
|
8
|
+
export function stampLink() {
|
|
9
|
+
const link = outward(node('a'), projectUrl);
|
|
10
|
+
link.append(verilyLogo());
|
|
11
|
+
return link;
|
|
12
|
+
}
|
|
7
13
|
const openDialogs = new WeakMap();
|
|
8
14
|
export const styles = `
|
|
9
15
|
* { box-sizing: border-box; }
|
|
@@ -36,6 +42,14 @@ export const styles = `
|
|
|
36
42
|
.summary a, .method a, .additional a { font: inherit; }
|
|
37
43
|
.reference { margin-top: 2px; }
|
|
38
44
|
.method { margin-top: 8px; }
|
|
45
|
+
/* In the corner the heading leaves empty, apart from everything that was checked. */
|
|
46
|
+
.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
|
+
.listing.preferred { border-color: #245f43; color: #245f43; }
|
|
48
|
+
/* Quiet, as its pill is: the surface and the name step back, and the controls do not. */
|
|
49
|
+
.account.retired { background: #f6f8f5; }
|
|
50
|
+
.account.retired > a, .account.retired > strong { color: #6b786f; }
|
|
51
|
+
.account.retired .mark { opacity: .45; }
|
|
52
|
+
.group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }
|
|
39
53
|
.additional { margin-top: 2px; padding-left: 12px; }
|
|
40
54
|
.joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }
|
|
41
55
|
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; }
|
|
@@ -48,9 +62,10 @@ export const styles = `
|
|
|
48
62
|
.action:disabled { opacity: .6; cursor: progress; }
|
|
49
63
|
.action.danger:hover { border-color: #b3261e; background: #fdecea; color: #b3261e; }
|
|
50
64
|
.row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }
|
|
51
|
-
.account .row { align-items: center; justify-content: start; margin-top: 12px; }
|
|
65
|
+
.account .row { align-items: center; justify-content: start; flex-wrap: wrap; margin-top: 12px; }
|
|
52
66
|
.account .action { padding: 5px 10px; font-size: 12px; }
|
|
53
|
-
footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin
|
|
67
|
+
footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin: 20px 0 -8px; color: #9aa9a0; font-size: 11px; }
|
|
68
|
+
footer a { display: flex; color: inherit; }
|
|
54
69
|
.logo { display: block; height: 13px; }
|
|
55
70
|
${markStyles}
|
|
56
71
|
`;
|
|
@@ -138,6 +153,8 @@ export function accountCard(heading, name, reference, url, ...extra) {
|
|
|
138
153
|
card.append(...extra);
|
|
139
154
|
return card;
|
|
140
155
|
}
|
|
156
|
+
/** What the holder is told before an account is retired, since it cannot be undone from here. */
|
|
157
|
+
const retiringNote = 'Retiring keeps this record as history. It will no longer read as verified, and who can read it cannot be changed afterwards.';
|
|
141
158
|
/**
|
|
142
159
|
* The links removing an account would remove. The record shown may itself be revoked while
|
|
143
160
|
* older ones of the account are not, and those must stay removable, so this is not read
|
|
@@ -147,6 +164,9 @@ const removable = (evidence) => evidence.links ?? (evidence.status === 'revoked'
|
|
|
147
164
|
/**
|
|
148
165
|
* Under a holder's own account: renewing it, which is showing the same account again, and
|
|
149
166
|
* removing it, which asks once more before it does. A removed link has nothing left to do.
|
|
167
|
+
*
|
|
168
|
+
* An account still in use can also be listed another way, or retired, which asks first as
|
|
169
|
+
* removing does. A retired one is renewed or removed and nothing else.
|
|
150
170
|
*/
|
|
151
171
|
function manageRow(evidence, manage) {
|
|
152
172
|
const row = node('div', '', 'row');
|
|
@@ -155,12 +175,49 @@ function manageRow(evidence, manage) {
|
|
|
155
175
|
control.type = 'button';
|
|
156
176
|
return control;
|
|
157
177
|
};
|
|
178
|
+
const [named] = removable(evidence);
|
|
179
|
+
const { mark } = manage;
|
|
180
|
+
// A mark is the account's, set through any one of its links that still stands.
|
|
181
|
+
const listable = mark !== undefined && named !== undefined && !['revoked', 'retired'].includes(evidence.status);
|
|
182
|
+
/** A control that lists the account one way, and says so if that did not go through. */
|
|
183
|
+
const list = (label, as) => {
|
|
184
|
+
const control = act(label);
|
|
185
|
+
control.onclick = async () => {
|
|
186
|
+
for (const other of row.children)
|
|
187
|
+
other.disabled = true;
|
|
188
|
+
try {
|
|
189
|
+
await mark(named, as);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
offer('That did not go through.');
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
return control;
|
|
196
|
+
};
|
|
158
197
|
const offer = (note = '') => {
|
|
159
198
|
const renew = act('Renew');
|
|
160
199
|
const remove = act('Remove', true);
|
|
161
|
-
|
|
200
|
+
// The record the card is drawn from is the one renewed. A retired one comes back by the
|
|
201
|
+
// method it was first shown by alone, and a removed one cannot be renewed at all, so
|
|
202
|
+
// the account behind it is connected again.
|
|
203
|
+
renew.onclick = () => manage.renew && evidence.status !== 'revoked'
|
|
204
|
+
? manage.renew(evidence.id, evidence.provider, evidence.status === 'retired' ? evidence.attestations.external[0].method : undefined)
|
|
205
|
+
: manage.connect(evidence.provider);
|
|
162
206
|
remove.onclick = confirm;
|
|
163
|
-
|
|
207
|
+
const listing = [];
|
|
208
|
+
if (listable) {
|
|
209
|
+
const retire = act('Retire');
|
|
210
|
+
retire.onclick = retiring;
|
|
211
|
+
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
|
+
}
|
|
213
|
+
row.replaceChildren(renew, ...listing, remove, ...(note ? [node('span', note, 'muted')] : []));
|
|
214
|
+
};
|
|
215
|
+
const retiring = () => {
|
|
216
|
+
const cancel = act('Cancel');
|
|
217
|
+
const sure = list('Retire', 'retired');
|
|
218
|
+
cancel.onclick = () => offer();
|
|
219
|
+
row.replaceChildren(node('span', retiringNote, 'muted'), cancel, sure);
|
|
220
|
+
cancel.focus();
|
|
164
221
|
};
|
|
165
222
|
const confirm = () => {
|
|
166
223
|
const cancel = act('Cancel');
|
|
@@ -290,13 +347,22 @@ function externalCard(evidence, manage, later = () => { }) {
|
|
|
290
347
|
if (evidence.signedUrl)
|
|
291
348
|
attribution.append(signedMark(evidence, later));
|
|
292
349
|
copy.append(node('div', status, 'state'), attribution);
|
|
293
|
-
|
|
350
|
+
const retired = evidence.status === 'retired';
|
|
351
|
+
// A retired account claims nothing now, which is not the same as a claim that failed.
|
|
352
|
+
summary.append(verificationMark(current ? 'current' : retired ? 'pending' : 'inactive'), copy);
|
|
294
353
|
const dates = node('dl');
|
|
295
354
|
const dateRows = [['Approved', evidence.approvedAt]];
|
|
296
355
|
if (evidence.status === 'revoked') {
|
|
297
356
|
if (evidence.revokedAt !== undefined)
|
|
298
357
|
dateRows.push(['Revoked on', evidence.revokedAt]);
|
|
299
358
|
}
|
|
359
|
+
else if (retired) {
|
|
360
|
+
// Two rows, always, so a reader sees any gap between them: an account retired weeks
|
|
361
|
+
// after it was last proved went unproved for those weeks.
|
|
362
|
+
dateRows.push(['Last verified', lastProved(evidence)]);
|
|
363
|
+
if (evidence.retiredAt !== undefined)
|
|
364
|
+
dateRows.push(['Retired', evidence.retiredAt]);
|
|
365
|
+
}
|
|
300
366
|
else {
|
|
301
367
|
// A proof that has gone unread has not reached its expiry, so it still reads forward.
|
|
302
368
|
dateRows.push([
|
|
@@ -307,7 +373,7 @@ function externalCard(evidence, manage, later = () => { }) {
|
|
|
307
373
|
// When an artifact was last read belongs with the other times, not inside a sentence.
|
|
308
374
|
// Only artifact methods drift: a sign-in is established once and does not go stale.
|
|
309
375
|
const main = evidence.attestations.external[0];
|
|
310
|
-
if (main.artifactUrl)
|
|
376
|
+
if (main.artifactUrl && !retired)
|
|
311
377
|
dateRows.push(['Last checked', main.confirmedAt]);
|
|
312
378
|
for (const [label, time] of dateRows) {
|
|
313
379
|
dates.append(node('dt', label), node('dd', moment(time)));
|
|
@@ -322,7 +388,18 @@ function externalCard(evidence, manage, later = () => { }) {
|
|
|
322
388
|
evidence.external.kind === 'mailbox' ? undefined : evidence.external.id, externalLink(evidence.external),
|
|
323
389
|
// Status first, then how it was shown, then when. The proof explains the state
|
|
324
390
|
// above it, so it cannot sit before that state has been given.
|
|
325
|
-
summary, ...attestationNote(main, names), ...evidence.attestations.external.slice(1).flatMap((a) => attestationNote(a, names, true)),
|
|
391
|
+
summary, ...attestationNote(main, names), ...evidence.attestations.external.slice(1).flatMap((a) => attestationNote(a, names, true)),
|
|
392
|
+
// How the holder lists the account, in their words and apart from how it was shown:
|
|
393
|
+
// nothing checked it, so it is never among the methods above it.
|
|
394
|
+
...(evidence.status === 'revoked' || retired
|
|
395
|
+
? []
|
|
396
|
+
: evidence.mark === 'preferred'
|
|
397
|
+
? [node('div', 'Preferred', 'listing preferred')]
|
|
398
|
+
: evidence.mark === 'unused'
|
|
399
|
+
? [node('div', 'No longer used', 'listing')]
|
|
400
|
+
: []), dates, ...(manage && removable(evidence).length ? [manageRow(evidence, manage)] : []));
|
|
401
|
+
const surface = retired ? 'account retired' : 'account';
|
|
402
|
+
card.className = surface;
|
|
326
403
|
// A signed record is not read, or acted on, before its signature has been checked: until
|
|
327
404
|
// the check comes back the whole card is veiled and out of reach, with a word over it
|
|
328
405
|
// saying why. A record handed over altered is then never read as it was handed over.
|
|
@@ -331,12 +408,12 @@ function externalCard(evidence, manage, later = () => { }) {
|
|
|
331
408
|
const checking = node('div', '', 'checking');
|
|
332
409
|
checking.setAttribute('role', 'status');
|
|
333
410
|
checking.append(node('span', '', 'spinner'), document.createTextNode('Checking signature…'));
|
|
334
|
-
card.className =
|
|
411
|
+
card.className = `${surface} veiled`;
|
|
335
412
|
for (const part of veiled)
|
|
336
413
|
part.inert = true;
|
|
337
414
|
card.append(checking);
|
|
338
415
|
void signatures(evidence).then(() => {
|
|
339
|
-
card.className =
|
|
416
|
+
card.className = surface;
|
|
340
417
|
for (const part of veiled)
|
|
341
418
|
part.inert = false;
|
|
342
419
|
checking.remove();
|
|
@@ -354,8 +431,9 @@ function addRow(manage) {
|
|
|
354
431
|
return row;
|
|
355
432
|
}
|
|
356
433
|
/**
|
|
357
|
-
* The subject once, then each account linked to it on its own card, in the order
|
|
358
|
-
*
|
|
434
|
+
* 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
|
+
* Every record given is of the one subject, which the caller has checked.
|
|
359
437
|
*/
|
|
360
438
|
function render(content, records, manage, later) {
|
|
361
439
|
const [first] = records;
|
|
@@ -369,7 +447,15 @@ function render(content, records, manage, later) {
|
|
|
369
447
|
site: first.siteName,
|
|
370
448
|
provider: first.providerName,
|
|
371
449
|
}));
|
|
372
|
-
|
|
450
|
+
const retired = records.filter((record) => record.status === 'retired');
|
|
451
|
+
content.replaceChildren(localCard, linkMark(), ...records
|
|
452
|
+
.filter((record) => record.status !== 'retired')
|
|
453
|
+
.map((record) => externalCard(record, manage, later)), ...(manage ? [addRow(manage)] : []), ...(retired.length
|
|
454
|
+
? [
|
|
455
|
+
node('h3', 'Retired accounts', 'group'),
|
|
456
|
+
...retired.map((record) => externalCard(record, manage, later)),
|
|
457
|
+
]
|
|
458
|
+
: []),
|
|
373
459
|
// Nothing below the cards may name a provider. Approval, method and dates belong to
|
|
374
460
|
// the card they came from, and a second provider on this subject gets its own card.
|
|
375
461
|
node('p', 'Verification does not guarantee legal identity, trustworthiness, or permanent ownership.', 'muted explanation'));
|
|
@@ -415,12 +501,44 @@ manage) {
|
|
|
415
501
|
close.type = 'button';
|
|
416
502
|
close.setAttribute('aria-label', 'Close verification details');
|
|
417
503
|
content.setAttribute('aria-live', 'polite');
|
|
418
|
-
stamp.append(
|
|
504
|
+
stamp.append(stampLink(), node('span', version));
|
|
419
505
|
header.append(heading, close);
|
|
420
506
|
dialog.append(header, content, stamp);
|
|
421
507
|
/** Links removed from this dialog, shown as removed before the page hands over new records. */
|
|
422
508
|
const removed = new Map();
|
|
509
|
+
/**
|
|
510
|
+
* How accounts were listed from this dialog, in the order it was asked, shown as asked
|
|
511
|
+
* before the page hands over new records: each names every link of its account, and
|
|
512
|
+
* holds how those links read when it was asked. Once any of them reads differently the
|
|
513
|
+
* backend has spoken, for this change or a later one made elsewhere, and what it says
|
|
514
|
+
* is shown from then on.
|
|
515
|
+
*/
|
|
516
|
+
const listed = [];
|
|
517
|
+
/** What of a record a listing, a retirement or a renewal changes. */
|
|
518
|
+
const listing = (record) => JSON.stringify([record.status, record.mark, record.retiredAt, record.approvedAt]);
|
|
519
|
+
/** How each record given reads now, by its id. */
|
|
520
|
+
const listings = (given) => new Map([given].flat().map((record) => [record.id, listing(record)]));
|
|
423
521
|
let held;
|
|
522
|
+
/** A record as the listings made here leave it, each applied as the backend applies it. */
|
|
523
|
+
const relisted = (evidence) => {
|
|
524
|
+
let record = evidence;
|
|
525
|
+
for (const { ids, as, at } of listed) {
|
|
526
|
+
if (['revoked', 'retired'].includes(record.status))
|
|
527
|
+
break;
|
|
528
|
+
if (!ids.includes(record.id)) {
|
|
529
|
+
// One preferred account for a holder: picking another moves the mark.
|
|
530
|
+
if (as === 'preferred' && record.mark === 'preferred')
|
|
531
|
+
record = { ...record, mark: undefined };
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
record =
|
|
535
|
+
as === 'retired'
|
|
536
|
+
? // Its signed form is another document now, to be read with the records to come.
|
|
537
|
+
{ ...record, status: 'retired', retiredAt: at, mark: undefined, signedUrl: undefined }
|
|
538
|
+
: { ...record, mark: as === 'current' ? undefined : as };
|
|
539
|
+
}
|
|
540
|
+
return record;
|
|
541
|
+
};
|
|
424
542
|
const actions = manage && {
|
|
425
543
|
// The other dialog opens over this one before this one goes, so the page behind is
|
|
426
544
|
// never uncovered between the two.
|
|
@@ -428,6 +546,14 @@ manage) {
|
|
|
428
546
|
manage.connect(provider);
|
|
429
547
|
dialog.close();
|
|
430
548
|
},
|
|
549
|
+
...(manage.renew
|
|
550
|
+
? {
|
|
551
|
+
renew(id, provider, method) {
|
|
552
|
+
manage.renew(id, provider, method);
|
|
553
|
+
dialog.close();
|
|
554
|
+
},
|
|
555
|
+
}
|
|
556
|
+
: {}),
|
|
431
557
|
async remove(ids) {
|
|
432
558
|
await manage.remove(ids);
|
|
433
559
|
for (const id of ids)
|
|
@@ -435,6 +561,23 @@ manage) {
|
|
|
435
561
|
if (held && dialog.open)
|
|
436
562
|
draw(held);
|
|
437
563
|
},
|
|
564
|
+
...(manage.mark
|
|
565
|
+
? {
|
|
566
|
+
async mark(id, as) {
|
|
567
|
+
await manage.mark(id, as);
|
|
568
|
+
// Every link of the account the one named belongs to, as the cards group them.
|
|
569
|
+
const account = arrange([held ?? []].flat()).find((card) => removable(card).includes(id));
|
|
570
|
+
listed.push({
|
|
571
|
+
ids: account ? removable(account) : [id],
|
|
572
|
+
as,
|
|
573
|
+
at: Date.now(),
|
|
574
|
+
before: listings(held ?? []),
|
|
575
|
+
});
|
|
576
|
+
if (held && dialog.open)
|
|
577
|
+
draw(held);
|
|
578
|
+
},
|
|
579
|
+
}
|
|
580
|
+
: {}),
|
|
438
581
|
};
|
|
439
582
|
root.append(dialog);
|
|
440
583
|
document.body.append(host);
|
|
@@ -447,11 +590,15 @@ manage) {
|
|
|
447
590
|
/** Redraws only for a record that reads differently from the one already on screen. */
|
|
448
591
|
const draw = (given) => {
|
|
449
592
|
held = given;
|
|
593
|
+
const now = listings(given);
|
|
594
|
+
for (const change of [...listed])
|
|
595
|
+
if (change.ids.some((id) => now.get(id) !== change.before.get(id)))
|
|
596
|
+
listed.splice(listed.indexOf(change), 1);
|
|
450
597
|
const records = arrange([given]
|
|
451
598
|
.flat()
|
|
452
599
|
.map((e) => removed.has(e.id) && e.status !== 'revoked'
|
|
453
600
|
? { ...e, status: 'revoked', revokedAt: removed.get(e.id) }
|
|
454
|
-
: standing(e)));
|
|
601
|
+
: relisted(standing(e))));
|
|
455
602
|
const key = JSON.stringify(records.map((e) => [e, statusLabel(e, Date.now())]));
|
|
456
603
|
if (key === drawn)
|
|
457
604
|
return;
|