device-center 0.2.0-beta.3 → 0.2.0-beta.4
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/CONTRIBUTING.md +2 -0
- package/Dockerfile +2 -1
- package/README.md +16 -1
- package/bin/device-center.mjs +3 -2
- package/docs/agent-install.md +41 -7
- package/docs/driver-binding.md +11 -1
- package/docs/npm.md +2 -0
- package/docs/onboarding.md +2 -0
- package/docs/security-and-sync.md +2 -0
- package/favicon.svg +4 -0
- package/index.html +1 -0
- package/login.html +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/scripts/driver.mjs +27 -10
- package/server/cloudAccess.mjs +7 -1
- package/server/drivers/browserPairing.mjs +41 -0
- package/server/drivers/cloudRegistry.mjs +23 -6
- package/server/index.mjs +4 -1
- package/src/api.js +2 -0
- package/src/install-walkthrough.js +49 -0
- package/src/login.js +8 -2
- package/src/main.js +16 -9
- package/src/messages.js +1566 -81
- package/src/onboarding.js +28 -5
- package/src/pair.css +23 -0
- package/src/pair.js +60 -0
- package/src/pairing-handoff.js +30 -0
- package/src/start.css +265 -45
- package/src/start.js +147 -26
- package/start.html +134 -26
package/src/login.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { initLanguage, getLocale, t } from './i18n.js';
|
|
2
|
+
import { loginDestination } from './pairing-handoff.js';
|
|
3
|
+
if (location.pathname === '/pair') {
|
|
4
|
+
await import('./pair.js');
|
|
5
|
+
} else {
|
|
2
6
|
initLanguage();
|
|
3
7
|
function setMessage(element, key) { if (key) element.dataset.i18n = key; else delete element.dataset.i18n; element.textContent = key ? t(key) : ''; }
|
|
4
8
|
const form = document.querySelector('#login');
|
|
@@ -11,7 +15,7 @@ async function continueUnified(manual = false) {
|
|
|
11
15
|
button.disabled = true;
|
|
12
16
|
try {
|
|
13
17
|
const response = await fetch('/api/auth/continue', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(10_000) });
|
|
14
|
-
if (response.ok) { clearInterval(timer); location.replace(
|
|
18
|
+
if (response.ok) { clearInterval(timer); location.replace(loginDestination(getLocale())); return; }
|
|
15
19
|
if (response.status === 401) setMessage(message, manual ? 'login.first' : null);
|
|
16
20
|
else {
|
|
17
21
|
clearInterval(timer);
|
|
@@ -53,7 +57,9 @@ form.addEventListener('submit', async (event) => {
|
|
|
53
57
|
const data = Object.fromEntries(new FormData(form));
|
|
54
58
|
const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(10_000) });
|
|
55
59
|
if (!response.ok) throw new Error(response.status === 429 ? 'login.rate' : 'login.credentials');
|
|
56
|
-
form.reset(); location.replace(
|
|
60
|
+
form.reset(); location.replace(loginDestination(getLocale()));
|
|
57
61
|
} catch (cause) { setMessage(message, cause.name === 'TimeoutError' ? 'login.timeout' : cause.message.startsWith('login.') ? cause.message : 'login.unavailable'); }
|
|
58
62
|
finally { button.disabled = false; }
|
|
59
63
|
});
|
|
64
|
+
|
|
65
|
+
}
|
package/src/main.js
CHANGED
|
@@ -237,9 +237,14 @@ function installDraft() {
|
|
|
237
237
|
try { return { text: agentInstallPrompt({ locale: getLocale(), mode: state.installMode, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) }), error: null }; }
|
|
238
238
|
catch (error) { return { text: '', error: t(error.translationKey ?? 'error.install') }; }
|
|
239
239
|
}
|
|
240
|
+
function manualInstallHref(draft) {
|
|
241
|
+
const manualParams = new URLSearchParams({ lang: getLocale(), method: 'manual', mode: state.installMode });
|
|
242
|
+
if (state.installMode === 'self-hosted' && !draft.error) manualParams.set('origin', state.installOrigin.trim());
|
|
243
|
+
return `/start?${manualParams}#install-title`;
|
|
244
|
+
}
|
|
240
245
|
function agentInstallBody() {
|
|
241
246
|
const draft = installDraft();
|
|
242
|
-
return `<p class="modal-intro">${h("panel.219")}</p><div class="method-tabs install-mode-tabs" role="group" aria-label="${h("panel.220")}">${[['local', t("panel.210")], ['official', t("panel.211")], ['self-hosted', t("panel.212")]].map(([mode, label]) => `<button data-action="install-mode" data-mode="${mode}" aria-pressed="${state.installMode === mode}" class="${state.installMode === mode ? 'active' : ''}">${label}</button>`).join('')}</div><p class="small-note">${state.installMode === 'local' ? t("panel.213") : state.installMode === 'official' ? t("panel.214") : t("panel.215")}</p>${state.installMode === 'self-hosted' ? `<label class="field-label" for="install-origin">${h("panel.216")}</label><input id="install-origin" data-field="installOrigin" type="url" value="${escapeHtml(state.installOrigin)}" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false">` : ''}<label class="binding-scope"><input type="checkbox" data-field="installBackground" ${state.installBackground ? 'checked' : ''}>${h("panel.221")}</label><ol class="install-flow"><li>${h("panel.222")}</li><li>${state.installMode === 'local' ? t("panel.217") : t("panel.218")}</li><li>${h("panel.223")}</li></ol><p id="install-error" class="form-error" role="alert" ${draft.error ? '' : 'hidden'}>${escapeHtml(draft.error ?? '')}</p><button class="button button-primary install-copy" data-action="agent-install-copy" ${draft.error ? 'disabled' : ''}>${icon('copy', 15)} ${h("panel.224")}</button><details class="install-details"><summary>${h("panel.225")}</summary><textarea id="agent-install-text" aria-label="${h("panel.226")}" readonly rows="7" spellcheck="false">${escapeHtml(draft.text)}</textarea></details><p class="small-note">${h("panel.227")}</p>`;
|
|
247
|
+
return `<p class="modal-intro">${h("panel.219")}</p><p class="small-note"><a data-manual-install href="${escapeHtml(manualInstallHref(draft))}">${h('guide.manual.openGuide')}</a></p><div class="method-tabs install-mode-tabs" role="group" aria-label="${h("panel.220")}">${[['local', t("panel.210")], ['official', t("panel.211")], ['self-hosted', t("panel.212")]].map(([mode, label]) => `<button data-action="install-mode" data-mode="${mode}" aria-pressed="${state.installMode === mode}" class="${state.installMode === mode ? 'active' : ''}">${label}</button>`).join('')}</div><p class="small-note">${state.installMode === 'local' ? t("panel.213") : state.installMode === 'official' ? t("panel.214") : t("panel.215")}</p>${state.installMode === 'self-hosted' ? `<label class="field-label" for="install-origin">${h("panel.216")}</label><input id="install-origin" data-field="installOrigin" type="url" value="${escapeHtml(state.installOrigin)}" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false">` : ''}<label class="binding-scope"><input type="checkbox" data-field="installBackground" ${state.installBackground ? 'checked' : ''}>${h("panel.221")}</label><ol class="install-flow"><li>${h("panel.222")}</li><li>${state.installMode === 'local' ? t("panel.217") : t("panel.218")}</li><li>${h("panel.223")}</li></ol><p id="install-error" class="form-error" role="alert" ${draft.error ? '' : 'hidden'}>${escapeHtml(draft.error ?? '')}</p><button class="button button-primary install-copy" data-action="agent-install-copy" ${draft.error ? 'disabled' : ''}>${icon('copy', 15)} ${h("panel.224")}</button><details class="install-details"><summary>${h("panel.225")}</summary><textarea id="agent-install-text" aria-label="${h("panel.226")}" readonly rows="7" spellcheck="false">${escapeHtml(draft.text)}</textarea></details><p class="small-note">${h("panel.227")}</p>`;
|
|
243
248
|
}
|
|
244
249
|
async function copyAgentInstall() {
|
|
245
250
|
const field = root.querySelector('#agent-install-text');
|
|
@@ -319,7 +324,7 @@ function logCommandFields() {
|
|
|
319
324
|
return `<div class="plain-panel"><h3>${h("panel.319")}</h3><label class="field-label" for="log-manager">${h("panel.320")}</label><select id="log-manager" data-log-field="logManager">${options(state.logManager)}</select><label class="field-label" for="log-target">${h("panel.321")}</label><select id="log-target" data-log-field="logTarget">${options(state.logTarget)}</select><label class="field-label" for="log-path">${h("panel.322")}</label><input id="log-path" data-log-field="logPath" value="${escapeHtml(state.logPath)}" placeholder="/home/me/app.log" autocomplete="off" spellcheck="false"><label class="field-label" for="log-label">${h("panel.323")}</label><input id="log-label" data-log-field="logLabel" value="${escapeHtml(state.logLabel)}" maxlength="40"><p id="log-command-error" class="small-note">${escapeHtml(draft.error??t("panel.318"))}</p><label class="field-label" for="log-trust-command">${h("panel.324")}</label><textarea id="log-trust-command" readonly rows="2">${escapeHtml(draft.trust)}</textarea><button class="button button-light" data-action="log-command-copy" data-field-id="log-trust-command" ${draft.error?'disabled':''}>${h("panel.325")}</button><label class="field-label" for="log-grant-command">${h("panel.326")}</label><textarea id="log-grant-command" readonly rows="3">${escapeHtml(draft.grant)}</textarea><button class="button button-primary" data-action="log-command-copy" data-field-id="log-grant-command" ${draft.error?'disabled':''}>${h("panel.327")}</button><p class="small-note">${h("panel.328")}</p></div>`;
|
|
320
325
|
}
|
|
321
326
|
function secureHelpBody() {
|
|
322
|
-
return `<p class="modal-intro">${h("panel.329")}</p>${logCommandFields()}<ol class="simple-steps"><li>${h("panel.330")} <code>npm install -g device-center@0.2.0-beta.
|
|
327
|
+
return `<p class="modal-intro">${h("panel.329")}</p>${logCommandFields()}<ol class="simple-steps"><li>${h("panel.330")} <code>npm install -g device-center@0.2.0-beta.4</code>${h("panel.331")} <code>device-center access enable --confirm</code>.</li><li>${h("panel.332")} <code>device-center access peers</code>${h("panel.333")}</li><li>${h("panel.334")}</li></ol><details open><summary>${h("panel.324")}</summary><code class="block-code">${h("panel.335")}</code></details><details open><summary>${h("panel.326")}</summary><code class="block-code">${h("panel.336")}</code></details><p>${h("panel.337")} <code>device_center_read_log</code>${h("panel.338")}</p><details><summary>${h("panel.339")}</summary><p>${h("panel.340")} <code>device-center access listen --bind 192.168.1.10 --port 5181 --confirm</code>${h("panel.341")} <code>--lan 192.168.1.10 --port 5181</code>${h("panel.342")}</p><p>${h("panel.343")}</p></details><p class="small-note">${h("panel.344")} <code>${h("panel.345")}</code> ${h("panel.346")}</p>`;
|
|
323
328
|
}
|
|
324
329
|
function cloudBindingBody() {
|
|
325
330
|
const pairing = state.pairing, disabled = state.pairBusy ? 'disabled' : '';
|
|
@@ -327,9 +332,9 @@ function cloudBindingBody() {
|
|
|
327
332
|
const bindings = state.overview?.drivers ?? [];
|
|
328
333
|
return `<p class="modal-intro">${h("panel.365")}</p>
|
|
329
334
|
<section class="binding-step"><h3><span>1</span> ${h("panel.366")}</h3><p>${h("panel.367")}</p><details><summary>${h("panel.368")}</summary><a class="button button-light" href="/api/driver/download" download>${h("panel.369")}</a><p>${h("panel.370")} <code>npm ci --ignore-scripts</code>${h("panel.371")} <code>${escapeHtml(sourceCommand)}</code>${h("panel.372")}</p></details></section>
|
|
330
|
-
<section class="binding-step"><h3><span>2</span> ${h("panel.373")}</h3><label class="binding-scope"><input id="request-ssh" type="checkbox" data-field="requestSsh" ${state.requestSsh ? 'checked' : ''} ${disabled}>${h("panel.374")}</label><textarea id="driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand())}</textarea><button class="button button-primary" data-action="driver-copy">${icon('copy', 15)} ${h("panel.309")}</button><p>${h("
|
|
331
|
-
<
|
|
332
|
-
${pairing ? `<div class="pair-review"><strong>${escapeHtml(pairing.name)}</strong><p>${escapeHtml(pairing.platform)} ${h("panel.350")} ${escapeHtml(new Date(pairing.expiresAt).toLocaleTimeString(getLocale()))}</p><label class="field-label">${h("panel.351")}</label><code class="fingerprint">${escapeHtml(pairing.fingerprint)}</code><p>${h("panel.352")}</p>${pairing.requestedSsh ? `<label class="binding-scope"><input id="pair-share" type="checkbox" data-field="pairShare" ${state.pairShare ? 'checked' : ''} ${disabled}>${h("panel.349")}</label>` : ''}<button class="button button-primary" data-action="pair-approve" ${disabled}>${h("panel.353")}</button></div>` : `<p class="small-note">${h("panel.354")}</p>`}</
|
|
335
|
+
<section class="binding-step"><h3><span>2</span> ${h("panel.373")}</h3><label class="binding-scope"><input id="request-ssh" type="checkbox" data-field="requestSsh" ${state.requestSsh ? 'checked' : ''} ${disabled}>${h("panel.374")}</label><textarea id="driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand())}</textarea><button class="button button-primary" data-action="driver-copy">${icon('copy', 15)} ${h("panel.309")}</button><p>${h("pair.browserHint")}</p></section>
|
|
336
|
+
<details class="binding-step" ${pairing || state.pairError ? 'open' : ''}><summary>${h("pair.manual")}</summary><p>${h("pair.manualHint")}</p><textarea id="manual-driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand().replace(' --browser', ''))}</textarea><button class="button button-light" data-action="driver-manual-copy">${h("panel.309")}</button><h3><span>3</span> ${h("panel.377")}</h3><div class="pair-code-row"><label for="pair-code" class="sr-only">${h("panel.378")}</label><input id="pair-code" data-field="pairCode" autocomplete="off" spellcheck="false" maxlength="9" placeholder="XXXX-XXXX" value="${escapeHtml(state.pairCode)}" ${disabled}><button class="button button-light" data-action="pair-lookup" ${disabled}>${state.pairBusy ? t("panel.347") : t("panel.348")}</button></div>${state.pairError ? `<p class="form-error" role="alert">${escapeHtml(t(state.pairError))}</p>` : ''}
|
|
337
|
+
${pairing ? `<div class="pair-review"><strong>${escapeHtml(pairing.name)}</strong><p>${escapeHtml(pairing.platform)} ${h("panel.350")} ${escapeHtml(new Date(pairing.expiresAt).toLocaleTimeString(getLocale()))}</p><label class="field-label">${h("panel.351")}</label><code class="fingerprint">${escapeHtml(pairing.fingerprint)}</code><p>${h("panel.352")}</p>${pairing.requestedSsh ? `<label class="binding-scope"><input id="pair-share" type="checkbox" data-field="pairShare" ${state.pairShare ? 'checked' : ''} ${disabled}>${h("panel.349")}</label>` : ''}<button class="button button-primary" data-action="pair-approve" ${disabled}>${h("panel.353")}</button></div>` : `<p class="small-note">${h("panel.354")}</p>`}</details>
|
|
333
338
|
${bindings.length ? `<details class="binding-management" open><summary>${h("count.active", { count: bindings.filter(item => item.revokedAt == null).length })}</summary>${bindings.map(item => `<div class="binding-row"><div><strong>${escapeHtml(item.name)}</strong><small>${item.revokedAt != null ? t("panel.355") : item.lastSeen == null ? t("panel.356") : `${h("panel.14")} ${formatAgo(item.lastSeen)}`}</small><code class="fingerprint">${escapeHtml(item.fingerprint)}</code></div>${item.revokedAt == null ? state.revokeId === item.id ? `<div class="revoke-confirm"><p>${h("panel.357")}</p><button class="button button-light" data-action="revoke-confirm" data-device="${item.id}" ${disabled}>${h("panel.358")}</button><button class="text-button" data-action="revoke-cancel" ${disabled}>${h("panel.359")}</button></div>` : `<button class="text-button" data-action="revoke-arm" data-device="${item.id}" ${disabled}>${h("panel.360")}</button>` : ''}</div>`).join('')}</details>` : ''}
|
|
334
339
|
<p class="small-note">${state.overview?.accountMode === 'multi-user' ? t("panel.363") : t("panel.364")}${h("panel.379")}</p>`;
|
|
335
340
|
}
|
|
@@ -350,13 +355,14 @@ async function pairAction(action, id) {
|
|
|
350
355
|
} catch (error) { state.pairError = pairErrorText(error.message); }
|
|
351
356
|
finally { state.pairBusy = false; render(); }
|
|
352
357
|
}
|
|
353
|
-
async function copyDriverCommand() {
|
|
354
|
-
const
|
|
358
|
+
async function copyDriverCommand(manual = false) {
|
|
359
|
+
const selector = manual ? '#manual-driver-command' : state.dialog === 'cloud-connect' ? '#cloud-connect-command' : '#driver-command';
|
|
360
|
+
const field = root.querySelector(selector); if (!field || !field.value) return;
|
|
355
361
|
field.focus(); field.select(); let copied = false;
|
|
356
362
|
try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
|
|
357
363
|
if (!copied) try { copied = document.execCommand('copy'); } catch { /* manual selection */ }
|
|
358
364
|
toast(copied ? t("panel.390") : t("panel.391"));
|
|
359
|
-
if (!copied) { const current = root.querySelector(
|
|
365
|
+
if (!copied) { const current = root.querySelector(selector); current?.focus(); current?.select(); }
|
|
360
366
|
}
|
|
361
367
|
function initializationBody() {
|
|
362
368
|
if (state.sshLoading) return `<p class="modal-intro">${h("panel.392")}</p>`;
|
|
@@ -535,7 +541,7 @@ root.addEventListener('click', async (event) => {
|
|
|
535
541
|
if (action === 'agent-install-copy') return copyAgentInstall();
|
|
536
542
|
if (action === 'logout') { try { await api.logout(); location.replace('/login?lang=' + getLocale()); } catch { state.toast = t("panel.491"); render(); } return; }
|
|
537
543
|
if (cloudMode() && action === 'add') { state.dialog = 'add'; state.toast = null; state.pairError = null; state.revokeId = null; render(); return; }
|
|
538
|
-
if (['driver-copy', 'cloud-command-copy'].includes(action)) return copyDriverCommand();
|
|
544
|
+
if (['driver-copy', 'cloud-command-copy', 'driver-manual-copy'].includes(action)) return copyDriverCommand(action === 'driver-manual-copy');
|
|
539
545
|
if (action === 'secure-help') { state.dialog='secure-help'; state.toast=null; state.remoteError=null; render(); if (!cloudMode()) { state.remoteLoading=true; render(); try { const data=await api.remotePeers(); state.remotePeers=data.peers; state.logManager=data.driverId??''; } catch { state.remoteError=true; } finally { state.remoteLoading=false; render(); } } return; }
|
|
540
546
|
if (action === 'log-command-copy') { const field=root.querySelector('#'+button.dataset.fieldId); if(!field?.value)return;field.focus();field.select();try {await navigator.clipboard.writeText(field.value);toast(t("panel.492"));}catch{toast(t("panel.493"));} return; }
|
|
541
547
|
if (action === 'cloud-connect') { state.dialog = 'cloud-connect'; state.toast = null; render(); return; }
|
|
@@ -594,6 +600,7 @@ root.addEventListener('input', (event) => {
|
|
|
594
600
|
root.querySelector('#agent-install-text').value = draft.text;
|
|
595
601
|
const message = root.querySelector('#install-error'); message.textContent = draft.error ?? ''; message.hidden = !draft.error;
|
|
596
602
|
root.querySelector('[data-action="agent-install-copy"]').disabled = Boolean(draft.error);
|
|
603
|
+
root.querySelector('[data-manual-install]').href = manualInstallHref(draft);
|
|
597
604
|
return;
|
|
598
605
|
}
|
|
599
606
|
if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
|