@weaveprotocol/core 0.2.1 → 0.2.3
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 +67 -46
- package/dist/doors/doors.d.ts +1 -1
- package/dist/elements/auth-styles.d.ts +1 -1
- package/dist/elements/auth-styles.d.ts.map +1 -1
- package/dist/elements/auth-styles.js +13 -0
- package/dist/elements/auth-styles.js.map +1 -1
- package/dist/elements/weave-auth.d.ts +2 -2
- package/dist/elements/weave-auth.d.ts.map +1 -1
- package/dist/elements/weave-auth.js +251 -119
- package/dist/elements/weave-auth.js.map +1 -1
- package/dist/identity/account-vault.d.ts +10 -3
- package/dist/identity/account-vault.d.ts.map +1 -1
- package/dist/identity/account-vault.js +10 -3
- package/dist/identity/account-vault.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/network/mailbox.d.ts +1 -1
- package/dist/network/mailbox.js +1 -1
- package/dist/network/peer-auth.d.ts +19 -0
- package/dist/network/peer-auth.d.ts.map +1 -1
- package/dist/network/peer-auth.js +22 -8
- package/dist/network/peer-auth.js.map +1 -1
- package/dist/node/actions.d.ts.map +1 -1
- package/dist/node/actions.js +9 -2
- package/dist/node/actions.js.map +1 -1
- package/dist/node/carrier.d.ts +16 -0
- package/dist/node/carrier.d.ts.map +1 -1
- package/dist/node/carrier.js +12 -0
- package/dist/node/carrier.js.map +1 -1
- package/dist/node/host.d.ts +2 -2
- package/dist/node/host.d.ts.map +1 -1
- package/dist/node/index.d.ts +3 -3
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/node.js +1 -1
- package/dist/node/node.js.map +1 -1
- package/dist/node/space-runtime.d.ts.map +1 -1
- package/dist/node/space-runtime.js +9 -1
- package/dist/node/space-runtime.js.map +1 -1
- package/dist/node/types.d.ts +6 -2
- package/dist/node/types.d.ts.map +1 -1
- package/dist/records/describe.d.ts +2 -0
- package/dist/records/describe.d.ts.map +1 -1
- package/dist/records/describe.js +5 -0
- package/dist/records/describe.js.map +1 -1
- package/dist/schema/collection-def.d.ts +16 -0
- package/dist/schema/collection-def.d.ts.map +1 -1
- package/dist/schema/collection-def.js +30 -0
- package/dist/schema/collection-def.js.map +1 -1
- package/dist/schemas/apps.d.ts +4 -1
- package/dist/schemas/apps.d.ts.map +1 -1
- package/dist/schemas/apps.js +8 -2
- package/dist/schemas/apps.js.map +1 -1
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/screens.d.ts +16 -4
- package/dist/schemas/screens.d.ts.map +1 -1
- package/dist/schemas/screens.js +49 -6
- package/dist/schemas/screens.js.map +1 -1
- package/dist/session/auth.d.ts +56 -10
- package/dist/session/auth.d.ts.map +1 -1
- package/dist/session/auth.js +247 -69
- package/dist/session/auth.js.map +1 -1
- package/dist/session/connect.d.ts +57 -3
- package/dist/session/connect.d.ts.map +1 -1
- package/dist/session/connect.js +49 -6
- package/dist/session/connect.js.map +1 -1
- package/dist/session/connection.d.ts +8 -1
- package/dist/session/connection.d.ts.map +1 -1
- package/dist/session/connection.js +6 -1
- package/dist/session/connection.js.map +1 -1
- package/dist/session/credentials.d.ts +22 -6
- package/dist/session/credentials.d.ts.map +1 -1
- package/dist/session/credentials.js +35 -6
- package/dist/session/credentials.js.map +1 -1
- package/dist/session/hosting.d.ts +1 -1
- package/dist/session/index.d.ts +5 -5
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +3 -3
- package/dist/session/index.js.map +1 -1
- package/dist/space/notify.d.ts +59 -1
- package/dist/space/notify.d.ts.map +1 -1
- package/dist/space/notify.js +91 -0
- package/dist/space/notify.js.map +1 -1
- package/dist/storage/blob/s3.js +1 -1
- package/package.json +52 -42
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
*
|
|
19
19
|
* It draws into its own light DOM, not a shadow root, on purpose: password
|
|
20
20
|
* managers find and fill forms in the page far more reliably than forms inside
|
|
21
|
-
* a shadow root, and the
|
|
22
|
-
*
|
|
21
|
+
* a shadow root, and the everyday password living in a password manager is
|
|
22
|
+
* much of the point. Its styles are scoped to the element instead.
|
|
23
23
|
*
|
|
24
24
|
* It fills whatever box it is put in — a page, a modal, a panel — and draws
|
|
25
25
|
* nothing once signed in; the host decides what happens then.
|
|
26
26
|
*/
|
|
27
|
-
import { createWeaveAuth } from '../session/auth.js';
|
|
28
|
-
import { accountCredentialName,
|
|
27
|
+
import { createWeaveAuth, MIN_PASSWORD_LENGTH } from '../session/auth.js';
|
|
28
|
+
import { accountCredentialName, offerToSave, recoveryKit } from '../session/credentials.js';
|
|
29
29
|
import { adoptStyles, h, svg } from './dom.js';
|
|
30
30
|
import { AUTH_CSS } from './auth-styles.js';
|
|
31
31
|
const Base = (globalThis.HTMLElement ?? class {
|
|
@@ -92,6 +92,15 @@ function option(title, text, onclick, disabled, recommended = false) {
|
|
|
92
92
|
function link(text, onclick, disabled = false) {
|
|
93
93
|
return h('button', { type: 'button', class: 'wa-link', onclick, disabled }, text);
|
|
94
94
|
}
|
|
95
|
+
/** Hands the page a text file to save. */
|
|
96
|
+
function download({ filename, text }) {
|
|
97
|
+
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
|
|
98
|
+
const anchor = h('a', { href: url, download: filename, style: 'display:none' });
|
|
99
|
+
globalThis.document.body.append(anchor);
|
|
100
|
+
anchor.click();
|
|
101
|
+
anchor.remove();
|
|
102
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
103
|
+
}
|
|
95
104
|
function describePairing(stage) {
|
|
96
105
|
switch (stage.kind) {
|
|
97
106
|
case 'waiting':
|
|
@@ -112,10 +121,14 @@ export class WeaveAuthElement extends Base {
|
|
|
112
121
|
#announced = undefined;
|
|
113
122
|
// What only this element cares about: which fold is open, what is typed.
|
|
114
123
|
#drafts = new Map();
|
|
115
|
-
#showCode = false;
|
|
116
124
|
#filledAs = '';
|
|
117
125
|
#copied = false;
|
|
126
|
+
#stored = false;
|
|
127
|
+
#choosingPassword = false;
|
|
128
|
+
#showPairHelp = false;
|
|
129
|
+
#mismatch = false;
|
|
118
130
|
#lastSelected = null;
|
|
131
|
+
#lastStage = null;
|
|
119
132
|
/** The flow this element draws. Set it to share one with the rest of the page; otherwise one is made from the attributes. */
|
|
120
133
|
get auth() {
|
|
121
134
|
this.#auth ??= createWeaveAuth({
|
|
@@ -160,8 +173,9 @@ export class WeaveAuthElement extends Base {
|
|
|
160
173
|
}
|
|
161
174
|
#changed(state) {
|
|
162
175
|
// A new account row, or a new screen, starts with empty fields.
|
|
163
|
-
if (state.selectedId !== this.#lastSelected) {
|
|
176
|
+
if (state.selectedId !== this.#lastSelected || state.stage !== this.#lastStage) {
|
|
164
177
|
this.#lastSelected = state.selectedId;
|
|
178
|
+
this.#lastStage = state.stage;
|
|
165
179
|
this.#reset();
|
|
166
180
|
}
|
|
167
181
|
this.#render(state);
|
|
@@ -173,8 +187,15 @@ export class WeaveAuthElement extends Base {
|
|
|
173
187
|
}
|
|
174
188
|
#reset() {
|
|
175
189
|
this.#drafts.clear();
|
|
176
|
-
this.#showCode = false;
|
|
177
190
|
this.#filledAs = '';
|
|
191
|
+
this.#copied = false;
|
|
192
|
+
this.#stored = false;
|
|
193
|
+
this.#choosingPassword = false;
|
|
194
|
+
this.#showPairHelp = false;
|
|
195
|
+
this.#mismatch = false;
|
|
196
|
+
}
|
|
197
|
+
#redraw() {
|
|
198
|
+
this.#render(this.auth.getState());
|
|
178
199
|
}
|
|
179
200
|
/** Redraws, keeping focus and what was typed. */
|
|
180
201
|
#render(state) {
|
|
@@ -197,46 +218,72 @@ export class WeaveAuthElement extends Base {
|
|
|
197
218
|
});
|
|
198
219
|
}
|
|
199
220
|
#screen(state) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
221
|
+
switch (state.stage) {
|
|
222
|
+
case 'ready':
|
|
223
|
+
return [];
|
|
224
|
+
case 'starting':
|
|
225
|
+
return [h('div', { class: 'wa-card' }, h('p', { class: 'wa-hint' }, 'Looking for your accounts…'))];
|
|
226
|
+
case 'pair':
|
|
227
|
+
return [this.#pair(state)];
|
|
228
|
+
case 'create':
|
|
229
|
+
return [this.#create(state)];
|
|
230
|
+
case 'recovery':
|
|
231
|
+
return [this.#recovery(state)];
|
|
232
|
+
case 'unlock':
|
|
233
|
+
return [this.#unlock(state)];
|
|
234
|
+
case 'pod':
|
|
235
|
+
return [this.#pod(state)];
|
|
236
|
+
case 'welcome':
|
|
237
|
+
return [this.#welcome(state)];
|
|
238
|
+
case 'existing':
|
|
239
|
+
return [this.#existing(state)];
|
|
240
|
+
case 'restore':
|
|
241
|
+
return [this.#restore(state)];
|
|
242
|
+
case 'signIn':
|
|
243
|
+
return [this.#signIn(state)];
|
|
244
|
+
}
|
|
218
245
|
}
|
|
219
|
-
|
|
246
|
+
/** Where accounts are read from, shown only when it is a pod — a new person should not have to think about it. */
|
|
247
|
+
#podLine(state) {
|
|
220
248
|
const place = state.place;
|
|
221
|
-
if (
|
|
249
|
+
if (place?.kind !== 'folder')
|
|
222
250
|
return null;
|
|
223
|
-
return h('p', { class: 'wa-small', style: 'margin-top:24px' },
|
|
251
|
+
return h('p', { class: 'wa-small', style: 'margin-top:24px' }, `Pod: ${place.directory?.name ?? 'your folder'} · `, link('Use this browser instead', () => void this.auth.useBrowser(), state.busy));
|
|
224
252
|
}
|
|
225
253
|
// ─── New here, or not ──────────────────────────────────────────────
|
|
226
254
|
#welcome(state) {
|
|
227
255
|
const auth = this.auth;
|
|
228
|
-
return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, '
|
|
256
|
+
return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, 'Welcome to Weave'), h('p', { class: 'wa-subtitle' }, 'One account for every Weave app. Your data stays with you, not on a server.'), h('div', { class: 'wa-options' }, option('Create an account', "Takes a minute. You'll get a recovery code to keep safe, then sign in with a passkey or a password.", () => auth.startCreating(), state.busy), option('I already have one', 'Open your pod, use your recovery code, or add this device from another.', () => auth.showExisting(), state.busy)), errorBox(state.error), this.#podLine(state));
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* "I already have an account", from a place that does not list it. Where the
|
|
260
|
+
* data lives comes up here and only here: someone with a pod has something to
|
|
261
|
+
* point at, and someone without one has other ways in.
|
|
262
|
+
*/
|
|
263
|
+
#existing(state) {
|
|
264
|
+
const auth = this.auth;
|
|
265
|
+
return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, 'Find your account'), h('p', { class: 'wa-subtitle' }, 'Any of these gets you in.'), h('div', { class: 'wa-options' }, state.folderAvailable
|
|
266
|
+
? option('Open your pod', 'The folder your Weave data lives in. Its accounts are listed next, ready to unlock with your passkey or password.', () => void auth.choosePod(), state.busy)
|
|
267
|
+
: null, option('Add this device from another', 'From a device where you are signed in. Nothing to type — your spaces come across too.', () => {
|
|
268
|
+
this.#showPairHelp = !this.#showPairHelp;
|
|
269
|
+
this.#redraw();
|
|
270
|
+
}, state.busy), this.#showPairHelp
|
|
271
|
+
? h('ol', { class: 'wa-steps' }, h('li', null, 'On the signed-in device, open your account page.'), h('li', null, 'Choose ', h('strong', null, 'Add your phone'), ' and show the pairing code.'), h('li', null, "Scan it with this device's camera and open the link."))
|
|
272
|
+
: null, option('Use your recovery code', 'The 26-character code you kept when you made the account. Works on any device.', () => auth.showRestore(), state.busy)), errorBox(state.error), h('div', { class: 'wa-links' }, link('Back', () => (state.accounts.length > 0 ? auth.showSignIn() : auth.showWelcome()), state.busy), link('Create a new account instead', () => auth.startCreating(), state.busy)), this.#podLine(state));
|
|
229
273
|
}
|
|
230
274
|
// ─── Choosing an account, and getting in ───────────────────────────
|
|
231
275
|
/**
|
|
232
|
-
* The
|
|
276
|
+
* The recovery-code form.
|
|
233
277
|
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
278
|
+
* Kept a password form with an off-screen username, because before
|
|
279
|
+
* passwords existed the recovery code *was* this site's login and many
|
|
280
|
+
* people's password managers hold it that way. The username field is
|
|
281
|
+
* off-screen, but deliberately not `display: none` — a field taken out of the
|
|
282
|
+
* layout is not counted as a username, while one merely moved out of view is
|
|
283
|
+
* filled normally. And writable: a manager fills the whole credential at
|
|
284
|
+
* once, so a read-only username keeps showing the account that was clicked
|
|
285
|
+
* while the code quietly belongs to another. Letting it be overwritten is
|
|
286
|
+
* what makes the mismatch detectable.
|
|
240
287
|
*/
|
|
241
288
|
#codeForm(state, selected) {
|
|
242
289
|
const username = h('input', {
|
|
@@ -248,32 +295,67 @@ export class WeaveAuthElement extends Base {
|
|
|
248
295
|
tabindex: '-1',
|
|
249
296
|
'aria-hidden': 'true',
|
|
250
297
|
});
|
|
251
|
-
const
|
|
298
|
+
const code = this.#input('code', {
|
|
252
299
|
type: 'password',
|
|
253
300
|
name: 'password',
|
|
254
|
-
placeholder: '
|
|
301
|
+
placeholder: 'XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XX',
|
|
255
302
|
autocomplete: 'current-password',
|
|
256
303
|
spellcheck: 'false',
|
|
257
|
-
|
|
304
|
+
autofocus: true,
|
|
305
|
+
'aria-label': 'Recovery code',
|
|
258
306
|
disabled: state.busy,
|
|
259
307
|
});
|
|
260
308
|
// Autofill sets the DOM value directly; read what it filled as well.
|
|
261
|
-
|
|
309
|
+
code.addEventListener('input', () => {
|
|
262
310
|
const filled = username.value.trim();
|
|
263
311
|
if (filled !== this.#filledAs) {
|
|
264
312
|
this.#filledAs = filled;
|
|
265
|
-
this.#
|
|
313
|
+
this.#redraw();
|
|
266
314
|
}
|
|
267
315
|
});
|
|
316
|
+
const wrong = selected !== null && this.#filledAs !== '' && this.#filledAs !== accountCredentialName(selected.name);
|
|
317
|
+
return [
|
|
318
|
+
h('form', {
|
|
319
|
+
class: 'wa-form',
|
|
320
|
+
onsubmit: (event) => {
|
|
321
|
+
event.preventDefault();
|
|
322
|
+
const value = code.value.trim();
|
|
323
|
+
if (value)
|
|
324
|
+
void this.auth.signInWithCode(value);
|
|
325
|
+
},
|
|
326
|
+
}, username, code, h('button', { type: 'submit', class: 'wa-button', disabled: state.busy }, state.busy ? 'Opening…' : 'Continue')),
|
|
327
|
+
wrong
|
|
328
|
+
? h('p', { class: 'wa-error', style: 'margin-top:10px' }, 'Your password manager filled ', h('strong', null, this.#filledAs), ', not ', h('strong', null, selected.name), '. Pick the entry named ', h('strong', null, selected.name), ', or open that account instead.')
|
|
329
|
+
: null,
|
|
330
|
+
].filter((node) => node !== null);
|
|
331
|
+
}
|
|
332
|
+
/** The everyday password, filed under the account's name so a manager fills the right one. */
|
|
333
|
+
#passwordForm(state, account) {
|
|
334
|
+
const password = this.#input('password', {
|
|
335
|
+
type: 'password',
|
|
336
|
+
name: 'password',
|
|
337
|
+
placeholder: 'Password',
|
|
338
|
+
autocomplete: 'current-password',
|
|
339
|
+
'aria-label': 'Password',
|
|
340
|
+
disabled: state.busy,
|
|
341
|
+
});
|
|
268
342
|
return h('form', {
|
|
269
343
|
class: 'wa-form',
|
|
270
344
|
onsubmit: (event) => {
|
|
271
345
|
event.preventDefault();
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
void this.auth.signInWithCode(code);
|
|
346
|
+
if (password.value)
|
|
347
|
+
void this.auth.signInWithPassword(password.value);
|
|
275
348
|
},
|
|
276
|
-
},
|
|
349
|
+
}, h('input', {
|
|
350
|
+
type: 'text',
|
|
351
|
+
name: 'username',
|
|
352
|
+
autocomplete: 'username',
|
|
353
|
+
value: accountCredentialName(account.name),
|
|
354
|
+
readonly: true,
|
|
355
|
+
class: 'wa-offscreen',
|
|
356
|
+
tabindex: '-1',
|
|
357
|
+
'aria-hidden': 'true',
|
|
358
|
+
}), password, h('button', { type: 'submit', class: 'wa-button', disabled: state.busy }, state.busy ? 'Signing in…' : 'Sign in'));
|
|
277
359
|
}
|
|
278
360
|
#signIn(state) {
|
|
279
361
|
const auth = this.auth;
|
|
@@ -284,58 +366,21 @@ export class WeaveAuthElement extends Base {
|
|
|
284
366
|
const body = [];
|
|
285
367
|
if (isSelected && entry) {
|
|
286
368
|
const hasShortcut = entry.shortcuts.length > 0;
|
|
287
|
-
const needsCode = this.#showCode || (!hasShortcut && !entry.hasPassword);
|
|
288
369
|
// An account with wraps, none of them usable here, has been used on
|
|
289
370
|
// another site — a passkey works on one web address only.
|
|
290
371
|
const seenElsewhere = entry.vault.wraps.length > 0;
|
|
291
|
-
if (hasShortcut
|
|
292
|
-
body.push(h('button', { type: 'button', class: 'wa-button', 'data-key': 'passkey', disabled: state.busy, onclick: () => void auth.signInWithPasskey() }, state.busy ? 'Waiting…' : '
|
|
372
|
+
if (hasShortcut) {
|
|
373
|
+
body.push(h('button', { type: 'button', class: 'wa-button', 'data-key': 'passkey', disabled: state.busy, onclick: () => void auth.signInWithPasskey() }, state.busy ? 'Waiting…' : 'Sign in with passkey'));
|
|
293
374
|
}
|
|
294
|
-
if (entry.hasPassword
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
placeholder: 'Password on this device',
|
|
299
|
-
autocomplete: 'current-password',
|
|
300
|
-
'aria-label': 'Device password',
|
|
301
|
-
disabled: state.busy,
|
|
302
|
-
});
|
|
303
|
-
body.push(h('form', {
|
|
304
|
-
class: 'wa-form',
|
|
305
|
-
style: hasShortcut ? 'margin-top:10px' : '',
|
|
306
|
-
onsubmit: (event) => {
|
|
307
|
-
event.preventDefault();
|
|
308
|
-
if (devicePassword.value)
|
|
309
|
-
void auth.signInWithPassword(devicePassword.value);
|
|
310
|
-
},
|
|
311
|
-
}, h('input', {
|
|
312
|
-
type: 'text',
|
|
313
|
-
name: 'username',
|
|
314
|
-
autocomplete: 'username',
|
|
315
|
-
value: deviceCredentialName(account.name),
|
|
316
|
-
readonly: true,
|
|
317
|
-
class: 'wa-offscreen',
|
|
318
|
-
tabindex: '-1',
|
|
319
|
-
'aria-hidden': 'true',
|
|
320
|
-
}), devicePassword, h('button', { type: 'submit', class: 'wa-button', disabled: state.busy }, 'Unlock')));
|
|
321
|
-
}
|
|
322
|
-
if (needsCode) {
|
|
323
|
-
if (!hasShortcut && !entry.hasPassword) {
|
|
324
|
-
body.push(h('p', { class: 'wa-small', style: 'margin:0 0 10px' }, seenElsewhere ? 'New to this app.' : 'No passkey set up here yet.', info('Why it is asking for the password', seenElsewhere
|
|
325
|
-
? 'This account’s passkeys belong to the apps that made them — a passkey works on one web address only. Sign in once with your account password and this app can add its own.'
|
|
326
|
-
: 'Once you are in, you can add a passkey so this app stops asking.')));
|
|
327
|
-
}
|
|
328
|
-
body.push(this.#codeForm(state, selected));
|
|
375
|
+
if (entry.hasPassword)
|
|
376
|
+
body.push(this.#passwordForm(state, account));
|
|
377
|
+
if (hasShortcut || entry.hasPassword) {
|
|
378
|
+
body.push(h('div', { class: 'wa-links' }, link('Use my recovery code', () => auth.showRestore(), state.busy)));
|
|
329
379
|
}
|
|
330
380
|
else {
|
|
331
|
-
body.push(h('
|
|
332
|
-
|
|
333
|
-
this.#
|
|
334
|
-
this.#render(auth.getState());
|
|
335
|
-
}, state.busy)));
|
|
336
|
-
}
|
|
337
|
-
if (this.#showCode && this.#filledAs !== '' && this.#filledAs !== accountCredentialName(account.name)) {
|
|
338
|
-
body.push(h('p', { class: 'wa-error', style: 'margin-top:10px' }, 'Your password manager filled ', h('strong', null, this.#filledAs), ', not ', h('strong', null, account.name), '. Pick the entry named ', h('strong', null, account.name), ', or open that account instead.'));
|
|
381
|
+
body.push(h('p', { class: 'wa-small', style: 'margin:0 0 10px' }, 'No passkey or password here yet. Use your recovery code once, then choose one.', info('Why it is asking for the recovery code', seenElsewhere
|
|
382
|
+
? 'This account’s passkeys belong to the sites that made them — a passkey works on one web address only. The recovery code opens it anywhere.'
|
|
383
|
+
: 'The recovery code opens the account anywhere, with nothing stored. Next you set up a passkey or password so it stops asking.')), ...this.#codeForm(state, selected));
|
|
339
384
|
}
|
|
340
385
|
}
|
|
341
386
|
return h('div', { class: 'wa-account' }, h('button', {
|
|
@@ -347,17 +392,20 @@ export class WeaveAuthElement extends Base {
|
|
|
347
392
|
onclick: () => (isSelected ? undefined : void auth.select(account.id)),
|
|
348
393
|
}, avatar(account.did), h('span', { style: 'flex:1;min-width:0' }, h('span', { class: 'wa-account-name' }, account.name), h('span', { class: 'wa-account-did' }, `${account.did.slice(0, 18)}…${account.did.slice(-4)}`))), body.length > 0 ? h('div', { class: 'wa-account-body' }, ...body) : null);
|
|
349
394
|
});
|
|
350
|
-
return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' },
|
|
351
|
-
? [
|
|
352
|
-
h('p', { class: 'wa-hint' }, place?.kind === 'folder' ? 'This pod has no accounts yet.' : 'No accounts in this browser yet.', info('Why this works with nothing stored', 'The password is your key written out, not a hint to look something up with — so it opens the account in an app that has never seen you. If your account is in a pod, opening the pod below brings it back without the password.')),
|
|
353
|
-
this.#codeForm(state, null),
|
|
354
|
-
]
|
|
355
|
-
: null, errorBox(state.error), h('div', { class: 'wa-links' }, link('Create a new account', () => auth.startCreating(), state.busy)), place
|
|
395
|
+
return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, 'Welcome back'), h('p', { class: 'wa-subtitle' }, accounts.length > 0 ? 'Choose your account.' : 'There are no accounts here yet.'), ...rows, errorBox(state.error), h('div', { class: 'wa-links' }, link('Another account', () => auth.showExisting(), state.busy), link('Create a new account', () => auth.startCreating(), state.busy)), place
|
|
356
396
|
? h('p', { class: 'wa-small', style: 'margin-top:16px' }, place.kind === 'folder' ? `Pod: ${place.directory?.name ?? 'your folder'}` : 'Accounts kept in this browser')
|
|
357
397
|
: null, state.folderAvailable
|
|
358
398
|
? h('div', { class: 'wa-links' }, link(place?.kind === 'folder' ? 'Open a different pod' : 'Open a pod', () => void auth.choosePod(), state.busy), place?.kind === 'folder' ? link('Use this browser instead', () => void auth.useBrowser(), state.busy) : null)
|
|
359
399
|
: null);
|
|
360
400
|
}
|
|
401
|
+
/** The recovery code, typed in: on a new device, or when no everyday way in is to hand. */
|
|
402
|
+
#restore(state) {
|
|
403
|
+
const auth = this.auth;
|
|
404
|
+
const selected = state.accounts.find((account) => account.id === state.selectedId) ?? null;
|
|
405
|
+
// From the account list it restores that account; from "I already have one", any.
|
|
406
|
+
const forSelected = selected !== null && state.accounts.length > 0 && state.entry !== null;
|
|
407
|
+
return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, 'Use your recovery code'), h('p', { class: 'wa-subtitle' }, forSelected ? `For ${selected.name}. ` : '', 'The 26-character code you kept when you made the account.'), ...this.#codeForm(state, forSelected ? selected : null), errorBox(state.error), h('div', { class: 'wa-links' }, link('Back', () => (state.accounts.length > 0 ? auth.showSignIn() : auth.showExisting()), state.busy)), h('p', { class: 'wa-small', style: 'margin-top:16px' }, "Lost it? If you're signed in on another device, add this one from there instead."));
|
|
408
|
+
}
|
|
361
409
|
// ─── Making an account ─────────────────────────────────────────────
|
|
362
410
|
#create(state) {
|
|
363
411
|
const auth = this.auth;
|
|
@@ -368,40 +416,124 @@ export class WeaveAuthElement extends Base {
|
|
|
368
416
|
autofocus: true,
|
|
369
417
|
disabled: state.busy,
|
|
370
418
|
});
|
|
371
|
-
return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, 'Create your account'), h('p', { class: 'wa-subtitle' }, '
|
|
419
|
+
return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, 'Create your account'), h('p', { class: 'wa-subtitle' }, 'What should apps call you? You can change it later.'), h('form', {
|
|
372
420
|
class: 'wa-form',
|
|
373
421
|
onsubmit: (event) => {
|
|
374
422
|
event.preventDefault();
|
|
375
423
|
if (name.value.trim())
|
|
376
424
|
void auth.createAccount(name.value.trim());
|
|
377
425
|
},
|
|
378
|
-
}, name, h('button', { type: 'submit', class: 'wa-button', disabled: state.busy }, state.busy ? 'Creating…' : '
|
|
426
|
+
}, name, h('button', { type: 'submit', class: 'wa-button', disabled: state.busy }, state.busy ? 'Creating…' : 'Continue')), h('p', { class: 'wa-small' }, "Next: a recovery code to keep safe, then a passkey or password for signing in."), errorBox(state.error), h('div', { class: 'wa-links' }, link('I already have an account', () => auth.showExisting(), state.busy)), this.#podLine(state));
|
|
379
427
|
}
|
|
380
428
|
/**
|
|
381
|
-
* The
|
|
429
|
+
* The recovery code, to keep.
|
|
382
430
|
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
431
|
+
* Deliberately not a password form: saved as this site's login, a manager
|
|
432
|
+
* would later offer it in the everyday password field — and replace it with
|
|
433
|
+
* that password when one is set, losing the only copy. So it is copied or
|
|
434
|
+
* downloaded, and kept as a note or on paper.
|
|
386
435
|
*/
|
|
387
|
-
#
|
|
436
|
+
#recovery(state) {
|
|
388
437
|
const auth = this.auth;
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
438
|
+
const code = state.freshCode ?? '';
|
|
439
|
+
const account = state.session?.account;
|
|
440
|
+
const restored = state.setup === 'restored';
|
|
441
|
+
return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, restored ? 'Keep your recovery code safe' : 'Your recovery code'), h('p', { class: 'wa-hint' }, restored
|
|
442
|
+
? 'You just used it. From now on a passkey or password signs you in here — keep this code for new devices and emergencies.'
|
|
443
|
+
: 'This code is your account. With it you can restore your account on any device, even if you lose everything else.', info('Why it matters', 'Nobody can reissue it: there is no server that knows your account. Anyone who has it can open your account, so keep it private. ', 'Save it as a secure note in your password manager, or print it and put it away — not as a login for this site, where a new password could replace it.')), h('code', { class: 'wa-code wa-code-large' }, code), h('div', { class: 'wa-row' }, h('button', {
|
|
444
|
+
type: 'button',
|
|
445
|
+
class: 'wa-secondary',
|
|
446
|
+
onclick: () => {
|
|
447
|
+
void globalThis.navigator.clipboard
|
|
448
|
+
?.writeText(code)
|
|
449
|
+
.then(() => {
|
|
450
|
+
this.#copied = true;
|
|
451
|
+
this.#redraw();
|
|
452
|
+
})
|
|
453
|
+
.catch(() => { });
|
|
395
454
|
},
|
|
396
|
-
},
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
455
|
+
}, this.#copied ? 'Copied' : 'Copy'), h('button', {
|
|
456
|
+
type: 'button',
|
|
457
|
+
class: 'wa-secondary',
|
|
458
|
+
onclick: () => download(recoveryKit({ code, name: account?.name ?? 'My account', did: state.session?.did ?? '' })),
|
|
459
|
+
}, 'Download')), h('label', { class: 'wa-check' }, h('input', {
|
|
460
|
+
type: 'checkbox',
|
|
461
|
+
checked: this.#stored,
|
|
462
|
+
onchange: (event) => {
|
|
463
|
+
this.#stored = event.target.checked;
|
|
464
|
+
this.#redraw();
|
|
465
|
+
},
|
|
466
|
+
}), h('span', null, "I've stored my recovery code somewhere safe.")), h('button', { type: 'button', class: 'wa-button', disabled: !this.#stored, onclick: () => auth.codeSaved() }, 'Continue'));
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* How this account is opened every day. Not skippable: without one, every
|
|
470
|
+
* visit asks for the recovery code, and it ends up being used as a password
|
|
471
|
+
* after all.
|
|
472
|
+
*/
|
|
473
|
+
#unlock(state) {
|
|
474
|
+
const auth = this.auth;
|
|
475
|
+
const account = state.session?.account;
|
|
476
|
+
const passkeys = typeof globalThis.PublicKeyCredential !== 'undefined';
|
|
477
|
+
const choosing = this.#choosingPassword || !passkeys;
|
|
478
|
+
const form = () => {
|
|
479
|
+
const password = this.#input('new-password', {
|
|
480
|
+
type: 'password',
|
|
481
|
+
name: 'password',
|
|
482
|
+
placeholder: 'Password',
|
|
483
|
+
autocomplete: 'new-password',
|
|
484
|
+
autofocus: true,
|
|
485
|
+
minlength: String(MIN_PASSWORD_LENGTH),
|
|
486
|
+
'aria-label': 'Password',
|
|
487
|
+
disabled: state.busy,
|
|
488
|
+
});
|
|
489
|
+
const confirm = this.#input('confirm-password', {
|
|
490
|
+
type: 'password',
|
|
491
|
+
name: 'confirm-password',
|
|
492
|
+
placeholder: 'The same again',
|
|
493
|
+
autocomplete: 'new-password',
|
|
494
|
+
'aria-label': 'Confirm password',
|
|
495
|
+
disabled: state.busy,
|
|
496
|
+
});
|
|
497
|
+
return h('form', {
|
|
498
|
+
class: 'wa-form',
|
|
499
|
+
style: passkeys ? 'margin-top:12px' : '',
|
|
500
|
+
onsubmit: (event) => {
|
|
501
|
+
event.preventDefault();
|
|
502
|
+
this.#mismatch = password.value !== confirm.value;
|
|
503
|
+
if (this.#mismatch) {
|
|
504
|
+
this.#redraw();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const chosen = password.value;
|
|
508
|
+
const filedAs = accountCredentialName(account?.name ?? 'My account');
|
|
509
|
+
void auth.setPassword(chosen).then((ok) => (ok ? offerToSave(filedAs, chosen, filedAs) : false));
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
// A visible username is what makes a manager file the password under the account's name.
|
|
513
|
+
h('input', {
|
|
514
|
+
type: 'text',
|
|
515
|
+
name: 'username',
|
|
516
|
+
autocomplete: 'username',
|
|
517
|
+
value: accountCredentialName(account?.name ?? 'My account'),
|
|
518
|
+
readonly: true,
|
|
519
|
+
'aria-label': 'Account',
|
|
520
|
+
}), password, confirm, this.#mismatch ? h('p', { class: 'wa-error' }, 'Those two do not match.') : null, h('button', { type: 'submit', class: 'wa-button', disabled: state.busy }, state.busy ? 'Saving…' : 'Use this password'), h('p', { class: 'wa-small', style: 'margin-top:0' }, `At least ${MIN_PASSWORD_LENGTH} characters. Let your password manager make one.`));
|
|
521
|
+
};
|
|
522
|
+
return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, 'How do you want to sign in?'), h('p', { class: 'wa-subtitle' }, 'This is what you will use every day.', info('Where it works', 'A passkey signs you in on this site, in this browser. A password works wherever your account is kept — this browser, or your pod in any app pointed at it. On a new device, add it from this one or use your recovery code.')), h('div', { class: 'wa-options' }, passkeys
|
|
523
|
+
? option('Passkey', 'Touch ID, Windows Hello, or your password manager. Nothing to type.', () => void auth.addPasskey(), state.busy, true)
|
|
524
|
+
: null, passkeys && !choosing
|
|
525
|
+
? option('Password', 'Saved in your password manager, like any other login.', () => {
|
|
526
|
+
this.#choosingPassword = true;
|
|
527
|
+
this.#redraw();
|
|
528
|
+
}, state.busy)
|
|
529
|
+
: null), choosing ? form() : null, state.setup === 'paired' && state.pairingStage
|
|
530
|
+
? h('p', { class: state.pairingStage.kind === 'failed' ? 'wa-error' : 'wa-small', style: 'margin-top:16px' }, describePairing(state.pairingStage))
|
|
531
|
+
: null, errorBox(state.error));
|
|
532
|
+
}
|
|
533
|
+
/** Offered once, at the end of making an account in a browser that can open a folder. */
|
|
534
|
+
#pod(state) {
|
|
535
|
+
const auth = this.auth;
|
|
536
|
+
return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, 'Keep your data in a folder?'), h('p', { class: 'wa-hint' }, 'A pod is a folder on your computer that holds your account and spaces. Back it up, or put it in iCloud or Dropbox. Any Weave app you point at it opens the same account.'), h('div', { class: 'wa-options' }, option('Choose a folder', 'Your browser will ask to see it, then to save into it.', () => void auth.choosePod(), state.busy)), errorBox(state.error), h('div', { class: 'wa-links' }, link('Not now — keep it in this browser', () => auth.finishSetup(), state.busy)), h('p', { class: 'wa-small' }, 'You can move it to a pod any time from your account page.'));
|
|
405
537
|
}
|
|
406
538
|
// ─── Arriving from a phone-pairing QR code ─────────────────────────
|
|
407
539
|
/**
|