@bakapiano/ccsm 0.18.5 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/devices.js +19 -5
- package/lib/tunnel.js +151 -0
- package/package.json +1 -1
- package/public/css/feedback.css +72 -0
- package/public/css/responsive.css +18 -8
- package/public/css/sidebar.css +11 -5
- package/public/css/widgets.css +714 -1
- package/public/index.html +8 -1
- package/public/js/api.js +23 -7
- package/public/js/backend.js +26 -0
- package/public/js/components/App.js +2 -0
- package/public/js/components/OfflineBanner.js +9 -35
- package/public/js/components/PendingApprovalOverlay.js +44 -2
- package/public/js/components/RestartOverlay.js +36 -0
- package/public/js/components/Sidebar.js +1 -1
- package/public/js/components/TerminalView.js +18 -7
- package/public/js/icons.js +11 -6
- package/public/js/main.js +17 -0
- package/public/js/pages/ConfigurePage.js +43 -25
- package/public/js/pages/RemotePage.js +318 -152
- package/public/js/state.js +6 -0
- package/server.js +24 -2
|
@@ -13,7 +13,7 @@ import { api } from '../api.js';
|
|
|
13
13
|
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
14
14
|
import { setToast } from '../toast.js';
|
|
15
15
|
import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
|
|
16
|
-
import { IconCopy, IconRecycle, IconExternal, IconInfo, IconPencil, IconClose } from '../icons.js';
|
|
16
|
+
import { IconCopy, IconRecycle, IconExternal, IconInfo, IconPencil, IconClose, IconCloudflareColor, IconMicrosoftColor } from '../icons.js';
|
|
17
17
|
import { fmtAgo } from '../util.js';
|
|
18
18
|
import { clockTick } from '../state.js';
|
|
19
19
|
|
|
@@ -61,7 +61,8 @@ function DeviceRow({ d, kind, onApprove, onReject, onRevoke, onRename, onDelete
|
|
|
61
61
|
<div class=${`remote-device is-${kind}`}>
|
|
62
62
|
<div class="remote-device-main">
|
|
63
63
|
<div class="remote-device-label">
|
|
64
|
-
${d.
|
|
64
|
+
${d.code ? html`<code class="remote-device-code" title="Match this with the code shown on the requesting device">${d.code}</code>` : null}
|
|
65
|
+
<span class="remote-device-name">${d.label || 'Unknown device'}</span>
|
|
65
66
|
${kind === 'approved' ? html`
|
|
66
67
|
<button class="icon-btn" title="Rename" onClick=${onRename}><${IconPencil} /></button>
|
|
67
68
|
` : null}
|
|
@@ -88,33 +89,174 @@ function DeviceRow({ d, kind, onApprove, onReject, onRevoke, onRename, onDelete
|
|
|
88
89
|
</div>`;
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
function
|
|
92
|
+
function ProviderTile({ id, label, hint, icon, selected, disabled, onSelect }) {
|
|
92
93
|
return html`
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
<button type="button"
|
|
95
|
+
class=${`provider-tile${selected ? ' is-selected' : ''}${disabled ? ' is-disabled' : ''}`}
|
|
96
|
+
aria-pressed=${selected ? 'true' : 'false'}
|
|
97
|
+
disabled=${disabled}
|
|
98
|
+
onClick=${() => !disabled && onSelect(id)}>
|
|
99
|
+
<span class="provider-tile-icon">${icon}</span>
|
|
100
|
+
<span class="provider-tile-body">
|
|
101
|
+
<span class="provider-tile-label">${label}</span>
|
|
102
|
+
${hint ? html`<span class="provider-tile-hint">${hint}</span>` : null}
|
|
103
|
+
</span>
|
|
104
|
+
</button>`;
|
|
98
105
|
}
|
|
99
106
|
|
|
100
|
-
function ProviderStatus({ id, info, onInstall, onLogin }) {
|
|
101
|
-
if (!info) return html`<span class="muted">probing…</span>`;
|
|
107
|
+
function ProviderStatus({ id, info, onInstall, onLogin, loggingIn }) {
|
|
108
|
+
if (!info) return html`<span class="provider-status-muted">probing…</span>`;
|
|
102
109
|
if (!info.installed) {
|
|
103
110
|
return html`
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
<div class="provider-status">
|
|
112
|
+
<span class="provider-status-state is-warn">
|
|
113
|
+
<span class="provider-status-dot is-warn"></span> Not installed
|
|
114
|
+
</span>
|
|
115
|
+
<button type="button" class="action small" onClick=${onInstall}>
|
|
116
|
+
Install via winget
|
|
117
|
+
</button>
|
|
118
|
+
</div>`;
|
|
119
|
+
}
|
|
120
|
+
if (id !== 'devtunnel') {
|
|
121
|
+
// Cloudflare quick tunnel · no account state, just version.
|
|
122
|
+
return html`
|
|
123
|
+
<div class="provider-status">
|
|
124
|
+
<span class="provider-status-state is-ok">
|
|
125
|
+
<span class="provider-status-dot is-ok"></span> Ready · anonymous
|
|
126
|
+
</span>
|
|
127
|
+
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
128
|
+
</div>`;
|
|
129
|
+
}
|
|
130
|
+
// devtunnel · signed-in / signed-out states each get their own row.
|
|
131
|
+
if (!info.loggedIn) {
|
|
132
|
+
// While a sign-in flow is in flight the signin-card below this
|
|
133
|
+
// row carries its own header + spinner + cancel button. Showing
|
|
134
|
+
// a second "Signing in…" CTA here is just noise — collapse the
|
|
135
|
+
// whole signed-out block down to a thin status line until the
|
|
136
|
+
// card resolves one way or the other.
|
|
137
|
+
if (loggingIn) {
|
|
138
|
+
return html`
|
|
139
|
+
<div class="provider-status">
|
|
140
|
+
<span class="provider-status-state">
|
|
141
|
+
<span class="provider-status-dot"></span> Signing in…
|
|
142
|
+
</span>
|
|
143
|
+
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
144
|
+
</div>`;
|
|
145
|
+
}
|
|
146
|
+
return html`
|
|
147
|
+
<div class="provider-status">
|
|
148
|
+
<span class="provider-status-state is-warn">
|
|
149
|
+
<span class="provider-status-dot is-warn"></span> Not signed in
|
|
150
|
+
</span>
|
|
151
|
+
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
152
|
+
<button type="button" class="btn-signin-microsoft provider-status-signin" onClick=${onLogin}>
|
|
153
|
+
<${IconMicrosoftColor} size=${18} />
|
|
154
|
+
<span>Sign in with Microsoft</span>
|
|
155
|
+
</button>
|
|
156
|
+
</div>`;
|
|
108
157
|
}
|
|
109
|
-
const tag = id === 'devtunnel'
|
|
110
|
-
? (info.loggedIn ? html`signed in as <code>${info.user}</code>` : html`<span class="warn">not signed in</span>`)
|
|
111
|
-
: html`anonymous`;
|
|
112
158
|
return html`
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
159
|
+
<div class="provider-status">
|
|
160
|
+
<span class="provider-status-state is-ok">
|
|
161
|
+
<span class="provider-status-dot is-ok"></span> Signed in
|
|
162
|
+
</span>
|
|
163
|
+
<span class="provider-status-user">${info.user}</span>
|
|
164
|
+
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
165
|
+
<button type="button" class="action subtle small provider-status-switch" onClick=${onLogin}>
|
|
166
|
+
Switch
|
|
167
|
+
</button>
|
|
168
|
+
</div>`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Device-code login panel. Shown when a `devtunnel user login -d` flow
|
|
172
|
+
// is in flight or just finished. The user clicks Open, signs in on
|
|
173
|
+
// microsoft.com, and we flip to "Signed in" automatically when the
|
|
174
|
+
// child exits 0 (the probe cache gets invalidated on exit).
|
|
175
|
+
function DevtunnelLoginPanel({ login, onCancel, onDismiss, onRetry }) {
|
|
176
|
+
if (!login) return null;
|
|
177
|
+
const { status, url, code, error, user, lines } = login;
|
|
178
|
+
const running = status === 'running';
|
|
179
|
+
const done = status === 'done';
|
|
180
|
+
const failed = status === 'error';
|
|
181
|
+
const canceled = status === 'canceled';
|
|
182
|
+
const host = (() => { try { return new URL(url).host; } catch { return url || ''; } })();
|
|
183
|
+
return html`
|
|
184
|
+
<div class=${`signin-card is-${status}`}>
|
|
185
|
+
${running ? html`
|
|
186
|
+
<div class="signin-card-header">
|
|
187
|
+
<span class="signin-card-spinner" aria-hidden="true"></span>
|
|
188
|
+
<span class="signin-card-eyebrow">Signing in to Microsoft</span>
|
|
189
|
+
<button type="button" class="signin-card-cancel" onClick=${onCancel} title="Cancel sign-in">
|
|
190
|
+
<${IconClose} /> Cancel
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="signin-card-code-block">
|
|
194
|
+
<span class="signin-card-code-label">Device code</span>
|
|
195
|
+
<div class="signin-card-code-row">
|
|
196
|
+
${code ? html`
|
|
197
|
+
<code class="signin-card-code">${code}</code>
|
|
198
|
+
<button type="button" class="action subtle small signin-card-code-copy"
|
|
199
|
+
title="Copy code" onClick=${() => copy(code)}>
|
|
200
|
+
<${IconCopy} />
|
|
201
|
+
</button>
|
|
202
|
+
` : html`<span class="signin-card-code-pending">generating…</span>`}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
<ol class="signin-card-steps">
|
|
206
|
+
<li>
|
|
207
|
+
${url ? html`
|
|
208
|
+
<a class="signin-card-open" href=${url} target="_blank" rel="noreferrer noopener">
|
|
209
|
+
<${IconExternal} /> Open <span class="signin-card-host">${host}</span>
|
|
210
|
+
</a>
|
|
211
|
+
` : html`<span class="signin-card-step-muted">Waiting for sign-in URL…</span>`}
|
|
212
|
+
</li>
|
|
213
|
+
<li>Paste the device code shown above.</li>
|
|
214
|
+
<li>Pick an account and approve — this page flips automatically.</li>
|
|
215
|
+
</ol>
|
|
216
|
+
` : null}
|
|
217
|
+
${done ? html`
|
|
218
|
+
<div class="signin-card-result is-ok">
|
|
219
|
+
<span class="signin-card-result-icon" aria-hidden="true">✓</span>
|
|
220
|
+
<div class="signin-card-result-body">
|
|
221
|
+
<div class="signin-card-result-title">Signed in</div>
|
|
222
|
+
<div class="signin-card-result-meta">
|
|
223
|
+
${user ? html`as <code>${user}</code> · ` : ''}you can start the tunnel now.
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
<button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
|
|
227
|
+
</div>
|
|
228
|
+
` : null}
|
|
229
|
+
${failed ? html`
|
|
230
|
+
<div class="signin-card-result is-error">
|
|
231
|
+
<span class="signin-card-result-icon" aria-hidden="true">!</span>
|
|
232
|
+
<div class="signin-card-result-body">
|
|
233
|
+
<div class="signin-card-result-title">Sign-in failed</div>
|
|
234
|
+
<div class="signin-card-result-meta">${error || 'devtunnel exited with an error.'}</div>
|
|
235
|
+
</div>
|
|
236
|
+
<div class="signin-card-result-actions">
|
|
237
|
+
<button type="button" class="action small" onClick=${onRetry}>Try again</button>
|
|
238
|
+
<button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
` : null}
|
|
242
|
+
${canceled ? html`
|
|
243
|
+
<div class="signin-card-result is-muted">
|
|
244
|
+
<div class="signin-card-result-body">
|
|
245
|
+
<div class="signin-card-result-title">Sign-in canceled</div>
|
|
246
|
+
</div>
|
|
247
|
+
<div class="signin-card-result-actions">
|
|
248
|
+
<button type="button" class="action small" onClick=${onRetry}>Try again</button>
|
|
249
|
+
<button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
` : null}
|
|
253
|
+
${lines && lines.length ? html`
|
|
254
|
+
<details class="signin-card-log">
|
|
255
|
+
<summary>CLI output · ${lines.length} ${lines.length === 1 ? 'line' : 'lines'}</summary>
|
|
256
|
+
<pre>${lines.join('\n')}</pre>
|
|
257
|
+
</details>
|
|
258
|
+
` : null}
|
|
259
|
+
</div>`;
|
|
118
260
|
}
|
|
119
261
|
|
|
120
262
|
export function RemotePage() {
|
|
@@ -124,12 +266,6 @@ export function RemotePage() {
|
|
|
124
266
|
const [token, setTokenLocal] = useState('');
|
|
125
267
|
const [busy, setBusy] = useState(false);
|
|
126
268
|
const [deviceList, setDeviceList] = useState([]);
|
|
127
|
-
// First /api/tunnel/status round-trip is the slow one — even with
|
|
128
|
-
// 30s server-side cache + parallel probe, a cold call shells out to
|
|
129
|
-
// where.exe / --version / `devtunnel user show` and adds ~700ms.
|
|
130
|
-
// We hide the form behind a spinner during that window so the user
|
|
131
|
-
// doesn't see empty radios + placeholders that suddenly fill in.
|
|
132
|
-
const [loading, setLoading] = useState(true);
|
|
133
269
|
const pollRef = useRef(null);
|
|
134
270
|
|
|
135
271
|
async function refresh() {
|
|
@@ -149,7 +285,6 @@ export function RemotePage() {
|
|
|
149
285
|
return cur || 'cloudflared';
|
|
150
286
|
});
|
|
151
287
|
} catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
|
|
152
|
-
finally { setLoading(false); }
|
|
153
288
|
}
|
|
154
289
|
|
|
155
290
|
useEffect(() => {
|
|
@@ -191,13 +326,19 @@ export function RemotePage() {
|
|
|
191
326
|
}
|
|
192
327
|
|
|
193
328
|
async function onStart() {
|
|
194
|
-
if (!token || token.length < 8) {
|
|
195
|
-
setToast('Token must be at least 8 characters', 'error');
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
329
|
setBusy(true);
|
|
199
330
|
try {
|
|
200
|
-
|
|
331
|
+
// Auto-mint a token if the user hasn't generated one yet — the
|
|
332
|
+
// registration token is now an implementation detail of starting
|
|
333
|
+
// a tunnel rather than a separate setup step.
|
|
334
|
+
let tok = token;
|
|
335
|
+
if (!tok || tok.length < 8) {
|
|
336
|
+
tok = genToken();
|
|
337
|
+
setTokenLocal(tok);
|
|
338
|
+
try { await api('POST', '/api/tunnel/token', { token: tok }); }
|
|
339
|
+
catch (e) { /* the start call below will fail too — surface that */ }
|
|
340
|
+
}
|
|
341
|
+
const s = await api('POST', '/api/tunnel/start', { provider, token: tok });
|
|
201
342
|
setStatus(s);
|
|
202
343
|
setToast(s.url ? 'Tunnel up' : 'Tunnel starting · URL appearing shortly', 'ok');
|
|
203
344
|
} catch (e) {
|
|
@@ -239,10 +380,25 @@ export function RemotePage() {
|
|
|
239
380
|
} catch (e) { setToast(`install failed · ${e.message}`, 'error'); }
|
|
240
381
|
}
|
|
241
382
|
function onLogin(p) {
|
|
242
|
-
if (p
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
383
|
+
if (p !== 'devtunnel') return;
|
|
384
|
+
// Kick off `devtunnel user login -d` on the host and let the
|
|
385
|
+
// panel below render the device code + URL. /status polling
|
|
386
|
+
// (every 2.5s) picks up the eventual outcome.
|
|
387
|
+
(async () => {
|
|
388
|
+
try {
|
|
389
|
+
const r = await api('POST', '/api/tunnel/devtunnel/login', { mode: 'microsoft' });
|
|
390
|
+
if (r?.login) setStatus((cur) => cur ? { ...cur, login: r.login } : cur);
|
|
391
|
+
refresh();
|
|
392
|
+
} catch (e) { setToast(`sign-in failed · ${e.message}`, 'error'); }
|
|
393
|
+
})();
|
|
394
|
+
}
|
|
395
|
+
async function onLoginCancel() {
|
|
396
|
+
try { await api('POST', '/api/tunnel/devtunnel/login/cancel'); refresh(); }
|
|
397
|
+
catch (e) { setToast(`cancel failed · ${e.message}`, 'error'); }
|
|
398
|
+
}
|
|
399
|
+
async function onLoginDismiss() {
|
|
400
|
+
try { await api('POST', '/api/tunnel/devtunnel/login/dismiss'); refresh(); }
|
|
401
|
+
catch (e) { setToast(`dismiss failed · ${e.message}`, 'error'); }
|
|
246
402
|
}
|
|
247
403
|
|
|
248
404
|
const running = status?.running;
|
|
@@ -251,17 +407,13 @@ export function RemotePage() {
|
|
|
251
407
|
const log = status?.log || [];
|
|
252
408
|
const cf = status?.providers?.cloudflared;
|
|
253
409
|
const dt = status?.providers?.devtunnel;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
<p>Probing tunnel providers…</p>
|
|
262
|
-
</div>
|
|
263
|
-
</div>`;
|
|
264
|
-
}
|
|
410
|
+
const dtLogin = status?.login || null;
|
|
411
|
+
const dtLoggingIn = dtLogin?.status === 'running';
|
|
412
|
+
// First /api/tunnel/status round-trip is the slow one — even with
|
|
413
|
+
// the 30s server-side cache + parallel probe, a cold call shells
|
|
414
|
+
// out and adds ~700ms. We render the full page immediately and let
|
|
415
|
+
// individual fields show their own "probing…" state instead of
|
|
416
|
+
// gating the whole panel behind a centered spinner.
|
|
265
417
|
|
|
266
418
|
return html`
|
|
267
419
|
<${PageTitleBar} title="Remote" />
|
|
@@ -269,49 +421,142 @@ export function RemotePage() {
|
|
|
269
421
|
|
|
270
422
|
<${Section}
|
|
271
423
|
title="Connection"
|
|
272
|
-
meta=${html`Pick
|
|
424
|
+
meta=${html`Pick which CLI ccsm spawns for the tunnel.`}>
|
|
273
425
|
<div class="config-grid">
|
|
274
426
|
<div class="field">
|
|
275
427
|
<span class="label">Provider</span>
|
|
276
|
-
<div class="
|
|
277
|
-
<${
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
selected=${provider === '
|
|
428
|
+
<div class="provider-tile-row">
|
|
429
|
+
<${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
|
|
430
|
+
hint="Anonymous · no login"
|
|
431
|
+
icon=${html`<${IconCloudflareColor} size=${32} />`}
|
|
432
|
+
selected=${provider === 'cloudflared'}
|
|
433
|
+
disabled=${running}
|
|
434
|
+
onSelect=${setProvider} />
|
|
435
|
+
<${ProviderTile} id="devtunnel" label="Microsoft Dev Tunnel"
|
|
436
|
+
hint="Requires sign-in"
|
|
437
|
+
icon=${html`<${IconMicrosoftColor} size=${32} />`}
|
|
438
|
+
selected=${provider === 'devtunnel'}
|
|
439
|
+
disabled=${running}
|
|
440
|
+
onSelect=${setProvider} />
|
|
281
441
|
</div>
|
|
442
|
+
${running ? html`<span class="hint">Stop the tunnel to switch provider.</span>` : null}
|
|
282
443
|
</div>
|
|
283
|
-
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
444
|
+
${provider === 'cloudflared' ? html`
|
|
445
|
+
<div class="field">
|
|
446
|
+
<span class="label">Cloudflare Tunnel</span>
|
|
447
|
+
<div class="remote-status-line">
|
|
448
|
+
<${ProviderStatus} id="cloudflared" info=${cf}
|
|
449
|
+
onInstall=${() => onInstall('cloudflared')} />
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
` : null}
|
|
453
|
+
${provider === 'devtunnel' ? html`
|
|
454
|
+
<div class="field">
|
|
455
|
+
<span class="label">Microsoft Dev Tunnel</span>
|
|
456
|
+
<div class="remote-status-line">
|
|
457
|
+
<${ProviderStatus} id="devtunnel" info=${dt}
|
|
458
|
+
onInstall=${() => onInstall('devtunnel')}
|
|
459
|
+
onLogin=${() => onLogin('devtunnel')}
|
|
460
|
+
loggingIn=${dtLoggingIn} />
|
|
461
|
+
</div>
|
|
462
|
+
${dtLogin ? html`
|
|
463
|
+
<${DevtunnelLoginPanel}
|
|
464
|
+
login=${dtLogin}
|
|
465
|
+
onCancel=${onLoginCancel}
|
|
466
|
+
onDismiss=${onLoginDismiss}
|
|
467
|
+
onRetry=${() => onLogin('devtunnel')} />
|
|
468
|
+
` : null}
|
|
469
|
+
</div>
|
|
470
|
+
` : null}
|
|
471
|
+
</div>
|
|
472
|
+
</${Section}>
|
|
473
|
+
|
|
474
|
+
<${Section}
|
|
475
|
+
title="Tunnel"
|
|
476
|
+
meta=${running
|
|
477
|
+
? html`Provider <code>${status?.provider}</code> · started ${new Date(status.startedAt).toLocaleTimeString()}`
|
|
478
|
+
: html`Not running.`}>
|
|
479
|
+
${!running ? html`
|
|
480
|
+
<div class="tunnel-hero">
|
|
481
|
+
<div class="tunnel-hero-body">
|
|
482
|
+
<div class="tunnel-hero-title">Bring this backend online</div>
|
|
483
|
+
<div class="tunnel-hero-meta">
|
|
484
|
+
ccsm will spawn
|
|
485
|
+
<code>${provider === 'devtunnel' ? 'devtunnel' : 'cloudflared'}</code>
|
|
486
|
+
and wait for it to print a public URL.
|
|
487
|
+
</div>
|
|
288
488
|
</div>
|
|
489
|
+
<button type="button" class="action tunnel-hero-cta"
|
|
490
|
+
disabled=${busy}
|
|
491
|
+
onClick=${onStart}>
|
|
492
|
+
<${IconExternal} /> ${busy ? 'Starting…' : 'Start tunnel'}
|
|
493
|
+
</button>
|
|
289
494
|
</div>
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
<div class="
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
495
|
+
` : html`
|
|
496
|
+
<div class="tunnel-live">
|
|
497
|
+
<div class="tunnel-live-head">
|
|
498
|
+
<span class="tunnel-live-state">
|
|
499
|
+
<span class="tunnel-live-dot"></span>
|
|
500
|
+
Live
|
|
501
|
+
</span>
|
|
502
|
+
<span class="tunnel-live-divider">·</span>
|
|
503
|
+
<span class="tunnel-live-provider">${status?.provider === 'devtunnel' ? 'Microsoft Dev Tunnel' : 'Cloudflare Tunnel'}</span>
|
|
504
|
+
<span class="tunnel-live-divider">·</span>
|
|
505
|
+
<span class="tunnel-live-meta">since ${new Date(status.startedAt).toLocaleTimeString()}</span>
|
|
506
|
+
<button type="button" class="tunnel-stop-link"
|
|
507
|
+
disabled=${busy}
|
|
508
|
+
onClick=${onStop}>
|
|
509
|
+
<${IconClose} /> ${busy ? 'Stopping…' : 'Stop tunnel'}
|
|
510
|
+
</button>
|
|
296
511
|
</div>
|
|
512
|
+
${url ? html`
|
|
513
|
+
<div class="tunnel-share">
|
|
514
|
+
<div class="tunnel-share-label">Share URL</div>
|
|
515
|
+
<div class="tunnel-share-url">
|
|
516
|
+
<code class="tunnel-share-value">${share}</code>
|
|
517
|
+
<div class="tunnel-share-actions">
|
|
518
|
+
<button type="button" class="action small" onClick=${() => copy(share)}>
|
|
519
|
+
<${IconCopy} /> Copy
|
|
520
|
+
</button>
|
|
521
|
+
<a class="action small" href=${share} target="_blank" rel="noreferrer noopener">
|
|
522
|
+
<${IconExternal} /> Open
|
|
523
|
+
</a>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
<div class="tunnel-share-hint">
|
|
527
|
+
Send this to the remote device · token embedded, stripped from the URL on first arrival.
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
` : html`
|
|
531
|
+
<div class="tunnel-share is-waiting">
|
|
532
|
+
<div class="signin-card-spinner" aria-hidden="true"></div>
|
|
533
|
+
<span>Waiting for the CLI to print a public URL…</span>
|
|
534
|
+
</div>
|
|
535
|
+
`}
|
|
536
|
+
${log.length ? html`
|
|
537
|
+
<details class="remote-log tunnel-log">
|
|
538
|
+
<summary>CLI log · ${log.length} lines</summary>
|
|
539
|
+
<pre>${log.join('\n')}</pre>
|
|
540
|
+
</details>
|
|
541
|
+
` : null}
|
|
297
542
|
</div>
|
|
298
|
-
|
|
543
|
+
`}
|
|
299
544
|
</${Section}>
|
|
300
545
|
|
|
301
546
|
<${Section}
|
|
302
547
|
title="Registration token"
|
|
303
|
-
meta=${html`
|
|
548
|
+
meta=${html`Auto-generated. Only used to register new devices — approved devices keep working after a rotate.`}>
|
|
304
549
|
<div class="config-grid">
|
|
305
550
|
<div class="field">
|
|
306
551
|
<span class="label">Token</span>
|
|
307
552
|
<div class="remote-token-row">
|
|
308
553
|
<input type="text" class="input remote-token-input"
|
|
309
554
|
readonly
|
|
310
|
-
placeholder="
|
|
555
|
+
placeholder="auto-generated on first Start tunnel"
|
|
311
556
|
value=${token} />
|
|
312
|
-
<button type="button" class="action" title="
|
|
557
|
+
<button type="button" class="action" title="Mint a fresh token (invalidates outstanding share URLs)"
|
|
313
558
|
onClick=${onGenerateToken}>
|
|
314
|
-
<${IconRecycle} /> Generate
|
|
559
|
+
<${IconRecycle} /> ${token ? 'Rotate' : 'Generate'}
|
|
315
560
|
</button>
|
|
316
561
|
<button type="button" class="action"
|
|
317
562
|
disabled=${!token}
|
|
@@ -321,72 +566,16 @@ export function RemotePage() {
|
|
|
321
566
|
</div>
|
|
322
567
|
<span class="hint">
|
|
323
568
|
${(!status?.token && !token)
|
|
324
|
-
? html
|
|
569
|
+
? html`No token yet — one is minted automatically the first time you start a tunnel.`
|
|
325
570
|
: html`Active. Rotating it invalidates outstanding share URLs but doesn't kick out devices you've already approved.`}
|
|
326
571
|
</span>
|
|
327
572
|
</div>
|
|
328
573
|
</div>
|
|
329
574
|
</${Section}>
|
|
330
575
|
|
|
331
|
-
<${Section}
|
|
332
|
-
title="Tunnel"
|
|
333
|
-
meta=${running
|
|
334
|
-
? html`Provider <code>${status?.provider}</code> · started ${new Date(status.startedAt).toLocaleTimeString()}`
|
|
335
|
-
: html`Not running.`}>
|
|
336
|
-
<div class="config-grid">
|
|
337
|
-
<div class="field">
|
|
338
|
-
<span class="label">State</span>
|
|
339
|
-
<div>
|
|
340
|
-
${!running ? html`
|
|
341
|
-
<button type="button" class="action primary"
|
|
342
|
-
disabled=${busy || !token || token.length < 8}
|
|
343
|
-
onClick=${onStart}>
|
|
344
|
-
Start tunnel
|
|
345
|
-
</button>
|
|
346
|
-
` : html`
|
|
347
|
-
<button type="button" class="action danger"
|
|
348
|
-
disabled=${busy}
|
|
349
|
-
onClick=${onStop}>
|
|
350
|
-
Stop tunnel
|
|
351
|
-
</button>
|
|
352
|
-
`}
|
|
353
|
-
${running && !url ? html`<span class="hint inline">Waiting for URL…</span>` : null}
|
|
354
|
-
</div>
|
|
355
|
-
</div>
|
|
356
|
-
|
|
357
|
-
${running && url ? html`
|
|
358
|
-
<div class="field">
|
|
359
|
-
<span class="label">Share URL</span>
|
|
360
|
-
<div class="remote-url-line">
|
|
361
|
-
<code class="remote-url-value">${share}</code>
|
|
362
|
-
<button type="button" class="action" onClick=${() => copy(share)}>
|
|
363
|
-
<${IconCopy} /> Copy
|
|
364
|
-
</button>
|
|
365
|
-
<a class="action" href=${share} target="_blank" rel="noreferrer noopener">
|
|
366
|
-
<${IconExternal} /> Open
|
|
367
|
-
</a>
|
|
368
|
-
</div>
|
|
369
|
-
<span class="hint">
|
|
370
|
-
Send this to the remote device · token embedded, stripped from the URL on first arrival.
|
|
371
|
-
</span>
|
|
372
|
-
</div>
|
|
373
|
-
` : null}
|
|
374
|
-
|
|
375
|
-
${log.length ? html`
|
|
376
|
-
<div class="field">
|
|
377
|
-
<span class="label">CLI log</span>
|
|
378
|
-
<details class="remote-log">
|
|
379
|
-
<summary>${log.length} lines</summary>
|
|
380
|
-
<pre>${log.join('\n')}</pre>
|
|
381
|
-
</details>
|
|
382
|
-
</div>
|
|
383
|
-
` : null}
|
|
384
|
-
</div>
|
|
385
|
-
</${Section}>
|
|
386
|
-
|
|
387
576
|
<${Section}
|
|
388
577
|
title="Devices"
|
|
389
|
-
meta=${html`
|
|
578
|
+
meta=${html`Approve each new device once.`}>
|
|
390
579
|
${(() => {
|
|
391
580
|
const pending = deviceList.filter((d) => d.status === 'pending');
|
|
392
581
|
const approved = deviceList.filter((d) => d.status === 'approved');
|
|
@@ -437,28 +626,5 @@ export function RemotePage() {
|
|
|
437
626
|
})()}
|
|
438
627
|
</${Section}>
|
|
439
628
|
|
|
440
|
-
<${Section} title="How access works" meta="What the token does, what an approved device gets, and how to lock things back down.">
|
|
441
|
-
<dl class="remote-facts">
|
|
442
|
-
<div class="remote-fact">
|
|
443
|
-
<dt>The token is just a knock</dt>
|
|
444
|
-
<dd>
|
|
445
|
-
The share URL embeds the token and the remote browser uses it once — only to register itself in the <strong>Pending</strong> list. After that the URL + token grant nothing on their own. Until you click <strong>Approve</strong>, the visitor's <code>/api/*</code> calls all return 403.
|
|
446
|
-
</dd>
|
|
447
|
-
</div>
|
|
448
|
-
<div class="remote-fact">
|
|
449
|
-
<dt>Approved devices are sticky</dt>
|
|
450
|
-
<dd>
|
|
451
|
-
Once approved, the device's per-browser UUID becomes the credential — every API + WebSocket call rides on that alone. <strong>Rotating the token doesn't kick them out</strong>; that only blocks new arrivals. To lock an existing device out, hit <strong>Revoke</strong> in the Devices list above.
|
|
452
|
-
</dd>
|
|
453
|
-
</div>
|
|
454
|
-
<div class="remote-fact">
|
|
455
|
-
<dt>This machine is exempt</dt>
|
|
456
|
-
<dd>
|
|
457
|
-
Loopback callers (<code>localhost</code>, <code>127.0.0.1</code>) skip both checks — your own browser on this host needs nothing. Tunnel traffic is distinguished by the <code>X-Forwarded-*</code> headers the proxies inject, so it can't masquerade as local.
|
|
458
|
-
</dd>
|
|
459
|
-
</div>
|
|
460
|
-
</dl>
|
|
461
|
-
</${Section}>
|
|
462
|
-
|
|
463
629
|
</div>`;
|
|
464
630
|
}
|
package/public/js/state.js
CHANGED
|
@@ -17,6 +17,12 @@ export const serverHealth = signal({ state: 'connecting' });
|
|
|
17
17
|
// frontend session. Gates UI (HealthOverlay) so it doesn't pop on the
|
|
18
18
|
// very first boot probe while the page is still wiring up.
|
|
19
19
|
export const hasBootedOnline = signal(false);
|
|
20
|
+
// Set true the moment the user clicks "Restart backend" — the
|
|
21
|
+
// RestartOverlay reads this signal and blocks the whole page until
|
|
22
|
+
// the next health poll returns a fresh PID. Cleared by the overlay
|
|
23
|
+
// itself on reconnect. Kept here (not in ConfigurePage local state)
|
|
24
|
+
// so a stale tab on another page can't miss the in-flight restart.
|
|
25
|
+
export const restartInFlight = signal(null); // { startedAt, prevPid } | null
|
|
20
26
|
|
|
21
27
|
// ── ui state (persisted in localStorage where noted) ───────────
|
|
22
28
|
export const activeTab = signal('sessions');
|
package/server.js
CHANGED
|
@@ -66,7 +66,7 @@ app.use((req, res, next) => {
|
|
|
66
66
|
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
|
67
67
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
68
68
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
69
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id');
|
|
69
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id, X-Device-Code');
|
|
70
70
|
res.setHeader('Vary', 'Origin');
|
|
71
71
|
}
|
|
72
72
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
@@ -123,6 +123,7 @@ async function deviceGate(req, res, next) {
|
|
|
123
123
|
try { await devices.record(id, {
|
|
124
124
|
userAgent: req.headers['user-agent'] || '',
|
|
125
125
|
ip: String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(),
|
|
126
|
+
code: req.headers['x-device-code'] || '',
|
|
126
127
|
}); } catch { /* lastSeen bump is best-effort */ }
|
|
127
128
|
if (d.status === 'approved') return next();
|
|
128
129
|
return res.status(403).json({
|
|
@@ -1071,6 +1072,26 @@ app.post('/api/tunnel/install', asyncH(async (req, res) => {
|
|
|
1071
1072
|
res.status(400).json({ error: e.message });
|
|
1072
1073
|
}
|
|
1073
1074
|
}));
|
|
1075
|
+
// Interactive `devtunnel user login -d` driver. The Remote page POSTs
|
|
1076
|
+
// here to start a device-code flow, then polls /api/tunnel/status to
|
|
1077
|
+
// learn the URL+code it should display and the eventual outcome —
|
|
1078
|
+
// avoids the older "copy this command into a shell" UX.
|
|
1079
|
+
app.post('/api/tunnel/devtunnel/login', asyncH(async (req, res) => {
|
|
1080
|
+
const { mode } = req.body || {};
|
|
1081
|
+
try {
|
|
1082
|
+
const snap = await tunnel.startDevtunnelLogin({ mode });
|
|
1083
|
+
res.json({ ok: true, login: snap });
|
|
1084
|
+
} catch (e) {
|
|
1085
|
+
res.status(400).json({ error: e.message });
|
|
1086
|
+
}
|
|
1087
|
+
}));
|
|
1088
|
+
app.post('/api/tunnel/devtunnel/login/cancel', asyncH(async (_req, res) => {
|
|
1089
|
+
res.json({ ok: true, login: tunnel.cancelDevtunnelLogin() });
|
|
1090
|
+
}));
|
|
1091
|
+
app.post('/api/tunnel/devtunnel/login/dismiss', asyncH(async (_req, res) => {
|
|
1092
|
+
tunnel.clearDevtunnelLogin();
|
|
1093
|
+
res.json({ ok: true });
|
|
1094
|
+
}));
|
|
1074
1095
|
|
|
1075
1096
|
// ---- devices ----
|
|
1076
1097
|
//
|
|
@@ -1099,7 +1120,8 @@ app.get('/api/devices/me', asyncH(async (req, res) => {
|
|
|
1099
1120
|
}
|
|
1100
1121
|
const ua = req.headers['user-agent'] || '';
|
|
1101
1122
|
const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
|
|
1102
|
-
const
|
|
1123
|
+
const code = String(req.headers['x-device-code'] || (req.query && req.query.code) || '').slice(0, 8);
|
|
1124
|
+
const d = await devices.record(id, { userAgent: ua, ip, code });
|
|
1103
1125
|
res.json(d);
|
|
1104
1126
|
}));
|
|
1105
1127
|
app.get('/api/devices', asyncH(async (_req, res) => {
|