@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.
Files changed (89) hide show
  1. package/README.md +67 -46
  2. package/dist/doors/doors.d.ts +1 -1
  3. package/dist/elements/auth-styles.d.ts +1 -1
  4. package/dist/elements/auth-styles.d.ts.map +1 -1
  5. package/dist/elements/auth-styles.js +13 -0
  6. package/dist/elements/auth-styles.js.map +1 -1
  7. package/dist/elements/weave-auth.d.ts +2 -2
  8. package/dist/elements/weave-auth.d.ts.map +1 -1
  9. package/dist/elements/weave-auth.js +251 -119
  10. package/dist/elements/weave-auth.js.map +1 -1
  11. package/dist/identity/account-vault.d.ts +10 -3
  12. package/dist/identity/account-vault.d.ts.map +1 -1
  13. package/dist/identity/account-vault.js +10 -3
  14. package/dist/identity/account-vault.js.map +1 -1
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/network/mailbox.d.ts +1 -1
  20. package/dist/network/mailbox.js +1 -1
  21. package/dist/network/peer-auth.d.ts +19 -0
  22. package/dist/network/peer-auth.d.ts.map +1 -1
  23. package/dist/network/peer-auth.js +22 -8
  24. package/dist/network/peer-auth.js.map +1 -1
  25. package/dist/node/actions.d.ts.map +1 -1
  26. package/dist/node/actions.js +9 -2
  27. package/dist/node/actions.js.map +1 -1
  28. package/dist/node/carrier.d.ts +16 -0
  29. package/dist/node/carrier.d.ts.map +1 -1
  30. package/dist/node/carrier.js +12 -0
  31. package/dist/node/carrier.js.map +1 -1
  32. package/dist/node/host.d.ts +2 -2
  33. package/dist/node/host.d.ts.map +1 -1
  34. package/dist/node/index.d.ts +3 -3
  35. package/dist/node/index.d.ts.map +1 -1
  36. package/dist/node/node.js +1 -1
  37. package/dist/node/node.js.map +1 -1
  38. package/dist/node/space-runtime.d.ts.map +1 -1
  39. package/dist/node/space-runtime.js +9 -1
  40. package/dist/node/space-runtime.js.map +1 -1
  41. package/dist/node/types.d.ts +6 -2
  42. package/dist/node/types.d.ts.map +1 -1
  43. package/dist/records/describe.d.ts +2 -0
  44. package/dist/records/describe.d.ts.map +1 -1
  45. package/dist/records/describe.js +5 -0
  46. package/dist/records/describe.js.map +1 -1
  47. package/dist/schema/collection-def.d.ts +16 -0
  48. package/dist/schema/collection-def.d.ts.map +1 -1
  49. package/dist/schema/collection-def.js +30 -0
  50. package/dist/schema/collection-def.js.map +1 -1
  51. package/dist/schemas/apps.d.ts +4 -1
  52. package/dist/schemas/apps.d.ts.map +1 -1
  53. package/dist/schemas/apps.js +8 -2
  54. package/dist/schemas/apps.js.map +1 -1
  55. package/dist/schemas/index.d.ts +1 -1
  56. package/dist/schemas/index.d.ts.map +1 -1
  57. package/dist/schemas/index.js +1 -1
  58. package/dist/schemas/index.js.map +1 -1
  59. package/dist/schemas/screens.d.ts +16 -4
  60. package/dist/schemas/screens.d.ts.map +1 -1
  61. package/dist/schemas/screens.js +49 -6
  62. package/dist/schemas/screens.js.map +1 -1
  63. package/dist/session/auth.d.ts +56 -10
  64. package/dist/session/auth.d.ts.map +1 -1
  65. package/dist/session/auth.js +247 -69
  66. package/dist/session/auth.js.map +1 -1
  67. package/dist/session/connect.d.ts +57 -3
  68. package/dist/session/connect.d.ts.map +1 -1
  69. package/dist/session/connect.js +49 -6
  70. package/dist/session/connect.js.map +1 -1
  71. package/dist/session/connection.d.ts +8 -1
  72. package/dist/session/connection.d.ts.map +1 -1
  73. package/dist/session/connection.js +6 -1
  74. package/dist/session/connection.js.map +1 -1
  75. package/dist/session/credentials.d.ts +22 -6
  76. package/dist/session/credentials.d.ts.map +1 -1
  77. package/dist/session/credentials.js +35 -6
  78. package/dist/session/credentials.js.map +1 -1
  79. package/dist/session/hosting.d.ts +1 -1
  80. package/dist/session/index.d.ts +5 -5
  81. package/dist/session/index.d.ts.map +1 -1
  82. package/dist/session/index.js +3 -3
  83. package/dist/session/index.js.map +1 -1
  84. package/dist/space/notify.d.ts +59 -1
  85. package/dist/space/notify.d.ts.map +1 -1
  86. package/dist/space/notify.js +91 -0
  87. package/dist/space/notify.js.map +1 -1
  88. package/dist/storage/blob/s3.js +1 -1
  89. 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 account password living in a password manager is the
22
- * point of the whole design. Its styles are scoped to the element instead.
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, deviceCredentialName, offerToSave } from '../session/credentials.js';
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
- if (state.stage === 'ready')
201
- return [];
202
- if (state.stage === 'starting')
203
- return [h('div', { class: 'wa-card' }, h('p', { class: 'wa-hint' }, 'Looking for your accounts…'))];
204
- if (state.stage === 'pair')
205
- return [this.#pair(state)];
206
- if (state.stage === 'create')
207
- return [state.freshCode ? this.#saveCode(state, state.freshCode) : this.#create(state)];
208
- if (state.stage === 'where')
209
- return [this.#where(state)];
210
- if (state.stage === 'welcome')
211
- return [this.#welcome(state)];
212
- return [this.#signIn(state)];
213
- }
214
- // ─── Where should your data live ───────────────────────────────────
215
- #where(state) {
216
- const auth = this.auth;
217
- return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, 'Where should your data live?'), h('p', { class: 'wa-subtitle' }, 'You can change this later.'), h('div', { class: 'wa-options' }, option('Choose a pod', 'A folder on your computer that holds your Weave data. Every app you open it in sees the same account and the same spaces.', () => void auth.choosePod(), state.busy, true), option('Continue in this browser', "Nothing to set up. Your data syncs with your other devices, but other apps on other addresses can't open it.", () => void auth.useBrowser(), state.busy)), h('p', { class: 'wa-small', style: 'margin-top:16px' }, 'Your browser will ask to see the folder, then to save into it.', info('Why a pod', 'Your data becomes yours the way any other file is: copy it, back it up, or put the folder in iCloud or Dropbox and your devices stay in step with no server involved. It is also the only storage a second app on a different address can read.')), errorBox(state.error));
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
- #placeLine(state) {
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 (!place)
249
+ if (place?.kind !== 'folder')
222
250
  return null;
223
- return h('p', { class: 'wa-small', style: 'margin-top:24px' }, place.kind === 'folder' ? `Pod: ${place.directory?.name ?? 'your folder'}` : 'Stored in this browser', state.folderAvailable ? [' · ', link('Change', () => this.auth.changeStorage(), state.busy)] : null);
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' }, 'Do you have a Weave account?'), h('p', { class: 'wa-subtitle' }, 'One account works in every Weave app.'), h('div', { class: 'wa-options' }, option('Create a new account', "We'll make you a strong password to save in your password manager.", () => auth.startCreating(), state.busy), option('I already have one', 'Sign in with the account password you saved when you made it.', () => auth.showSignIn(), state.busy)), errorBox(state.error), this.#placeLine(state));
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 account-password form.
276
+ * The recovery-code form.
233
277
  *
234
- * The username field is off-screen, but deliberately not `display: none` —
235
- * a field taken out of the layout is not counted as a username, while one
236
- * merely moved out of view is filled normally. And writable: a manager fills
237
- * the whole credential at once, so a read-only username keeps showing the
238
- * account that was clicked while the password quietly belongs to another.
239
- * Letting it be overwritten is what makes the mismatch detectable.
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 password = this.#input('code', {
298
+ const code = this.#input('code', {
252
299
  type: 'password',
253
300
  name: 'password',
254
- placeholder: 'Your account password',
301
+ placeholder: 'XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XX',
255
302
  autocomplete: 'current-password',
256
303
  spellcheck: 'false',
257
- 'aria-label': 'Account password',
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
- password.addEventListener('input', () => {
309
+ code.addEventListener('input', () => {
262
310
  const filled = username.value.trim();
263
311
  if (filled !== this.#filledAs) {
264
312
  this.#filledAs = filled;
265
- this.#render(this.auth.getState());
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
- const code = password.value.trim();
273
- if (code)
274
- void this.auth.signInWithCode(code);
346
+ if (password.value)
347
+ void this.auth.signInWithPassword(password.value);
275
348
  },
276
- }, username, password, h('button', { type: 'submit', class: 'wa-button', disabled: state.busy }, state.busy ? 'Signing in…' : 'Sign in'));
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 && !this.#showCode) {
292
- body.push(h('button', { type: 'button', class: 'wa-button', 'data-key': 'passkey', disabled: state.busy, onclick: () => void auth.signInWithPasskey() }, state.busy ? 'Waiting…' : 'Unlock with passkey'));
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 && !this.#showCode) {
295
- const devicePassword = this.#input('device-password', {
296
- type: 'password',
297
- name: 'password',
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('div', { class: 'wa-links' }, link('Use my account password instead', () => {
332
- this.#showCode = true;
333
- this.#filledAs = '';
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' }, accounts.length > 0 ? 'Welcome back' : 'Sign in'), h('p', { class: 'wa-subtitle' }, accounts.length > 0 ? 'Choose your account.' : 'Paste the account password you saved when you made it.'), ...rows, accounts.length === 0
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' }, 'Your data stays with you, not on a server.'), h('form', {
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…' : 'Create account')), h('div', { class: 'wa-links' }, link('I already have an account', () => auth.showSignIn(), state.busy)), h('p', { class: 'wa-small' }, "We'll generate a strong password and show it once.", info('What happens next', 'Save it in your password manager. It opens your account in any Weave app, even one that has never seen you — and nobody can reissue it.')), errorBox(state.error));
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 new account's password, shown once.
429
+ * The recovery code, to keep.
382
430
  *
383
- * Framed as a password because that is what it is for: your password
384
- * manager saves it here and fills it in on the next app. A real form with
385
- * both fields is what makes a manager offer to save.
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
- #saveCode(state, code) {
436
+ #recovery(state) {
388
437
  const auth = this.auth;
389
- const filedAs = accountCredentialName(state.session?.account.name ?? 'My account');
390
- return h('div', { class: 'wa-card' }, wordmark(), h('h1', { class: 'wa-title' }, 'Save your password'), h('p', { class: 'wa-hint' }, 'Save this to your password manager now — it will fill itself in from then on.', info('About this password', 'It is generated rather than chosen, and it ', h('em', null, 'is'), ' your key rather than a backup of it. That is what lets it work on an app that has never seen you, with nothing stored anywhere. It is shown once and nobody can reissue it.')), h('form', {
391
- class: 'wa-form',
392
- onsubmit: (event) => {
393
- event.preventDefault();
394
- void offerToSave(filedAs, code, filedAs).then(() => auth.codeSaved());
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
- }, h('input', { type: 'text', name: 'username', autocomplete: 'username', value: filedAs, readonly: true, 'aria-label': 'Account name' }), h('input', { type: 'password', name: 'password', autocomplete: 'new-password', value: code, readonly: true, 'aria-label': 'Account password' }), h('button', { type: 'submit', class: 'wa-button' }, "I've saved it — continue")), h('code', { class: 'wa-code' }, code), h('div', { class: 'wa-links' }, link(this.#copied ? 'Copied' : 'Copy', () => {
397
- void globalThis.navigator.clipboard
398
- ?.writeText(code)
399
- .then(() => {
400
- this.#copied = true;
401
- this.#render(auth.getState());
402
- })
403
- .catch(() => { });
404
- })), h('p', { class: 'wa-small' }, 'It is the only way into this account from an app that has never seen it. Nobody can reissue it, and it is stored nowhere but where you put it. On this device you can add a passkey afterwards, so you will rarely need it again.'));
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
  /**