shelldeck 0.0.2__tar.gz → 0.0.3__tar.gz
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.
- {shelldeck-0.0.2 → shelldeck-0.0.3}/CHANGELOG.md +17 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/PKG-INFO +5 -3
- {shelldeck-0.0.2 → shelldeck-0.0.3}/README.md +4 -2
- {shelldeck-0.0.2 → shelldeck-0.0.3}/pyproject.toml +1 -1
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/auth.py +11 -2
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/db.py +23 -4
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/server.py +86 -14
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/app.css +45 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/app.js +14 -6
- shelldeck-0.0.3/shelldeck/static/devices.js +76 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/index.html +2 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/ui.js +2 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/views.js +2 -1
- {shelldeck-0.0.2 → shelldeck-0.0.3}/tests/test_shelldeck.py +41 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/.gitignore +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/LICENSE +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/__init__.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/__main__.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/addons/__init__.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/addons/fast_context.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/cli.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/gitgraph.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/integration/bash.sh +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/integration/shelldeck.fish +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/integration/shelldeck.ps1 +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/integration/zsh/.zshenv +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/integration/zsh/.zshrc +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/pty.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/runner.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/scheduler.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/shells.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/gitgraph.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/history.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/icon-192.png +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/icon-32.png +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/icon-512.png +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/icon.svg +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/manifest.webmanifest +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/monitor.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/LICENSE-xterm.txt +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-fit.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-search.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-serialize.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-web-links.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-webgl.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/xterm.css +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/xterm.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/stats.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/tests/test_cli.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.3}/tests/test_gitgraph.py +0 -0
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to shelldeck are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [0.0.3] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Devices page: every signed-in browser with where it came from (this machine, network, or a share), when it signed in, last activity, and whether it is in use; revoke one or sign out all others.
|
|
10
|
+
- One device at a time: other browsers stay signed in but idle; logging in on one takes over and shows the others an "In use on another device" screen.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Logging in again in the same browser replaces its old login instead of adding one.
|
|
15
|
+
- Share logins are stored with their share and removed when it stops, is replaced, or the server restarts.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Re-running the one-line installer now upgrades an existing install to the latest release.
|
|
20
|
+
|
|
5
21
|
## [0.0.2] - 2026-09-29
|
|
6
22
|
|
|
7
23
|
### Added
|
|
@@ -32,5 +48,6 @@ First public release.
|
|
|
32
48
|
- Mandatory password with per-browser logins, idle lock, and HTTPS or SSH-tunnel remote access.
|
|
33
49
|
- `sd` CLI with a startup banner, `sd search` (LLM-free code search), and one-line installers for Windows and Linux.
|
|
34
50
|
|
|
51
|
+
[0.0.3]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.3
|
|
35
52
|
[0.0.2]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.2
|
|
36
53
|
[0.0.1]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shelldeck
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: Project-first multi-terminal for Windows and Linux: real shells, grouped by project, in your browser
|
|
5
5
|
Project-URL: Homepage, https://github.com/codejunction/shelldeck
|
|
6
6
|
Project-URL: Source, https://github.com/codejunction/shelldeck
|
|
@@ -70,7 +70,7 @@ irm https://raw.githubusercontent.com/codejunction/shelldeck/main/install.ps1 |
|
|
|
70
70
|
curl -fsSL https://raw.githubusercontent.com/codejunction/shelldeck/main/install.sh | sh
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Already use Python tooling? Any of these work too:
|
|
73
|
+
The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Run it again to update to the latest release (or `uv tool upgrade shelldeck`); stop the server first with `sd stop` so Windows can replace `sd.exe`. Already use Python tooling? Any of these work too:
|
|
74
74
|
|
|
75
75
|
```sh
|
|
76
76
|
uv tool install shelldeck # or: pipx install shelldeck, or: pip install shelldeck
|
|
@@ -130,6 +130,7 @@ sd ~/code/api # add a folder as a project and open a terminal in it
|
|
|
130
130
|
### Secure by default
|
|
131
131
|
|
|
132
132
|
- **Password required.** Every browser logs in separately and locks after 30 minutes idle.
|
|
133
|
+
- **One device at a time.** Any number of browsers can stay signed in, but only one uses shelldeck; logging in on another takes over and idles the rest. The **Devices** page lists every signed-in browser (where from, last active, in use or idle) and revokes any of them.
|
|
133
134
|
- **Local only.** shelldeck listens on `127.0.0.1` and refuses requests from other websites.
|
|
134
135
|
- **Remote use** goes over an SSH tunnel or HTTPS. See [Remote access](#remote-access).
|
|
135
136
|
|
|
@@ -226,7 +227,7 @@ sd share # starts shelldeck if needed, prints a QR code and a link; Ctr
|
|
|
226
227
|
- **Encrypted.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened.
|
|
227
228
|
- **Two locks.** The random `trycloudflare.com` address alone is refused; only the printed link (a one-per-share token made on the host) lets a browser in, and then it still needs your password. A password must exist before sharing.
|
|
228
229
|
- **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
|
|
229
|
-
- **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it. Each `sd share` gets a new address and link.
|
|
230
|
+
- **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Each `sd share` gets a new address and link.
|
|
230
231
|
|
|
231
232
|
**HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
|
|
232
233
|
|
|
@@ -242,6 +243,7 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
|
|
|
242
243
|
|
|
243
244
|
- **Local only.** The server binds to `127.0.0.1` and rejects HTTP and WebSocket requests whose `Host` or `Origin` isn't the app itself, so other websites can't reach your shells.
|
|
244
245
|
- **Passwords** are stored as PBKDF2-SHA256 (600k iterations). A login is a random token in an `HttpOnly`, `SameSite=Strict` cookie, and only its SHA-256 is stored. After 5 wrong passwords, each further try waits longer.
|
|
246
|
+
- **Devices.** Each login records where it came from (this machine, the network, or a share). Only one login is in use at a time; the others get `423` until they enter the password again. Revoke any login from the Devices page.
|
|
245
247
|
- **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
|
|
246
248
|
- **Forgot the password?** On the host machine, `sd login-link` prints a one-time login URL valid for 5 minutes, and `sd reset-password` removes the password. There is deliberately no way to do either from the browser.
|
|
247
249
|
- **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
|
|
@@ -35,7 +35,7 @@ irm https://raw.githubusercontent.com/codejunction/shelldeck/main/install.ps1 |
|
|
|
35
35
|
curl -fsSL https://raw.githubusercontent.com/codejunction/shelldeck/main/install.sh | sh
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Already use Python tooling? Any of these work too:
|
|
38
|
+
The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Run it again to update to the latest release (or `uv tool upgrade shelldeck`); stop the server first with `sd stop` so Windows can replace `sd.exe`. Already use Python tooling? Any of these work too:
|
|
39
39
|
|
|
40
40
|
```sh
|
|
41
41
|
uv tool install shelldeck # or: pipx install shelldeck, or: pip install shelldeck
|
|
@@ -95,6 +95,7 @@ sd ~/code/api # add a folder as a project and open a terminal in it
|
|
|
95
95
|
### Secure by default
|
|
96
96
|
|
|
97
97
|
- **Password required.** Every browser logs in separately and locks after 30 minutes idle.
|
|
98
|
+
- **One device at a time.** Any number of browsers can stay signed in, but only one uses shelldeck; logging in on another takes over and idles the rest. The **Devices** page lists every signed-in browser (where from, last active, in use or idle) and revokes any of them.
|
|
98
99
|
- **Local only.** shelldeck listens on `127.0.0.1` and refuses requests from other websites.
|
|
99
100
|
- **Remote use** goes over an SSH tunnel or HTTPS. See [Remote access](#remote-access).
|
|
100
101
|
|
|
@@ -191,7 +192,7 @@ sd share # starts shelldeck if needed, prints a QR code and a link; Ctr
|
|
|
191
192
|
- **Encrypted.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened.
|
|
192
193
|
- **Two locks.** The random `trycloudflare.com` address alone is refused; only the printed link (a one-per-share token made on the host) lets a browser in, and then it still needs your password. A password must exist before sharing.
|
|
193
194
|
- **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
|
|
194
|
-
- **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it. Each `sd share` gets a new address and link.
|
|
195
|
+
- **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Each `sd share` gets a new address and link.
|
|
195
196
|
|
|
196
197
|
**HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
|
|
197
198
|
|
|
@@ -207,6 +208,7 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
|
|
|
207
208
|
|
|
208
209
|
- **Local only.** The server binds to `127.0.0.1` and rejects HTTP and WebSocket requests whose `Host` or `Origin` isn't the app itself, so other websites can't reach your shells.
|
|
209
210
|
- **Passwords** are stored as PBKDF2-SHA256 (600k iterations). A login is a random token in an `HttpOnly`, `SameSite=Strict` cookie, and only its SHA-256 is stored. After 5 wrong passwords, each further try waits longer.
|
|
211
|
+
- **Devices.** Each login records where it came from (this machine, the network, or a share). Only one login is in use at a time; the others get `423` until they enter the password again. Revoke any login from the Devices page.
|
|
210
212
|
- **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
|
|
211
213
|
- **Forgot the password?** On the host machine, `sd login-link` prints a one-time login URL valid for 5 minutes, and `sd reset-password` removes the password. There is deliberately no way to do either from the browser.
|
|
212
214
|
- **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
|
|
@@ -141,12 +141,21 @@ def _h(token: str) -> str:
|
|
|
141
141
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
142
142
|
|
|
143
143
|
|
|
144
|
-
def new_session(client: str) -> str:
|
|
144
|
+
def new_session(client: str, via: str = "local") -> str:
|
|
145
145
|
token = secrets.token_urlsafe(32)
|
|
146
|
-
db.add_auth_session(_h(token), time.time(), client)
|
|
146
|
+
db.add_auth_session(_h(token), time.time(), client, via)
|
|
147
147
|
return token
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
def alive(h: str | None) -> bool:
|
|
151
|
+
"""A session (by hash) that still exists and hasn't idled out."""
|
|
152
|
+
if not h:
|
|
153
|
+
return False
|
|
154
|
+
hit = _cache.get(h)
|
|
155
|
+
row = hit[1] if hit and time.time() - hit[0] < _CACHE_TTL else db.get_auth_session(h)
|
|
156
|
+
return bool(row) and time.time() - row["last_seen"] <= LOCK_TIMEOUT_SECONDS
|
|
157
|
+
|
|
158
|
+
|
|
150
159
|
def session_hash(token: str | None) -> str | None:
|
|
151
160
|
"""sha256 of a live session token, else None. Idle sessions are removed here."""
|
|
152
161
|
if not token:
|
|
@@ -136,7 +136,8 @@ def init_db() -> None:
|
|
|
136
136
|
token_hash TEXT PRIMARY KEY,
|
|
137
137
|
created_at REAL NOT NULL,
|
|
138
138
|
last_seen REAL NOT NULL,
|
|
139
|
-
client TEXT
|
|
139
|
+
client TEXT,
|
|
140
|
+
via TEXT
|
|
140
141
|
);
|
|
141
142
|
|
|
142
143
|
-- command history (sessions/projects may be deleted later; keep the text)
|
|
@@ -162,6 +163,9 @@ def init_db() -> None:
|
|
|
162
163
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(sessions)")}
|
|
163
164
|
if "name" not in cols:
|
|
164
165
|
conn.execute("ALTER TABLE sessions ADD COLUMN name TEXT")
|
|
166
|
+
if "via" not in {r[1] for r in conn.execute("PRAGMA table_info(auth_sessions)")}:
|
|
167
|
+
conn.execute("ALTER TABLE auth_sessions ADD COLUMN via TEXT")
|
|
168
|
+
conn.execute("DELETE FROM auth_sessions WHERE via LIKE 'share:%'") # shares end with the server
|
|
165
169
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)")}
|
|
166
170
|
if "color" not in cols:
|
|
167
171
|
conn.execute("ALTER TABLE projects ADD COLUMN color INTEGER")
|
|
@@ -317,15 +321,30 @@ def list_sessions_with_project() -> list[dict]:
|
|
|
317
321
|
return [dict(r) for r in rows]
|
|
318
322
|
|
|
319
323
|
|
|
320
|
-
def add_auth_session(token_hash: str, now: float, client: str) -> None:
|
|
324
|
+
def add_auth_session(token_hash: str, now: float, client: str, via: str = "local") -> None:
|
|
321
325
|
with _connect() as conn:
|
|
322
326
|
conn.execute(
|
|
323
|
-
"INSERT INTO auth_sessions (token_hash, created_at, last_seen, client) VALUES (?, ?, ?, ?)",
|
|
324
|
-
(token_hash, now, now, client[:200]),
|
|
327
|
+
"INSERT INTO auth_sessions (token_hash, created_at, last_seen, client, via) VALUES (?, ?, ?, ?, ?)",
|
|
328
|
+
(token_hash, now, now, client[:200], via),
|
|
325
329
|
)
|
|
326
330
|
conn.commit()
|
|
327
331
|
|
|
328
332
|
|
|
333
|
+
def list_auth_sessions() -> list[dict]:
|
|
334
|
+
with _connect() as conn:
|
|
335
|
+
conn.row_factory = sqlite3.Row
|
|
336
|
+
return [dict(r) for r in conn.execute("SELECT * FROM auth_sessions ORDER BY last_seen DESC")]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def delete_auth_sessions_via(via: str) -> list[str]:
|
|
340
|
+
"""Delete the logins made through `via` (e.g. one share); returns their hashes."""
|
|
341
|
+
with _connect() as conn:
|
|
342
|
+
gone = [r[0] for r in conn.execute("SELECT token_hash FROM auth_sessions WHERE via = ?", (via,))]
|
|
343
|
+
conn.execute("DELETE FROM auth_sessions WHERE via = ?", (via,))
|
|
344
|
+
conn.commit()
|
|
345
|
+
return gone
|
|
346
|
+
|
|
347
|
+
|
|
329
348
|
def get_auth_session(token_hash: str) -> dict | None:
|
|
330
349
|
with _connect() as conn:
|
|
331
350
|
conn.row_factory = sqlite3.Row
|
|
@@ -183,6 +183,18 @@ def _who(conn) -> tuple[str, str | None] | None:
|
|
|
183
183
|
return ("browser", h) if h else None
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
# One device uses shelldeck at a time; the others stay signed in but idle until they log in again.
|
|
187
|
+
_active: dict[str, str | None] = {"h": None}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _claim(h: str) -> bool:
|
|
191
|
+
"""True when browser session `h` may act: it is the active one, or no live session is."""
|
|
192
|
+
if _active["h"] != h and auth.alive(_active["h"]):
|
|
193
|
+
return False
|
|
194
|
+
_active["h"] = h
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
|
|
186
198
|
def _denied() -> JSONResponse:
|
|
187
199
|
return err("locked" if auth.has_password() else "setup_required", 401)
|
|
188
200
|
|
|
@@ -198,6 +210,8 @@ async def guard(request: Request, call_next):
|
|
|
198
210
|
if not who:
|
|
199
211
|
return _denied()
|
|
200
212
|
request.state.session = who[1]
|
|
213
|
+
if who[1] and not _claim(who[1]):
|
|
214
|
+
return err("in_use", 423)
|
|
201
215
|
if who[1] and request.method != "GET": # polling GETs are not activity
|
|
202
216
|
auth.touch(who[1])
|
|
203
217
|
return await call_next(request)
|
|
@@ -234,11 +248,21 @@ async def shutdown():
|
|
|
234
248
|
# ---------------------------------------------------------------------------
|
|
235
249
|
|
|
236
250
|
|
|
237
|
-
def
|
|
238
|
-
"""Start a login session: cookie on `res` (JSON body by default)."""
|
|
239
|
-
token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}")
|
|
251
|
+
def _via(request: Request) -> str:
|
|
240
252
|
if _via_share(request):
|
|
241
|
-
_share["
|
|
253
|
+
return f"share:{_share['host']}"
|
|
254
|
+
return "local" if _is_local(request) else "network"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def _login_response(request: Request, body: dict, res=None):
|
|
258
|
+
"""Start a login session (cookie on `res`, JSON body by default). It becomes the active device:
|
|
259
|
+
this browser's previous login is replaced and every other device goes idle."""
|
|
260
|
+
if old := auth.session_hash(request.cookies.get(auth.COOKIE)):
|
|
261
|
+
auth.end_session(old)
|
|
262
|
+
token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}", _via(request))
|
|
263
|
+
h = auth.session_hash(token)
|
|
264
|
+
_active["h"] = h
|
|
265
|
+
await _close_session_sockets(lambda owner: owner != h, IN_USE)
|
|
242
266
|
res = res or JSONResponse(body)
|
|
243
267
|
res.set_cookie(
|
|
244
268
|
auth.COOKIE, token, httponly=True, samesite="strict", path="/",
|
|
@@ -263,6 +287,7 @@ async def auth_status(request: Request):
|
|
|
263
287
|
"has_password": auth.has_password(),
|
|
264
288
|
"setup_code_required": not auth.has_password() and not _is_local(request),
|
|
265
289
|
"authenticated": bool(who),
|
|
290
|
+
"in_use_elsewhere": bool(who and who[1] and who[1] != _active["h"] and auth.alive(_active["h"])),
|
|
266
291
|
"idle_timeout": auth.LOCK_TIMEOUT_SECONDS,
|
|
267
292
|
"min_length": auth.MIN_PASSWORD,
|
|
268
293
|
}
|
|
@@ -285,7 +310,7 @@ async def auth_setup(request: Request, payload: dict):
|
|
|
285
310
|
return err(problem)
|
|
286
311
|
auth.set_password(password)
|
|
287
312
|
log.info("password created from %s", _client(request))
|
|
288
|
-
return _login_response(request, {"status": "ok"})
|
|
313
|
+
return await _login_response(request, {"status": "ok"})
|
|
289
314
|
|
|
290
315
|
|
|
291
316
|
@app.post("/api/auth/login")
|
|
@@ -299,7 +324,7 @@ async def auth_login(request: Request, payload: dict):
|
|
|
299
324
|
if not ok:
|
|
300
325
|
log.warning("failed login from %s", _client(request))
|
|
301
326
|
return err("invalid_password", 401)
|
|
302
|
-
return _login_response(request, {"status": "ok"})
|
|
327
|
+
return await _login_response(request, {"status": "ok"})
|
|
303
328
|
|
|
304
329
|
|
|
305
330
|
_login_links: dict[str, float] = {} # one-time code -> expiry
|
|
@@ -325,7 +350,7 @@ async def share_start(request: Request, payload: dict):
|
|
|
325
350
|
return err("invalid_host")
|
|
326
351
|
await _share_stop()
|
|
327
352
|
token = secrets.token_urlsafe(32)
|
|
328
|
-
_share.update(host=host, key=hashlib.sha256(token.encode()).hexdigest()
|
|
353
|
+
_share.update(host=host, key=hashlib.sha256(token.encode()).hexdigest())
|
|
329
354
|
log.info("sharing via %s", host)
|
|
330
355
|
return {"path": f"/share/{token}"}
|
|
331
356
|
|
|
@@ -340,11 +365,11 @@ async def share_stop(request: Request):
|
|
|
340
365
|
|
|
341
366
|
async def _share_stop() -> None:
|
|
342
367
|
"""Close the gate and sign out every browser that logged in through the tunnel."""
|
|
343
|
-
|
|
368
|
+
if not _share:
|
|
369
|
+
return
|
|
370
|
+
gone = db.delete_auth_sessions_via(f"share:{_share['host']}")
|
|
344
371
|
_share.clear()
|
|
345
|
-
|
|
346
|
-
auth.end_session(h)
|
|
347
|
-
await _close_session_sockets(lambda owner: owner in sessions)
|
|
372
|
+
await _revoke(gone)
|
|
348
373
|
|
|
349
374
|
|
|
350
375
|
@app.get("/share/{token}")
|
|
@@ -362,7 +387,7 @@ async def auth_use_link(request: Request, code: str):
|
|
|
362
387
|
expiry = _login_links.pop(code, 0)
|
|
363
388
|
if expiry < time.time() or not auth.has_password():
|
|
364
389
|
return err("link_expired", 403)
|
|
365
|
-
return _login_response(request, {}, RedirectResponse("/", status_code=303))
|
|
390
|
+
return await _login_response(request, {}, RedirectResponse("/", status_code=303))
|
|
366
391
|
|
|
367
392
|
|
|
368
393
|
@app.post("/api/auth/logout")
|
|
@@ -377,6 +402,44 @@ async def auth_logout(request: Request):
|
|
|
377
402
|
return res
|
|
378
403
|
|
|
379
404
|
|
|
405
|
+
@app.get("/api/devices")
|
|
406
|
+
async def devices(request: Request):
|
|
407
|
+
"""Browsers signed in: where from, when, and which one is in use."""
|
|
408
|
+
online: dict[str, int] = {}
|
|
409
|
+
for owner in socket_owner.values():
|
|
410
|
+
if owner:
|
|
411
|
+
online[owner] = online.get(owner, 0) + 1
|
|
412
|
+
rows = []
|
|
413
|
+
for r in db.list_auth_sessions():
|
|
414
|
+
h = r["token_hash"]
|
|
415
|
+
if not auth.alive(h):
|
|
416
|
+
continue
|
|
417
|
+
ip, _, agent = (r["client"] or "").partition(" ")
|
|
418
|
+
rows.append({
|
|
419
|
+
"id": h, "ip": ip, "agent": agent, "via": r["via"] or "local",
|
|
420
|
+
"created_at": r["created_at"], "last_seen": r["last_seen"],
|
|
421
|
+
"active": h == _active["h"], "current": h == request.state.session, "sockets": online.get(h, 0),
|
|
422
|
+
})
|
|
423
|
+
return {"devices": rows, "share": _share.get("host")}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@app.delete("/api/devices/{device_id}")
|
|
427
|
+
async def revoke_device(request: Request, device_id: str):
|
|
428
|
+
"""Sign one browser out, or with id "others" every browser but this one."""
|
|
429
|
+
if device_id == "others":
|
|
430
|
+
return await _revoke([d["token_hash"] for d in db.list_auth_sessions() if d["token_hash"] != request.state.session])
|
|
431
|
+
return await _revoke([device_id])
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
async def _revoke(hashes: list[str]) -> dict:
|
|
435
|
+
for h in hashes:
|
|
436
|
+
auth.end_session(h)
|
|
437
|
+
if _active["h"] == h:
|
|
438
|
+
_active["h"] = None
|
|
439
|
+
await _close_session_sockets(lambda owner: owner in hashes)
|
|
440
|
+
return {"revoked": len(hashes)}
|
|
441
|
+
|
|
442
|
+
|
|
380
443
|
@app.put("/api/auth/password")
|
|
381
444
|
async def auth_password(request: Request, payload: dict):
|
|
382
445
|
"""Change the password (logged-in browser only). Signs out every other browser."""
|
|
@@ -743,11 +806,14 @@ async def _close(ws: WebSocket, code: int = 1000) -> None:
|
|
|
743
806
|
pass
|
|
744
807
|
|
|
745
808
|
|
|
746
|
-
|
|
809
|
+
IN_USE = 4423 # socket close code: another device took over
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
async def _close_session_sockets(match, code: int = 1008) -> None:
|
|
747
813
|
"""Close sockets whose login (session hash; None for the CLI) matches."""
|
|
748
814
|
for ws, owner in list(socket_owner.items()):
|
|
749
815
|
if owner and match(owner):
|
|
750
|
-
await _close(ws,
|
|
816
|
+
await _close(ws, code)
|
|
751
817
|
|
|
752
818
|
|
|
753
819
|
# ---------------------------------------------------------------------------
|
|
@@ -893,6 +959,9 @@ async def alarms_ws(ws: WebSocket):
|
|
|
893
959
|
if not who:
|
|
894
960
|
await ws.close(code=1008)
|
|
895
961
|
return
|
|
962
|
+
if who[1] and not _claim(who[1]):
|
|
963
|
+
await ws.close(code=IN_USE)
|
|
964
|
+
return
|
|
896
965
|
await ws.accept()
|
|
897
966
|
alarm_sockets.add(ws)
|
|
898
967
|
socket_owner[ws] = who[1]
|
|
@@ -914,6 +983,9 @@ async def terminal_ws(ws: WebSocket, session_id: str):
|
|
|
914
983
|
if not who:
|
|
915
984
|
await ws.close(code=1008)
|
|
916
985
|
return
|
|
986
|
+
if who[1] and not _claim(who[1]):
|
|
987
|
+
await ws.close(code=IN_USE)
|
|
988
|
+
return
|
|
917
989
|
login = who[1]
|
|
918
990
|
session = db.get_session(session_id)
|
|
919
991
|
if not session:
|
|
@@ -2322,6 +2322,25 @@ textarea:focus {
|
|
|
2322
2322
|
border-radius: 2px;
|
|
2323
2323
|
}
|
|
2324
2324
|
|
|
2325
|
+
/* ------------------------------------------------------------ devices */
|
|
2326
|
+
|
|
2327
|
+
.dev-name {
|
|
2328
|
+
font-weight: 600;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
.dev-dot {
|
|
2332
|
+
display: inline-block;
|
|
2333
|
+
width: 7px;
|
|
2334
|
+
height: 7px;
|
|
2335
|
+
margin-right: 6px;
|
|
2336
|
+
border-radius: 50%;
|
|
2337
|
+
background: var(--faint);
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
.dev-dot.on {
|
|
2341
|
+
background: var(--ok);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2325
2344
|
/* ------------------------------------------------------------ phones */
|
|
2326
2345
|
|
|
2327
2346
|
@media (max-width: 560px) {
|
|
@@ -2354,4 +2373,30 @@ textarea:focus {
|
|
|
2354
2373
|
.hist-table .hist-actions {
|
|
2355
2374
|
grid-column: 1 / -1;
|
|
2356
2375
|
}
|
|
2376
|
+
|
|
2377
|
+
/* devices: name + button, then where from + last active */
|
|
2378
|
+
.dev-table thead,
|
|
2379
|
+
.dev-table td:nth-child(3) {
|
|
2380
|
+
display: none;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
.dev-table tr {
|
|
2384
|
+
display: grid;
|
|
2385
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
2386
|
+
align-items: center;
|
|
2387
|
+
column-gap: 10px;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
.dev-table td {
|
|
2391
|
+
border: 0 !important;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
.dev-table tr + tr {
|
|
2395
|
+
border-top: 1px solid var(--border);
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
.dev-table td:nth-child(5) {
|
|
2399
|
+
grid-column: 2;
|
|
2400
|
+
grid-row: 1;
|
|
2401
|
+
}
|
|
2357
2402
|
}
|
|
@@ -444,7 +444,7 @@ class Term {
|
|
|
444
444
|
ws.onclose = (ev) => {
|
|
445
445
|
if (this.closed) return;
|
|
446
446
|
if (ev.code === 4404 || ev.code === 1000) return this.exited();
|
|
447
|
-
if (ev.code === 1008) return bus.dispatchEvent(new Event("locked")); // logging in reloads the page
|
|
447
|
+
if (ev.code === 1008 || ev.code === 4423) return bus.dispatchEvent(new Event("locked")); // logging in reloads the page
|
|
448
448
|
this.status("Reconnecting…");
|
|
449
449
|
const delay = Math.min(10000, 500 * 2 ** this.retry++);
|
|
450
450
|
clearTimeout(this.retryTimer);
|
|
@@ -1452,12 +1452,12 @@ function toggleSidebar() {
|
|
|
1452
1452
|
|
|
1453
1453
|
// ------------------------------------------------------------- top bar/views
|
|
1454
1454
|
|
|
1455
|
-
const VIEW_TITLES = { bookmarks: "Bookmarks", scheduler: "Scheduler", tasks: "Tasks", monitor: "Task manager", history: "Command history" };
|
|
1455
|
+
const VIEW_TITLES = { bookmarks: "Bookmarks", scheduler: "Scheduler", tasks: "Tasks", monitor: "Task manager", history: "Command history", devices: "Devices" };
|
|
1456
1456
|
|
|
1457
1457
|
export function switchView(view) {
|
|
1458
1458
|
if (S.view === view) return;
|
|
1459
1459
|
S.view = view;
|
|
1460
|
-
for (const v of ["terminals", "bookmarks", "scheduler", "tasks", "monitor", "history"]) $(`#view-${v}`).hidden = v !== view;
|
|
1460
|
+
for (const v of ["terminals", "bookmarks", "scheduler", "tasks", "monitor", "history", "devices"]) $(`#view-${v}`).hidden = v !== view;
|
|
1461
1461
|
for (const b of $$(".sb-link[data-view]")) b.classList.toggle("active", b.dataset.view === view && view !== "terminals");
|
|
1462
1462
|
$("#term-actions").hidden = view !== "terminals";
|
|
1463
1463
|
if (mobile.matches) $("#app").classList.remove("sb-mobile-open");
|
|
@@ -1603,6 +1603,7 @@ function commandPalette() {
|
|
|
1603
1603
|
["Search all terminals", "search", () => searchAllDialog(), "Ctrl+Shift+F"],
|
|
1604
1604
|
["Command history", "history", () => switchView("history"), "Ctrl+Alt+R"],
|
|
1605
1605
|
["Task manager", "activity", () => switchView("monitor"), "Ctrl+Alt+M"],
|
|
1606
|
+
["Devices", "devices", () => switchView("devices")],
|
|
1606
1607
|
["Open terminals", "terminal", () => switchView("terminals")],
|
|
1607
1608
|
["Open bookmarks", "bookmark", () => switchView("bookmarks")],
|
|
1608
1609
|
["Open scheduler", "clock", () => switchView("scheduler"), "Ctrl+Alt+S"],
|
|
@@ -1718,7 +1719,7 @@ let authMode = null; // "login" | "setup" while the lock screen is up
|
|
|
1718
1719
|
/** Lock screen: log in, or create the first password. */
|
|
1719
1720
|
async function showAuth(status = null) {
|
|
1720
1721
|
status ||= await api("/api/auth/status").catch(() => ({ has_password: true }));
|
|
1721
|
-
const mode = status.has_password ? "
|
|
1722
|
+
const mode = !status.has_password ? "setup" : status.in_use_elsewhere ? "inuse" : "login";
|
|
1722
1723
|
if (!$("#lock").hidden && authMode === mode) return;
|
|
1723
1724
|
authMode = mode;
|
|
1724
1725
|
const form = $("#lock-form");
|
|
@@ -1733,6 +1734,13 @@ async function showAuth(status = null) {
|
|
|
1733
1734
|
<p class="faint small">You are connecting from another machine. The setup code is printed where shelldeck runs, and saved in its data folder as <code>setup-code</code>.</p>` : ""}
|
|
1734
1735
|
<div class="error" id="lock-error"></div>
|
|
1735
1736
|
<button class="btn primary" type="submit">Create password</button>`
|
|
1737
|
+
: mode === "inuse"
|
|
1738
|
+
? `<img src="/static/icon.svg" alt="" width="40" height="40" />
|
|
1739
|
+
<h2>In use on another device</h2>
|
|
1740
|
+
<p class="muted">shelldeck is used on one device at a time. Enter the password to use it here; the other device goes idle.</p>
|
|
1741
|
+
<input type="password" name="password" placeholder="Password" autocomplete="current-password" required />
|
|
1742
|
+
<div class="error" id="lock-error"></div>
|
|
1743
|
+
<button class="btn primary" type="submit">Use here</button>`
|
|
1736
1744
|
: `<img src="/static/icon.svg" alt="" width="40" height="40" />
|
|
1737
1745
|
<h2>shelldeck is locked</h2>
|
|
1738
1746
|
<input type="password" name="password" placeholder="Password" autocomplete="current-password" required />
|
|
@@ -1775,7 +1783,7 @@ function connectAlarms() {
|
|
|
1775
1783
|
if (msg.type === "alarm_snapshot") msg.alarms.forEach((t) => views.showAlarm(t, false));
|
|
1776
1784
|
};
|
|
1777
1785
|
ws.onclose = (ev) => {
|
|
1778
|
-
if (ev.code !== 1008) setTimeout(connectAlarms, 3000);
|
|
1786
|
+
if (ev.code !== 1008 && ev.code !== 4423) setTimeout(connectAlarms, 3000);
|
|
1779
1787
|
};
|
|
1780
1788
|
}
|
|
1781
1789
|
|
|
@@ -1868,7 +1876,7 @@ async function init() {
|
|
|
1868
1876
|
wireLock();
|
|
1869
1877
|
views.init();
|
|
1870
1878
|
const status = await api("/api/auth/status").catch(() => null);
|
|
1871
|
-
if (!status?.authenticated) {
|
|
1879
|
+
if (!status?.authenticated || status.in_use_elsewhere) {
|
|
1872
1880
|
await finishBoot();
|
|
1873
1881
|
return showAuth(status);
|
|
1874
1882
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Devices page: every signed-in browser, which one is in use, and revoke.
|
|
2
|
+
import { api, confirmDialog, esc, icon, toast, toastError } from "./ui.js";
|
|
3
|
+
|
|
4
|
+
function ago(ts) {
|
|
5
|
+
const s = Math.max(0, Date.now() / 1000 - ts);
|
|
6
|
+
if (s < 60) return "just now";
|
|
7
|
+
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
|
8
|
+
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
|
|
9
|
+
return new Date(ts * 1000).toLocaleDateString();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** "Edge on Windows" from a user agent; good enough to tell your own devices apart. */
|
|
13
|
+
function deviceName(ua) {
|
|
14
|
+
const browser = /Edg\//.test(ua) ? "Edge" : /OPR\//.test(ua) ? "Opera" : /Firefox\//.test(ua) ? "Firefox"
|
|
15
|
+
: /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "Browser";
|
|
16
|
+
const os = /iPhone|iPad/.test(ua) ? "iOS" : /Android/.test(ua) ? "Android" : /Windows/.test(ua) ? "Windows"
|
|
17
|
+
: /Mac OS X/.test(ua) ? "macOS" : /Linux/.test(ua) ? "Linux" : "";
|
|
18
|
+
return os ? `${browser} on ${os}` : browser;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function viaLabel(via) {
|
|
22
|
+
if (via.startsWith("share:")) return `Share <span class="faint">${esc(via.slice(6))}</span>`;
|
|
23
|
+
return via === "network" ? "Network" : "This machine";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function renderDevices(el) {
|
|
27
|
+
if (!el.dataset.wired) {
|
|
28
|
+
el.dataset.wired = "1";
|
|
29
|
+
el.innerHTML = `<div class="page-head">
|
|
30
|
+
<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>
|
|
31
|
+
<button class="btn" data-dev="others">${icon("power")}Sign out other devices</button>
|
|
32
|
+
</div>
|
|
33
|
+
<p class="dev-summary muted"></p>
|
|
34
|
+
<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></tbody></table></div>`;
|
|
35
|
+
el.addEventListener("click", (e) => onClick(e, el));
|
|
36
|
+
}
|
|
37
|
+
let data;
|
|
38
|
+
try {
|
|
39
|
+
data = await api("/api/devices");
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return toastError(e);
|
|
42
|
+
}
|
|
43
|
+
const n = data.devices.length;
|
|
44
|
+
el.querySelector(".dev-summary").textContent =
|
|
45
|
+
`${n} signed in · ${data.devices.filter((d) => d.active).length} in use` + (data.share ? ` · sharing via ${data.share}` : "");
|
|
46
|
+
el.querySelector("[data-dev=others]").disabled = n < 2;
|
|
47
|
+
el.querySelector("tbody").innerHTML = data.devices
|
|
48
|
+
.map(
|
|
49
|
+
(d) => `<tr>
|
|
50
|
+
<td><div class="dev-name">${esc(deviceName(d.agent))}${d.current ? ' <span class="exit ok">this device</span>' : ""}</div>
|
|
51
|
+
<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>
|
|
52
|
+
<td>${viaLabel(d.via)}<div class="hist-meta">${esc(d.ip)}</div></td>
|
|
53
|
+
<td class="nowrap faint">${esc(ago(d.created_at))}</td>
|
|
54
|
+
<td class="nowrap faint">${esc(ago(d.last_seen))}</td>
|
|
55
|
+
<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>
|
|
56
|
+
</tr>`,
|
|
57
|
+
)
|
|
58
|
+
.join("");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function onClick(e, el) {
|
|
62
|
+
const b = e.target.closest("[data-dev]");
|
|
63
|
+
if (!b) return;
|
|
64
|
+
const me = b.dataset.me === "1";
|
|
65
|
+
const others = b.dataset.dev === "others";
|
|
66
|
+
const what = others ? "Sign out every other device?" : me ? "Sign out this device?" : "Revoke this device?";
|
|
67
|
+
if (!(await confirmDialog(`${what} It will need the password to come back.`, { ok: "Sign out", danger: true }))) return;
|
|
68
|
+
try {
|
|
69
|
+
const r = await api(`/api/devices/${others ? "others" : encodeURIComponent(b.dataset.id)}`, { method: "DELETE" });
|
|
70
|
+
if (me) return location.reload();
|
|
71
|
+
toast(`${r.revoked} device${r.revoked === 1 ? "" : "s"} signed out`);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return toastError(err);
|
|
74
|
+
}
|
|
75
|
+
renderDevices(el);
|
|
76
|
+
}
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
<button class="sb-link" data-action="view" data-view="tasks"><i data-icon="kanban"></i>Tasks</button>
|
|
48
48
|
<button class="sb-link" data-action="view" data-view="history"><i data-icon="history"></i>History</button>
|
|
49
49
|
<button class="sb-link" data-action="view" data-view="monitor"><i data-icon="activity"></i>Task manager</button>
|
|
50
|
+
<button class="sb-link" data-action="view" data-view="devices"><i data-icon="devices"></i>Devices</button>
|
|
50
51
|
<div class="sb-row">
|
|
51
52
|
<button class="sb-link" data-action="settings"><i data-icon="settings"></i>Settings</button>
|
|
52
53
|
<span id="conn" class="conn" title="Server connection"></span>
|
|
@@ -88,6 +89,7 @@
|
|
|
88
89
|
<section id="view-tasks" class="view page" hidden></section>
|
|
89
90
|
<section id="view-monitor" class="view page" hidden></section>
|
|
90
91
|
<section id="view-history" class="view page" hidden></section>
|
|
92
|
+
<section id="view-devices" class="view page" hidden></section>
|
|
91
93
|
<div id="keybar" aria-label="Terminal keys">
|
|
92
94
|
<button data-key="esc">Esc</button><button data-key="tab">Tab</button><button data-key="ctrl" aria-pressed="false">Ctrl</button>
|
|
93
95
|
<button data-key="left" aria-label="Left">←</button><button data-key="up" aria-label="Up">↑</button><button data-key="down" aria-label="Down">↓</button><button data-key="right" aria-label="Right">→</button>
|
|
@@ -22,6 +22,7 @@ export async function api(path, { method = "GET", body } = {}) {
|
|
|
22
22
|
/* empty body */
|
|
23
23
|
}
|
|
24
24
|
if (res.status === 401 && (data?.error === "locked" || data?.error === "setup_required")) bus.dispatchEvent(new Event("locked"));
|
|
25
|
+
if (res.status === 423) bus.dispatchEvent(new Event("locked")); // another device is in use
|
|
25
26
|
if (!res.ok) {
|
|
26
27
|
const e = new Error(data?.error || `HTTP ${res.status}`);
|
|
27
28
|
e.data = data;
|
|
@@ -98,6 +99,7 @@ const P = {
|
|
|
98
99
|
external: '<path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
|
99
100
|
pencil: '<path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
|
100
101
|
trash: '<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
|
102
|
+
devices: '<rect x="2" y="4" width="14" height="10" rx="1.5"/><path d="M5 18h8"/><rect x="17" y="8" width="5" height="12" rx="1"/>',
|
|
101
103
|
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
|
102
104
|
up: '<path d="M12 19V5M5 12l7-7 7 7"/>',
|
|
103
105
|
clipboard: '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { S, TERMINAL_THEMES, applySettings, findSession, newTerminal, orderedProjects, palette, refreshProjects, shellLabel } from "./app.js";
|
|
2
2
|
import { renderMonitor } from "./monitor.js";
|
|
3
3
|
import { renderHistory } from "./history.js";
|
|
4
|
+
import { renderDevices } from "./devices.js";
|
|
4
5
|
import { $, $$, api, authError, confirmDialog, dialog, esc, fmtTime, fromLocalInput, hydrateIcons, icon, toLocalInput, toast, toastError, withEyes } from "./ui.js";
|
|
5
6
|
|
|
6
7
|
let bookmarks = [];
|
|
@@ -12,7 +13,7 @@ export function init() {}
|
|
|
12
13
|
export function show(view, el) {
|
|
13
14
|
current = view;
|
|
14
15
|
clearInterval(pollTimer);
|
|
15
|
-
const render = { bookmarks: renderBookmarks, scheduler: renderScheduler, tasks: renderTasks, monitor: renderMonitor, history: renderHistory }[view];
|
|
16
|
+
const render = { bookmarks: renderBookmarks, scheduler: renderScheduler, tasks: renderTasks, monitor: renderMonitor, history: renderHistory, devices: renderDevices }[view];
|
|
16
17
|
render(el);
|
|
17
18
|
if (!["bookmarks", "monitor", "history"].includes(view)) { // monitor refreshes from its own 2s poller
|
|
18
19
|
pollTimer = setInterval(() => {
|
|
@@ -23,6 +23,7 @@ def client(tmp_path, monkeypatch):
|
|
|
23
23
|
def browser(tmp_path, monkeypatch):
|
|
24
24
|
"""A client with no CLI token: it must log in like a browser."""
|
|
25
25
|
auth._failures.clear()
|
|
26
|
+
monkeypatch.setitem(server._active, "h", None)
|
|
26
27
|
monkeypatch.setenv("SHELLDECK_HOME", str(tmp_path))
|
|
27
28
|
monkeypatch.setattr(server, "ALLOWED_HOSTS", {"testserver"})
|
|
28
29
|
with TestClient(server.app) as c:
|
|
@@ -394,9 +395,49 @@ def test_share_gate_needs_link_then_password(browser):
|
|
|
394
395
|
login = r.headers["set-cookie"].split(";")[0]
|
|
395
396
|
authed = {**tunnel, "cookie": f"{gate['cookie']}; {login}"}
|
|
396
397
|
assert b.get("/api/projects", headers=authed).status_code == 200
|
|
398
|
+
vias = {d["via"] for d in b.get("/api/devices", headers=authed).json()["devices"]}
|
|
399
|
+
assert vias == {"local", "share:" + tunnel["host"]}
|
|
397
400
|
|
|
398
401
|
# stopping closes the gate and signs out tunnel logins
|
|
399
402
|
assert b.delete("/api/share", headers=cli).status_code == 200
|
|
400
403
|
assert b.get("/", headers=authed).status_code == 403
|
|
401
404
|
auth._cache.clear()
|
|
402
405
|
assert b.get("/api/projects", headers={"cookie": login}).status_code == 401
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_devices_one_active_takeover_and_revoke(browser, tmp_path):
|
|
409
|
+
a = browser
|
|
410
|
+
a.post("/api/auth/setup", json={"password": "longenough", "confirm": "longenough"})
|
|
411
|
+
sid = a.post("/api/sessions", json={"cwd": str(tmp_path)}).json()["id"]
|
|
412
|
+
c = TestClient(server.app)
|
|
413
|
+
assert c.post("/api/auth/login", json={"password": "longenough"}).status_code == 200
|
|
414
|
+
|
|
415
|
+
# c logged in last, so c is in use and a is idle (API 423, sockets closed with 4423)
|
|
416
|
+
assert c.get("/api/projects").status_code == 200
|
|
417
|
+
assert a.get("/api/projects").status_code == 423
|
|
418
|
+
assert a.get("/api/auth/status").json()["in_use_elsewhere"] is True
|
|
419
|
+
with pytest.raises(WebSocketDisconnect) as ex, a.websocket_connect(f"/ws/{sid}"):
|
|
420
|
+
pass
|
|
421
|
+
assert ex.value.code == server.IN_USE
|
|
422
|
+
devs = c.get("/api/devices").json()["devices"]
|
|
423
|
+
assert len(devs) == 2 and [d["current"] for d in devs if d["active"]] == [True]
|
|
424
|
+
|
|
425
|
+
# a takes over with the password; its old login is replaced, not duplicated
|
|
426
|
+
assert a.post("/api/auth/login", json={"password": "longenough"}).status_code == 200
|
|
427
|
+
assert a.get("/api/projects").status_code == 200 and c.get("/api/projects").status_code == 423
|
|
428
|
+
assert len(a.get("/api/devices").json()["devices"]) == 2
|
|
429
|
+
|
|
430
|
+
# a revokes c; c is signed out
|
|
431
|
+
other = next(d["id"] for d in a.get("/api/devices").json()["devices"] if not d["current"])
|
|
432
|
+
assert a.delete(f"/api/devices/{other}").json() == {"revoked": 1}
|
|
433
|
+
assert c.get("/api/projects").status_code == 401
|
|
434
|
+
assert [d["current"] for d in a.get("/api/devices").json()["devices"]] == [True]
|
|
435
|
+
|
|
436
|
+
# "others" keeps this browser; once the active one logs out, an idle one may act again
|
|
437
|
+
c.post("/api/auth/login", json={"password": "longenough"})
|
|
438
|
+
assert c.delete("/api/devices/others").json() == {"revoked": 1}
|
|
439
|
+
assert a.get("/api/projects").status_code == 401
|
|
440
|
+
d = TestClient(server.app)
|
|
441
|
+
d.post("/api/auth/login", json={"password": "longenough"})
|
|
442
|
+
d.post("/api/auth/logout")
|
|
443
|
+
assert c.get("/api/projects").status_code == 200
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|