Repository navigation
Expand file tree
/
Copy pathdevices.js
More file actions
114 lines (107 loc) · 6.15 KB
/
Copy pathdevices.js
File metadata and controls
114 lines (107 loc) · 6.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// Devices page: every signed-in browser, which one is in use, and revoke.
import { pendingHtml, sharePending } from "./share.js";
import { api, bus, confirmDialog, esc, icon, toast, toastError } from "./ui.js";
function ago(ts) {
const s = Math.max(0, Date.now() / 1000 - ts);
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
return new Date(ts * 1000).toLocaleDateString();
}
/** "Edge on Windows" from a user agent; good enough to tell your own devices apart. */
export function deviceName(ua) {
if (/CFNetwork|Darwin/.test(ua) && !/Mozilla/.test(ua)) return "iOS app"; // native fetch, before login
const browser = /Edg\//.test(ua) ? "Edge" : /OPR\//.test(ua) ? "Opera" : /Firefox\//.test(ua) ? "Firefox"
: /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "Browser";
const os = /iPhone|iPad/.test(ua) ? "iOS" : /Android/.test(ua) ? "Android" : /Windows/.test(ua) ? "Windows"
: /Mac OS X/.test(ua) ? "macOS" : /Linux/.test(ua) ? "Linux" : "";
return os ? `${browser} on ${os}` : browser;
}
function viaLabel(via) {
if (via.startsWith("share:")) return `Share <span class="faint">${esc(via.slice(6))}</span>`;
return via === "network" ? "Network" : "This machine";
}
export async function renderDevices(el) {
if (!el.dataset.wired) {
el.dataset.wired = "1";
el.innerHTML = `<div class="page-head">
<div><h1>Devices</h1><p>Browsers signed in to shelldeck. One device is in use at a time; logging in on another makes it the one in use and idles the rest until they log in again. Stopping a share signs out its devices.</p></div>
<button class="btn" data-dev="others">${icon("power")}Sign out other devices</button>
</div>
<p class="dev-summary muted"></p>
<div class="dev-pending" hidden><h3 class="share-h">Waiting for approval</h3><div class="card share-pending"></div></div>
<div class="card mon-table dev-table"><table><thead><tr><th>Device</th><th>From</th><th>Signed in</th><th>Last active</th><th></th></tr></thead><tbody class="dev-logins"></tbody></table></div>
<h3 class="share-h">Trusted devices</h3>
<p class="muted small">iPhone apps that reach shelldeck through a share without a link or your approval. They still need the password. Revoke one you don't recognise.</p>
<div class="card mon-table dev-table"><table><thead><tr><th>Device</th><th>Trusted since</th><th>Last seen</th><th></th></tr></thead><tbody class="dev-trusted"></tbody></table></div>`;
el.addEventListener("click", (e) => onClick(e, el));
bus.addEventListener("share", () => !el.hidden && renderPending(el));
bus.addEventListener("trusted", () => !el.hidden && renderDevices(el));
}
let data;
try {
data = await api("/api/devices");
} catch (e) {
return toastError(e);
}
pendingFallback = data.pending || [];
renderPending(el);
const n = data.devices.length;
el.querySelector(".dev-summary").textContent =
`${n} signed in · ${data.devices.filter((d) => d.active).length} in use` + (data.share ? ` · sharing via ${data.share}` : "");
el.querySelector("[data-dev=others]").disabled = n < 2;
const trusted = (await api("/api/trusted").catch(() => ({ devices: [] }))).devices;
el.querySelector(".dev-trusted").innerHTML = trusted.length
? trusted.map((d) => `<tr>
<td><div class="dev-name">${esc(d.name)}</div><div class="hist-meta faint">${esc(d.id)}</div></td>
<td class="nowrap faint">${esc(ago(d.created_at))}</td>
<td class="nowrap faint">${d.last_seen ? esc(ago(d.last_seen)) : "not yet"}</td>
<td class="nowrap"><button class="btn sm" data-dev="untrust" data-id="${esc(d.id)}">Revoke</button></td>
</tr>`).join("")
: `<tr><td colspan="4" class="faint">None</td></tr>`;
el.querySelector(".dev-logins").innerHTML = data.devices
.map(
(d) => `<tr>
<td><div class="dev-name">${d.kind === "remote" ? "iPhone app (remote)" : esc(deviceName(d.agent))}${d.current ? ' <span class="exit ok">this device</span>' : ""}</div>
<div class="hist-meta">${d.active ? '<span class="dev-dot on"></span>In use' : '<span class="dev-dot"></span>Idle'}${d.sockets ? ` · ${d.sockets} connection${d.sockets > 1 ? "s" : ""}` : ""}</div></td>
<td>${viaLabel(d.via)}<div class="hist-meta">${esc(d.ip)}</div></td>
<td class="nowrap faint">${esc(ago(d.created_at))}</td>
<td class="nowrap faint">${esc(ago(d.last_seen))}</td>
<td class="nowrap"><button class="btn sm" data-dev="revoke" data-id="${esc(d.id)}" data-me="${d.current ? 1 : ""}">${d.current ? "Sign out" : "Revoke"}</button></td>
</tr>`,
)
.join("");
}
let pendingFallback = []; // /api/devices `pending`, for browsers that get no share state
/** Share devices waiting for the host to allow them; Allow/Deny are handled in share.js. */
function renderPending(el) {
const list = sharePending() ?? pendingFallback;
el.querySelector(".dev-pending").hidden = !list.length;
el.querySelector(".share-pending").innerHTML = pendingHtml(list);
}
async function onClick(e, el) {
const b = e.target.closest("[data-dev]");
if (!b) return;
if (b.dataset.dev === "untrust") {
if (!(await confirmDialog("Revoke this trusted device? It will need a new QR code and your approval to come back.", { ok: "Revoke", danger: true }))) return;
try {
await api(`/api/trusted/${encodeURIComponent(b.dataset.id)}`, { method: "DELETE" });
toast("Trusted device revoked");
} catch (err) {
return toastError(err);
}
return renderDevices(el);
}
const me = b.dataset.me === "1";
const others = b.dataset.dev === "others";
const what = others ? "Sign out every other device?" : me ? "Sign out this device?" : "Revoke this device?";
if (!(await confirmDialog(`${what} It will need the password to come back.`, { ok: "Sign out", danger: true }))) return;
try {
const r = await api(`/api/devices/${others ? "others" : encodeURIComponent(b.dataset.id)}`, { method: "DELETE" });
if (me) return location.reload();
toast(`${r.revoked} device${r.revoked === 1 ? "" : "s"} signed out`);
} catch (err) {
return toastError(err);
}
renderDevices(el);
}