@putkoff/abstract-securefiles 0.0.52 → 0.0.54

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/types.d.ts CHANGED
@@ -72,3 +72,29 @@ export interface SecureFilesProps {
72
72
  /** API origin; defaults to https://api.abstractendeavors.com */
73
73
  apiBase?: string;
74
74
  }
75
+ export interface Person {
76
+ username: string;
77
+ connected_at: string | null;
78
+ /** what I share with this person: everything, or specific item ids */
79
+ my_grant: {
80
+ all: boolean;
81
+ ids: number[];
82
+ };
83
+ /** how many items they share with me */
84
+ shared_with_me: number;
85
+ }
86
+ export interface SharedItem {
87
+ id: number;
88
+ filename: string;
89
+ filepath: string;
90
+ kind: "file" | "folder";
91
+ size: number | null;
92
+ available: boolean;
93
+ device: Device | null;
94
+ created_at: string | null;
95
+ }
96
+ export interface SharedOwner {
97
+ owner: string;
98
+ all: boolean;
99
+ files: SharedItem[];
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@putkoff/abstract-securefiles",
3
- "version": "0.0.52",
3
+ "version": "0.0.54",
4
4
  "description": "A reusable Secure file system",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",
@@ -6,6 +6,9 @@ import { CopyButton } from "./CopyButton";
6
6
 
7
7
  const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
8
8
 
9
+ const UNIX_CMD = "pipx install abstract-securefiles && pipx ensurepath && ~/.local/bin/securefiles setup";
10
+ const WIN_CMD = "py -m pip install --user pipx; py -m pipx install abstract-securefiles; py -m pipx ensurepath";
11
+
9
12
  function ago(iso: string | null) {
10
13
  if (!iso) return "never";
11
14
  const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
@@ -58,22 +61,40 @@ function AddComputer({ api, onPaired }: { api: SecureFilesApi; onPaired: () => v
58
61
  <h4>Connect a computer</h4>
59
62
  <ol className="sf-steps">
60
63
  <li>
61
- On that computer, install the agent (Linux, macOS or Windows with Python 3.9+):
62
- <div className="sf-row">
63
- <code className="sf-codeline">pipx install abstract-securefiles &amp;&amp; securefiles setup</code>
64
- <CopyButton text="pipx install abstract-securefiles && securefiles setup" label="Copy" />
64
+ On that computer, install the agent (needs Python 3.9+):
65
+ <div className="sf-os">
66
+ <b>Linux / macOS</b> (Terminal)
67
+ <div className="sf-row">
68
+ <code className="sf-codeline">{UNIX_CMD}</code>
69
+ <CopyButton text={UNIX_CMD} label="Copy" />
70
+ </div>
71
+ <span className="sf-muted">
72
+ No pipx? <code>python3 -m pip install --user pipx</code> then use <code>python3 -m pipx</code>. On a Mac
73
+ without Python, run <code>python3</code> once and accept Apple's developer-tools install.
74
+ </span>
75
+ </div>
76
+ <div className="sf-os">
77
+ <b>Windows</b> (PowerShell; install Python from python.org first)
78
+ <div className="sf-row">
79
+ <code className="sf-codeline">{WIN_CMD}</code>
80
+ <CopyButton text={WIN_CMD} label="Copy" />
81
+ </div>
82
+ <span className="sf-muted">Then open a new PowerShell window and run <code>securefiles setup</code>.</span>
65
83
  </div>
66
- <span className="sf-muted">No pipx? Use <code>python3 -m pip install --user abstract-securefiles</code> then <code>securefiles setup</code>.</span>
67
84
  </li>
68
85
  <li>
69
- In the panel that opens (<code>http://127.0.0.1:47821</code>), enter this code:
86
+ In the panel that opens (<code>http://127.0.0.1:47821</code>), enter this code, or run{" "}
87
+ <code>securefiles pair {code}</code>:
70
88
  <div className="sf-row">
71
89
  <span className="sf-paircode">{code}</span>
72
90
  <CopyButton text={code} label="Copy" />
73
91
  </div>
74
92
  <span className="sf-muted">Valid for about {minutesLeft} more minutes, once.</span>
75
93
  </li>
76
- <li>Pick the files and folders to share there. They appear here under <code>devices/&lt;computer&gt;/</code>.</li>
94
+ <li>
95
+ Pick files and folders to share in that panel (or <code>securefiles share PATH</code>). They appear here
96
+ under <code>devices/&lt;computer&gt;/</code>, where you set links, passwords and limits.
97
+ </li>
77
98
  </ol>
78
99
  <div className="sf-row">
79
100
  <button type="button" className="sf-btn" onClick={() => setCode(null)}>
package/src/People.tsx ADDED
@@ -0,0 +1,327 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
2
+ import type { SecureFilesApi } from "./api";
3
+ import type { Person, SecureFile, SharedOwner } from "./types";
4
+ import { ConfirmButton } from "./ConfirmButton";
5
+ import { CopyButton } from "./CopyButton";
6
+
7
+ const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
8
+
9
+ function formatSize(bytes: number | null) {
10
+ if (bytes == null) return "";
11
+ const units = ["B", "KB", "MB", "GB", "TB"];
12
+ let n = bytes;
13
+ let i = 0;
14
+ while (n >= 1024 && i < units.length - 1) {
15
+ n /= 1024;
16
+ i++;
17
+ }
18
+ return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
19
+ }
20
+
21
+ function GrantEditor({
22
+ person,
23
+ files,
24
+ onSave,
25
+ onClose,
26
+ }: {
27
+ person: Person;
28
+ files: SecureFile[];
29
+ onSave: (grant: { all: boolean; ids: number[] }) => Promise<void>;
30
+ onClose: () => void;
31
+ }) {
32
+ const [all, setAll] = useState(person.my_grant.all);
33
+ const [ids, setIds] = useState<Set<number>>(new Set(person.my_grant.ids));
34
+ const [search, setSearch] = useState("");
35
+ const [saving, setSaving] = useState(false);
36
+ const [error, setError] = useState<string | null>(null);
37
+ const visible = useMemo(() => {
38
+ const q = search.trim().toLowerCase();
39
+ return [...files]
40
+ .sort((a, b) => a.filepath.localeCompare(b.filepath))
41
+ .filter((f) => !q || f.filepath.toLowerCase().includes(q));
42
+ }, [files, search]);
43
+
44
+ const save = async () => {
45
+ setSaving(true);
46
+ setError(null);
47
+ try {
48
+ await onSave({ all, ids: all ? [] : [...ids] });
49
+ onClose();
50
+ } catch (err) {
51
+ setError(errText(err));
52
+ } finally {
53
+ setSaving(false);
54
+ }
55
+ };
56
+
57
+ return (
58
+ <div className="sf-card">
59
+ <h4>What {person.username} can access</h4>
60
+ <label className="sf-check">
61
+ <input type="radio" checked={all} onChange={() => setAll(true)} />
62
+ <span>
63
+ <b>All my files</b> <span className="sf-muted">— including anything you add later</span>
64
+ </span>
65
+ </label>
66
+ <label className="sf-check">
67
+ <input type="radio" checked={!all} onChange={() => setAll(false)} />
68
+ <b>Only the items I choose</b>
69
+ </label>
70
+ {!all && (
71
+ <>
72
+ <input
73
+ className="sf-input"
74
+ type="search"
75
+ placeholder="Filter your files…"
76
+ value={search}
77
+ onChange={(e) => setSearch(e.target.value)}
78
+ />
79
+ <div className="sf-browser">
80
+ {visible.length === 0 && <p className="sf-muted">No files.</p>}
81
+ {visible.map((f) => (
82
+ <label key={f.id} className="sf-check sf-browser__file">
83
+ <input
84
+ type="checkbox"
85
+ checked={ids.has(f.id)}
86
+ onChange={(e) =>
87
+ setIds((s) => {
88
+ const next = new Set(s);
89
+ e.target.checked ? next.add(f.id) : next.delete(f.id);
90
+ return next;
91
+ })
92
+ }
93
+ />
94
+ <span className="sf-browser__name">{f.filepath}</span>
95
+ <span className="sf-muted">{f.kind === "folder" ? "folder · .tar" : formatSize(f.size)}</span>
96
+ </label>
97
+ ))}
98
+ </div>
99
+ <span className="sf-muted">{ids.size} selected</span>
100
+ </>
101
+ )}
102
+ {error && <p className="sf-error">{error}</p>}
103
+ <div className="sf-row sf-row--end">
104
+ <button type="button" className="sf-btn" onClick={onClose}>
105
+ Cancel
106
+ </button>
107
+ <button type="button" className="sf-btn sf-btn--primary" disabled={saving} onClick={save}>
108
+ {saving ? "Saving…" : "Save"}
109
+ </button>
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ export function People({ api, files }: { api: SecureFilesApi; files: SecureFile[] }) {
116
+ const [people, setPeople] = useState<Person[]>([]);
117
+ const [code, setCode] = useState<string | null>(null);
118
+ const [entered, setEntered] = useState("");
119
+ const [editing, setEditing] = useState<string | null>(null);
120
+ const [error, setError] = useState<string | null>(null);
121
+ const [notice, setNotice] = useState<string | null>(null);
122
+
123
+ const load = useCallback(async () => {
124
+ try {
125
+ setPeople((await api.people()).people);
126
+ } catch (err) {
127
+ setError(errText(err));
128
+ }
129
+ }, [api]);
130
+
131
+ useEffect(() => {
132
+ load();
133
+ }, [load]);
134
+
135
+ useEffect(() => {
136
+ if (!code) return;
137
+ const timer = setInterval(load, 5000);
138
+ const expire = setTimeout(() => setCode(null), 15 * 60 * 1000);
139
+ return () => {
140
+ clearInterval(timer);
141
+ clearTimeout(expire);
142
+ };
143
+ }, [code, load]);
144
+
145
+ const invite = async () => {
146
+ setError(null);
147
+ try {
148
+ setCode((await api.invitePerson()).code);
149
+ } catch (err) {
150
+ setError(errText(err));
151
+ }
152
+ };
153
+
154
+ const accept = async () => {
155
+ setError(null);
156
+ setNotice(null);
157
+ try {
158
+ const res = await api.acceptPerson(entered);
159
+ setPeople(res.people);
160
+ setEntered("");
161
+ setNotice(`Connected with ${res.connected_with}.`);
162
+ } catch (err) {
163
+ setError(errText(err));
164
+ }
165
+ };
166
+
167
+ const describe = (p: Person) =>
168
+ p.my_grant.all ? "can access all your files" : `can access ${p.my_grant.ids.length} of your items`;
169
+
170
+ return (
171
+ <details className="sf-admin">
172
+ <summary>
173
+ People <span className="sf-muted">({people.length})</span>
174
+ </summary>
175
+ <div className="sf-stack">
176
+ <p className="sf-muted">
177
+ Connect with other abstractendeavors.com accounts, then choose what each person can access: all your files
178
+ or specific ones. They download while signed in; nothing is public.
179
+ </p>
180
+ {people.length > 0 && (
181
+ <ul className="sf-roots">
182
+ {people.map((p) => (
183
+ <li key={p.username}>
184
+ <span>
185
+ <b>{p.username}</b>{" "}
186
+ <span className={p.my_grant.all ? "sf-tag sf-tag--warn" : "sf-muted"}>{describe(p)}</span>
187
+ {p.shared_with_me > 0 && (
188
+ <span className="sf-muted"> · shares {p.shared_with_me} with you</span>
189
+ )}
190
+ </span>
191
+ <span className="sf-row">
192
+ <button type="button" className="sf-btn" onClick={() => setEditing(p.username)}>
193
+ Choose access
194
+ </button>
195
+ <ConfirmButton
196
+ label="Disconnect"
197
+ onConfirm={async () => {
198
+ try {
199
+ setPeople((await api.removePerson(p.username)).people);
200
+ } catch (err) {
201
+ setError(errText(err));
202
+ }
203
+ }}
204
+ />
205
+ </span>
206
+ </li>
207
+ ))}
208
+ </ul>
209
+ )}
210
+ {editing && (
211
+ <GrantEditor
212
+ key={editing}
213
+ person={people.find((p) => p.username === editing)!}
214
+ files={files}
215
+ onClose={() => setEditing(null)}
216
+ onSave={async (grant) => setPeople((await api.setGrants(editing, grant)).people)}
217
+ />
218
+ )}
219
+ <div className="sf-row sf-row--bottom">
220
+ {code ? (
221
+ <div className="sf-field">
222
+ <span>Send this code to the person (valid 15 minutes, once):</span>
223
+ <div className="sf-row">
224
+ <span className="sf-paircode">{code}</span>
225
+ <CopyButton text={code} label="Copy" />
226
+ <button type="button" className="sf-btn" onClick={() => setCode(null)}>
227
+ Done
228
+ </button>
229
+ </div>
230
+ <span className="sf-muted">They enter it under People → "Have a code?" on their Secure Files page.</span>
231
+ </div>
232
+ ) : (
233
+ <button type="button" className="sf-btn sf-btn--primary" onClick={invite}>
234
+ Invite someone
235
+ </button>
236
+ )}
237
+ <label className="sf-field sf-field--narrow">
238
+ <span>Have a code?</span>
239
+ <div className="sf-row">
240
+ <input
241
+ className="sf-input"
242
+ value={entered}
243
+ placeholder="ABCD-1234"
244
+ onChange={(e) => setEntered(e.target.value)}
245
+ onKeyDown={(e) => e.key === "Enter" && entered.trim() && accept()}
246
+ />
247
+ <button type="button" className="sf-btn" disabled={!entered.trim()} onClick={accept}>
248
+ Connect
249
+ </button>
250
+ </div>
251
+ </label>
252
+ </div>
253
+ {notice && <p className="sf-muted">{notice}</p>}
254
+ {error && <p className="sf-error">{error}</p>}
255
+ </div>
256
+ </details>
257
+ );
258
+ }
259
+
260
+ export function SharedWithMe({ api }: { api: SecureFilesApi }) {
261
+ const [owners, setOwners] = useState<SharedOwner[] | null>(null);
262
+ const [error, setError] = useState<string | null>(null);
263
+
264
+ const load = useCallback(async () => {
265
+ try {
266
+ setOwners((await api.sharedWithMe()).owners);
267
+ setError(null);
268
+ } catch (err) {
269
+ setError(errText(err));
270
+ }
271
+ }, [api]);
272
+
273
+ useEffect(() => {
274
+ load();
275
+ const onVisible = () => document.visibilityState === "visible" && load();
276
+ document.addEventListener("visibilitychange", onVisible);
277
+ return () => document.removeEventListener("visibilitychange", onVisible);
278
+ }, [load]);
279
+
280
+ if (!owners || owners.length === 0) return error ? <p className="sf-error">{error}</p> : null;
281
+ return (
282
+ <section className="sf-stack">
283
+ <h3 className="sf-subhead">Shared with me</h3>
284
+ {owners.map((o) => (
285
+ <div key={o.owner} className="sf-table-wrap">
286
+ <table className="sf-table">
287
+ <thead>
288
+ <tr>
289
+ <th>
290
+ From <b>{o.owner}</b> {o.all && <span className="sf-muted">(all their files)</span>}
291
+ </th>
292
+ <th>Size</th>
293
+ <th className="sf-col-actions" />
294
+ </tr>
295
+ </thead>
296
+ <tbody>
297
+ {o.files.map((f) => (
298
+ <tr key={f.id}>
299
+ <td>
300
+ {f.filepath}
301
+ {f.kind === "folder" && <span className="sf-tag">folder · .tar</span>}
302
+ {f.device && (
303
+ <span className={`sf-tag${f.device.online ? "" : " sf-tag--warn"}`}>
304
+ on {f.device.name} · {f.device.online ? "online" : "offline"}
305
+ </span>
306
+ )}
307
+ </td>
308
+ <td className="sf-muted">{formatSize(f.size)}</td>
309
+ <td className="sf-col-actions">
310
+ <button
311
+ type="button"
312
+ className="sf-btn"
313
+ disabled={!f.available}
314
+ onClick={() => api.downloadShared(f.id)}
315
+ >
316
+ Download
317
+ </button>
318
+ </td>
319
+ </tr>
320
+ ))}
321
+ </tbody>
322
+ </table>
323
+ </div>
324
+ ))}
325
+ </section>
326
+ );
327
+ }
@@ -7,6 +7,7 @@ import { CopyButton } from "./CopyButton";
7
7
  import { ConfirmButton } from "./ConfirmButton";
8
8
  import { ServerFiles } from "./ServerFiles";
9
9
  import { MyComputers } from "./MyComputers";
10
+ import { People, SharedWithMe } from "./People";
10
11
  import "./styles.css";
11
12
 
12
13
  interface FolderNode {
@@ -279,6 +280,8 @@ export default function SecureFiles({ apiBase }: SecureFilesProps) {
279
280
 
280
281
  <MyComputers api={api} devices={devices} onChange={load} />
281
282
 
283
+ <People api={api} files={files} />
284
+
282
285
  {isAdmin && <ServerFiles api={api} folders={folders} onLinked={load} />}
283
286
 
284
287
  <section className="sf-toolbar">
@@ -352,6 +355,8 @@ export default function SecureFiles({ apiBase }: SecureFilesProps) {
352
355
  </div>
353
356
  )}
354
357
 
358
+ <SharedWithMe api={api} />
359
+
355
360
  {editing && (
356
361
  <ShareDialog
357
362
  key={editing.id}
package/src/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BrowseResult, Device, LinkRoot, RootChallenge, SecureFile, ShareUpdate } from "./types";
1
+ import type { BrowseResult, Device, LinkRoot, Person, RootChallenge, SecureFile, SharedOwner, ShareUpdate } from "./types";
2
2
 
3
3
  export const DEFAULT_API_BASE = "https://api.abstractendeavors.com";
4
4
 
@@ -42,6 +42,37 @@ export function createApi(apiBase: string = DEFAULT_API_BASE) {
42
42
 
43
43
  removeDevice: (id: number) => json<{ devices: Device[] }>(`/devices/${id}`, { method: "DELETE" }),
44
44
 
45
+ people: () => json<{ people: Person[] }>("/people"),
46
+
47
+ invitePerson: () => json<{ code: string; expires_in: number }>("/people/invite", { method: "POST", body: "{}" }),
48
+
49
+ acceptPerson: (code: string) =>
50
+ json<{ connected_with: string; people: Person[] }>("/people/accept", {
51
+ method: "POST",
52
+ body: JSON.stringify({ code }),
53
+ }),
54
+
55
+ setGrants: (username: string, grant: { all: boolean; ids: number[] }) =>
56
+ json<{ people: Person[] }>(`/people/${encodeURIComponent(username)}/grants`, {
57
+ method: "PUT",
58
+ body: JSON.stringify(grant),
59
+ }),
60
+
61
+ removePerson: (username: string) =>
62
+ json<{ people: Person[] }>(`/people/${encodeURIComponent(username)}`, { method: "DELETE" }),
63
+
64
+ sharedWithMe: () => json<{ owners: SharedOwner[] }>("/shared-with-me"),
65
+
66
+ /** Browser-native download of an item someone shared with me (streams to disk, resumable). */
67
+ downloadShared(id: number): void {
68
+ const a = document.createElement("a");
69
+ a.href = `${root}/shared-with-me/${id}`;
70
+ a.rel = "noopener";
71
+ document.body.appendChild(a);
72
+ a.click();
73
+ a.remove();
74
+ },
75
+
45
76
  roots: () => json<{ roots: LinkRoot[] }>("/admin/roots"),
46
77
 
47
78
  requestRoot: (path: string) =>
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { default as SecureFiles } from "./SecureFiles";
2
2
  export { createApi, DEFAULT_API_BASE, ApiError } from "./api";
3
3
  export type { SecureFilesApi } from "./api";
4
- export type { SecureFile, ShareUpdate, SecureFilesProps, LinkRoot, RootChallenge, BrowseResult, Device } from "./types";
4
+ export type { SecureFile, ShareUpdate, SecureFilesProps, LinkRoot, RootChallenge, BrowseResult, Device, Person, SharedItem, SharedOwner } from "./types";
package/src/styles.css CHANGED
@@ -478,3 +478,13 @@
478
478
  .sf-small {
479
479
  font-size: 12px;
480
480
  }
481
+ .sf-os {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 4px;
485
+ margin-top: 6px;
486
+ }
487
+ .sf-subhead {
488
+ margin: 8px 0 0;
489
+ font-size: 1.1em;
490
+ }
package/src/types.ts CHANGED
@@ -69,3 +69,29 @@ export interface SecureFilesProps {
69
69
  /** API origin; defaults to https://api.abstractendeavors.com */
70
70
  apiBase?: string;
71
71
  }
72
+
73
+ export interface Person {
74
+ username: string;
75
+ connected_at: string | null;
76
+ /** what I share with this person: everything, or specific item ids */
77
+ my_grant: { all: boolean; ids: number[] };
78
+ /** how many items they share with me */
79
+ shared_with_me: number;
80
+ }
81
+
82
+ export interface SharedItem {
83
+ id: number;
84
+ filename: string;
85
+ filepath: string;
86
+ kind: "file" | "folder";
87
+ size: number | null;
88
+ available: boolean;
89
+ device: Device | null;
90
+ created_at: string | null;
91
+ }
92
+
93
+ export interface SharedOwner {
94
+ owner: string;
95
+ all: boolean;
96
+ files: SharedItem[];
97
+ }