fedipod-server 0.6.0 → 0.8.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.
@@ -28,7 +28,7 @@ const MODERATION = $('moderation');
28
28
  const STATUS = $('status-ctl');
29
29
  const STATUS_PICK = $('status-pick'); // held: getElementById can't see it mid-render
30
30
  const BSKY_CTL = $('bsky-ctl');
31
- const BSKY_CONNECT_CTL = $('bsky-connect-ctl');
31
+ const IDENT_CTL = $('ident-ctl');
32
32
  const FOLLOWS_CTL = $('follows-ctl');
33
33
  const FOLLOWS_PICK = $('follows-pick');
34
34
  const UPDATE_CTL = $('update-ctl');
@@ -113,6 +113,49 @@ document.getElementById('win').addEventListener('win:closed', () => {
113
113
  });
114
114
 
115
115
 
116
+ // One row per identity connected elsewhere, indented under the row that adds
117
+ // them. Each carries its own way out: disconnecting one has nothing to do with
118
+ // the others.
119
+ function identityRows() {
120
+ const out = [];
121
+ const row = (label, ...nodes) => {
122
+ const dt = document.createElement('dt');
123
+ dt.className = 'under';
124
+ dt.textContent = label;
125
+ const dd = document.createElement('dd');
126
+ dd.append(...nodes);
127
+ out.push([dt, dd]);
128
+ };
129
+ if (config.atproto?.connected) {
130
+ const a = document.createElement('a');
131
+ a.href = `https://bsky.app/profile/${config.atproto.handle}`;
132
+ a.textContent = `@${config.atproto.handle}`;
133
+ a.target = '_blank';
134
+ a.rel = 'noopener';
135
+ a.title = ` Open this account's Bluesky page in a new tab`;
136
+ BSKY_CTL.hidden = false;
137
+ BSKY_CROSSPOST.value = config.atproto.crossPost ? 'on' : 'off';
138
+ row('Bluesky', a, ' ', BSKY_CTL);
139
+ } else {
140
+ BSKY_CTL.hidden = true;
141
+ }
142
+ for (const acct of config.fediAccounts || []) {
143
+ const name = document.createElement('span');
144
+ name.textContent = acct.needsReconnect ? `${acct.handle} — sign in again` : acct.handle;
145
+ if (acct.needsReconnect) name.className = 'warn';
146
+ const off = document.createElement('button');
147
+ off.type = 'button';
148
+ off.className = 'inline danger';
149
+ off.textContent = 'Disconnect';
150
+ off.title = ` Stop reading ${acct.handle} and remove its stored token`;
151
+ off.addEventListener('click', async () => {
152
+ if (await write('/fediacct/disconnect', { id: acct.id }, `${acct.handle} disconnected`)) await load();
153
+ });
154
+ row(acct.host, name, ' ', off);
155
+ }
156
+ return out;
157
+ }
158
+
116
159
  function render() {
117
160
  // The bar names the actor, the same way on every page. Only the tab title is
118
161
  // this page's own business.
@@ -134,7 +177,7 @@ function render() {
134
177
  ['Fediverse identity', config.address || `@${config.handle} — no resolvable address`,
135
178
  config.accountId && config.address ? { href: `/admin/client/#/a/${config.accountId}` } : null],
136
179
  ['Solid identity', config.webId, config.remotePod ? { href: config.remotePod, blank: true } : null],
137
- ['Bluesky identity', 'ctl'],
180
+ ['Other identities', 'ctl'],
138
181
  ['local store', config.home],
139
182
  // The address you actually open, not the bare number — the named origin when
140
183
  // there is one, since that is what the client and the OAuth redirect use.
@@ -142,11 +185,14 @@ function render() {
142
185
  .replace(/\/$/, '')],
143
186
  ];
144
187
  if (config.version || config.update || config.pendingUpgrade?.length) rows.push(['software', 'ctl']);
188
+ rows.push(['gateway', 'ctl']);
145
189
  if (config.quiescedAt) rows.push(['parked since', config.quiescedAt]);
146
190
  if (config.movedTo) rows.push(['moved to', config.movedTo]);
147
191
  // A person gates followers here; a group's gate is the joins control on its
148
192
  // kind row, so the row would be a second switch for the same thing.
149
193
  if (config.kind !== 'group') rows.splice(2, 0, ['new followers', 'ctl']);
194
+ // Rows that belong under the one being written, appended after it.
195
+ let kids = null;
150
196
  for (const [k, v, link] of rows) {
151
197
  if (!v) continue;
152
198
  const dt = document.createElement('dt');
@@ -185,6 +231,11 @@ function render() {
185
231
  FOLLOWS_CTL.hidden = false;
186
232
  FOLLOWS_PICK.value = config.autoAcceptFollows ? 'auto' : 'approve';
187
233
  }
234
+ if (k === 'gateway') {
235
+ dd.textContent = '';
236
+ dd.append(GATEWAY_CTL);
237
+ refreshGateway();
238
+ }
188
239
  if (k === 'software') {
189
240
  dd.textContent = '';
190
241
  dd.append(UPDATE_CTL);
@@ -201,27 +252,19 @@ function render() {
201
252
  UPDATE_WORD.textContent = words.join('; ');
202
253
  UPDATE_GO.hidden = !u?.available;
203
254
  }
204
- // Connected: the account itself, then the way out. Not: a form on the page.
205
- if (k === 'Bluesky identity') {
255
+ // The way to add one. Each account already connected is a row of its own
256
+ // underneath, so this row stays the action and never becomes a list.
257
+ if (k === 'Other identities') {
206
258
  dd.textContent = '';
207
- if (config.atproto?.connected) {
208
- const a = document.createElement('a');
209
- a.href = `https://bsky.app/profile/${config.atproto.handle}`;
210
- a.textContent = `@${config.atproto.handle}`;
211
- a.target = '_blank';
212
- a.rel = 'noopener';
213
- a.title = ` Open this account's Bluesky page in a new tab`;
214
- dd.append(a, ' ', BSKY_CTL);
215
- BSKY_CTL.hidden = false;
216
- BSKY_CONNECT_CTL.hidden = true;
217
- BSKY_CROSSPOST.value = config.atproto.crossPost ? 'on' : 'off';
218
- } else {
219
- dd.append(BSKY_CONNECT_CTL);
220
- BSKY_CONNECT_CTL.hidden = false;
221
- BSKY_CTL.hidden = true;
222
- }
259
+ dd.append(IDENT_CTL);
260
+ IDENT_CTL.hidden = false;
261
+ kids = identityRows();
223
262
  }
224
263
  facts.append(dt, dd);
264
+ if (kids) {
265
+ for (const [ckt, ckd] of kids) facts.append(ckt, ckd);
266
+ kids = null;
267
+ }
225
268
  }
226
269
  if (!config.address) {
227
270
  const p = document.createElement('p');
@@ -237,7 +280,6 @@ function render() {
237
280
  renderAliases();
238
281
  renderOthers();
239
282
  renderInbox();
240
- renderGateway();
241
283
  if (config.kind === 'group') {
242
284
  // Its lists have no bound, so this page scrolls — see body.group in the CSS.
243
285
  document.body.classList.add('group');
@@ -415,9 +457,17 @@ function setRail(closed) {
415
457
  $('rail-toggle').addEventListener('click', () => setRail(!$('rail').classList.contains('closed')));
416
458
  if (matchMedia('(max-width: 47rem)').matches) setRail(true);
417
459
 
418
- $('bsky-open').addEventListener('click', () => {
460
+ // One way in for both networks, because from the record they are the same
461
+ // thing: an account elsewhere whose timeline joins the one you read here.
462
+ $('ident-open').addEventListener('click', () => {
419
463
  closePanels();
420
- solWindow.show('bsky-form', 'Connect Bluesky account');
464
+ solWindow.show('ident-form', 'Connect an identity');
465
+ });
466
+ $('ident-pick-fedi').addEventListener('click', () => {
467
+ solWindow.show('fediacct-form', 'Connect a Fediverse account');
468
+ });
469
+ $('ident-pick-bsky').addEventListener('click', () => {
470
+ solWindow.show('bsky-form', 'Connect a Bluesky account');
421
471
  });
422
472
 
423
473
  let bskyBusy = false;
@@ -438,6 +488,20 @@ $('bsky-form').addEventListener('submit', async (ev) => {
438
488
  }
439
489
  } finally { bskyBusy = false; }
440
490
  });
491
+ // The sign-in happens at the other server, so this hands the browser over
492
+ // rather than taking a password. What comes back lands on /fediacct/callback,
493
+ // which this machine answers and nowhere else does.
494
+ let fediBusy = false;
495
+ $('fediacct-form').addEventListener('submit', async (ev) => {
496
+ ev.preventDefault();
497
+ if (fediBusy) return;
498
+ fediBusy = true;
499
+ try {
500
+ const r = await write('/fediacct/connect', { host: $('fediacct-host').value.trim() }, '');
501
+ if (r?.authorize) location.href = r.authorize;
502
+ } finally { fediBusy = false; }
503
+ });
504
+
441
505
  $('bsky-disconnect').addEventListener('click', async () => {
442
506
  if (bskyBusy) return;
443
507
  bskyBusy = true;
@@ -937,35 +1001,62 @@ async function renderInbox() {
937
1001
  panel.hidden = false;
938
1002
  }
939
1003
 
940
- // The gateway panel: attach through a multi-user front with this agent's own
941
- // credential, see what the door has verified, detach back to the pod inbox.
942
- let gwTimer = null;
943
- async function renderGateway() {
1004
+ // The gateway, as a facts row under software: attach through a multi-user
1005
+ // front with this agent's own credential, detach back to the pod inbox. The
1006
+ // forms live in the floating window, like every other disclosure.
1007
+ const GATEWAY_CTL = $('gateway-ctl'); // held: it rides into a generated row
1008
+ const GATEWAY_WORD = $('gateway-word');
1009
+ const GW_OPEN_ATTACH = $('gateway-open-attach');
1010
+ const GW_OPEN_DETACH = $('gateway-open-detach');
1011
+ let gwState = null;
1012
+ async function refreshGateway() {
944
1013
  const { status, json: g } = await api('/gateway');
945
- if (status !== 200 || !g) return; // no answer, no surface
946
- $('pane-gateway').hidden = false;
1014
+ if (status !== 200 || !g) return;
1015
+ gwState = g;
1016
+ GATEWAY_CTL.hidden = false;
947
1017
  if (g.configured) {
948
- const host = (() => { try { return new URL(g.url).host; } catch { return g.url; } })();
949
- const st = g.stats || {};
950
- $('gateway-summary').textContent = `Attached to ${host} (mode ${g.mode})`
951
- + (g.frontActor ? `, publishing as ${g.frontActor}` : '')
952
- + ` — ${st.verified || 0} deliveries verified, ${st.unverified || 0} unverified.`;
953
- $('gateway-attach-form').hidden = true;
954
- $('gateway-attached').hidden = false;
1018
+ let host = g.url;
1019
+ try { host = new URL(g.url).host; } catch { /* show it as-is */ }
1020
+ const frontName = g.frontActor?.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1];
1021
+ GATEWAY_WORD.textContent = frontName ? `${host} publishing as @${frontName}@${host}` : host;
1022
+ GW_OPEN_ATTACH.hidden = true;
1023
+ GW_OPEN_DETACH.hidden = false;
955
1024
  } else {
956
- $('gateway-summary').textContent = 'Mail arrives directly at your pod\'s own inbox.';
957
- $('gateway-attach-form').hidden = false;
958
- $('gateway-attached').hidden = true;
959
- if (!$('gw-name').value && config?.handle) $('gw-name').value = config.handle;
1025
+ GATEWAY_WORD.textContent = '';
1026
+ GW_OPEN_ATTACH.hidden = false;
1027
+ GW_OPEN_DETACH.hidden = true;
960
1028
  }
961
1029
  }
1030
+ const gwShape = () => document.querySelector('input[name=gwShape]:checked')?.value || 'pod';
1031
+ function gwPreviews() {
1032
+ $('gw-pod-preview').textContent = config?.address || `@${config?.handle || 'you'}@your.pod`;
1033
+ try { $('gw-front-host').textContent = new URL($('gw-front').value.trim()).host; }
1034
+ catch { /* not a URL yet — the placeholder host stands */ }
1035
+ }
1036
+ function gwShapeChanged() { gwPreviews(); gwCheck(); }
1037
+ for (const r of document.querySelectorAll('input[name=gwShape]')) r.addEventListener('change', gwShapeChanged);
1038
+ // Typing in the blank IS choosing that shape.
1039
+ $('gw-name').addEventListener('focus', () => {
1040
+ const r = document.querySelector('input[name=gwShape][value=front]');
1041
+ if (!r.checked) { r.checked = true; gwShapeChanged(); }
1042
+ });
1043
+ GW_OPEN_ATTACH.addEventListener('click', () => {
1044
+ gwShapeChanged();
1045
+ solWindow.show('gateway-attach-form', 'Attach to a gateway');
1046
+ });
1047
+ GW_OPEN_DETACH.addEventListener('click', () => {
1048
+ $('gw-detach-fronted').hidden = !gwState?.frontActor;
1049
+ $('gw-detach-plain').hidden = !!gwState?.frontActor;
1050
+ solWindow.show('gateway-detach-form', 'Detach from gateway');
1051
+ });
962
1052
  // Live availability, asked through the agent (the front answers it without CORS).
1053
+ let gwTimer = null;
963
1054
  function gwCheck() {
964
1055
  clearTimeout(gwTimer);
965
1056
  const front = $('gw-front').value.trim().replace(/\/+$/, '');
966
1057
  const name = $('gw-name').value.trim().toLowerCase();
967
1058
  $('gw-name-msg').textContent = ''; $('gw-name-msg').className = 'hint';
968
- if (!front || !name) return;
1059
+ if (gwShape() !== 'front' || !front || !name) return;
969
1060
  gwTimer = setTimeout(async () => {
970
1061
  const { status, json } = await postJson('/gateway', { action: 'check', front, handle: name });
971
1062
  if (status !== 200 || !json) return;
@@ -975,24 +1066,30 @@ function gwCheck() {
975
1066
  $('gw-name-msg').className = json.available ? 'hint' : 'warn';
976
1067
  }, 300);
977
1068
  }
978
- $('gw-front').addEventListener('input', gwCheck);
979
- $('gw-name').addEventListener('input', gwCheck);
980
- $('gw-attach').onclick = async () => {
1069
+ $('gw-front').addEventListener('input', () => { gwPreviews(); gwCheck(); });
1070
+ $('gw-name').addEventListener('input', () => { gwPreviews(); gwCheck(); });
1071
+ $('gw-attach').addEventListener('click', async () => {
981
1072
  const front = $('gw-front').value.trim().replace(/\/+$/, '');
982
- const fronted = $('gw-shape').value === 'front';
1073
+ const fronted = gwShape() === 'front';
1074
+ const name = $('gw-name').value.trim().toLowerCase();
1075
+ if (fronted && !name) { say('give the handle you want at the gateway', 'err'); return; }
983
1076
  $('gw-attach').disabled = true;
1077
+ // Pod-based sends no name: the door's label is the agent's business, not
1078
+ // the user's, and the agent picks a free variant by itself.
984
1079
  const r = await write('/gateway',
985
- { action: 'attach', front, handle: $('gw-name').value.trim().toLowerCase(), fronted },
986
- fronted ? 'attached — restart the agent to publish under the gateway name'
1080
+ { action: 'attach', front, ...(fronted ? { handle: name, fronted: true } : {}) },
1081
+ fronted ? 'attached — the agent is restarting to publish under the gateway handle; reload in a moment'
987
1082
  : 'attached — your mail now arrives through the gateway, filtered');
988
1083
  $('gw-attach').disabled = false;
989
- if (r) renderGateway();
990
- };
991
- $('gw-detach').onclick = async () => {
1084
+ if (r) { closePanels(); refreshGateway(); }
1085
+ });
1086
+ $('gw-detach').addEventListener('click', async () => {
1087
+ const fronted = !!gwState?.frontActor;
992
1088
  const r = await write('/gateway', { action: 'forget' },
993
- 'detached — the actor was republished advertising your pod\'s own inbox');
994
- if (r) renderGateway();
995
- };
1089
+ fronted ? 'detached — the agent is restarting under your pod\'s own name; reload in a moment'
1090
+ : 'detached — the actor was republished advertising your pod\'s own inbox');
1091
+ if (r) { closePanels(); refreshGateway(); }
1092
+ });
996
1093
 
997
1094
  $('inbox-keep').addEventListener('click', () => {
998
1095
  dismissed = true;
@@ -15,14 +15,14 @@
15
15
  :root { color-scheme: light dark; font-size: 112.5%;
16
16
  --bar-col: 1280px; /* the bar centres on the same column */
17
17
  --link: #1a4f8a; --heading: #5b3a8e;
18
- --ink: #1b1c1e; --muted: #5f6368;
18
+ --ink: #1b1c1e; --muted: #4a4e53;
19
19
  --btn-bg: #f4efe6; --btn-bg-hover: #ece3d2; --btn-bg-active: #e2d5bd; --btn-edge: #8a7a61;
20
20
  --field-bg: #ffffff; --field-bg-hover: #f7f4ee; --field-edge: #767676;
21
21
  --danger: #b00020; --ring: var(--link);
22
22
  --chevron: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' fill='none' stroke='%23444444' stroke-width='1.6' stroke-linecap='round'/></svg>"); }
23
23
  @media (prefers-color-scheme: dark) {
24
24
  :root { --link: #7fb3e8; --heading: #b9a3e3;
25
- --ink: #e8eaed; --muted: #a5a9ad;
25
+ --ink: #e8eaed; --muted: #b9bdc2;
26
26
  --btn-bg: #35302a; --btn-bg-hover: #423b31; --btn-bg-active: #4d4436; --btn-edge: #9a8d7a;
27
27
  --field-bg: #383d44; --field-bg-hover: #40464e; --field-edge: #9aa0a6;
28
28
  --danger: #ff8a8a;
@@ -98,7 +98,8 @@ p { margin: .5rem 0; }
98
98
  fieldset { border: 1px solid #bbb; border-radius: .4rem; margin: 1rem 0; padding: .75rem 1rem 1rem; }
99
99
  legend { padding: 0 .3rem; font-weight: 600; color: var(--heading); }
100
100
  label { display: block; margin: .75rem 0 .2rem; }
101
- .hint { color: var(--muted); font-size: .9rem; margin: .15rem 0 0; }
101
+ .hint { color: var(--muted); font-size: 1rem; margin: .15rem 0 0; }
102
+ .choice input[type=text] { width: 9ch; display: inline-block; padding: .15rem .3rem; }
102
103
  /* SETTINGS: light wells, unmistakably fields, in both schemes. */
103
104
  input[type=text], input[type=email], input[type=url], input[type=password], textarea {
104
105
  font: inherit; width: 100%; padding: .5rem; box-sizing: border-box;
@@ -132,6 +133,9 @@ dl { margin: .5rem 0; } /* grid only above 34rem — see the media query
132
133
  gap: .6rem .9rem; align-items: baseline; }
133
134
  }
134
135
  dt { color: var(--muted); }
136
+ /* An identity connected under "Other identities" belongs to the row above it,
137
+ and the indent is what says so. */
138
+ dt.under { padding-left: 1.25rem; }
135
139
  dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
136
140
  .rows { list-style: none; padding: 0; margin: .3rem 0; }
137
141
  .rows li { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center;
@@ -215,16 +219,16 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
215
219
  <label class="choice"><input type="radio" name="newKind" value="person" checked>
216
220
  A <strong>person</strong> — an account you read and post from</label>
217
221
  <label class="choice"><input type="radio" name="newKind" value="group">
218
- A <strong>group</strong> of fediverse users you manage</label>
222
+ A <strong>group</strong> of Fediverse users you manage</label>
219
223
  </fieldset>
220
224
 
221
225
  <fieldset>
222
226
  <legend>Actor's Handle</legend>
223
227
  <label for="new-handle">Handle</label>
224
228
  <input type="text" id="new-handle" autocomplete="off"
225
- placeholder="handle part of fediverse address: @HANDLE@server"
229
+ placeholder="handle part of Fediverse address: @HANDLE@server"
226
230
  aria-describedby="new-handle-hint">
227
- <p class="hint" id="new-handle-hint">The name in your fediverse address. This is permanent.
231
+ <p class="hint" id="new-handle-hint">The name in your Fediverse address. This is permanent.
228
232
  Letters, digits, hyphens, and underscores only.</p>
229
233
  </fieldset>
230
234
 
@@ -302,8 +306,8 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
302
306
  <option value="auto">accepted automatically</option>
303
307
  </select>
304
308
  </span>
305
- <span id="bsky-connect-ctl" hidden>
306
- <button type="button" id="bsky-open" class="inline" title=" Connect a Bluesky account this identity also posts as">Connect Bluesky account</button>
309
+ <span id="ident-ctl" hidden>
310
+ <button type="button" id="ident-open" class="inline" title=" Connect an account you hold on another network, so its timeline joins the one you read here">Connect an identity</button>
307
311
  </span>
308
312
  <span id="bsky-ctl" hidden>
309
313
  <select id="bsky-crosspost" aria-label="Bluesky crossposting" title=" Whether your public posts also go to the Bluesky account">
@@ -314,36 +318,13 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
314
318
  </span>
315
319
  </section>
316
320
 
317
- <!-- The inbox gateway: a mail filter for an account that stays yours. -->
318
- <section id="pane-gateway" hidden>
319
- <h2><span class="hlabel">Gateway</span></h2>
320
- <p id="gateway-summary"></p>
321
- <div id="gateway-attach-form" hidden>
322
- <p class="hint">A gateway filters your fediverse mail before it reaches your pod: your
323
- actor advertises the gateway's door as its inbox, each delivery is verified there,
324
- spam is dropped, and the rest lands in your pod as before. Attaching proves the pod
325
- with this agent's own credential — no password leaves this machine.</p>
326
- <p>
327
- <label for="gw-front">Gateway</label>
328
- <input type="url" id="gw-front" placeholder="https://fedipod.net" autocomplete="off">
329
- <label for="gw-name">Your name there</label>
330
- <input type="text" id="gw-name" autocomplete="off" autocapitalize="off" spellcheck="false">
331
- </p>
332
- <p id="gw-name-msg" class="hint"></p>
333
- <p>
334
- <select id="gw-shape" aria-label="Name shape" title=" Which address the fediverse sees — the pod-based name keeps everything on your pod; a gateway-based name survives changing pods">
335
- <option value="pod">keep my pod-based name</option>
336
- <option value="front">use a gateway-based name</option>
337
- </select>
338
- <button id="gw-attach" class="primary" title=" Create the gateway account and point your mail through its door">Attach</button>
339
- </p>
340
- </div>
341
- <div id="gateway-attached" hidden>
342
- <p>
343
- <button id="gw-detach" class="inline danger" title=" Republish the actor with your pod's own inbox and forget the gateway">Detach</button>
344
- </p>
345
- </div>
346
- </section>
321
+ <span id="gateway-ctl" hidden>
322
+ <span id="gateway-word"></span>
323
+ <button type="button" id="gateway-open-attach" class="inline" hidden
324
+ title=" Attach this account to a mail-filtering gateway">Attach to a gateway</button>
325
+ <button type="button" id="gateway-open-detach" class="inline danger" hidden
326
+ title=" Go back to your pod's own inbox">Detach from gateway</button>
327
+ </span>
347
328
 
348
329
  <section id="pane-group" hidden>
349
330
  <!-- Both queues are consequences of a moderation setting: with the setting off
@@ -366,7 +347,7 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
366
347
  on arrival. Shown only when something is actually waiting. -->
367
348
  <div id="block-modqueue" hidden>
368
349
  <h3>Moderation requests <span class="muted" id="modqueue-count"></span></h3>
369
- <p class="hint">Asked for by a moderator over the fediverse. A delivery cannot prove who
350
+ <p class="hint">Asked for by a moderator over the Fediverse. A delivery cannot prove who
370
351
  sent it, so nothing here has been acted on.</p>
371
352
  <ul class="rows" role="list" id="modqueue"></ul>
372
353
  </div>
@@ -445,9 +426,74 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
445
426
  <button id="bsky-connect" type="submit" class="primary" title=" Sign in to the Bluesky account with the app password">Connect</button>
446
427
  </p>
447
428
  </form>
429
+ <form id="ident-form" hidden>
430
+ <p class="hint">Either kind brings that account's timeline and notifications
431
+ into the feed you read here, and acts as that account when you reply to,
432
+ favourite or boost one of its posts.</p>
433
+ <p>
434
+ <button type="button" id="ident-pick-fedi" class="primary" title=" Sign in at a Mastodon-API server: Mastodon, GoToSocial, Pleroma, Akkoma, Pixelfed or Friendica">A Fediverse account</button>
435
+ <button type="button" id="ident-pick-bsky" title=" Sign in to Bluesky or another ATProto server with an app password">A Bluesky account</button>
436
+ </p>
437
+ </form>
438
+ <form id="fediacct-form" hidden>
439
+ <ol class="hint">
440
+ <li>Name the server your other account is on.</li>
441
+ <li>That server asks you to sign in and to allow FediPod. It is the only
442
+ place your password is typed; this agent never sees or stores one.</li>
443
+ <li>Its timeline and notifications then join the ones you already read here,
444
+ and a like or a reply on one of its posts goes out as that account.</li>
445
+ <li>What arrives is held on this machine for reading. None of it is written
446
+ to your pod.</li>
447
+ </ol>
448
+ <label for="fediacct-host">server</label>
449
+ <input type="text" id="fediacct-host" placeholder="mastodon.social" autocomplete="off"
450
+ title=" The server your other account lives on — Mastodon, GoToSocial, Pleroma, Akkoma, Pixelfed and Friendica all speak the API this needs">
451
+ <p>
452
+ <button id="fediacct-connect" type="submit" class="primary" title=" Go to that server to sign in and allow FediPod">Continue to that server</button>
453
+ </p>
454
+ </form>
448
455
  <!-- Every consequence is written here rather than assembled in script: the
449
456
  page asking "are you sure" has to say what it is sure about, and this is
450
457
  the only record of it. Script picks which block to show, nothing else. -->
458
+ <!-- The gateway popups: attach proves the pod with this agent's own credential;
459
+ detach republishes the pod's own inbox. -->
460
+ <form id="gateway-attach-form" hidden>
461
+ <p class="hint">A gateway filters your Fediverse mail before it reaches your pod: each
462
+ delivery is verified at its door, spam is dropped, and the rest lands in your pod as
463
+ before. Attaching proves the pod with this agent's own credential — no password
464
+ leaves this machine.</p>
465
+ <label for="gw-front">gateway</label>
466
+ <input type="url" id="gw-front" placeholder="https://fedipod.net" autocomplete="off">
467
+ <fieldset>
468
+ <legend>Your Fediverse handle</legend>
469
+ <label class="choice"><input type="radio" name="gwShape" value="pod" checked>
470
+ Keep my pod-based handle: <strong id="gw-pod-preview"></strong><br>
471
+ <span class="hint">Your address stays tied to your own pod — you can drop the
472
+ gateway any time and keep everything.</span></label>
473
+ <label class="choice"><input type="radio" name="gwShape" value="front">
474
+ Create a handle at the gateway:
475
+ @<input type="text" id="gw-name" placeholder="name" autocomplete="off"
476
+ autocapitalize="off" spellcheck="false">@<span id="gw-front-host">fedipod.net</span><br>
477
+ <span class="hint">Your address lives on the gateway's domain, but not your data
478
+ or key; you can change pods later and keep the handle.</span>
479
+ <span id="gw-name-msg" class="hint"></span></label>
480
+ </fieldset>
481
+ <p>
482
+ <button id="gw-attach" type="button" class="primary"
483
+ title=" Create the gateway account and point your mail through its door">Attach</button>
484
+ </p>
485
+ </form>
486
+ <form id="gateway-detach-form" hidden>
487
+ <p class="hint" id="gw-detach-plain">Deliveries go back to your pod's own inbox — the
488
+ actor is republished advertising it. Your name and data never moved.</p>
489
+ <p class="warn" id="gw-detach-fronted" hidden>This identity publishes under the gateway's
490
+ name. Detaching moves every published id back to the pod — a rename other servers
491
+ see, not just a mail change.</p>
492
+ <p>
493
+ <button id="gw-detach" type="button" class="primary"
494
+ title=" Forget the gateway and republish the actor with the pod inbox">Detach</button>
495
+ </p>
496
+ </form>
451
497
  <form id="confirm-form" hidden>
452
498
  <div id="warn-rotate-key" class="warn" hidden>
453
499
  <p>A new keypair replaces the one in this home and the actor is republished so other
@@ -20,7 +20,7 @@ fieldset { border: 1px solid #bbb; border-radius: .4rem; margin: 1rem 0; padding
20
20
  legend { padding: 0 .3rem; font-weight: 600; }
21
21
  label { display: block; margin: .75rem 0 .2rem; }
22
22
  .hint { color: #666; font-size: .9rem; margin: .15rem 0 0; }
23
- input[type=text], input[type=email], input[type=url], input[type=password], textarea {
23
+ input[type=text], input[type=email], input[type=url], input[type=password], select, textarea {
24
24
  font: inherit; width: 100%; padding: .5rem; box-sizing: border-box;
25
25
  border: 1px solid #767676; border-radius: .3rem; background: Field; color: FieldText;
26
26
  }
@@ -94,7 +94,8 @@ button:disabled { opacity: .5; cursor: default; }
94
94
  <ul class="steps" id="run-steps"></ul>
95
95
  <p class="sr-only" id="run-say" role="status"></p>
96
96
  <p class="err" id="run-error" role="alert"></p>
97
- <p><button id="run-again" title=" Try the step that failed again" hidden>Try again</button></p>
97
+ <p><button id="run-again" title=" Try the step that failed again" hidden>Try again</button>
98
+ <button id="run-reenter" title=" Discard the credential and enter the account and pod again" hidden>Re-enter credentials</button></p>
98
99
  </section>
99
100
 
100
101
  <section id="pane-form" hidden>
@@ -124,8 +125,19 @@ button:disabled { opacity: .5; cursor: default; }
124
125
  <label class="choice"><input type="radio" name="mode" value="existing">
125
126
  Use a pod I already have</label>
126
127
 
127
- <label for="issuer">Solid identity provider</label>
128
- <input type="url" id="issuer" name="issuer" value="https://solidcommunity.net" autocomplete="url">
128
+ <div id="row-issuer-new">
129
+ <label for="issuer-new">Solid pod provider</label>
130
+ <select id="issuer-new" name="issuerNew" aria-describedby="issuer-new-hint">
131
+ <option value="https://solidcommunity.net">solidcommunity.net</option>
132
+ </select>
133
+ <p class="hint" id="issuer-new-hint">Providers that give each pod its own subdomain, so the
134
+ Fediverse address works everywhere.</p>
135
+ </div>
136
+
137
+ <div id="row-issuer-existing" hidden>
138
+ <label for="issuer">Solid identity provider</label>
139
+ <input type="url" id="issuer" name="issuer" value="https://solidcommunity.net" autocomplete="url">
140
+ </div>
129
141
 
130
142
  <label for="email">Solid Account email/username</label>
131
143
  <input type="email" id="email" name="email" autocomplete="email" placeholder="you@example.org">
@@ -153,6 +165,10 @@ button:disabled { opacity: .5; cursor: default; }
153
165
  </div>
154
166
  </fieldset>
155
167
 
168
+ <p id="row-reenter" class="warn" hidden>Finishing a setup that already has an account credential.
169
+ Wrong account or pod?
170
+ <button type="button" id="form-reenter" title=" Discard the credential and enter the account and pod again">Re-enter credentials</button></p>
171
+
156
172
  <h2>You will be</h2>
157
173
  <p class="address" id="preview">…</p>
158
174
  <div id="preview-notes"></div>
@@ -60,20 +60,34 @@ function paneForm() {
60
60
  if (fr.kind) $('form').elements.kind.value = fr.kind;
61
61
  if (fr.handle) $('handle').value = fr.handle;
62
62
  if (fr.pod) { $('form').elements.mode.value = 'existing'; $('pod').value = fr.pod; }
63
- if (fr.issuer) $('issuer').value = fr.issuer;
63
+ // A gateway-arranged signup names its own provider; give the dropdown that
64
+ // choice too, so the carried-over answer survives the narrower control.
65
+ if (fr.issuer) {
66
+ $('issuer').value = fr.issuer;
67
+ const sel = $('issuer-new');
68
+ if (![...sel.options].some(o => o.value === fr.issuer)) {
69
+ const o = document.createElement('option');
70
+ o.value = fr.issuer;
71
+ o.textContent = host(fr.issuer);
72
+ sel.appendChild(o);
73
+ }
74
+ sel.value = fr.issuer;
75
+ }
64
76
  if (fr.gatewayHost) strap(`finishing your ${fr.gatewayHost} signup — enter your Solid account details`);
65
77
  }
66
78
  // Resuming: the account exists and the credential is minted. Asking for a
67
79
  // password again would mint a second one and orphan the first, which cannot
68
80
  // be recovered.
69
- if (state.resumable) {
70
- // Both already settled in the credential this run is resuming from.
71
- $('fs-pod').hidden = true;
72
- $('row-password').hidden = true;
73
- $('submit').textContent = 'Finish setting up';
74
- } else if (state.passwordSupplied) {
75
- $('row-password').hidden = true; // AP_PASSWORD is set in the environment
76
- }
81
+ // Set both ways, not just hidden-when-resuming: re-entering credentials
82
+ // re-renders this in the other mode, and a one-way hide would strand the
83
+ // account and pod fields off-screen. Resuming settles the account and the
84
+ // credential, so those fields go; a fresh run shows them.
85
+ $('row-reenter').hidden = !state.resumable;
86
+ $('fs-pod').hidden = state.resumable;
87
+ // The password field also goes when AP_PASSWORD supplied it in the environment.
88
+ $('row-password').hidden = state.resumable || state.passwordSupplied;
89
+ $('submit').textContent = state.resumable ? 'Finish setting up' : 'Create it';
90
+ if (state.resumable) $('form-reenter').onclick = reEnter; // the escape from a wrong credential
77
91
  show('pane-form');
78
92
  // The markup's autofocus was set while this pane was still hidden, so it
79
93
  // never fired — move focus to the first field now that the pane is showing.
@@ -94,7 +108,7 @@ function answers() {
94
108
  kind,
95
109
  mode,
96
110
  handle: f.handle.value.trim(),
97
- issuer: f.issuer.value.trim(),
111
+ issuer: (mode === 'new' ? f.issuerNew.value : f.issuer.value).trim(),
98
112
  email: f.email.value.trim(),
99
113
  };
100
114
  if (mode === 'new') a.podName = f.podName.value.trim() || a.handle;
@@ -123,6 +137,8 @@ function onEdit() {
123
137
  const mode = state.resumable ? 'existing' : $('form').elements.mode.value;
124
138
  $('row-podname').hidden = mode !== 'new';
125
139
  $('row-pod').hidden = mode === 'new';
140
+ $('row-issuer-new').hidden = mode !== 'new';
141
+ $('row-issuer-existing').hidden = mode === 'new';
126
142
  clearTimeout(editTimer);
127
143
  editTimer = setTimeout(preview, 150);
128
144
  }
@@ -194,6 +210,30 @@ async function watchRun() {
194
210
  state = json || state;
195
211
  paneForm();
196
212
  };
213
+ // A failure past the credential (a wrong pod answering 401 to the first
214
+ // write) leaves a credential that "Try again" can only re-use. Offer to
215
+ // discard it and re-enter the account and pod — only when there is one.
216
+ const { json: st } = await api('/setup/state');
217
+ const reenter = $('run-reenter');
218
+ reenter.hidden = !st?.hasCredential;
219
+ reenter.onclick = reEnter;
220
+ }
221
+
222
+ // Discard the saved credential (server confirms, then the full form returns).
223
+ // Destructive and unrecoverable — a minted credential is shown once — so it
224
+ // asks first.
225
+ async function reEnter() {
226
+ if (!confirm('Discard the saved account credential and enter the account and pod again?\n\n'
227
+ + 'The old credential is left on your account — revoke it from the account dashboard if you want it gone.')) return;
228
+ const { status, json } = await postJson('/setup/reset', {});
229
+ if (status !== 200) {
230
+ const box = $('pane-form').hidden ? $('run-error') : $('form-error');
231
+ box.textContent = json?.error || `could not reset (HTTP ${status})`;
232
+ return;
233
+ }
234
+ const { json: st } = await api('/setup/state');
235
+ state = st || {};
236
+ paneForm();
197
237
  }
198
238
 
199
239
  function renderRun(run) {