@putkoff/abstract-securefiles 0.0.53 → 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.53",
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",
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
@@ -484,3 +484,7 @@
484
484
  gap: 4px;
485
485
  margin-top: 6px;
486
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
+ }