@putkoff/abstract-securefiles 0.0.50 → 0.0.52

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/dist/index.js CHANGED
@@ -35,6 +35,9 @@ function createApi(apiBase = DEFAULT_API_BASE) {
35
35
  }
36
36
  return {
37
37
  list: () => json("/userInfo"),
38
+ devices: () => json("/devices"),
39
+ pairDevice: () => json("/devices/pair", { method: "POST", body: "{}" }),
40
+ removeDevice: (id) => json(`/devices/${id}`, { method: "DELETE" }),
38
41
  roots: () => json("/admin/roots"),
39
42
  requestRoot: (path) => json("/admin/roots", { method: "POST", body: JSON.stringify({ path }) }),
40
43
  confirmRoot: (nonce, signature, label) => json("/admin/roots/confirm", {
@@ -243,6 +246,25 @@ function CopyButton({ text, label = "Copy link" }) {
243
246
  return (jsx("button", { type: "button", className: "sf-btn", onClick: copy, children: copied ? "Copied" : label }));
244
247
  }
245
248
 
249
+ function ConfirmButton({ label, onConfirm, disabled, }) {
250
+ const [armed, setArmed] = useState(false);
251
+ useEffect(() => {
252
+ if (!armed)
253
+ return;
254
+ const t = setTimeout(() => setArmed(false), 3000);
255
+ return () => clearTimeout(t);
256
+ }, [armed]);
257
+ return (jsx("button", { type: "button", className: `sf-btn sf-btn--danger${armed ? " sf-btn--armed" : ""}`, disabled: disabled, onClick: () => {
258
+ if (armed) {
259
+ setArmed(false);
260
+ onConfirm();
261
+ }
262
+ else {
263
+ setArmed(true);
264
+ }
265
+ }, children: armed ? "Confirm" : label }));
266
+ }
267
+
246
268
  function ShareDialog({ file, onClose, onSave, }) {
247
269
  const [shareable, setShareable] = useState(file.shareable);
248
270
  const [limit, setLimit] = useState(file.download_limit ? String(file.download_limit) : "");
@@ -258,6 +280,19 @@ function ShareDialog({ file, onClose, onSave, }) {
258
280
  }, [onClose]);
259
281
  const limitValid = limit === "" || /^\d+$/.test(limit);
260
282
  const passwordValid = pwMode !== "set" || password.length > 0;
283
+ const newLink = async () => {
284
+ setSaving(true);
285
+ setError(null);
286
+ try {
287
+ await onSave({ id: file.id, shareable: true, regenerate_link: true });
288
+ }
289
+ catch (err) {
290
+ setError(err instanceof Error ? err.message : String(err));
291
+ }
292
+ finally {
293
+ setSaving(false);
294
+ }
295
+ };
261
296
  const save = async () => {
262
297
  setSaving(true);
263
298
  setError(null);
@@ -282,26 +317,7 @@ function ShareDialog({ file, onClose, onSave, }) {
282
317
  setSaving(false);
283
318
  }
284
319
  };
285
- return (jsx("div", { className: "sf-overlay", onMouseDown: (e) => e.target === e.currentTarget && onClose(), children: jsxs("div", { className: "sf-dialog", role: "dialog", "aria-modal": "true", "aria-label": `Share settings for ${file.filename}`, children: [jsxs("header", { className: "sf-dialog__head", children: [jsx("h3", { children: "Share settings" }), jsx("button", { type: "button", className: "sf-icon-btn", "aria-label": "Close", onClick: onClose, children: "\u2715" })] }), jsx("p", { className: "sf-muted", children: file.filepath }), jsxs("label", { className: "sf-check", children: [jsx("input", { type: "checkbox", checked: shareable, onChange: (e) => setShareable(e.target.checked) }), "Anyone with the link can download"] }), jsxs("fieldset", { disabled: !shareable, className: "sf-fieldset", children: [jsxs("label", { className: "sf-field", children: [jsx("span", { children: "Download limit (blank = unlimited)" }), jsx("input", { className: "sf-input", inputMode: "numeric", value: limit, onChange: (e) => setLimit(e.target.value.trim()), placeholder: "unlimited" }), !limitValid && jsx("span", { className: "sf-error", children: "Enter a whole number" })] }), jsxs("p", { className: "sf-muted", children: ["Downloaded ", file.download_count, " time", file.download_count === 1 ? "" : "s", file.download_limit ? ` of ${file.download_limit}` : "", ".", " ", jsxs("label", { className: "sf-check sf-check--inline", children: [jsx("input", { type: "checkbox", checked: resetCount, onChange: (e) => setResetCount(e.target.checked) }), "reset count"] })] }), jsxs("div", { className: "sf-field", children: [jsx("span", { children: "Password" }), jsxs("div", { className: "sf-row", children: [file.has_password && (jsxs("label", { className: "sf-check sf-check--inline", children: [jsx("input", { type: "radio", checked: pwMode === "keep", onChange: () => setPwMode("keep") }), "keep current"] })), jsxs("label", { className: "sf-check sf-check--inline", children: [jsx("input", { type: "radio", checked: pwMode === "set", onChange: () => setPwMode("set") }), file.has_password ? "change" : "require password"] }), jsxs("label", { className: "sf-check sf-check--inline", children: [jsx("input", { type: "radio", checked: pwMode === "remove", onChange: () => setPwMode("remove") }), file.has_password ? "remove" : "none"] })] }), pwMode === "set" && (jsx("input", { className: "sf-input", type: "password", autoComplete: "new-password", value: password, onChange: (e) => setPassword(e.target.value), placeholder: "New password" }))] }), jsxs("div", { className: "sf-field", children: [jsx("span", { children: "Share link" }), jsxs("div", { className: "sf-row", children: [jsx("input", { className: "sf-input", readOnly: true, value: file.share_url, onFocus: (e) => e.target.select() }), jsx(CopyButton, { text: file.share_url })] }), !file.shareable && jsx("span", { className: "sf-muted", children: "The link works once you save with sharing on." })] })] }), error && jsx("p", { className: "sf-error", children: error }), jsxs("footer", { className: "sf-row sf-dialog__foot", children: [jsx("button", { type: "button", className: "sf-btn", onClick: onClose, children: "Cancel" }), jsx("button", { type: "button", className: "sf-btn sf-btn--primary", disabled: saving || !limitValid || !passwordValid, onClick: save, children: saving ? "Saving…" : "Save" })] })] }) }));
286
- }
287
-
288
- function ConfirmButton({ label, onConfirm, disabled, }) {
289
- const [armed, setArmed] = useState(false);
290
- useEffect(() => {
291
- if (!armed)
292
- return;
293
- const t = setTimeout(() => setArmed(false), 3000);
294
- return () => clearTimeout(t);
295
- }, [armed]);
296
- return (jsx("button", { type: "button", className: `sf-btn sf-btn--danger${armed ? " sf-btn--armed" : ""}`, disabled: disabled, onClick: () => {
297
- if (armed) {
298
- setArmed(false);
299
- onConfirm();
300
- }
301
- else {
302
- setArmed(true);
303
- }
304
- }, children: armed ? "Confirm" : label }));
320
+ return (jsx("div", { className: "sf-overlay", onMouseDown: (e) => e.target === e.currentTarget && onClose(), children: jsxs("div", { className: "sf-dialog", role: "dialog", "aria-modal": "true", "aria-label": `Share settings for ${file.filename}`, children: [jsxs("header", { className: "sf-dialog__head", children: [jsx("h3", { children: "Share settings" }), jsx("button", { type: "button", className: "sf-icon-btn", "aria-label": "Close", onClick: onClose, children: "\u2715" })] }), jsx("p", { className: "sf-muted", children: file.filepath }), jsxs("label", { className: "sf-check", children: [jsx("input", { type: "checkbox", checked: shareable, onChange: (e) => setShareable(e.target.checked) }), "Anyone with the link can download"] }), jsxs("fieldset", { disabled: !shareable, className: "sf-fieldset", children: [jsxs("label", { className: "sf-field", children: [jsx("span", { children: "Download limit (blank = unlimited)" }), jsx("input", { className: "sf-input", inputMode: "numeric", value: limit, onChange: (e) => setLimit(e.target.value.trim()), placeholder: "unlimited" }), !limitValid && jsx("span", { className: "sf-error", children: "Enter a whole number" })] }), jsxs("p", { className: "sf-muted", children: ["Downloaded ", file.download_count, " time", file.download_count === 1 ? "" : "s", file.download_limit ? ` of ${file.download_limit}` : "", ".", " ", jsxs("label", { className: "sf-check sf-check--inline", children: [jsx("input", { type: "checkbox", checked: resetCount, onChange: (e) => setResetCount(e.target.checked) }), "reset count"] })] }), jsxs("div", { className: "sf-field", children: [jsx("span", { children: "Password" }), jsxs("div", { className: "sf-row", children: [file.has_password && (jsxs("label", { className: "sf-check sf-check--inline", children: [jsx("input", { type: "radio", checked: pwMode === "keep", onChange: () => setPwMode("keep") }), "keep current"] })), jsxs("label", { className: "sf-check sf-check--inline", children: [jsx("input", { type: "radio", checked: pwMode === "set", onChange: () => setPwMode("set") }), file.has_password ? "change" : "require password"] }), jsxs("label", { className: "sf-check sf-check--inline", children: [jsx("input", { type: "radio", checked: pwMode === "remove", onChange: () => setPwMode("remove") }), file.has_password ? "remove" : "none"] })] }), pwMode === "set" && (jsx("input", { className: "sf-input", type: "password", autoComplete: "new-password", value: password, onChange: (e) => setPassword(e.target.value), placeholder: "New password" }))] }), jsxs("div", { className: "sf-field", children: [jsx("span", { children: "Share link" }), file.share_url ? (jsxs("div", { className: "sf-row", children: [jsx("input", { className: "sf-input", readOnly: true, value: file.share_url, onFocus: (e) => e.target.select() }), jsx(CopyButton, { text: file.share_url }), jsx(ConfirmButton, { label: "New link", disabled: saving, onConfirm: newLink })] })) : (jsx("span", { className: "sf-muted", children: "A private link is created when you save with sharing on." })), file.share_url && !file.shareable && (jsx("span", { className: "sf-muted", children: "The link works once you save with sharing on." })), jsx("span", { className: "sf-muted", children: "\"New link\" invalidates the current one immediately." })] })] }), error && jsx("p", { className: "sf-error", children: error }), jsxs("footer", { className: "sf-row sf-dialog__foot", children: [jsx("button", { type: "button", className: "sf-btn", onClick: onClose, children: "Cancel" }), jsx("button", { type: "button", className: "sf-btn sf-btn--primary", disabled: saving || !limitValid || !passwordValid, onClick: save, children: saving ? "Saving…" : "Save" })] })] }) }));
305
321
  }
306
322
 
307
323
  function formatSize(bytes) {
@@ -314,7 +330,7 @@ function formatSize(bytes) {
314
330
  }
315
331
  return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
316
332
  }
317
- const errText = (err) => (err instanceof Error ? err.message : String(err));
333
+ const errText$1 = (err) => (err instanceof Error ? err.message : String(err));
318
334
  function AddRoot({ api, onAdded }) {
319
335
  const [path, setPath] = useState("");
320
336
  const [label, setLabel] = useState("");
@@ -330,7 +346,7 @@ function AddRoot({ api, onAdded }) {
330
346
  setSignature("");
331
347
  }
332
348
  catch (err) {
333
- setError(errText(err));
349
+ setError(errText$1(err));
334
350
  }
335
351
  finally {
336
352
  setBusy(false);
@@ -349,7 +365,7 @@ function AddRoot({ api, onAdded }) {
349
365
  setSignature("");
350
366
  }
351
367
  catch (err) {
352
- setError(errText(err));
368
+ setError(errText$1(err));
353
369
  }
354
370
  finally {
355
371
  setBusy(false);
@@ -372,7 +388,7 @@ function Browser({ api, roots, folders, onLinked, }) {
372
388
  setPicked(new Set());
373
389
  }
374
390
  catch (err) {
375
- setError(errText(err));
391
+ setError(errText$1(err));
376
392
  }
377
393
  }, [api]);
378
394
  useEffect(() => {
@@ -400,7 +416,7 @@ function Browser({ api, roots, folders, onLinked, }) {
400
416
  await open(listing.path);
401
417
  }
402
418
  catch (err) {
403
- setResult(errText(err));
419
+ setResult(errText$1(err));
404
420
  }
405
421
  finally {
406
422
  setBusy(false);
@@ -424,7 +440,7 @@ function ServerFiles({ api, folders, onLinked, }) {
424
440
  setError(null);
425
441
  }
426
442
  catch (err) {
427
- setError(errText(err));
443
+ setError(errText$1(err));
428
444
  }
429
445
  }, [api]);
430
446
  return (jsxs("details", { className: "sf-admin", onToggle: (e) => e.currentTarget.open && !roots && load(), children: [jsx("summary", { children: "Server files (admin)" }), jsxs("div", { className: "sf-stack", children: [jsx("p", { className: "sf-muted", children: "Link files or whole folders that already live on the server instead of uploading copies. A linked folder downloads as one uncompressed .tar with an exact size, and interrupted downloads can be resumed. Only items inside allowed directories can be linked; removing a directory immediately disables its links. Deleting a linked entry never touches the original. Available from the local network only." }), error && jsx("p", { className: "sf-error", children: error }), roots && (jsxs(Fragment, { children: [jsxs("div", { className: "sf-card", children: [jsx("h4", { children: "Allowed directories" }), roots.length === 0 ? (jsx("p", { className: "sf-muted", children: "None yet." })) : (jsx("ul", { className: "sf-roots", children: roots.map((r) => (jsxs("li", { children: [jsxs("span", { children: [jsx("code", { children: r.path }), " ", r.label && jsxs("span", { className: "sf-muted", children: ["\u2014 ", r.label] }), !r.exists && jsx("span", { className: "sf-error", children: " (missing)" })] }), jsx(ConfirmButton, { label: "Remove", onConfirm: async () => {
@@ -433,11 +449,70 @@ function ServerFiles({ api, folders, onLinked, }) {
433
449
  onLinked();
434
450
  }
435
451
  catch (err) {
436
- setError(errText(err));
452
+ setError(errText$1(err));
437
453
  }
438
454
  } })] }, r.id))) }))] }), jsx(AddRoot, { api: api, onAdded: setRoots }), jsx(Browser, { api: api, roots: roots, folders: folders, onLinked: onLinked })] }))] })] }));
439
455
  }
440
456
 
457
+ const errText = (err) => (err instanceof Error ? err.message : String(err));
458
+ function ago(iso) {
459
+ if (!iso)
460
+ return "never";
461
+ const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
462
+ if (secs < 90)
463
+ return "just now";
464
+ if (secs < 3600)
465
+ return `${Math.round(secs / 60)} min ago`;
466
+ if (secs < 86400)
467
+ return `${Math.round(secs / 3600)} h ago`;
468
+ return `${Math.round(secs / 86400)} d ago`;
469
+ }
470
+ function AddComputer({ api, onPaired }) {
471
+ const [code, setCode] = useState(null);
472
+ const [expiresAt, setExpiresAt] = useState(0);
473
+ const [, setTick] = useState(0);
474
+ const [error, setError] = useState(null);
475
+ useEffect(() => {
476
+ if (!code)
477
+ return;
478
+ const timer = setInterval(() => {
479
+ if (Date.now() > expiresAt)
480
+ setCode(null);
481
+ setTick((t) => t + 1);
482
+ onPaired();
483
+ }, 5000);
484
+ return () => clearInterval(timer);
485
+ }, [code, expiresAt, onPaired]);
486
+ const create = async () => {
487
+ setError(null);
488
+ try {
489
+ const res = await api.pairDevice();
490
+ setCode(res.code);
491
+ setExpiresAt(Date.now() + res.expires_in * 1000);
492
+ }
493
+ catch (err) {
494
+ setError(errText(err));
495
+ }
496
+ };
497
+ const minutesLeft = Math.max(1, Math.round((expiresAt - Date.now()) / 60000));
498
+ if (!code) {
499
+ return (jsxs("div", { className: "sf-row", children: [jsx("button", { type: "button", className: "sf-btn sf-btn--primary", onClick: create, children: "Add a computer" }), error && jsx("span", { className: "sf-error", children: error })] }));
500
+ }
501
+ return (jsxs("div", { className: "sf-card", children: [jsx("h4", { children: "Connect a computer" }), jsxs("ol", { className: "sf-steps", children: [jsxs("li", { children: ["On that computer, install the agent (Linux, macOS or Windows with Python 3.9+):", jsxs("div", { className: "sf-row", children: [jsx("code", { className: "sf-codeline", children: "pipx install abstract-securefiles && securefiles setup" }), jsx(CopyButton, { text: "pipx install abstract-securefiles && securefiles setup", label: "Copy" })] }), jsxs("span", { className: "sf-muted", children: ["No pipx? Use ", jsx("code", { children: "python3 -m pip install --user abstract-securefiles" }), " then ", jsx("code", { children: "securefiles setup" }), "."] })] }), jsxs("li", { children: ["In the panel that opens (", jsx("code", { children: "http://127.0.0.1:47821" }), "), enter this code:", jsxs("div", { className: "sf-row", children: [jsx("span", { className: "sf-paircode", children: code }), jsx(CopyButton, { text: code, label: "Copy" })] }), jsxs("span", { className: "sf-muted", children: ["Valid for about ", minutesLeft, " more minutes, once."] })] }), jsxs("li", { children: ["Pick the files and folders to share there. They appear here under ", jsx("code", { children: "devices/<computer>/" }), "."] })] }), jsx("div", { className: "sf-row", children: jsx("button", { type: "button", className: "sf-btn", onClick: () => setCode(null), children: "Done" }) })] }));
502
+ }
503
+ function MyComputers({ api, devices, onChange, }) {
504
+ const [error, setError] = useState(null);
505
+ return (jsxs("details", { className: "sf-admin", children: [jsxs("summary", { children: ["My computers", " ", jsxs("span", { className: "sf-muted", children: ["(", devices.filter((d) => d.online).length, " online / ", devices.length, ")"] })] }), jsxs("div", { className: "sf-stack", children: [jsx("p", { className: "sf-muted", children: "Share files and folders straight from your own computers \u2014 nothing is uploaded. Downloads stream from the computer through this site with the same links, passwords, limits and resume, while it's online." }), devices.length > 0 && (jsx("ul", { className: "sf-roots", children: devices.map((d) => (jsxs("li", { children: [jsxs("span", { children: [jsx("span", { className: `sf-dot${d.online ? " sf-dot--on" : ""}` }), " ", jsx("b", { children: d.name }), " ", jsxs("span", { className: "sf-muted", children: [d.online ? "online" : `offline · last seen ${ago(d.last_seen)}`, d.platform ? ` · ${d.platform}` : ""] })] }), jsx(ConfirmButton, { label: "Remove", onConfirm: async () => {
506
+ try {
507
+ await api.removeDevice(d.id);
508
+ onChange();
509
+ }
510
+ catch (err) {
511
+ setError(errText(err));
512
+ }
513
+ } })] }, d.id))) })), error && jsx("p", { className: "sf-error", children: error }), jsx(AddComputer, { api: api, onPaired: onChange })] })] }));
514
+ }
515
+
441
516
  function styleInject(css, ref) {
442
517
  if ( ref === void 0 ) ref = {};
443
518
  var insertAt = ref.insertAt;
@@ -465,7 +540,7 @@ function styleInject(css, ref) {
465
540
  }
466
541
  }
467
542
 
468
- var css_248z = ".sf-root{--sf-border:color-mix(in srgb,currentColor 22%,transparent);--sf-subtle:color-mix(in srgb,currentColor 6%,transparent);--sf-accent:#2563eb;--sf-danger:#dc2626;display:flex;flex-direction:column;font-size:14px;gap:16px;margin:0 auto;max-width:1100px;padding:16px;text-align:left}.sf-root *,.sf-root :after,.sf-root :before{box-sizing:border-box}.sf-head{align-items:baseline;display:flex;gap:12px;justify-content:space-between}.sf-head h2{font-size:1.4em;margin:0}.sf-muted{opacity:.7}.sf-error{color:var(--sf-danger)}.sf-row{align-items:center;display:flex;flex-wrap:wrap;gap:8px}.sf-row--end{justify-content:flex-end}.sf-field{display:flex;flex:1;flex-direction:column;gap:4px;min-width:200px}.sf-input{background:var(--sf-subtle);border:1px solid var(--sf-border);border-radius:6px;color:inherit;font:inherit;min-width:0;padding:6px 10px;width:100%}.sf-row .sf-input{flex:1;width:auto}.sf-btn{background:var(--sf-subtle);border:1px solid var(--sf-border);border-radius:6px;color:inherit;cursor:pointer;font:inherit;padding:5px 12px;white-space:nowrap}.sf-btn:hover:not(:disabled){border-color:var(--sf-accent)}.sf-btn:disabled{cursor:default;opacity:.45}.sf-btn--primary{background:var(--sf-accent);border-color:var(--sf-accent);color:#fff}.sf-btn--armed,.sf-btn--danger:hover:not(:disabled){border-color:var(--sf-danger);color:var(--sf-danger)}.sf-btn--armed{background:color-mix(in srgb,var(--sf-danger) 12%,transparent)}.sf-icon-btn{background:none;border:1px solid transparent;border-radius:6px;color:inherit;cursor:pointer;font:inherit;padding:3px 6px}.sf-icon-btn:hover{border-color:var(--sf-border)}.sf-upload{display:flex;flex-direction:column;gap:10px}.sf-drop{align-items:center;border:2px dashed var(--sf-border);border-radius:12px;cursor:pointer;display:flex;gap:20px;padding:28px 24px;transition:border-color .15s,background .15s,box-shadow .15s;-webkit-user-select:none;-moz-user-select:none;user-select:none}.sf-drop:focus-visible,.sf-drop:hover{border-color:var(--sf-accent);box-shadow:0 4px 14px color-mix(in srgb,currentColor 10%,transparent);outline:none}.sf-drop p{margin:0}.sf-drop__icon{flex-shrink:0;height:72px;opacity:.6;width:72px}.sf-drop__title{font-size:1.1em;font-weight:600;margin-bottom:4px!important}.sf-drop__count{color:var(--sf-accent);font-weight:600;margin-top:6px!important}.sf-drop--active{background:color-mix(in srgb,var(--sf-accent) 8%,transparent);border-color:var(--sf-accent)}.sf-drop--active .sf-drop__icon{color:var(--sf-accent);opacity:1}.sf-mode{gap:14px}.sf-queue progress{width:120px}.sf-upload>.sf-row{align-items:flex-end}.sf-queue{display:flex;flex-direction:column;gap:4px;list-style:none;margin:0;max-height:200px;overflow:auto;padding:0}.sf-queue li{display:flex;gap:12px;justify-content:space-between}.sf-queue__name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sf-toolbar{display:flex;flex-wrap:wrap;gap:12px;justify-content:space-between}.sf-search{max-width:320px}.sf-table-wrap{border:1px solid var(--sf-border);border-radius:8px;overflow-x:auto}.sf-table{border-collapse:collapse;width:100%}.sf-table td,.sf-table th{border-bottom:1px solid var(--sf-border);padding:8px 10px;text-align:left;vertical-align:middle}.sf-table tbody tr:last-child td{border-bottom:none}.sf-table th{background:var(--sf-subtle);font-weight:600}.sf-col-check{width:36px}.sf-col-actions{text-align:right!important}.sf-dir td{background:var(--sf-subtle)}.sf-dir__toggle{background:none;border:none;color:inherit;cursor:pointer;font:inherit;font-weight:600;padding:0}.sf-busy{opacity:.55}.sf-pill{background:var(--sf-subtle);border:1px solid var(--sf-border);border-radius:999px;color:inherit;cursor:pointer;font:inherit;font-size:12px;padding:2px 10px}.sf-pill--on{background:color-mix(in srgb,#16a34a 18%,transparent);border-color:#16a34a}.sf-overlay{align-items:center;background:rgba(0,0,0,.5);display:flex;inset:0;justify-content:center;padding:16px;position:fixed;z-index:1000}.sf-dialog{--sf-border:color-mix(in srgb,CanvasText 22%,transparent);--sf-subtle:color-mix(in srgb,CanvasText 6%,transparent);background:Canvas;border-radius:10px;color:CanvasText;display:flex;flex-direction:column;gap:12px;max-height:90vh;overflow:auto;padding:18px 20px;text-align:left;width:min(520px,100%)}.sf-dialog__head{align-items:center;display:flex;justify-content:space-between}.sf-dialog__head h3{margin:0}.sf-dialog>.sf-muted{margin:0;word-break:break-all}.sf-dialog__foot{justify-content:flex-end}.sf-fieldset{border:none;display:flex;flex-direction:column;gap:12px;margin:0;padding:0}.sf-fieldset:disabled{opacity:.5}.sf-fieldset p{margin:0}.sf-check{align-items:center;cursor:pointer;display:flex;gap:8px}.sf-check--inline{display:inline-flex}.sf-stack{display:flex;flex-direction:column;gap:12px}.sf-stack p{margin:0}.sf-row--bottom{align-items:flex-end}.sf-field--narrow{flex:0 1 200px;min-width:140px}.sf-admin{border:1px solid var(--sf-border);border-radius:10px;padding:10px 14px}.sf-admin>summary{cursor:pointer;font-weight:600}.sf-admin[open]>summary{margin-bottom:12px}.sf-card{border:1px solid var(--sf-border);border-radius:8px;display:flex;flex-direction:column;gap:10px;padding:12px}.sf-card h4,.sf-card p{margin:0}.sf-code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;resize:vertical}.sf-roots{display:flex;flex-direction:column;gap:6px;list-style:none;margin:0;padding:0}.sf-roots li{align-items:center;display:flex;gap:12px;justify-content:space-between}.sf-root-select{max-width:420px}.sf-browser{border:1px solid var(--sf-border);border-radius:6px;display:flex;flex-direction:column;max-height:320px;overflow:auto}.sf-browser>*{border-bottom:1px solid var(--sf-border);padding:5px 10px}.sf-browser>:last-child{border-bottom:none}.sf-browser__dir{background:none;border:none;border-bottom:1px solid var(--sf-border);color:inherit;cursor:pointer;font:inherit;text-align:left}.sf-browser__all,.sf-browser__dir:hover{background:var(--sf-subtle)}.sf-browser__all{font-weight:600}.sf-browser__name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sf-tag{border:1px solid var(--sf-border);border-radius:999px;display:inline-block;font-size:11px;margin-left:8px;padding:0 7px;vertical-align:middle}.sf-tag--warn{border-color:var(--sf-danger);color:var(--sf-danger)}.sf-strike{opacity:.7;text-decoration:line-through}.sf-browser__row{align-items:center;display:flex;gap:8px}.sf-browser__row .sf-browser__dir{border-bottom:none;flex:1;padding:0}";
543
+ var css_248z = ".sf-root{--sf-border:color-mix(in srgb,currentColor 22%,transparent);--sf-subtle:color-mix(in srgb,currentColor 6%,transparent);--sf-accent:#2563eb;--sf-danger:#dc2626;display:flex;flex-direction:column;font-size:14px;gap:16px;margin:0 auto;max-width:1100px;padding:16px;text-align:left}.sf-root *,.sf-root :after,.sf-root :before{box-sizing:border-box}.sf-head{align-items:baseline;display:flex;gap:12px;justify-content:space-between}.sf-head h2{font-size:1.4em;margin:0}.sf-muted{opacity:.7}.sf-error{color:var(--sf-danger)}.sf-row{align-items:center;display:flex;flex-wrap:wrap;gap:8px}.sf-row--end{justify-content:flex-end}.sf-field{display:flex;flex:1;flex-direction:column;gap:4px;min-width:200px}.sf-input{background:var(--sf-subtle);border:1px solid var(--sf-border);border-radius:6px;color:inherit;font:inherit;min-width:0;padding:6px 10px;width:100%}.sf-row .sf-input{flex:1;width:auto}.sf-btn{background:var(--sf-subtle);border:1px solid var(--sf-border);border-radius:6px;color:inherit;cursor:pointer;font:inherit;padding:5px 12px;white-space:nowrap}.sf-btn:hover:not(:disabled){border-color:var(--sf-accent)}.sf-btn:disabled{cursor:default;opacity:.45}.sf-btn--primary{background:var(--sf-accent);border-color:var(--sf-accent);color:#fff}.sf-btn--armed,.sf-btn--danger:hover:not(:disabled){border-color:var(--sf-danger);color:var(--sf-danger)}.sf-btn--armed{background:color-mix(in srgb,var(--sf-danger) 12%,transparent)}.sf-icon-btn{background:none;border:1px solid transparent;border-radius:6px;color:inherit;cursor:pointer;font:inherit;padding:3px 6px}.sf-icon-btn:hover{border-color:var(--sf-border)}.sf-upload{display:flex;flex-direction:column;gap:10px}.sf-drop{align-items:center;border:2px dashed var(--sf-border);border-radius:12px;cursor:pointer;display:flex;gap:20px;padding:28px 24px;transition:border-color .15s,background .15s,box-shadow .15s;-webkit-user-select:none;-moz-user-select:none;user-select:none}.sf-drop:focus-visible,.sf-drop:hover{border-color:var(--sf-accent);box-shadow:0 4px 14px color-mix(in srgb,currentColor 10%,transparent);outline:none}.sf-drop p{margin:0}.sf-drop__icon{flex-shrink:0;height:72px;opacity:.6;width:72px}.sf-drop__title{font-size:1.1em;font-weight:600;margin-bottom:4px!important}.sf-drop__count{color:var(--sf-accent);font-weight:600;margin-top:6px!important}.sf-drop--active{background:color-mix(in srgb,var(--sf-accent) 8%,transparent);border-color:var(--sf-accent)}.sf-drop--active .sf-drop__icon{color:var(--sf-accent);opacity:1}.sf-mode{gap:14px}.sf-queue progress{width:120px}.sf-upload>.sf-row{align-items:flex-end}.sf-queue{display:flex;flex-direction:column;gap:4px;list-style:none;margin:0;max-height:200px;overflow:auto;padding:0}.sf-queue li{display:flex;gap:12px;justify-content:space-between}.sf-queue__name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sf-toolbar{display:flex;flex-wrap:wrap;gap:12px;justify-content:space-between}.sf-search{max-width:320px}.sf-table-wrap{border:1px solid var(--sf-border);border-radius:8px;overflow-x:auto}.sf-table{border-collapse:collapse;width:100%}.sf-table td,.sf-table th{border-bottom:1px solid var(--sf-border);padding:8px 10px;text-align:left;vertical-align:middle}.sf-table tbody tr:last-child td{border-bottom:none}.sf-table th{background:var(--sf-subtle);font-weight:600}.sf-col-check{width:36px}.sf-col-actions{text-align:right!important}.sf-dir td{background:var(--sf-subtle)}.sf-dir__toggle{background:none;border:none;color:inherit;cursor:pointer;font:inherit;font-weight:600;padding:0}.sf-busy{opacity:.55}.sf-pill{background:var(--sf-subtle);border:1px solid var(--sf-border);border-radius:999px;color:inherit;cursor:pointer;font:inherit;font-size:12px;padding:2px 10px}.sf-pill--on{background:color-mix(in srgb,#16a34a 18%,transparent);border-color:#16a34a}.sf-overlay{align-items:center;background:rgba(0,0,0,.5);display:flex;inset:0;justify-content:center;padding:16px;position:fixed;z-index:1000}.sf-dialog{--sf-border:color-mix(in srgb,CanvasText 22%,transparent);--sf-subtle:color-mix(in srgb,CanvasText 6%,transparent);background:Canvas;border-radius:10px;color:CanvasText;display:flex;flex-direction:column;gap:12px;max-height:90vh;overflow:auto;padding:18px 20px;text-align:left;width:min(520px,100%)}.sf-dialog__head{align-items:center;display:flex;justify-content:space-between}.sf-dialog__head h3{margin:0}.sf-dialog>.sf-muted{margin:0;word-break:break-all}.sf-dialog__foot{justify-content:flex-end}.sf-fieldset{border:none;display:flex;flex-direction:column;gap:12px;margin:0;padding:0}.sf-fieldset:disabled{opacity:.5}.sf-fieldset p{margin:0}.sf-check{align-items:center;cursor:pointer;display:flex;gap:8px}.sf-check--inline{display:inline-flex}.sf-stack{display:flex;flex-direction:column;gap:12px}.sf-stack p{margin:0}.sf-row--bottom{align-items:flex-end}.sf-field--narrow{flex:0 1 200px;min-width:140px}.sf-admin{border:1px solid var(--sf-border);border-radius:10px;padding:10px 14px}.sf-admin>summary{cursor:pointer;font-weight:600}.sf-admin[open]>summary{margin-bottom:12px}.sf-card{border:1px solid var(--sf-border);border-radius:8px;display:flex;flex-direction:column;gap:10px;padding:12px}.sf-card h4,.sf-card p{margin:0}.sf-code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;resize:vertical}.sf-roots{display:flex;flex-direction:column;gap:6px;list-style:none;margin:0;padding:0}.sf-roots li{align-items:center;display:flex;gap:12px;justify-content:space-between}.sf-root-select{max-width:420px}.sf-browser{border:1px solid var(--sf-border);border-radius:6px;display:flex;flex-direction:column;max-height:320px;overflow:auto}.sf-browser>*{border-bottom:1px solid var(--sf-border);padding:5px 10px}.sf-browser>:last-child{border-bottom:none}.sf-browser__dir{background:none;border:none;border-bottom:1px solid var(--sf-border);color:inherit;cursor:pointer;font:inherit;text-align:left}.sf-browser__all,.sf-browser__dir:hover{background:var(--sf-subtle)}.sf-browser__all{font-weight:600}.sf-browser__name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sf-tag{border:1px solid var(--sf-border);border-radius:999px;display:inline-block;font-size:11px;margin-left:8px;padding:0 7px;vertical-align:middle}.sf-tag--warn{border-color:var(--sf-danger);color:var(--sf-danger)}.sf-strike{opacity:.7;text-decoration:line-through}.sf-browser__row{align-items:center;display:flex;gap:8px}.sf-browser__row .sf-browser__dir{border-bottom:none;flex:1;padding:0}.sf-dot{background:var(--sf-danger);border-radius:50%;display:inline-block;height:9px;vertical-align:middle;width:9px}.sf-dot--on{background:#16a34a}.sf-steps{display:flex;flex-direction:column;gap:10px;margin:0;padding-left:20px}.sf-steps li>.sf-row{margin:4px 0}.sf-codeline{background:var(--sf-subtle);border:1px solid var(--sf-border);border-radius:6px;font-size:12px;padding:4px 8px}.sf-codeline,.sf-paircode{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.sf-paircode{font-size:22px;font-weight:700;letter-spacing:.12em}.sf-small{font-size:12px}";
469
544
  styleInject(css_248z);
470
545
 
471
546
  function buildTree(files) {
@@ -491,6 +566,7 @@ function SecureFiles({ apiBase }) {
491
566
  const [files, setFiles] = useState([]);
492
567
  const [user, setUser] = useState(null);
493
568
  const [isAdmin, setIsAdmin] = useState(false);
569
+ const [devices, setDevices] = useState([]);
494
570
  const [loading, setLoading] = useState(true);
495
571
  const [error, setError] = useState(null);
496
572
  const [notice, setNotice] = useState(null);
@@ -505,6 +581,7 @@ function SecureFiles({ apiBase }) {
505
581
  const data = await api.list();
506
582
  setUser(data.user_name);
507
583
  setIsAdmin(data.is_admin);
584
+ setDevices(data.devices || []);
508
585
  setFiles(data.files);
509
586
  setSelected((prev) => new Set([...prev].filter((id) => data.files.some((f) => f.id === id))));
510
587
  }
@@ -587,7 +664,7 @@ function SecureFiles({ apiBase }) {
587
664
  const searching = search.trim().length > 0;
588
665
  const renderFile = (f, depth) => {
589
666
  const busy = busyIds.has(f.id);
590
- return (jsxs("tr", { className: busy ? "sf-busy" : undefined, children: [jsx("td", { className: "sf-col-check", children: jsx("input", { type: "checkbox", "aria-label": `Select ${f.filename}`, checked: selected.has(f.id), onChange: (e) => toggle([f.id], e.target.checked) }) }), jsxs("td", { style: { paddingLeft: 10 + depth * 20 }, children: [jsx("span", { className: f.available ? undefined : "sf-strike", children: f.filename }), f.kind === "folder" && (jsx("span", { className: "sf-tag", title: "Downloads as one uncompressed, resumable .tar of the whole folder", children: "folder \u00B7 .tar" })), f.linked && (jsx("span", { className: "sf-tag", title: f.source_path ?? undefined, children: "linked" })), !f.available && (jsx("span", { className: "sf-tag sf-tag--warn", title: f.linked ? "Source file is gone or its directory is no longer allowed" : "File is missing on disk", children: "missing" }))] }), jsxs("td", { children: [jsx("button", { type: "button", className: `sf-pill${f.shareable ? " sf-pill--on" : ""}`, disabled: busy || !f.available, onClick: () => setShared([f], !f.shareable), title: f.shareable ? "Click to stop sharing" : "Click to share", children: f.shareable ? "Shared" : "Private" }), f.shareable && f.has_password && jsx("span", { title: "Password protected", children: " \uD83D\uDD12" })] }), jsxs("td", { children: [f.download_count, " / ", f.download_limit ?? "∞"] }), jsx("td", { className: "sf-col-actions", children: jsxs("div", { className: "sf-row sf-row--end", children: [jsx("button", { type: "button", className: "sf-btn", disabled: busy || !f.available, onClick: () => download([f]), children: "Download" }), f.shareable && f.available && jsx(CopyButton, { text: f.share_url }), jsx("button", { type: "button", className: "sf-icon-btn", "aria-label": `Share settings for ${f.filename}`, title: "Share settings", onClick: () => setEditing(f), children: "\u2699\uFE0F" }), jsx(ConfirmButton, { label: f.linked ? "Unlink" : "Delete", disabled: busy, onConfirm: () => remove([f]) })] }) })] }, f.id));
667
+ return (jsxs("tr", { className: busy ? "sf-busy" : undefined, children: [jsx("td", { className: "sf-col-check", children: jsx("input", { type: "checkbox", "aria-label": `Select ${f.filename}`, checked: selected.has(f.id), onChange: (e) => toggle([f.id], e.target.checked) }) }), jsxs("td", { style: { paddingLeft: 10 + depth * 20 }, children: [jsx("span", { className: f.available || f.device ? undefined : "sf-strike", children: f.filename }), f.kind === "folder" && (jsx("span", { className: "sf-tag", title: "Downloads as one uncompressed, resumable .tar of the whole folder", children: "folder \u00B7 .tar" })), f.linked && (jsx("span", { className: "sf-tag", title: f.source_path ?? undefined, children: "linked" })), f.device && (jsxs("span", { className: `sf-tag${f.device.online ? "" : " sf-tag--warn"}`, title: "Served from your computer through the relay", children: ["on ", f.device.name, " \u00B7 ", f.device.online ? "online" : "offline"] })), !f.available && !f.device && (jsx("span", { className: "sf-tag sf-tag--warn", title: f.linked ? "Source file is gone or its directory is no longer allowed" : "File is missing on disk", children: "missing" }))] }), jsxs("td", { children: [jsx("button", { type: "button", className: `sf-pill${f.shareable ? " sf-pill--on" : ""}`, disabled: busy || !f.available, onClick: () => setShared([f], !f.shareable), title: f.shareable ? "Click to stop sharing" : "Click to share", children: f.shareable ? "Shared" : "Private" }), f.shareable && f.has_password && jsx("span", { title: "Password protected", children: " \uD83D\uDD12" })] }), jsxs("td", { children: [f.download_count, " / ", f.download_limit ?? "∞"] }), jsx("td", { className: "sf-col-actions", children: jsxs("div", { className: "sf-row sf-row--end", children: [jsx("button", { type: "button", className: "sf-btn", disabled: busy || !f.available, onClick: () => download([f]), children: "Download" }), f.shareable && f.available && f.share_url && jsx(CopyButton, { text: f.share_url }), jsx("button", { type: "button", className: "sf-icon-btn", "aria-label": `Share settings for ${f.filename}`, title: "Share settings", onClick: () => setEditing(f), children: "\u2699\uFE0F" }), f.device ? (jsxs("span", { className: "sf-muted sf-small", title: "Stop sharing it from that computer's Secure Files panel", children: ["managed on ", f.device.name] })) : (jsx(ConfirmButton, { label: f.linked ? "Unlink" : "Delete", disabled: busy, onConfirm: () => remove([f]) }))] }) })] }, f.id));
591
668
  };
592
669
  const renderFolder = (node, depth) => {
593
670
  const rows = [];
@@ -604,7 +681,7 @@ function SecureFiles({ apiBase }) {
604
681
  rows.push(...ownFiles.map((f) => renderFile(f, depth)));
605
682
  return rows;
606
683
  };
607
- return (jsxs("div", { className: "sf-root", children: [jsxs("header", { className: "sf-head", children: [jsx("h2", { children: "Secure Files" }), user && jsxs("span", { className: "sf-muted", children: ["Signed in as ", user] })] }), jsx(UploadPanel, { api: api, folders: folders, onUploaded: load }), isAdmin && jsx(ServerFiles, { api: api, folders: folders, onLinked: load }), jsxs("section", { className: "sf-toolbar", children: [jsx("input", { className: "sf-input sf-search", type: "search", placeholder: "Search files\u2026", value: search, onChange: (e) => setSearch(e.target.value) }), jsxs("div", { className: "sf-row", children: [jsxs("span", { className: "sf-muted", children: [selected.size, " selected"] }), folders.length > 0 && (jsxs(Fragment, { children: [jsx("button", { type: "button", className: "sf-btn", onClick: () => setCollapsed(new Set()), children: "Expand all" }), jsx("button", { type: "button", className: "sf-btn", onClick: () => setCollapsed(new Set(folders)), children: "Collapse all" })] })), jsx("button", { type: "button", className: "sf-btn", disabled: !selected.size, onClick: () => download(selectedFiles), children: "Download" }), jsx("button", { type: "button", className: "sf-btn", disabled: !selected.size, onClick: () => setShared(selectedFiles, true), children: "Share" }), jsx("button", { type: "button", className: "sf-btn", disabled: !selected.size, onClick: () => setShared(selectedFiles, false), children: "Unshare" }), jsx(ConfirmButton, { label: "Delete", disabled: !selected.size, onConfirm: () => remove(selectedFiles) })] })] }), notice && (jsx("p", { className: "sf-error", role: "alert", children: notice })), loading ? (jsx("p", { className: "sf-muted", children: "Loading\u2026" })) : error ? (jsx("p", { className: "sf-error", role: "alert", children: error })) : files.length === 0 ? (jsx("p", { className: "sf-muted", children: "No files yet \u2014 upload something above." })) : (jsx("div", { className: "sf-table-wrap", children: jsxs("table", { className: "sf-table", children: [jsx("thead", { children: jsxs("tr", { children: [jsx("th", { className: "sf-col-check", children: jsx("input", { type: "checkbox", "aria-label": "Select all", checked: allVisibleSelected, onChange: (e) => toggle(filtered.map((f) => f.id), e.target.checked) }) }), jsx("th", { children: "Name" }), jsx("th", { children: "Sharing" }), jsx("th", { children: "Downloads" }), jsx("th", { className: "sf-col-actions", children: "Actions" })] }) }), jsx("tbody", { children: renderFolder(tree, 0) })] }) })), editing && (jsx(ShareDialog, { file: files.find((f) => f.id === editing.id) ?? editing, onClose: () => setEditing(null), onSave: saveShare }, editing.id))] }));
684
+ return (jsxs("div", { className: "sf-root", children: [jsxs("header", { className: "sf-head", children: [jsx("h2", { children: "Secure Files" }), user && jsxs("span", { className: "sf-muted", children: ["Signed in as ", user] })] }), jsx(UploadPanel, { api: api, folders: folders.filter((d) => d !== "devices" && !d.startsWith("devices/")), onUploaded: load }), jsx(MyComputers, { api: api, devices: devices, onChange: load }), isAdmin && jsx(ServerFiles, { api: api, folders: folders, onLinked: load }), jsxs("section", { className: "sf-toolbar", children: [jsx("input", { className: "sf-input sf-search", type: "search", placeholder: "Search files\u2026", value: search, onChange: (e) => setSearch(e.target.value) }), jsxs("div", { className: "sf-row", children: [jsxs("span", { className: "sf-muted", children: [selected.size, " selected"] }), folders.length > 0 && (jsxs(Fragment, { children: [jsx("button", { type: "button", className: "sf-btn", onClick: () => setCollapsed(new Set()), children: "Expand all" }), jsx("button", { type: "button", className: "sf-btn", onClick: () => setCollapsed(new Set(folders)), children: "Collapse all" })] })), jsx("button", { type: "button", className: "sf-btn", disabled: !selected.size, onClick: () => download(selectedFiles), children: "Download" }), jsx("button", { type: "button", className: "sf-btn", disabled: !selected.size, onClick: () => setShared(selectedFiles, true), children: "Share" }), jsx("button", { type: "button", className: "sf-btn", disabled: !selected.size, onClick: () => setShared(selectedFiles, false), children: "Unshare" }), jsx(ConfirmButton, { label: "Delete", disabled: !selected.size, onConfirm: () => remove(selectedFiles) })] })] }), notice && (jsx("p", { className: "sf-error", role: "alert", children: notice })), loading ? (jsx("p", { className: "sf-muted", children: "Loading\u2026" })) : error ? (jsx("p", { className: "sf-error", role: "alert", children: error })) : files.length === 0 ? (jsx("p", { className: "sf-muted", children: "No files yet \u2014 upload something above." })) : (jsx("div", { className: "sf-table-wrap", children: jsxs("table", { className: "sf-table", children: [jsx("thead", { children: jsxs("tr", { children: [jsx("th", { className: "sf-col-check", children: jsx("input", { type: "checkbox", "aria-label": "Select all", checked: allVisibleSelected, onChange: (e) => toggle(filtered.map((f) => f.id), e.target.checked) }) }), jsx("th", { children: "Name" }), jsx("th", { children: "Sharing" }), jsx("th", { children: "Downloads" }), jsx("th", { className: "sf-col-actions", children: "Actions" })] }) }), jsx("tbody", { children: renderFolder(tree, 0) })] }) })), editing && (jsx(ShareDialog, { file: files.find((f) => f.id === editing.id) ?? editing, onClose: () => setEditing(null), onSave: saveShare }, editing.id))] }));
608
685
  }
609
686
 
610
687
  export { ApiError, DEFAULT_API_BASE, SecureFiles, createApi };