fedipod-server 0.7.0 → 0.9.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.
@@ -148,6 +191,8 @@ function render() {
148
191
  // A person gates followers here; a group's gate is the joins control on its
149
192
  // kind row, so the row would be a second switch for the same thing.
150
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;
151
196
  for (const [k, v, link] of rows) {
152
197
  if (!v) continue;
153
198
  const dt = document.createElement('dt');
@@ -207,27 +252,19 @@ function render() {
207
252
  UPDATE_WORD.textContent = words.join('; ');
208
253
  UPDATE_GO.hidden = !u?.available;
209
254
  }
210
- // Connected: the account itself, then the way out. Not: a form on the page.
211
- 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') {
212
258
  dd.textContent = '';
213
- if (config.atproto?.connected) {
214
- const a = document.createElement('a');
215
- a.href = `https://bsky.app/profile/${config.atproto.handle}`;
216
- a.textContent = `@${config.atproto.handle}`;
217
- a.target = '_blank';
218
- a.rel = 'noopener';
219
- a.title = ` Open this account's Bluesky page in a new tab`;
220
- dd.append(a, ' ', BSKY_CTL);
221
- BSKY_CTL.hidden = false;
222
- BSKY_CONNECT_CTL.hidden = true;
223
- BSKY_CROSSPOST.value = config.atproto.crossPost ? 'on' : 'off';
224
- } else {
225
- dd.append(BSKY_CONNECT_CTL);
226
- BSKY_CONNECT_CTL.hidden = false;
227
- BSKY_CTL.hidden = true;
228
- }
259
+ dd.append(IDENT_CTL);
260
+ IDENT_CTL.hidden = false;
261
+ kids = identityRows();
229
262
  }
230
263
  facts.append(dt, dd);
264
+ if (kids) {
265
+ for (const [ckt, ckd] of kids) facts.append(ckt, ckd);
266
+ kids = null;
267
+ }
231
268
  }
232
269
  if (!config.address) {
233
270
  const p = document.createElement('p');
@@ -420,9 +457,17 @@ function setRail(closed) {
420
457
  $('rail-toggle').addEventListener('click', () => setRail(!$('rail').classList.contains('closed')));
421
458
  if (matchMedia('(max-width: 47rem)').matches) setRail(true);
422
459
 
423
- $('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', () => {
424
463
  closePanels();
425
- 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');
426
471
  });
427
472
 
428
473
  let bskyBusy = false;
@@ -443,6 +488,20 @@ $('bsky-form').addEventListener('submit', async (ev) => {
443
488
  }
444
489
  } finally { bskyBusy = false; }
445
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
+
446
505
  $('bsky-disconnect').addEventListener('click', async () => {
447
506
  if (bskyBusy) return;
448
507
  bskyBusy = true;
@@ -133,6 +133,9 @@ dl { margin: .5rem 0; } /* grid only above 34rem — see the media query
133
133
  gap: .6rem .9rem; align-items: baseline; }
134
134
  }
135
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; }
136
139
  dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
137
140
  .rows { list-style: none; padding: 0; margin: .3rem 0; }
138
141
  .rows li { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center;
@@ -216,16 +219,16 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
216
219
  <label class="choice"><input type="radio" name="newKind" value="person" checked>
217
220
  A <strong>person</strong> — an account you read and post from</label>
218
221
  <label class="choice"><input type="radio" name="newKind" value="group">
219
- A <strong>group</strong> of fediverse users you manage</label>
222
+ A <strong>group</strong> of Fediverse users you manage</label>
220
223
  </fieldset>
221
224
 
222
225
  <fieldset>
223
226
  <legend>Actor's Handle</legend>
224
227
  <label for="new-handle">Handle</label>
225
228
  <input type="text" id="new-handle" autocomplete="off"
226
- placeholder="handle part of fediverse address: @HANDLE@server"
229
+ placeholder="handle part of Fediverse address: @HANDLE@server"
227
230
  aria-describedby="new-handle-hint">
228
- <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.
229
232
  Letters, digits, hyphens, and underscores only.</p>
230
233
  </fieldset>
231
234
 
@@ -303,8 +306,8 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
303
306
  <option value="auto">accepted automatically</option>
304
307
  </select>
305
308
  </span>
306
- <span id="bsky-connect-ctl" hidden>
307
- <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>
308
311
  </span>
309
312
  <span id="bsky-ctl" hidden>
310
313
  <select id="bsky-crosspost" aria-label="Bluesky crossposting" title=" Whether your public posts also go to the Bluesky account">
@@ -344,7 +347,7 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
344
347
  on arrival. Shown only when something is actually waiting. -->
345
348
  <div id="block-modqueue" hidden>
346
349
  <h3>Moderation requests <span class="muted" id="modqueue-count"></span></h3>
347
- <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
348
351
  sent it, so nothing here has been acted on.</p>
349
352
  <ul class="rows" role="list" id="modqueue"></ul>
350
353
  </div>
@@ -423,20 +426,46 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
423
426
  <button id="bsky-connect" type="submit" class="primary" title=" Sign in to the Bluesky account with the app password">Connect</button>
424
427
  </p>
425
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>
426
455
  <!-- Every consequence is written here rather than assembled in script: the
427
456
  page asking "are you sure" has to say what it is sure about, and this is
428
457
  the only record of it. Script picks which block to show, nothing else. -->
429
458
  <!-- The gateway popups: attach proves the pod with this agent's own credential;
430
459
  detach republishes the pod's own inbox. -->
431
460
  <form id="gateway-attach-form" hidden>
432
- <p class="hint">A gateway filters your fediverse mail before it reaches your pod: each
461
+ <p class="hint">A gateway filters your Fediverse mail before it reaches your pod: each
433
462
  delivery is verified at its door, spam is dropped, and the rest lands in your pod as
434
463
  before. Attaching proves the pod with this agent's own credential — no password
435
464
  leaves this machine.</p>
436
465
  <label for="gw-front">gateway</label>
437
466
  <input type="url" id="gw-front" placeholder="https://fedipod.net" autocomplete="off">
438
467
  <fieldset>
439
- <legend>Your fediverse handle</legend>
468
+ <legend>Your Fediverse handle</legend>
440
469
  <label class="choice"><input type="radio" name="gwShape" value="pod" checked>
441
470
  Keep my pod-based handle: <strong id="gw-pod-preview"></strong><br>
442
471
  <span class="hint">Your address stays tied to your own pod — you can drop the
@@ -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) {
@@ -0,0 +1,133 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>FediPod — accounts on this server</title>
7
+ <style>
8
+ :root { color-scheme: light dark; --line:#d9d4c7; --dim:#45443d; --accent:#6a8a5a; }
9
+ @media (prefers-color-scheme: dark) { :root { --dim:#c2c0b4; } }
10
+ body { font: 19px/1.6 system-ui, sans-serif; max-width: 44rem; margin: 2rem auto; padding: 0 1.1rem; }
11
+ h1 { font-size: 1.7rem; margin: 0 0 .2rem; }
12
+ p.lede { color: var(--dim); margin-top: 0; }
13
+ label { display: block; font-weight: 600; margin: .8rem 0 .2rem; }
14
+ input[type=url] { width: 100%; padding: .5rem .6rem; font: inherit;
15
+ border: 1px solid var(--line); border-radius: 8px; box-sizing: border-box; }
16
+ .hint { color: var(--dim); font-size: .95rem; }
17
+ .primary { background: var(--accent); color: #fff; border: none; border-radius: 9px;
18
+ padding: .6rem 1.1rem; font: inherit; cursor: pointer; }
19
+ .primary[disabled] { opacity: .5; cursor: not-allowed; }
20
+ button { font: inherit; cursor: pointer; }
21
+ [hidden] { display: none !important; }
22
+ table { border-collapse: collapse; width: 100%; margin: 1.2rem 0; }
23
+ th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(--line); }
24
+ th { font-weight: 600; }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <h1>Accounts on this server</h1>
29
+ <p class="lede">Every name this server answers for: accounts whose identity lives here, and
30
+ accounts that only use it as their gateway. Sign in as the server's admin to see them.</p>
31
+
32
+ <form id="roster-form">
33
+ <p class="field">
34
+ <label for="roster-issuer">Your identity provider</label>
35
+ <input type="url" id="roster-issuer" autocomplete="off" value="https://solidcommunity.net">
36
+ </p>
37
+ <p class="actions">
38
+ <button type="submit" id="roster-signin" class="primary">Sign in &amp; list accounts</button>
39
+ </p>
40
+ <p class="hint" id="roster-note" hidden></p>
41
+ </form>
42
+
43
+ <table id="roster-table" hidden>
44
+ <thead>
45
+ <tr><th>Account</th><th>Kind</th><th>Identity</th><th>Pod</th><th>Remove</th></tr>
46
+ </thead>
47
+ <tbody id="roster-rows"></tbody>
48
+ </table>
49
+
50
+ <script type="module">
51
+ const $ = (id) => document.getElementById(id);
52
+ const note = (text) => { const n = $('roster-note'); n.hidden = !text; n.textContent = text || ''; };
53
+
54
+ // The Solid-OIDC client for the sign-in round trip. Constructed once; the
55
+ // redirect state lives in this tab's sessionStorage, so the same session
56
+ // finishes the login on the way back.
57
+ let session = null;
58
+ try {
59
+ const { SessionCore } = await import('/solid-oidc-client.js');
60
+ session = new SessionCore({ redirect_uris: [location.origin + '/admin'], client_name: 'FediPod admin' });
61
+ } catch { /* leave null — the "did not load" note fires */ }
62
+
63
+ $('roster-issuer').addEventListener('input', () => {
64
+ $('roster-signin').disabled = !/^https?:\/\/\S+/.test($('roster-issuer').value.trim());
65
+ });
66
+
67
+ $('roster-form').addEventListener('submit', (ev) => {
68
+ ev.preventDefault();
69
+ if (!session) { note('the sign-in library did not load — reload and try again'); return; }
70
+ session.login($('roster-issuer').value.trim(), location.origin + '/admin')
71
+ .catch((e) => note('sign-in failed to start: ' + e.message));
72
+ });
73
+
74
+ // Back from the identity provider: read the roster with the proven login.
75
+ (async () => {
76
+ if (!session) return;
77
+ await session.handleRedirectFromLogin().catch(() => {});
78
+ if (!session.isActive) return;
79
+ const webId = session.webId;
80
+
81
+ // Signed fetches build a DPoP proof from the URL, so it must be absolute.
82
+ const load = async () => {
83
+ let res;
84
+ try { res = await session.authFetch(location.origin + '/api/roster'); }
85
+ catch (e) { note('the roster request failed: ' + e.message); return; }
86
+ const d = await res.json().catch(() => ({}));
87
+ if (res.status === 403) { note('signed in as ' + webId + ', which is not this server’s admin'); return; }
88
+ if (res.status === 501) { note('this server names no admin — set FEDIPOD_ADMIN_WEBID and redeploy'); return; }
89
+ if (res.status !== 200) { note('roster unavailable: ' + (d.error || 'HTTP ' + res.status)); return; }
90
+ const rows = $('roster-rows');
91
+ rows.textContent = '';
92
+ for (const a of d.accounts || []) {
93
+ const tr = document.createElement('tr');
94
+ const cell = (child) => { const td = document.createElement('td'); td.append(child); tr.append(td); };
95
+ cell(a.address);
96
+ cell(a.kind);
97
+ cell(a.fronted ? 'lives here' : 'gateway only');
98
+ const pod = document.createElement('a');
99
+ pod.href = a.podHome; pod.textContent = new URL(a.podHome).host;
100
+ cell(pod);
101
+ const rm = document.createElement('button');
102
+ rm.type = 'button'; rm.textContent = 'Remove';
103
+ rm.onclick = () => revoke(a);
104
+ cell(rm);
105
+ rows.append(tr);
106
+ }
107
+ $('roster-table').hidden = false;
108
+ note((d.accounts || []).length + ' account(s) on ' + d.host);
109
+ };
110
+
111
+ const revoke = async (a) => {
112
+ if (!confirm('Remove ' + a.address + ' from this server? The name stops resolving here; nothing on their pod is touched.')) return;
113
+ let res;
114
+ try {
115
+ res = await session.authFetch(location.origin + '/api/revoke', {
116
+ method: 'POST', headers: { 'content-type': 'application/json' },
117
+ body: JSON.stringify({ handle: a.handle }),
118
+ });
119
+ } catch (e) { note('the remove request failed: ' + e.message); return; }
120
+ const d = await res.json().catch(() => ({}));
121
+ if (res.status !== 200) { note('remove refused: ' + (d.error || 'HTTP ' + res.status)); return; }
122
+ if (!d.removed) { note(a.address + ' stays: ' + d.reason); return; }
123
+ await load();
124
+ note('removed ' + a.address);
125
+ };
126
+
127
+ note('signed in as ' + webId + ' — reading the roster…');
128
+ await load();
129
+ })();
130
+ </script>
131
+ <i>(cc) 4.0 By, Jeff Zucker, 2026</i>
132
+ </body>
133
+ </html>
@@ -19,8 +19,8 @@
19
19
  </style>
20
20
  </head>
21
21
  <body>
22
- <h1>FediPod - join the fediverse from a Solid pod.</h1>
23
- <p class="lede">Welcome to <b>FediPod</b>! Here, you can get a free pod and fediverse account or link to your existing pod/account.</p>
22
+ <h1>FediPod - join the Fediverse from a Solid pod.</h1>
23
+ <p class="lede">Welcome to <b>FediPod</b>! Here, you can get a free pod and Fediverse account or link to your existing pod/account.</p>
24
24
 
25
25
  <p class="hint" id="current-version" hidden>Current FediPod: <strong id="current-version-num"></strong><br>
26
26
  — install or update an existing install with <code id="current-version-cmd"></code></p>
@@ -25,7 +25,7 @@
25
25
  </head>
26
26
  <body>
27
27
  <h1>Run your identity on this server</h1>
28
- <p class="lede">If your pod is hosted by this server, the server can run your fediverse identity
28
+ <p class="lede">If your pod is hosted by this server, the server can run your Fediverse identity
29
29
  for you: it accepts follows, delivers your posts and serves your Mastodon app, with nothing for
30
30
  you to keep running. Sign in with your pod to prove it is yours.</p>
31
31
 
@@ -45,10 +45,18 @@ you to keep running. Sign in with your pod to prove it is yours.</p>
45
45
  <p class="hint" id="run-note" hidden></p>
46
46
  </form>
47
47
 
48
- <script src="/solid-client-authn.bundle.js"></script>
49
- <script>
48
+ <script type="module">
50
49
  const $ = (id) => document.getElementById(id);
51
50
 
51
+ // The Solid-OIDC client for the sign-in round trip. Constructed once; the
52
+ // redirect state (PKCE, csrf) lives in this tab's sessionStorage, so the same
53
+ // session finishes the login on the way back.
54
+ let session = null;
55
+ try {
56
+ const { SessionCore } = await import('/solid-oidc-client.js');
57
+ session = new SessionCore({ redirect_uris: [location.origin + '/run'], client_name: 'FediPod gateway' });
58
+ } catch { /* leave null — the "did not load" notes below fire */ }
59
+
52
60
  // Offered only where this server actually runs identities. An
53
61
  // unauthenticated probe tells the two apart: 501 = not offered here,
54
62
  // anything else = offered (a real opt-in still needs the signed-in proof).
@@ -85,36 +93,32 @@ you to keep running. Sign in with your pod to prove it is yours.</p>
85
93
  $('run-issuer').addEventListener('input', runFormCheck);
86
94
 
87
95
  const runStart = (action) => {
88
- const auth = window.solidClientAuthentication;
89
96
  const n = $('run-note');
90
- if (!auth) { n.hidden = false; n.textContent = 'the sign-in library did not load — reload and try again'; return; }
97
+ if (!session) { n.hidden = false; n.textContent = 'the sign-in library did not load — reload and try again'; return; }
91
98
  const u = new URL($('run-pod-url').value.trim());
92
99
  if (u.pathname === '') u.pathname = '/';
93
100
  if (!u.pathname.endsWith('/')) u.pathname += '/';
94
101
  u.search = ''; u.hash = '';
95
102
  sessionStorage.setItem('fp-run', JSON.stringify({ podBase: u.href, action }));
96
- auth.login({
97
- oidcIssuer: $('run-issuer').value.trim(),
98
- redirectUrl: location.origin + '/run',
99
- clientName: 'FediPod gateway',
100
- }).catch((e) => { n.hidden = false; n.textContent = 'sign-in failed to start: ' + e.message; });
103
+ session.login($('run-issuer').value.trim(), location.origin + '/run')
104
+ .catch((e) => { n.hidden = false; n.textContent = 'sign-in failed to start: ' + e.message; });
101
105
  };
102
106
  $('run-continue').onclick = () => runStart('opt-in');
103
107
  $('run-leave').onclick = () => runStart('opt-out');
104
108
 
105
109
  // Back from the identity provider: finish the opt-in with the proven login.
106
110
  (async () => {
107
- const auth = window.solidClientAuthentication;
108
- if (!auth) return;
109
- const info = await auth.handleIncomingRedirect().catch(() => null);
111
+ if (!session) return;
112
+ await session.handleRedirectFromLogin().catch(() => {});
110
113
  const pending = sessionStorage.getItem('fp-run');
111
- if (!info?.isLoggedIn || !pending) return;
114
+ if (!session.isActive || !pending) return;
112
115
  sessionStorage.removeItem('fp-run');
113
116
  const p = JSON.parse(pending);
114
117
  const n = $('run-note');
115
118
  n.hidden = false;
116
- n.textContent = 'signed in as ' + info.webId + ' — asking the server…';
117
- const res = await auth.fetch('/api/agent', {
119
+ n.textContent = 'signed in as ' + session.webId + ' — asking the server…';
120
+ // The signed fetch builds a DPoP proof from the URL, so it must be absolute.
121
+ const res = await session.authFetch(location.origin + '/api/agent', {
118
122
  method: 'POST', headers: { 'content-type': 'application/json' },
119
123
  body: JSON.stringify({ action: p.action, podBase: p.podBase }),
120
124
  }).catch(() => null);