@lifeaitools/clauth 2.1.1 → 2.10.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/.clauth-skill/references/keys-guide.md +270 -270
- package/cli/api.js +238 -238
- package/cli/commands/install.js +413 -396
- package/cli/commands/login.test.js +49 -1
- package/cli/commands/ops-install.js +11 -10
- package/cli/commands/ops.test.js +270 -0
- package/cli/commands/scrub.test.js +70 -0
- package/cli/commands/serve.js +2613 -11219
- package/cli/commands/uninstall.js +164 -164
- package/cli/dashboard/dashboard.js +592 -0
- package/cli/dashboard/global.css +65 -0
- package/cli/dashboard/panel-element.js +79 -0
- package/cli/dashboard/panels/build-status-panel.css +12 -0
- package/cli/dashboard/panels/build-status-panel.js +98 -0
- package/cli/dashboard/panels/panel-registry.js +58 -0
- package/cli/dashboard/panels/services-panel.css +95 -0
- package/cli/dashboard/panels/services-panel.js +872 -0
- package/cli/dashboard/panels/surfaces-panel.css +59 -0
- package/cli/dashboard/panels/surfaces-panel.js +474 -0
- package/cli/dashboard/panels/toolbar-panel.css +42 -0
- package/cli/dashboard/panels/toolbar-panel.js +374 -0
- package/cli/dashboard/panels/tunnel-panel.css +61 -0
- package/cli/dashboard/panels/tunnel-panel.js +688 -0
- package/cli/dashboard/panels/webdav-panel.css +38 -0
- package/cli/dashboard/panels/webdav-panel.js +166 -0
- package/cli/fingerprint.test.js +95 -0
- package/cli/http/components/callagent-terminal-component.js +310 -0
- package/cli/http/components/dashboard-component.js +123 -0
- package/cli/http/components/inbox-component.js +41 -0
- package/cli/http/components/mcp-transport-component.js +275 -0
- package/cli/http/components/oauth-component.js +207 -0
- package/cli/http/components/service-crud-component.js +288 -0
- package/cli/http/components/supervisor-component.js +104 -0
- package/cli/http/components/system-component.js +184 -0
- package/cli/http/components/tunnel-component.js +508 -0
- package/cli/http/components/vault-auth-component.js +264 -0
- package/cli/http/components/watchdog-component.js +28 -0
- package/cli/http/request-utils.js +21 -0
- package/cli/http/servers/ops-server.js +335 -0
- package/cli/index.js +5 -91
- package/cli/lib/fs-git.test.js +467 -0
- package/cli/mcp/providers/call-agent-provider.js +30 -0
- package/cli/mcp/providers/clauth-core-provider.js +461 -0
- package/cli/mcp/providers/ops-provider.js +42 -0
- package/cli/mcp/providers/terminal-provider.js +81 -0
- package/cli/mcp/tool-registry.js +48 -0
- package/cli/mcp/tool-schemas.js +822 -0
- package/cli/mcp/tool-schemas.test.js +49 -0
- package/cli/ops/pm2-preflight.js +54 -0
- package/cli/recovery.js +6 -0
- package/cli/recovery.test.js +179 -0
- package/cli/services/agent-pool-instance.js +38 -0
- package/cli/services/claude-binary.js +29 -0
- package/cli/services/delegation-lane-instance.js +35 -0
- package/cli/services/delegation-lane-instance.test.js +37 -0
- package/cli/services/file-io.js +38 -0
- package/cli/services/file-io.test.js +10 -0
- package/cli/services/fs-mount.js +66 -0
- package/cli/services/fs-mount.test.js +14 -0
- package/cli/services/fs-upload-sessions.js +20 -0
- package/cli/services/fs-upload-sessions.test.js +14 -0
- package/cli/services/git-exec.js +37 -0
- package/cli/services/git-exec.test.js +41 -0
- package/cli/services/git-import-guard.js +28 -0
- package/cli/services/git-import-guard.test.js +18 -0
- package/cli/services/service-status.js +191 -0
- package/cli/services/service-status.test.js +39 -0
- package/cli/services/terminal-dispatch.js +135 -0
- package/cli/services/terminal-dispatch.test.js +9 -0
- package/cli/services/terminal-registry.js +39 -0
- package/cli/services/terminal-registry.test.js +31 -0
- package/cli/services/terminal-sessions.js +100 -0
- package/cli/services/terminal-sessions.test.js +26 -0
- package/cli/services/terminal-window.js +106 -0
- package/cli/services/vault-read.js +14 -0
- package/cli/services/vault-read.test.js +35 -0
- package/cli/supervisor-registry.js +116 -0
- package/cli/supervisor-registry.test.js +165 -0
- package/cli/supervisor-ui.test.js +230 -30
- package/cli/webdav-config.js +84 -0
- package/cli/webdav-config.test.js +79 -0
- package/cli/webdav-obscure.js +23 -0
- package/cli/webdav-obscure.test.js +41 -0
- package/cli/webdav-rclone-discovery.js +44 -0
- package/cli/webdav-service.js +30 -118
- package/cli/webdav-service.test.js +107 -0
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +2 -2
- package/scripts/bootstrap.cjs +121 -121
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
- package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
- package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
- package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
- package/cli/commands/codevelop.js +0 -1190
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
const BASE = location.origin;
|
|
2
|
+
(function reportOrigin() {
|
|
3
|
+
const isLocal = /^(127\.0\.0\.1|localhost|\[::1\])$/.test(location.hostname);
|
|
4
|
+
const el = document.getElementById("originFooter");
|
|
5
|
+
if (el) el.textContent = (isLocal ? "LOCAL" : "REMOTE") + " · " + BASE + " · 10-strike lockout";
|
|
6
|
+
})();
|
|
7
|
+
|
|
8
|
+
// SERVICE_HINTS moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
9
|
+
|
|
10
|
+
// KEY_URLS moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
11
|
+
|
|
12
|
+
// Extra links shown below the primary KEY_URLS link
|
|
13
|
+
// EXTRA_LINKS moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
14
|
+
|
|
15
|
+
// ── OAuth import config ─────────────────────
|
|
16
|
+
// Services where Google downloads a JSON file. jsonFields = keys to extract
|
|
17
|
+
// from the downloaded JSON (top-level or under an "installed"/"web" wrapper).
|
|
18
|
+
// extra = additional fields the user must provide separately.
|
|
19
|
+
// OAUTH_IMPORT moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
20
|
+
|
|
21
|
+
// serviceDomId moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
22
|
+
|
|
23
|
+
function htmlEscape(value) {
|
|
24
|
+
return String(value ?? "")
|
|
25
|
+
.replace(/&/g, "&")
|
|
26
|
+
.replace(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">")
|
|
28
|
+
.replace(/"/g, """)
|
|
29
|
+
.replace(/'/g, "'");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Produces a JS string literal safe to embed inside a DOUBLE-QUOTED HTML
|
|
33
|
+
// attribute, e.g. onclick="fn(<jsArg(x)>)".
|
|
34
|
+
//
|
|
35
|
+
// JSON.stringify wraps its result in double quotes. Interpolated into
|
|
36
|
+
// onclick="..." that quote CLOSES the attribute at the first character of the
|
|
37
|
+
// argument, so the browser parsed
|
|
38
|
+
// onclick="selectSupervisorSurface("rdc-skills:local","…")"
|
|
39
|
+
// as onclick="selectSupervisorSurface(" followed by garbage. Every surface
|
|
40
|
+
// card's click handler and every command button was dead HTML — the source
|
|
41
|
+
// read correctly and nothing on the page responded, which is precisely how it
|
|
42
|
+
// was reported. The ampersand escape below then made it worse by rewriting any
|
|
43
|
+
// pre-existing entity that might otherwise have saved it.
|
|
44
|
+
//
|
|
45
|
+
// Escaping the quote as " is what makes it attribute-safe: the HTML parser
|
|
46
|
+
// decodes the entity back to a plain double quote before the JS is compiled, so
|
|
47
|
+
// the handler sees a normal string literal. Order matters: the ampersand must be
|
|
48
|
+
// escaped before the quote, or the entity's own ampersand is double-escaped.
|
|
49
|
+
function jsArg(value) {
|
|
50
|
+
return JSON.stringify(String(value ?? ""))
|
|
51
|
+
.replace(/&/g, "\u0026")
|
|
52
|
+
.replace(/</g, "\u003c")
|
|
53
|
+
.replace(/>/g, "\u003e")
|
|
54
|
+
.replace(/'/g, "\u0027")
|
|
55
|
+
.replace(/"/g, """);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// renderSetPanel moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
59
|
+
|
|
60
|
+
// ── Boot: check lock state ──────────────────
|
|
61
|
+
// Injected by the daemon when it serves the page on an unlocked vault, so a
|
|
62
|
+
// fresh load (incl. when auto-unlocked via --pw/boot.key) is write-ready.
|
|
63
|
+
let writeToken = window.__CLAUTH_INIT_WRITE_TOKEN__;
|
|
64
|
+
|
|
65
|
+
function writeHeaders(extra) {
|
|
66
|
+
if (!writeToken) throw new Error("Writes are locked — click 🔓 Unlock Writes to enable saving.");
|
|
67
|
+
return { ...(extra || {}), "X-Clauth-Write-Token": writeToken };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Write-access choke point ────────────────
|
|
71
|
+
// ONE mechanism sits in front of every write action.
|
|
72
|
+
//
|
|
73
|
+
// A button on this dashboard IS the human being present — they are past the
|
|
74
|
+
// lock screen already. So a click must never demand a second password. Before
|
|
75
|
+
// each action the page silently acquires a current write token from
|
|
76
|
+
// POST /write-token, which succeeds whenever the vault is unlocked. The
|
|
77
|
+
// password modal is the LOCKED-VAULT fallback only, and the common case never
|
|
78
|
+
// reaches it.
|
|
79
|
+
//
|
|
80
|
+
// Acquiring per action (rather than trusting the token injected at page load)
|
|
81
|
+
// is what makes staleness structurally impossible: the server session has a
|
|
82
|
+
// 10-minute TTL while a dashboard tab can stay open for hours. That gap is the
|
|
83
|
+
// actual defect behind "adding a service says I need write unlock" — the page
|
|
84
|
+
// kept sending a token the daemon had already expired, and writeGuard answered
|
|
85
|
+
// 403 "write token required". One loopback round-trip per click closes it.
|
|
86
|
+
//
|
|
87
|
+
// If the vault really is locked, the action is parked, the modal opens, and a
|
|
88
|
+
// successful unlock re-fires it exactly once. Cancelling clears the park, so no
|
|
89
|
+
// delayed write can fire later.
|
|
90
|
+
//
|
|
91
|
+
// writeHeaders() above still throws, but only as a backstop for a write path
|
|
92
|
+
// that never went through here — WRITE_ACTIONS is asserted complete against
|
|
93
|
+
// every writeHeaders() call site by cli/supervisor-ui.test.js.
|
|
94
|
+
let pendingWriteAction = null;
|
|
95
|
+
|
|
96
|
+
// Returns true when writeToken now holds a token the daemon will accept.
|
|
97
|
+
// False means the vault is genuinely locked (or the daemon is unreachable),
|
|
98
|
+
// which is the only case that deserves a password prompt.
|
|
99
|
+
async function ensureWriteAccess() {
|
|
100
|
+
try {
|
|
101
|
+
const r = await fetch(BASE + "/write-token", { method: "POST" }).then(res => res.json());
|
|
102
|
+
if (r && r.write_token) {
|
|
103
|
+
writeToken = r.write_token;
|
|
104
|
+
refreshWriteLockUi();
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
} catch {}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Every dashboard function whose body calls writeHeaders(). Adding a write
|
|
112
|
+
// action without adding it here fails the registry-completeness test.
|
|
113
|
+
const WRITE_ACTIONS = [
|
|
114
|
+
"rescanSupervisorPlugins",
|
|
115
|
+
"restartDaemon",
|
|
116
|
+
"runSupervisorSurface",
|
|
117
|
+
"rotateKey",
|
|
118
|
+
"setExpiry",
|
|
119
|
+
"saveCardEdit",
|
|
120
|
+
"deleteService",
|
|
121
|
+
"saveKey",
|
|
122
|
+
"toggleService",
|
|
123
|
+
"changePassword",
|
|
124
|
+
"addService",
|
|
125
|
+
"enrollMachine",
|
|
126
|
+
"submitMount",
|
|
127
|
+
"deleteMount",
|
|
128
|
+
"wizSubmitCfToken",
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
function withWriteAccess(name, fn) {
|
|
132
|
+
// async, so every path returns a thenable — a caller doing action(x).catch(…)
|
|
133
|
+
// must not TypeError on the no-write-access path only.
|
|
134
|
+
const guarded = async function (...args) {
|
|
135
|
+
if (!(await ensureWriteAccess())) {
|
|
136
|
+
// Vault is locked. Park the WHOLE call — nothing has run yet, so a
|
|
137
|
+
// confirm() inside the action is asked once, on the retry, rather than
|
|
138
|
+
// before a no-op.
|
|
139
|
+
pendingWriteAction = { name, fn, self: this, args };
|
|
140
|
+
openWriteUnlockModal();
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
return fn.apply(this, args);
|
|
144
|
+
};
|
|
145
|
+
guarded.__writeGuarded = true;
|
|
146
|
+
guarded.__unguarded = fn;
|
|
147
|
+
return guarded;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function clearPendingWriteAction() { pendingWriteAction = null; }
|
|
151
|
+
|
|
152
|
+
// Read-only seams so tests (and the console) can observe park state without
|
|
153
|
+
// reaching into a module-scoped binding.
|
|
154
|
+
function hasPendingWriteAction() { return pendingWriteAction !== null; }
|
|
155
|
+
function pendingWriteActionName() { return pendingWriteAction ? pendingWriteAction.name : null; }
|
|
156
|
+
|
|
157
|
+
function takePendingWriteAction() {
|
|
158
|
+
const pending = pendingWriteAction;
|
|
159
|
+
pendingWriteAction = null;
|
|
160
|
+
return pending;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function installWriteAccessGuards(scope) {
|
|
164
|
+
for (const name of WRITE_ACTIONS) {
|
|
165
|
+
const fn = scope[name];
|
|
166
|
+
if (typeof fn === "function" && fn.__writeGuarded) continue;
|
|
167
|
+
if (typeof fn !== "function") {
|
|
168
|
+
// Fail loud, not silent. A registered name that is not a global function
|
|
169
|
+
// is almost always a write action refactored from a function declaration
|
|
170
|
+
// to a const/arrow binding: const/let create no global property, so
|
|
171
|
+
// scope[name] is undefined, the guard would no-op, and the inline
|
|
172
|
+
// onclick="" would resolve through the lexical binding straight to the
|
|
173
|
+
// UNGUARDED function — silently restoring the write-lock defect for that
|
|
174
|
+
// action. Skipping it here is how that regression would ship green.
|
|
175
|
+
throw new Error(
|
|
176
|
+
"clauth write-guard install failed: '" + name + "' is registered in WRITE_ACTIONS but is not a " +
|
|
177
|
+
"global function. Declare it as 'function " + name + "(...)' — a const/let/arrow binding cannot be guarded."
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
scope[name] = withWriteAccess(name, fn);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Safe to run here: every dashboard function is a top-level function
|
|
185
|
+
// declaration, so all of them are hoisted and initialised before this
|
|
186
|
+
// statement executes. Inline onclick="" handlers resolve through the same
|
|
187
|
+
// global properties, so they get the guarded versions.
|
|
188
|
+
installWriteAccessGuards(typeof window !== "undefined" ? window : globalThis);
|
|
189
|
+
|
|
190
|
+
async function boot() {
|
|
191
|
+
try {
|
|
192
|
+
const ping = await fetch(BASE + "/ping").then(r => r.json());
|
|
193
|
+
if (ping.locked) {
|
|
194
|
+
showLockScreen(ping.hard_locked);
|
|
195
|
+
} else {
|
|
196
|
+
showMain(ping);
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
showLockScreen(false);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function showLockScreen(hardLocked) {
|
|
204
|
+
document.getElementById("lock-screen").style.display = "flex";
|
|
205
|
+
document.getElementById("main-view").style.display = "none";
|
|
206
|
+
const input = document.getElementById("lock-input");
|
|
207
|
+
const btn = document.getElementById("unlock-btn");
|
|
208
|
+
const sub = document.getElementById("lock-sub");
|
|
209
|
+
const err = document.getElementById("lock-err");
|
|
210
|
+
if (hardLocked) {
|
|
211
|
+
input.disabled = true;
|
|
212
|
+
btn.disabled = true;
|
|
213
|
+
sub.textContent = "Too many failed attempts";
|
|
214
|
+
err.textContent = "✗ Vault locked — restart daemon to try again";
|
|
215
|
+
} else {
|
|
216
|
+
input.disabled = false;
|
|
217
|
+
btn.disabled = false;
|
|
218
|
+
sub.textContent = "Paste your password to unlock";
|
|
219
|
+
err.textContent = "";
|
|
220
|
+
setTimeout(() => input.focus(), 50);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Upgrade detection ────────────────────────────────────────────
|
|
225
|
+
function checkUpgrade(ping) {
|
|
226
|
+
const currentVersion = ping.app_version || ping.version;
|
|
227
|
+
if (!currentVersion) return;
|
|
228
|
+
|
|
229
|
+
const lastVersion = localStorage.getItem('clauth_last_version');
|
|
230
|
+
|
|
231
|
+
if (lastVersion && lastVersion !== currentVersion) {
|
|
232
|
+
// Version changed — show upgrade banner
|
|
233
|
+
document.getElementById('upgrade-to-version').textContent = currentVersion;
|
|
234
|
+
|
|
235
|
+
// Build details from migration result if available
|
|
236
|
+
let details = '';
|
|
237
|
+
if (ping.last_migrations?.applied?.length > 0) {
|
|
238
|
+
details = '· Migrations: ' + ping.last_migrations.applied.map(m => m.description).join(' · ');
|
|
239
|
+
}
|
|
240
|
+
// Show breaking migration warning if any pending
|
|
241
|
+
if (ping.pending_breaking?.length > 0) {
|
|
242
|
+
details += ' ⚠ Breaking migrations require confirmation — see below.';
|
|
243
|
+
}
|
|
244
|
+
document.getElementById('upgrade-details').textContent = details;
|
|
245
|
+
document.getElementById('upgrade-banner').style.display = 'block';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Always update stored version
|
|
249
|
+
localStorage.setItem('clauth_last_version', currentVersion);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function dismissUpgrade() {
|
|
253
|
+
document.getElementById('upgrade-banner').style.display = 'none';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function showMain(ping) {
|
|
257
|
+
checkUpgrade(ping);
|
|
258
|
+
document.getElementById("lock-screen").style.display = "none";
|
|
259
|
+
document.getElementById("main-view").style.display = "block";
|
|
260
|
+
// Reset lock screen state for next lock
|
|
261
|
+
document.getElementById("lock-input").disabled = false;
|
|
262
|
+
document.getElementById("unlock-btn").disabled = false;
|
|
263
|
+
document.getElementById("lock-sub").textContent = "Paste your password to unlock";
|
|
264
|
+
document.getElementById("lock-err").textContent = "";
|
|
265
|
+
if (ping) {
|
|
266
|
+
document.getElementById("s-pid").textContent = ping.pid || "—";
|
|
267
|
+
document.getElementById("s-fails").textContent =
|
|
268
|
+
ping.failures + "/" + (ping.failures + ping.failures_remaining);
|
|
269
|
+
}
|
|
270
|
+
window.ClauthPanelRegistry.mountAll();
|
|
271
|
+
refreshWriteLockUi();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Surfaces/Supervisor panel moved to ./panels/surfaces-panel.js (panel-componentization, sixth slice).
|
|
275
|
+
|
|
276
|
+
// ── Unlock ──────────────────────────────────
|
|
277
|
+
async function unlock() {
|
|
278
|
+
const input = document.getElementById("lock-input");
|
|
279
|
+
const btn = document.getElementById("unlock-btn");
|
|
280
|
+
const err = document.getElementById("lock-err");
|
|
281
|
+
const sub = document.getElementById("lock-sub");
|
|
282
|
+
const pw = input.value;
|
|
283
|
+
|
|
284
|
+
if (input.disabled) return;
|
|
285
|
+
if (!pw) { err.textContent = "Password is required."; return; }
|
|
286
|
+
|
|
287
|
+
btn.disabled = true; btn.textContent = "Verifying…"; err.textContent = "";
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const r = await fetch(BASE + "/auth", {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers: { "Content-Type": "application/json" },
|
|
293
|
+
body: JSON.stringify({ password: pw })
|
|
294
|
+
}).then(r => r.json());
|
|
295
|
+
|
|
296
|
+
if (r.hard_locked) {
|
|
297
|
+
input.value = "";
|
|
298
|
+
input.disabled = true;
|
|
299
|
+
btn.disabled = true;
|
|
300
|
+
btn.textContent = "Unlock";
|
|
301
|
+
if (r.terminal) {
|
|
302
|
+
// Server rendered a terminal verdict (invalid_token / machine_locked).
|
|
303
|
+
// Show the reason — this is the one-line signal that diagnoses the lockout.
|
|
304
|
+
sub.textContent = r.reason ? ("Vault rejected: " + r.reason) : "Vault rejected credentials";
|
|
305
|
+
err.textContent = "✗ Recovery required — unlock machine + re-seal boot.key (see runbook)";
|
|
306
|
+
} else {
|
|
307
|
+
sub.textContent = "Too many failed attempts";
|
|
308
|
+
err.textContent = r.reason
|
|
309
|
+
? ("✗ Vault locked (" + r.reason + ") — restart daemon to try again")
|
|
310
|
+
: "✗ Vault locked — restart daemon to try again";
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (r.transport_error || r.backend_error) {
|
|
316
|
+
// Backend/database outage — NOT a wrong password. Show the specific reason
|
|
317
|
+
// the daemon classified (timeout / db_error / rate_limited / network) so the
|
|
318
|
+
// user knows it's the backend, not their credentials. No attempt was spent.
|
|
319
|
+
err.textContent = "⚠ " + (r.error || "Vault backend unreachable — try again in a moment");
|
|
320
|
+
err.style.color = "#facc15";
|
|
321
|
+
btn.disabled = false; btn.textContent = "Unlock";
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (r.error) {
|
|
326
|
+
const rem = r.failures_remaining !== undefined
|
|
327
|
+
? ` (${r.failures_remaining} attempt${r.failures_remaining !== 1 ? "s" : ""} remaining)`
|
|
328
|
+
: "";
|
|
329
|
+
throw new Error(r.error + rem);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
writeToken = r.write_token || null;
|
|
333
|
+
// Full-vault unlock is a fresh page state, not a retry of a parked write.
|
|
334
|
+
// Kept symmetric with unlockWrites() so no path can carry a stale park.
|
|
335
|
+
clearPendingWriteAction();
|
|
336
|
+
input.value = "";
|
|
337
|
+
const ping = await fetch(BASE + "/ping").then(r => r.json());
|
|
338
|
+
showMain(ping);
|
|
339
|
+
document.querySelector("clauth-services-panel")?.loadServices();
|
|
340
|
+
} catch (e) {
|
|
341
|
+
input.value = "";
|
|
342
|
+
input.className = "lock-input error";
|
|
343
|
+
err.textContent = "✗ " + (e.message || "Invalid password");
|
|
344
|
+
setTimeout(() => input.className = "lock-input", 600);
|
|
345
|
+
} finally {
|
|
346
|
+
if (!input.disabled) { btn.disabled = false; btn.textContent = "Unlock"; }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Lock ────────────────────────────────────
|
|
351
|
+
// lockVault moved to ./panels/toolbar-panel.js (panel-componentization, second slice).
|
|
352
|
+
|
|
353
|
+
// ── Unlock writes (re-establish write scope without locking) ──
|
|
354
|
+
// Needed when the daemon auto-unlocks via --pw/boot.key: the page never sees the
|
|
355
|
+
// unlock screen, so it holds no write token. POST /auth mints one (10-min TTL).
|
|
356
|
+
// unlockWrites moved to ./panels/toolbar-panel.js (panel-componentization, second slice).
|
|
357
|
+
|
|
358
|
+
function openWriteUnlockModal() {
|
|
359
|
+
const overlay = document.getElementById("write-unlock-overlay");
|
|
360
|
+
const input = document.getElementById("write-unlock-input");
|
|
361
|
+
const err = document.getElementById("write-unlock-err");
|
|
362
|
+
if (err) err.textContent = "";
|
|
363
|
+
if (input) { input.value = ""; input.className = "lock-input"; }
|
|
364
|
+
if (overlay) overlay.style.display = "flex";
|
|
365
|
+
if (input) setTimeout(() => input.focus(), 50);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function closeWriteUnlockModal() {
|
|
369
|
+
// Dismissing the modal must disarm the parked write. submitWriteUnlock()
|
|
370
|
+
// takes the park before it calls this, so a successful unlock still retries.
|
|
371
|
+
clearPendingWriteAction();
|
|
372
|
+
const overlay = document.getElementById("write-unlock-overlay");
|
|
373
|
+
if (overlay) overlay.style.display = "none";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function submitWriteUnlock() {
|
|
377
|
+
const input = document.getElementById("write-unlock-input");
|
|
378
|
+
const btn = document.getElementById("write-unlock-btn");
|
|
379
|
+
const err = document.getElementById("write-unlock-err");
|
|
380
|
+
const pw = input ? input.value : "";
|
|
381
|
+
if (!pw) { if (err) err.textContent = "Password is required."; return; }
|
|
382
|
+
if (btn) { btn.disabled = true; btn.textContent = "Verifying..."; }
|
|
383
|
+
try {
|
|
384
|
+
const r = await fetch(BASE + "/auth", {
|
|
385
|
+
method: "POST",
|
|
386
|
+
headers: { "Content-Type": "application/json" },
|
|
387
|
+
body: JSON.stringify({ password: pw }),
|
|
388
|
+
}).then(r => r.json());
|
|
389
|
+
if (r.error) {
|
|
390
|
+
if (input) { input.className = "lock-input error"; setTimeout(() => input.className = "lock-input", 600); }
|
|
391
|
+
if (err) err.textContent = "Invalid: " + (r.error || "Invalid password");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
writeToken = r.write_token || null;
|
|
395
|
+
// Take the park BEFORE closing — closeWriteUnlockModal() clears it.
|
|
396
|
+
const pending = takePendingWriteAction();
|
|
397
|
+
refreshWriteLockUi();
|
|
398
|
+
closeWriteUnlockModal();
|
|
399
|
+
if (pending && writeToken) {
|
|
400
|
+
// Re-fire the ORIGINAL unguarded action, exactly once. Using the
|
|
401
|
+
// unguarded reference means it can never re-park itself into a loop.
|
|
402
|
+
try {
|
|
403
|
+
await pending.fn.apply(pending.self, pending.args);
|
|
404
|
+
} catch (retryErr) {
|
|
405
|
+
console.error("[clauth] write action '" + pending.name + "' failed after unlock:", retryErr);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
} catch (e) {
|
|
409
|
+
if (err) err.textContent = "Unlock error: " + (e.message || e);
|
|
410
|
+
} finally {
|
|
411
|
+
if (btn) { btn.disabled = false; btn.textContent = "Unlock Writes"; }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Reflect write-lock state on the button so it is obvious when a save will fail.
|
|
416
|
+
// #unlock-writes-btn now lives inside <clauth-toolbar-panel>'s own shadow
|
|
417
|
+
// root -- document.getElementById can never cross a shadow boundary, so this
|
|
418
|
+
// delegates to the panel's own public method instead of reaching into raw
|
|
419
|
+
// DOM, same fix as tunnel-panel.js's updateHealthCheckResult().
|
|
420
|
+
function refreshWriteLockUi() {
|
|
421
|
+
const panel = document.querySelector("clauth-toolbar-panel");
|
|
422
|
+
panel?.refreshWriteLockButton(!!writeToken);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Make Live (blue-green: promote staged instance to live port) ──
|
|
426
|
+
async function makeLive() {
|
|
427
|
+
if (!confirm("Promote this staged instance to live?\n\nThe current live daemon (port 52437) will be stopped and this instance will restart on port 52437.")) return;
|
|
428
|
+
const banner = document.getElementById("staged-banner");
|
|
429
|
+
if (banner) banner.innerHTML = '<div style="color:#fef3c7"><strong>⏳ Promoting to live...</strong></div>';
|
|
430
|
+
try {
|
|
431
|
+
const resp = await fetch(BASE + "/make-live", { method: "POST" });
|
|
432
|
+
const data = await resp.json();
|
|
433
|
+
if (data.ok) {
|
|
434
|
+
document.getElementById("main-view").innerHTML = '<div style="text-align:center;padding:80px 20px"><div style="font-size:3rem;margin-bottom:16px">✅</div><div style="font-size:1.1rem;color:#4ade80">Promoted to live!</div><div style="font-size:.85rem;color:#94a3b8;margin-top:8px">Restarting on port 52437... page will reload in 5 seconds.</div></div>';
|
|
435
|
+
setTimeout(() => { window.location.href = "http://127.0.0.1:" + 52437; }, 5000);
|
|
436
|
+
} else {
|
|
437
|
+
if (banner) banner.innerHTML = '<div style="color:#fca5a5"><strong>❌ Promotion failed:</strong> ' + (data.error || 'unknown') + '</div>';
|
|
438
|
+
}
|
|
439
|
+
} catch (err) {
|
|
440
|
+
if (banner) banner.innerHTML = '<div style="color:#fca5a5"><strong>❌ Promotion failed:</strong> ' + err.message + '</div>';
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Restart daemon (keeps boot.key — vault stays unlocked) ──
|
|
445
|
+
// restartDaemon moved to ./panels/toolbar-panel.js (panel-componentization, second slice).
|
|
446
|
+
|
|
447
|
+
// ── Stop daemon (user-initiated — clears boot.key, requires password on restart) ──
|
|
448
|
+
// stopDaemon moved to ./panels/toolbar-panel.js (panel-componentization, second slice).
|
|
449
|
+
|
|
450
|
+
// ── Load services ───────────────────────────
|
|
451
|
+
// allServices moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
452
|
+
// activeProjectTab moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
453
|
+
// serviceSearchQuery moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
454
|
+
|
|
455
|
+
// serviceSort moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
456
|
+
|
|
457
|
+
// matchesServiceSearch moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
458
|
+
|
|
459
|
+
// setServiceSearch moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
460
|
+
|
|
461
|
+
// renderProjectTabs moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
462
|
+
|
|
463
|
+
// switchProjectTab moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
464
|
+
|
|
465
|
+
// MCP_SURFACE_DUPLICATE_SERVICES moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
466
|
+
|
|
467
|
+
// renderServiceGrid moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
468
|
+
|
|
469
|
+
// loadServices moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
470
|
+
|
|
471
|
+
// ── Expiry & Rotation ───────────────────────
|
|
472
|
+
// ROTATION_CAPABLE moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
473
|
+
|
|
474
|
+
// loadExpiry moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
475
|
+
|
|
476
|
+
// rotateKey moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
477
|
+
|
|
478
|
+
// setExpiry moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
479
|
+
|
|
480
|
+
// ── Reveal ──────────────────────────────────
|
|
481
|
+
// reveal moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
482
|
+
|
|
483
|
+
// copyKey moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
484
|
+
|
|
485
|
+
// ── Project edit ────────────────────────────
|
|
486
|
+
// toggleProjectEdit moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
487
|
+
|
|
488
|
+
// saveProject moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
489
|
+
|
|
490
|
+
// clearProject moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
491
|
+
|
|
492
|
+
// ── Rename service ───────────────────────────
|
|
493
|
+
// toggleLabelEdit moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
494
|
+
|
|
495
|
+
// saveLabel moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
496
|
+
|
|
497
|
+
// Legacy rename (slug) — kept for API compatibility but hidden from UI
|
|
498
|
+
// toggleRename moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
499
|
+
// saveRename moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
500
|
+
|
|
501
|
+
// ── Delete service ───────────────────────────
|
|
502
|
+
// deleteService moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
503
|
+
|
|
504
|
+
// ── Set key ─────────────────────────────────
|
|
505
|
+
// toggleSet moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
506
|
+
|
|
507
|
+
// getServiceKeyType moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
508
|
+
|
|
509
|
+
// generateSecretKey moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
510
|
+
|
|
511
|
+
// saveKey moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
512
|
+
|
|
513
|
+
// ── Enable / Disable service ────────────────
|
|
514
|
+
// toggleService moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
515
|
+
|
|
516
|
+
// ── Check all credentials ───────────────────
|
|
517
|
+
// checkAll moved to ./panels/toolbar-panel.js (panel-componentization, second slice).
|
|
518
|
+
|
|
519
|
+
// ── Change password ─────────────────────────
|
|
520
|
+
// toggleChangePw moved to ./panels/toolbar-panel.js (panel-componentization, second slice).
|
|
521
|
+
|
|
522
|
+
// changePassword moved to ./panels/toolbar-panel.js (panel-componentization, follow-up correction -- paired with its toggle function, already there).
|
|
523
|
+
|
|
524
|
+
// ── Add Service ──────────────────────────────
|
|
525
|
+
// TYPE_LABELS moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
526
|
+
|
|
527
|
+
// toggleAddService moved to ./panels/toolbar-panel.js (panel-componentization, second slice).
|
|
528
|
+
|
|
529
|
+
// addService moved to ./panels/services-panel.js (panel-componentization, fourth slice).
|
|
530
|
+
|
|
531
|
+
// Enter key on lock screen and add-service panel
|
|
532
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
533
|
+
document.getElementById("lock-input").addEventListener("keydown", e => {
|
|
534
|
+
if (e.key === "Enter") unlock();
|
|
535
|
+
});
|
|
536
|
+
["add-name", "add-label", "add-description", "add-project"].forEach(id => {
|
|
537
|
+
const el = document.getElementById(id);
|
|
538
|
+
if (el) el.addEventListener("keydown", e => { if (e.key === "Enter") addService(); });
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ── Shared fetch helper ─────────────────────
|
|
543
|
+
async function apiFetch(path, opts) {
|
|
544
|
+
try {
|
|
545
|
+
return await fetch(BASE + path, opts).then(r => r.json());
|
|
546
|
+
} catch { return null; }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── Enroll Machine ──────────────────────────
|
|
550
|
+
|
|
551
|
+
// toggleEnrollPanel moved to ./panels/toolbar-panel.js (panel-componentization, second slice).
|
|
552
|
+
|
|
553
|
+
// enrollMachine moved to ./panels/toolbar-panel.js (panel-componentization, follow-up correction -- paired with its toggle function, already there).
|
|
554
|
+
|
|
555
|
+
// WebDAV mount management (loadWebdavMounts, esc, addMountPrompt,
|
|
556
|
+
// hideAddForm, submitMount, deleteMount) moved to ./panels/webdav-panel.js
|
|
557
|
+
// (panel-componentization, third slice) -- found still duplicated here
|
|
558
|
+
// verbatim during a later audit (2026-08-23): the original extraction never
|
|
559
|
+
// removed these from dashboard.js, so both files declared the same
|
|
560
|
+
// functions as classic-script globals. Since dashboard.js loads LAST in
|
|
561
|
+
// script order, its copies silently won every identifier-based lookup
|
|
562
|
+
// (onclick="..." strings, and webdav-panel.js's own internal calls to
|
|
563
|
+
// loadWebdavMounts() by name) even though webdav-panel.js's copies were the
|
|
564
|
+
// ones actually intended to be canonical -- only the panel registry's
|
|
565
|
+
// `mount: loadWebdavMounts` escaped this, because it captured the function
|
|
566
|
+
// VALUE at registration time (before dashboard.js's redeclaration), not a
|
|
567
|
+
// live binding. Functionally harmless only because both copies were
|
|
568
|
+
// byte-identical; a real drift hazard regardless. Removed here to match
|
|
569
|
+
// the tunnel-panel/build-status-panel pattern already below.
|
|
570
|
+
|
|
571
|
+
// Tunnel panel moved to ./panels/tunnel-panel.js (panel-componentization, first slice).
|
|
572
|
+
|
|
573
|
+
// ── Build Status ──────────────────────────────────
|
|
574
|
+
// updateBuildStatus moved to ./panels/build-status-panel.js (panel-componentization, fifth slice).
|
|
575
|
+
|
|
576
|
+
// Detect daemon restart (PID change) and reload so boot() runs fresh.
|
|
577
|
+
// Without this, an old tab stays in main-view after restart, sees tunnel
|
|
578
|
+
// as not_started (vault locked), and gets stuck showing "checking tunnel".
|
|
579
|
+
let _watchedPid = null;
|
|
580
|
+
setInterval(async () => {
|
|
581
|
+
try {
|
|
582
|
+
const p = await fetch(BASE + "/ping").then(r => r.json());
|
|
583
|
+
if (_watchedPid && p.pid !== _watchedPid) { window.location.reload(); return; }
|
|
584
|
+
_watchedPid = p.pid;
|
|
585
|
+
// Also recover if vault became locked on us (e.g. user locked from another tab)
|
|
586
|
+
if (p.locked && document.getElementById("main-view").style.display !== "none") {
|
|
587
|
+
window.location.reload();
|
|
588
|
+
}
|
|
589
|
+
} catch {}
|
|
590
|
+
}, 5000);
|
|
591
|
+
|
|
592
|
+
boot();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* cli/dashboard/global.css
|
|
2
|
+
Color tokens + truly shared chrome: reset, body, lock screen, the
|
|
3
|
+
write-unlock modal, the main-view wrapper, header, status bar, the
|
|
4
|
+
generic .dash-panel/.btn base classes, error bar, loading state,
|
|
5
|
+
upgrade banner, and the footer. Every panel-specific stylesheet
|
|
6
|
+
(tunnel-panel.css, toolbar-panel.css, webdav-panel.css,
|
|
7
|
+
services-panel.css, build-status-panel.css, surfaces-panel.css) is
|
|
8
|
+
loaded AFTER this file and depends on these tokens -- never hardcode a
|
|
9
|
+
hex color in a panel stylesheet; add or reuse a token here instead. */
|
|
10
|
+
:root{
|
|
11
|
+
--blue:#3b82f6;--blue-light:#60a5fa;--blue-dark:#2563eb;--blue-hover-bg:#1e4a7f;--blue-hover-text:#93c5fd;--cyan:#38bdf8;--cyan-light:#e0f2fe;
|
|
12
|
+
--green:#4ade80;--green-bright:#22c55e;--green-dark:#166534;--green-light:#86efac;--green-bg:#1a3d22;--green-hover-bg:#1a3d22;--emerald:#34d399;--mint:#6ee7b7;
|
|
13
|
+
--red:#f87171;--red-bright:#ef4444;--red-dark:#991b1b;--red-darker:#7f1d1d;--red-hover-bg:#2d1f1f;--pink:#fecaca;--pink-light:#fca5a5;
|
|
14
|
+
--amber:#d9a86c;--orange:#f59e0b;--orange-bright:#fb923c;--gold:#facc15;--gold-light:#fde68a;--copper:#d97706;--teal-light:#cffafe;
|
|
15
|
+
--purple:#a78bfa;
|
|
16
|
+
--grey:#94a3b8;--grey-dim:#64748b;--grey-darker:#475569;--grey-light:#cbd5e1;
|
|
17
|
+
--text-bright:#e2e8f0;--text-white:#f8fafc;--white:#fff;
|
|
18
|
+
--border:#334155;--border-dark:#1e3a5f;--panel-border:#233249;
|
|
19
|
+
--bg-card:#1e293b;--bg-dark:#0f172a;--bg-darker:#0a0f1a;--bg-modal:#1a1f2e;--bg-black:#030712;
|
|
20
|
+
--bg-tunnel:#0f1d2d;--bg-build:#0f1a2d;--bg-wizard:#0a1628;
|
|
21
|
+
--bg-check:#0f2d2d;--border-check:#064e3b;--bg-check-hover:#134e4a;
|
|
22
|
+
--bg-rotate:#0e7490;--border-rotate:#06b6d4;
|
|
23
|
+
--bg-copy:#1a3328;--bg-copy-hover:#1a4338;
|
|
24
|
+
--bg-set:#2d1f4a;--bg-set-hover:#3d2f5a;
|
|
25
|
+
--bg-generate:#3b2f0d;--bg-generate-hover:#56420d;
|
|
26
|
+
--bg-disable-hover:#3d2525;
|
|
27
|
+
}
|
|
28
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
29
|
+
body{background:var(--bg-darker);color:var(--text-bright);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh}
|
|
30
|
+
/* ── Lock screen ── */
|
|
31
|
+
#lock-screen{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem}
|
|
32
|
+
.lock-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:2.5rem 2rem;width:100%;max-width:380px;text-align:center}
|
|
33
|
+
.lock-icon{font-size:2.5rem;margin-bottom:1rem}
|
|
34
|
+
.lock-title{font-size:1.25rem;font-weight:600;color:var(--text-white);margin-bottom:.4rem}
|
|
35
|
+
.lock-sub{font-size:.85rem;color:var(--grey-dim);margin-bottom:1.75rem}
|
|
36
|
+
.lock-input{width:100%;background:var(--bg-dark);border:1px solid var(--border);border-radius:8px;color:var(--text-bright);font-family:'Courier New',monospace;font-size:1rem;padding:10px 14px;outline:none;text-align:center;letter-spacing:.1em;transition:border-color .2s;margin-bottom:1rem}
|
|
37
|
+
.lock-input:focus{border-color:var(--blue)}
|
|
38
|
+
.lock-input.error{border-color:var(--red-bright);animation:shake .3s}
|
|
39
|
+
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
|
|
40
|
+
.btn-unlock{width:100%;background:var(--blue);color:var(--white);border:none;border-radius:8px;padding:10px;font-size:.95rem;font-weight:600;cursor:pointer;transition:background .15s}
|
|
41
|
+
.btn-unlock:hover{background:var(--blue-dark)}
|
|
42
|
+
.btn-unlock:disabled{background:var(--border-dark);color:#4a6fa5;cursor:not-allowed}
|
|
43
|
+
.lock-err{color:var(--red);font-size:.82rem;margin-top:.75rem;min-height:1.2em}
|
|
44
|
+
#write-unlock-overlay{display:none;position:fixed;inset:0;background:rgba(2,6,23,.72);z-index:9999;align-items:center;justify-content:center;padding:2rem}
|
|
45
|
+
.write-unlock-cancel{width:100%;background:transparent;color:var(--grey);border:1px solid var(--border);border-radius:8px;padding:8px;font-size:.85rem;cursor:pointer;margin-top:.6rem;transition:border-color .15s}
|
|
46
|
+
.write-unlock-cancel:hover{border-color:var(--grey-dim);color:var(--grey-light)}
|
|
47
|
+
/* ── Main view ── */
|
|
48
|
+
#main-view{display:none;padding:2rem}
|
|
49
|
+
.header{display:flex;align-items:center;gap:10px;margin-bottom:1.5rem;flex-wrap:wrap}
|
|
50
|
+
.header h1{font-size:1.4rem;font-weight:600;flex:1}
|
|
51
|
+
.dot{width:10px;height:10px;border-radius:50%;background:var(--green-bright);animation:pulse 2s infinite;flex-shrink:0}
|
|
52
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
|
53
|
+
.status-bar{display:flex;gap:1.5rem;margin-bottom:1.5rem;font-size:.82rem;color:var(--grey);flex-wrap:wrap}
|
|
54
|
+
.status-bar span{color:var(--text-bright);font-weight:500}
|
|
55
|
+
.dash-panel{margin-bottom:1.25rem;border:1px solid var(--panel-border);background:linear-gradient(135deg,rgba(15,23,42,.96),rgba(10,15,26,.98));border-radius:12px;padding:14px}
|
|
56
|
+
.error-bar{background:var(--red-darker);color:#fca5a5;border:1px solid var(--red-dark);padding:10px 14px;border-radius:8px;margin-bottom:1rem;display:none;font-size:.85rem}
|
|
57
|
+
.loading{color:var(--grey-dim);font-style:italic}
|
|
58
|
+
.btn{padding:6px 13px;border-radius:6px;border:none;cursor:pointer;font-size:.8rem;font-weight:500;transition:all .15s}
|
|
59
|
+
.btn-save{background:var(--border-dark);color:var(--blue-light);padding:6px 16px}.btn-save:hover{background:var(--blue-hover-bg)}
|
|
60
|
+
.btn-cancel{background:transparent;color:var(--grey-dim);padding:6px 10px}.btn-cancel:hover{color:var(--grey)}
|
|
61
|
+
.upgrade-banner{background:linear-gradient(135deg,rgba(0,200,100,.12),rgba(0,150,80,.08));border:1px solid rgba(0,200,100,.3);border-radius:6px;padding:10px 16px;margin-bottom:12px;font-size:13px}
|
|
62
|
+
.upgrade-banner .upgrade-content{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
|
63
|
+
.upgrade-banner button{margin-left:auto;background:none;border:1px solid rgba(0,200,100,.4);color:#0c6;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:12px}
|
|
64
|
+
.upgrade-details-list{color:rgba(255,255,255,.6);font-size:12px}
|
|
65
|
+
.footer{margin-top:2rem;font-size:.75rem;color:var(--amber);text-align:center}
|