diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac4010..55bf0ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,12 @@ - Deleting a live file closes it for the people who have it open (a snapshot is kept); moving one into a private folder takes it out of live sharing - Renaming or moving a file or folder re-points everyone's open tabs to the new path, so nobody recreates it under the old name. People who can't open the new location are told nothing about it and keep a private copy +- The Files tab has **My files**, **Shared** and, for admins, **All** tabs instead of a Home screen; the same tabs appear in the Open, Save and Move dialogs +- Who can open a workflow now follows its folder only. For a live workflow in a folder you own, *This tab* shows **Folder access** + ### Removed +- Per-workflow access lists (restrict the folder instead). Lists set on individual workflows before are dropped - *Share this tab*, *Stop sharing*, the automatic-sharing settings and the admin 🗑 for idle rooms ## 1.0.0 diff --git a/README.md b/README.md index fba0b76..c350391 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ What this fork adds over Nexus: Admin tab
Accounts and server settings - Access list of a workflow
Who can open a workflow + Access list of a shared folder
Who can open a folder @@ -90,7 +90,7 @@ Behind a reverse proxy, turn on WebSocket support, or nothing will be live. See | | | |---|---| | [Installing](docs/install.md) | Custom node, Docker/compose, first-run setup, upgrading from Nexus | -| [Sharing workflows](docs/sharing.md) | Live workflows by folder, access lists, snapshots, follow | +| [Sharing workflows](docs/sharing.md) | Live workflows by folder, folder access, snapshots, follow | | [Accounts and permissions](docs/accounts.md) | Edit, Queue, Admin, renaming, what viewers can do | | [API access](docs/api.md) | API keys, websockets, trusted IPs, other apps such as Open WebUI | | [Settings](docs/settings.md) | Panel settings, ComfyUI settings, every key in `config.json` | @@ -116,5 +116,5 @@ I run one ComfyUI box for several people. [ComfyUI-Nexus](https://github.com/dax - [x] Chat with badge, blink and sound - [x] Server monitor with floating widget - [x] Conflict-free node ids when two people add nodes at the same instant -- [x] Per-workflow access lists (share with specific people) +- [x] Folder access lists (share with specific people) - [ ] Publish to the Comfy registry diff --git a/docs/accounts.md b/docs/accounts.md index 2048787..c18e85c 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -25,7 +25,7 @@ Admins use **Admin → Accounts** in the RigShare panel: Admins can also toggle Edit, Queue and Manager straight from the **People** tab. -A shared workflow can also be restricted to chosen people, with *Can view* or *Can edit* each. See [Who can open it](sharing.md#who-can-open-it). +A shared folder can be restricted to chosen people, with *Can view* or *Can edit* each; its workflows follow it. See [Who can open it](sharing.md#who-can-open-it). The last admin cannot be demoted or deleted. diff --git a/docs/api.md b/docs/api.md index 0f131bd..b2a657c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -49,7 +49,6 @@ The panel uses these; they take the same session cookie or API key. | `GET /rigshare/api/rooms` | Shared workflows | | `GET /rigshare/api/room?key=…` | One room, with its workflow JSON | | `GET /rigshare/api/snapshots?room=…` | History of a room | -| `GET/PUT /rigshare/api/room/acl?key=…` | Who can open a room (owner or admin) | | `GET /rigshare/api/people` | Accounts that can be added to an access list | | `GET/POST/PATCH/DELETE /rigshare/api/users…` | Accounts (admin) | | `GET/PATCH /rigshare/api/config` | Server settings (admin) | diff --git a/docs/development.md b/docs/development.md index a82989c..37a603c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,8 +15,9 @@ node --test "tests/js/*.test.mjs" | `tests/python/test_store.py` | Accounts, first-run setup, password hashing, JWT sessions (tampering, `alg: none`, expiry, refresh, revocation), API keys, snapshots | | `tests/python/test_graphdoc.py` | Workflow patches, link repair, id renumbering for simultaneous edits, and parity with the JavaScript implementation | | `tests/python/test_workspace.py` | Folder rules: path normalisation (double encoding, `../`), private and shared folders, access lists, moves, deletes, browsing | -| `tests/python/test_server.py` | End to end against a real aiohttp server: login gate, cookies, API keys, permissions, Manager gating, template filtering, live rooms, access lists, folders through ComfyUI's userdata API | +| `tests/python/test_server.py` | End to end against a real aiohttp server: login gate, cookies, API keys, permissions, Manager gating, template filtering, live rooms, folder access, moves, folders through ComfyUI's userdata API | | `tests/js/graphdoc.test.mjs` | The client-side patch logic | +| `tests/js/files.test.mjs` | Files tabs (My files, Shared, All), start folders, breadcrumbs | | `tests/js/sync.test.mjs` | Client diffs, and the regression test for opening a file overwriting the previous tab's shared workflow | The server tests use a small stand-in for ComfyUI's `/userdata` API (`tests/python/helpers.py`), so they run anywhere Python does. GitHub Actions runs both suites on every push and pull request. diff --git a/docs/security.md b/docs/security.md index f743150..06500b9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -29,7 +29,7 @@ What RigShare does to keep the rig yours, and what it leaves to you. - ComfyUI's queue, interrupt, free and upload endpoints are checked server-side, and so are saving (Edit) and deleting (Admin) workflow files (`api_protection`). - Without *Admin*, account and server settings are refused. - Without *Manager*, ComfyUI Manager's API is refused and its button hidden. Only a few read-only lookups the frontend makes for everyone stay open. -- A restricted workflow is refused to anyone not on its list: joining it, reading it, its snapshots, and its file through ComfyUI's API. +- A workflow in a restricted folder is refused to anyone not on the folder's list: joining it, reading it, its snapshots, and its file through ComfyUI's API. ## What is up to you diff --git a/docs/sharing.md b/docs/sharing.md index 10d2666..56fe623 100644 --- a/docs/sharing.md +++ b/docs/sharing.md @@ -34,19 +34,20 @@ Viewers (no *Edit* permission) see a read-only canvas. Anything they change loca ## When a workflow stops being live - When everyone closes it, it leaves the live list. The server keeps its latest state, so the next person to open it picks up where the others left off, saved or not. That state is forgotten after 14 days without anyone opening it (`room_expiry_days`). -- Renaming or moving it (or its folder) keeps it live: everyone who has it open follows it to the new name, and its history and access list go with it. +- Renaming or moving it (or its folder) keeps it live: everyone who has it open follows it to the new name, and its history goes with it. - Moving it into a private folder takes it out of live sharing straight away. Its owner's tab follows it; everyone else keeps a private copy. - Deleting it (admins, or the owner of its shared folder) closes it for the people who have it open. Their tabs stay as private copies, and a *Before delete* snapshot is kept. ## Who can open it -By default everyone who can open its folder can open a live workflow, with their usual permissions. The **Access** button under *This tab* lets its owner (whoever opened it first) or any admin choose *Only people I choose* and give each person **Can view** or **Can edit**. +Access follows the folder, not the workflow. To keep a workflow to some people, put it in a shared folder and restrict the folder: in the Files tab (🔒 on the folder), or with **Folder access** under *This tab* when the workflow on screen is in a folder you own. Choose *Only people I choose* and give each person **Can view** or **Can edit**. -- The owner and admins always have access. +- Workflows at the top level of Shared (not in a folder) can be opened by everyone with an account, with their usual permissions. +- The folder's owner (whoever created it) and admins always have access. - A role can only narrow someone's account permissions: *Can edit* does nothing for an account without *Edit*. - People who lose access are taken out of the workflow straight away; their tab stays open as a private copy. - Without access, the workflow is gone from their list and from the People tab, and they cannot open it, its snapshots, or its file through ComfyUI. -- A 🔒 marks restricted workflows in the list. +- A 🔒 marks workflows in restricted folders in the live list. Saved workflow files still show up by name in ComfyUI's workflow browser; opening one without access is refused. diff --git a/rigshare_core/hub.py b/rigshare_core/hub.py index 954ad6d..76ef5b1 100644 --- a/rigshare_core/hub.py +++ b/rigshare_core/hub.py @@ -35,9 +35,6 @@ def clean_text(text, limit): return text.strip()[:limit] -ROLES = ("view", "edit") - - def is_live_key(key): """Only saved workflows outside a private folder can be live.""" if not isinstance(key, str) or not key.startswith("file:workflows/"): @@ -46,14 +43,8 @@ def is_live_key(key): class Room: - def __init__(self, key, name, kind, doc, version=0, updated=None, owner=None, acl=None): + def __init__(self, key, name, kind, doc, version=0, updated=None): self.key = key - # Username of whoever first shared it; may manage its access list. - self.owner = owner - # None: everyone with an account can open it. Otherwise - # {"members": {username: "view" | "edit"}}: only those people (plus - # the owner and admins). A room role can only narrow account permissions. - self.acl = acl self.name = name self.kind = kind self.doc = doc @@ -66,7 +57,7 @@ class Room: def to_file(self): return {"key": self.key, "name": self.name, "kind": self.kind, "doc": self.doc, - "version": self.version, "updated": self.updated, "owner": self.owner, "acl": self.acl} + "version": self.version, "updated": self.updated} class Client: @@ -105,8 +96,7 @@ class Hub: continue try: self.rooms[data["key"]] = Room(data["key"], data.get("name") or data["key"], data.get("kind", "file"), - data.get("doc"), data.get("version", 0), data.get("updated"), - data.get("owner"), data.get("acl")) + data.get("doc"), data.get("version", 0), data.get("updated")) except (KeyError, TypeError): continue self.chat = deque(maxlen=int(store.config.get("chat_history", 200))) @@ -257,43 +247,34 @@ class Hub: def members(self, key): return [c for c in self.clients.values() if c.kind and c.room == key] - # ----- access lists ------------------------------------------------- + # ----- access --------------------------------------------------------- def access(self, room, kind, key, perms): """What someone may do in a room: None (no access), "view" or "edit". - A saved workflow's room also follows its folder (private spaces, - restricted shared folders): the stricter of the two wins. + Access follows the workflow's folder (restricted shared folders), within + the person's account permissions. """ - role = self._room_access(room, kind, key, perms) - if role and self.workspace and room.key.startswith("file:workflows/"): - from .workspace import min_role - role = min_role(role, self.workspace.role(room.key[len("file:workflows/"):], kind, key, perms)) - return role - - @staticmethod - def _room_access(room, kind, key, perms): - can_edit = bool(perms.get("edit") or perms.get("admin")) - if perms.get("admin") or not room.acl: - return "edit" if can_edit else "view" - if kind == "user" and key == room.owner: - return "edit" if can_edit else "view" - role = (room.acl.get("members") or {}).get(key) if kind == "user" else None - if role not in ROLES: - return None - return "edit" if role == "edit" and can_edit else "view" - - @staticmethod - def can_manage(room, kind, key, perms): - return bool(perms.get("admin")) or (kind == "user" and key is not None and key == room.owner) + if self.workspace and room.key.startswith("file:workflows/"): + return self.workspace.role(room.key[len("file:workflows/"):], kind, key, perms) + return "edit" if (perms.get("edit") or perms.get("admin")) else "view" def client_access(self, room, client): return self.access(room, client.kind, client.key, client.perms) + def folder_of(self, room): + """The shared folder ("shared/") a room's file lives in, or None.""" + area, folder = Workspace.area(room.key[len("file:workflows/"):]) + return folder if area == "shared" else None + + def folder_restricted(self, folder): + return bool(folder and self.workspace and (self.workspace.folders.get(folder) or {}).get("acl")) + def room_info(self, room, client): - return {"role": self.client_access(room, client), - "manage": self.can_manage(room, client.kind, client.key, client.perms), - "restricted": bool(room.acl), "owner": room.owner} + folder = self.folder_of(room) + manage = bool(folder and self.workspace and self.workspace.can_manage(folder, client.kind, client.key, client.perms)) + return {"role": self.client_access(room, client), "folder": folder, + "restricted": self.folder_restricted(folder), "manage": manage} def room_list(self, client=None): """Live workflows: the rooms someone has open right now.""" @@ -306,7 +287,7 @@ class Hub: out.append({"key": room.key, "name": room.name, "kind": room.kind, "version": room.version, "updated": int(room.updated), "members": members, "nodes": len((room.doc or {}).get("nodes") or []), - "restricted": bool(room.acl), "owner": room.owner}) + "restricted": self.folder_restricted(self.folder_of(room))}) out.sort(key=lambda r: (-len(r["members"]), -r["updated"])) return out @@ -329,20 +310,6 @@ class Hub: await self.send(client, {"type": "presence", "users": self.users_for(client), "rooms": self.room_list(client)}) - async def set_acl(self, room, acl): - """Apply a new access list and remove people who lost access.""" - room.acl = acl - room.dirty = True - for client in self.members(room.key): - role = self.client_access(room, client) - if role is None: - client.room = None - await self.broadcast({"type": "leave", "id": client.id}, room=room.key) - await self.send(client, {"type": "room_denied", "room": room.key, "name": room.name}) - else: - await self.send(client, {"type": "room_info", "room": room.key, **self.room_info(room, client)}) - await self.broadcast_presence() - def rekey_rooms(self, old_prefix, new_prefix): """Follow files that moved: rooms of ``file:...`` get the new path.""" moved = [] @@ -361,11 +328,9 @@ class Hub: moved.append(room.key) return moved - def path_access(self, path, client, key=None, room=None): - """May ``client`` open the workflows path (file or folder)? ``room`` adds its access list.""" + def path_access(self, path, client, key=None): + """May ``client`` open the workflows path (file or folder)?""" key = client.key if key is None else key - if room is not None and self._room_access(room, client.kind, key, client.perms) is None: - return False rel = Workspace.workflows_rel(path) if rel is None or not self.workspace: return True @@ -373,8 +338,7 @@ class Hub: def who_sees(self, path): """Connected clients that may open ``path``; call before moving it.""" - room = self.rooms.get("file:" + path) - return [c for c in self.clients.values() if c.kind and self.path_access(path, c, room=room)] + return [c for c in self.clients.values() if c.kind and self.path_access(path, c)] async def path_moved(self, old, new, renamed=None, seen_by=None): """A workflow file or folder moved (userdata paths, e.g. ``workflows/a.json``). @@ -384,11 +348,10 @@ class Hub: where it went so their open tabs follow too, or that it went somewhere they cannot open. ``renamed`` maps usernames changed by the same action. """ - room = self.rooms.get("file:" + old) notes = [] for client in self.who_sees(old) if seen_by is None else seen_by: key = (renamed or {}).get(client.key, client.key) if client.kind == "user" else client.key - notes.append((client, self.path_access(new, client, key=key, room=room))) + notes.append((client, self.path_access(new, client, key=key))) self.rekey_rooms("file:" + old, "file:" + new) for client, follows in notes: await self.send(client, {"type": "path_moved", "from": old, "to": new} if follows @@ -410,23 +373,6 @@ class Hub: await self.send(client, {"type": "room_info", "room": room.key, **self.room_info(room, client)}) await self.broadcast_presence() - def rename_member(self, old, new): - for room in self.rooms.values(): - changed = False - if room.owner == old: - room.owner, changed = new, True - members = (room.acl or {}).get("members") or {} - if old in members: - members[new] = members.pop(old) - changed = True - room.dirty = room.dirty or changed - - def forget_member(self, username): - for room in self.rooms.values(): - members = (room.acl or {}).get("members") or {} - if members.pop(username, None) is not None: - room.dirty = True - def server_info(self): cfg = self.store.config return {"name": cfg.get("server_name"), "allow_guests": cfg.get("allow_guests"), @@ -516,8 +462,7 @@ class Hub: created = False if room is None: doc = data.get("doc") if isinstance(data.get("doc"), dict) else {} - room = Room(key, clean_text(data.get("name"), 120) or key, "file", doc, - owner=client.key if client.kind == "user" else None) + room = Room(key, clean_text(data.get("name"), 120) or key, "file", doc) room.dirty = True self.rooms[key] = room created = True diff --git a/rigshare_core/routes.py b/rigshare_core/routes.py index 6f6571c..d8830ac 100644 --- a/rigshare_core/routes.py +++ b/rigshare_core/routes.py @@ -291,7 +291,6 @@ def setup(server, store, hub): except ValueError as e: return error(str(e)) if user["username"] != username: - hub.rename_member(username, user["username"]) if hub.workspace: hub.workspace.rename_user(username, user["username"]) await hub.path_moved(f"workflows/users/{username}", f"workflows/users/{user['username']}", @@ -308,7 +307,6 @@ def setup(server, store, hub): username = request.match_info["username"] try: store.delete_user(username) - hub.forget_member(username) if hub.workspace: hub.workspace.forget_member(username) except KeyError: @@ -492,41 +490,6 @@ def setup(server, store, hub): "admin": bool(u["perms"].get("admin")), "edit": bool(u["perms"].get("edit"))} for u in store.users.values()]) - @routes.get("/rigshare/api/room/acl") - async def get_acl(request): - room = hub.rooms.get(request.query.get("key", "")) - if not room: - return error("No such room", 404) - if not hub.can_manage(room, *who(request)): - return error("Only the owner or an admin can manage access", 403) - return web.json_response({"key": room.key, "owner": room.owner, "restricted": bool(room.acl), - "members": (room.acl or {}).get("members") or {}}) - - @routes.put("/rigshare/api/room/acl") - async def put_acl(request): - body = await request.json() - room = hub.rooms.get(body.get("key", "")) - if not room: - return error("No such room", 404) - if not hub.can_manage(room, *who(request)): - return error("Only the owner or an admin can manage access", 403) - acl = None - if body.get("restricted"): - members = {} - for username, role in (body.get("members") or {}).items(): - if username in store.users and role in ("view", "edit") and username != room.owner: - members[username] = role - acl = {"members": members} - if room.owner is None: - # Someone must stay in charge besides admins: the person restricting it. - kind, key, _ = who(request) - if kind == "user": - room.owner = key - await hub.set_acl(room, acl) - log.info(f"[RigShare] {display(request)} set access for {room.name}: " - f"{'everyone' if acl is None else ', '.join(f'{u}={r}' for u, r in acl['members'].items()) or 'owner only'}") - return web.json_response({"ok": True, "owner": room.owner, "restricted": bool(acl)}) - @routes.get("/rigshare/api/room") async def get_room(request): room = hub.rooms.get(request.query.get("key", "")) @@ -635,7 +598,7 @@ def setup(server, store, hub): return (action, src, None) if action else None def check_userdata(request, action, src, dest): - """Folder rules, then the per-workflow access list of a live room.""" + """Folder rules (private spaces, restricted shared folders).""" kind, key, perms = who(request) w = hub.workspace can_edit = bool(perms.get("edit") or perms.get("admin")) @@ -644,12 +607,7 @@ def setup(server, store, hub): rel = Workspace.workflows_rel(path) if rel is None: return "edit" - folder = w.role(rel, kind, key, perms) if w else ("edit" if can_edit else "view") - room = hub.rooms.get("file:" + path) - if folder and room and room.acl: - from .workspace import min_role - folder = min_role(folder, hub.access(room, kind, key, perms)) - return folder + return w.role(rel, kind, key, perms) if w else ("edit" if can_edit else "view") def deletable(path): rel = Workspace.workflows_rel(path) diff --git a/tests/js/files.test.mjs b/tests/js/files.test.mjs new file mode 100644 index 0000000..99872a8 --- /dev/null +++ b/tests/js/files.test.mjs @@ -0,0 +1,51 @@ +// Files browser tabs: My files, Shared, and All for admins. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +// Just enough DOM for the browser to build its elements. +class FakeNode { + constructor(tag) { this.tag = tag; this.children = []; this.style = {}; this.attrs = {}; this.className = ""; } + append(...c) { this.children.push(...c); } + replaceChildren(...c) { this.children = c; } + addEventListener() {} + setAttribute(k, v) { this.attrs[k] = v; } + querySelector() { return new FakeNode("div"); } + get isConnected() { return false; } +} +globalThis.Node ??= FakeNode; +globalThis.document ??= { createElement: (t) => new FakeNode(t), createTextNode: (t) => Object.assign(new FakeNode("#text"), { text: t }) }; +const { FilesBrowser } = await import("../../web/rigshare/files.js"); + +const browser = (admin, start) => new FilesBrowser({ + app: {}, sync: {}, mode: "open", start, + client: { self: { key: "bob" }, perms: { admin }, users: [], rooms: [], on() {}, emit() {} }, +}); + +test("tabs: My files and Shared for everyone, All for admins", () => { + assert.deepEqual(browser(false).tabs.map((t) => t.label), ["My files", "Shared"]); + assert.deepEqual(browser(true).tabs.map((t) => t.label), ["My files", "Shared", "All"]); +}); + +test("starts in My files and picks the tab a folder belongs to", () => { + const b = browser(false); + assert.equal(b.tab, "mine"); + assert.equal(b.path, "users/bob"); + assert.equal(browser(false, "shared/Team").tab, "shared"); + assert.equal(browser(false, "renders").tab, "shared"); + assert.equal(browser(true, "users/alice").tab, "all"); + assert.equal(browser(true, "").tab, "all"); +}); + +test("people without All never start outside their tabs", () => { + for (const start of ["", "users", "users/alice/x"]) { + const b = browser(false, start); + assert.ok(["users/bob", "@shared"].includes(b.path), `${start} -> ${b.path}`); + } +}); + +test("breadcrumbs start at the tab", () => { + assert.deepEqual(browser(false, "users/bob/renders/old").trail().map((c) => c.label), ["My files", "renders", "old"]); + assert.deepEqual(browser(false, "shared/Team/sub").trail().map((c) => c.label), ["Shared", "Team", "sub"]); + assert.deepEqual(browser(true, "users/alice").trail().map((c) => c.label), ["All", "users", "alice"]); + assert.equal(browser(false, "shared/Team").label, "Shared / Team"); +}); diff --git a/tests/python/test_server.py b/tests/python/test_server.py index 0344c1b..bb94ad8 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -99,19 +99,22 @@ def test_template_filter_and_live_options(run, tmp_path): run(scenario()) -def test_rooms_conflicts_and_access_lists(run): +def test_rooms_conflicts_and_folder_access(run): async def scenario(): async with rig_server() as rig: admin = await rig.setup_admin() for name in ("alice", "bob", "carol"): await rig.add_user(name, edit=True) + H = rig.headers + r = await rig.http.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Team"}, headers=H["alice"]) + assert r.status == 200 alice, bob, carol = [await rig.ws(n) for n in ("alice", "bob", "carol")] - room = "file:workflows/w.json" + room = "file:workflows/shared/Team/w.json" await alice.send({"type": "join", "room": room, "name": "w", "doc": BASE_DOC}) joined = await alice.until("room") - assert joined["created"] and joined["manage"] + assert joined["created"] and joined["manage"] and joined["folder"] == "shared/Team" await bob.send({"type": "join", "room": room, "name": "w", "doc": BASE_DOC}) - await bob.until("room") + assert not (await bob.until("room"))["manage"], "only the folder owner or admins manage access" await alice.drain(); await bob.drain() def add(target_slot): @@ -126,23 +129,25 @@ def test_rooms_conflicts_and_access_lists(run): assert [s["link"] for s in doc["nodes"][1]["inputs"]] == [1, 2] assert any(m["type"] == "doc" and m.get("reason") is None for m in await bob.drain()), "quiet resync" - r = await rig.http.put(rig.url("/rigshare/api/room/acl"), json={"key": room, "restricted": True, "members": {"carol": "view"}}, headers=rig.headers["bob"]) - assert r.status == 403, "only owner or admin" - r = await rig.http.put(rig.url("/rigshare/api/room/acl"), json={"key": room, "restricted": True, "members": {"carol": "view"}}, headers=rig.headers["alice"]) + # Access follows the folder: restricting it takes out whoever lost access. + r = await rig.http.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "restricted": True, "members": {"carol": "view"}}, headers=H["bob"]) + assert r.status == 403, "only the folder owner or an admin" + r = await rig.http.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "restricted": True, "members": {"carol": "view"}}, headers=H["alice"]) assert r.status == 200 msgs = await bob.drain() assert any(m["type"] == "room_denied" for m in msgs) presence = [m for m in msgs if m["type"] == "presence"][-1] assert all(r["key"] != room for r in presence["rooms"]) - assert (await rig.http.get(rig.url(f"/api/userdata/{enc('workflows/w.json')}"), headers=rig.headers["bob"])).status == 403 + assert (await alice.until("room_info"))["restricted"] is True await carol.send({"type": "join", "room": room, "name": "w", "doc": BASE_DOC}) assert (await carol.until("room"))["role"] == "view" await carol.drain() await carol.send({"type": "graph", "room": room, "patch": {"nodes": [dict(BASE_DOC["nodes"][0], pos=[9, 9])]}}) assert "view" in ((await carol.until("doc")).get("reason") or "") - r = await rig.http.put(rig.url("/rigshare/api/room/acl"), json={"key": room, "restricted": True, "members": {"carol": "edit"}}, headers=admin) + await rig.http.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "restricted": True, "members": {"carol": "edit"}}, headers=admin) assert (await carol.until("room_info"))["role"] == "edit" + assert (await rig.http.put(rig.url("/rigshare/api/room/acl"), json={"key": room}, headers=admin)).status in (404, 405), "no per-workflow lists" for s in (alice, bob, carol): await s.close() run(scenario()) diff --git a/web/rigshare/files.js b/web/rigshare/files.js index a88c2c7..270409b 100644 --- a/web/rigshare/files.js +++ b/web/rigshare/files.js @@ -1,21 +1,22 @@ // Drive-style file browser for the RigShare workspace. // -// Home ─┬─ My files users//… only you (and admins) -// ├─ Shared shared folders + the common top level -// └─ Everyone's files (admins) +// [My files] users//… only you (and admins) +// [Shared] shared folders + the common top level +// [All] the whole workflows folder, everyone's files (admins) // // One component serves the Files tab ("browse"), the "Open from RigShare" // dialog ("open"), the Save dialog ("save") and the "Move to…" picker ("pick"). import { h, timeAgo, storage } from "./ui.js"; -const HOME = "@home"; const SHARED = "@shared"; +const ALL = ""; export class FilesBrowser { constructor({ app, client, sync, mode = "browse", start, onOpenFile, onPickFile, onChange, accessEditor }) { Object.assign(this, { app, client, sync, mode, onOpenFile, onPickFile, onChange, accessEditor }); - this.path = start || HOME; + this.path = null; + this.tab = null; this.listing = null; this.filter = ""; this.accessFor = null; @@ -26,23 +27,61 @@ export class FilesBrowser { onkeydown: (e) => e.stopPropagation(), }); this.actions = h("div", { class: "rs-fb-actions" }); + this.tabsEl = h("div", { class: "rs-fb-tabs", role: "tablist" }); this.list = h("div", { class: "rs-fb-list" }); this.el = h("div", { class: `rs-fb rs-fb-${mode}` }, + this.tabsEl, h("div", { class: "rs-fb-bar" }, this.crumbs, this.actions), h("div", { class: "rs-fb-tools" }, this.search), this.list); // The sidebar browser lives as long as the page: keep its live dots current. if (mode === "browse") client.on("presence", () => { if (this.listing && this.el.isConnected) this.renderList(); }); + this.setStart(start); } get me() { return this.client.self?.key; } + /** The folder tabs: each is a root the browser can't go above. */ + get tabs() { + return [ + this.me ? { id: "mine", label: "My files", icon: "pi-user", root: `users/${this.me}`, hint: "Only you" } : null, + { id: "shared", label: "Shared", icon: "pi-users", root: SHARED, hint: "Shared folders and common workflows" }, + this.client.perms.admin ? { id: "all", label: "All", icon: "pi-shield", root: ALL, hint: "The whole workflows folder, everyone's files (admins)" } : null, + ].filter(Boolean); + } + + /** Which tab a path belongs to when opened directly (not through a tab). */ + tabFor(path) { + const ids = this.tabs.map((t) => t.id); + if (this.me && (path === `users/${this.me}` || path?.startsWith(`users/${this.me}/`))) return "mine"; + if (path === ALL || path === "users" || path?.startsWith("users/")) return ids.includes("all") ? "all" : ids[0]; + return "shared"; + } + + setStart(start) { + if (start === undefined || start === null || start === "@home") start = this.me ? `users/${this.me}` : SHARED; + this.tab = this.tabFor(start); + const allowed = this.tab === "all" || this.tab === "shared" || start.startsWith(`users/${this.me}`); + this.path = allowed && !(this.tab === "shared" && (start === ALL || start.startsWith("users"))) ? start : this.currentTab.root; + } + + get currentTab() { + return this.tabs.find((t) => t.id === this.tab) ?? this.tabs[0]; + } + + async switchTab(id) { + const tab = this.tabs.find((t) => t.id === id); + if (!tab) return; + this.tab = id; + await this.go(tab.root); + } + /** Folder path usable for saving ("" is the common top level), or null. */ get saveFolder() { if (!this.listing || this.listing.role !== "edit") return null; - if (this.path === HOME || this.path === "users") return null; + if (this.path === "users") return null; return this.path === SHARED ? "" : this.path; } @@ -70,20 +109,19 @@ export class FilesBrowser { } async reload() { - if (this.path === HOME) { - const entries = []; - if (this.me) entries.push({ type: "home", path: `users/${this.me}`, name: "My files", hint: "Only you", icon: "pi-user" }); - entries.push({ type: "home", path: SHARED, name: "Shared", hint: "Shared folders and common workflows", icon: "pi-users" }); - if (this.client.perms.admin) entries.push({ type: "home", path: "users", name: "Everyone's files", hint: "Private folders of every account (admins)", icon: "pi-shield" }); - this.listing = { path: HOME, role: "view", entries, can_mkdir: false }; - } else { - this.list.replaceChildren(h("div", { class: "rs-fb-empty" }, h("i", { class: "pi pi-spin pi-spinner" }))); - try { - this.listing = await this.client.request("GET", `/rigshare/api/workspace/list?path=${encodeURIComponent(this.path)}`); - } catch (e) { - this.toast("error", e.message); - if (this.path !== HOME) return this.go(HOME); - } + // Tabs depend on who is signed in (admin, username): keep the current one valid. + if (!this.tabs.some((t) => t.id === this.tab)) { + this.tab = this.tabs[0].id; + this.path = this.tabs[0].root; + } + this.list.replaceChildren(h("div", { class: "rs-fb-empty" }, h("i", { class: "pi pi-spin pi-spinner" }))); + try { + this.listing = await this.client.request("GET", `/rigshare/api/workspace/list?path=${encodeURIComponent(this.path)}`); + } catch (e) { + this.toast("error", e.message); + this.listing = null; + const root = this.currentTab.root; + if (this.path !== root) return this.go(root); } this.render(); this.onChange?.(this); @@ -91,35 +129,32 @@ export class FilesBrowser { // ----- breadcrumbs ------------------------------------------------------ + /** Breadcrumbs from the current tab's root down to the current folder. */ trail() { - const out = [{ label: "Home", path: HOME, icon: "pi-home" }]; + const tab = this.currentTab; + const out = [{ label: tab.label, path: tab.root }]; const p = this.path; - if (p === HOME) return out; + if (p === tab.root) return out; const parts = p.split("/"); - if (p === SHARED) return [...out, { label: "Shared", path: SHARED }]; - if (parts[0] === "users") { - if (parts[1] === this.me) { - out.push({ label: "My files", path: `users/${this.me}` }); - parts.slice(2).forEach((part, i) => out.push({ label: part, path: parts.slice(0, i + 3).join("/") })); - } else { - out.push({ label: "Everyone's files", path: "users" }); - parts.slice(1).forEach((part, i) => out.push({ label: part, path: parts.slice(0, i + 2).join("/") })); - } - return out; - } - out.push({ label: "Shared", path: SHARED }); - const rest = parts[0] === "shared" ? parts.slice(1) : parts; - const offset = parts[0] === "shared" ? 1 : 0; - rest.forEach((part, i) => out.push({ label: part, path: parts.slice(0, i + 1 + offset).join("/") })); + // Number of leading path parts the tab root stands for. + let skip = 0; + if (tab.id === "mine") skip = 2; + else if (tab.id === "shared" && parts[0] === "shared") skip = 1; + parts.slice(skip).forEach((part, i) => out.push({ label: part, path: parts.slice(0, i + 1 + skip).join("/") })); return out; } /** Human label of the current folder, e.g. "My files / renders". */ get label() { - return this.trail().slice(1).map((c) => c.label).join(" / ") || "Home"; + return this.trail().map((c) => c.label).join(" / "); } render() { + this.tabsEl.replaceChildren(...this.tabs.map((t) => h("button", { + class: `rs-fb-tab ${t.id === this.tab ? "active" : ""}`, role: "tab", title: t.hint, + "aria-selected": String(t.id === this.tab), + onclick: () => { if (t.id !== this.tab || this.path !== t.root) this.switchTab(t.id); }, + }, h("i", { class: `pi ${t.icon}` }), h("span", {}, t.label)))); const trail = this.trail(); this.crumbs.replaceChildren(...trail.flatMap((c, i) => [ i ? h("i", { class: "pi pi-angle-right rs-fb-sep" }) : null, @@ -133,7 +168,6 @@ export class FilesBrowser { }, h("i", { class: "pi pi-folder-plus" })) : null, h("button", { class: "rs-btn rs-btn-icon", title: "Refresh", onclick: () => this.reload() }, h("i", { class: "pi pi-refresh" })), ].filter(Boolean)); - this.el.querySelector(".rs-fb-tools").style.display = this.path === HOME ? "none" : ""; this.renderList(); } @@ -153,12 +187,6 @@ export class FilesBrowser { } row(e) { - if (e.type === "home") { - return h("button", { class: "rs-fb-row rs-fb-home", onclick: () => this.go(e.path) }, - h("i", { class: `pi ${e.icon} rs-fb-icon` }), - h("span", { class: "rs-grow rs-min0" }, h("div", { class: "rs-name" }, e.name), h("div", { class: "rs-muted rs-small" }, e.hint)), - h("i", { class: "pi pi-angle-right rs-muted" })); - } const isFolder = e.type === "folder"; const owner = e.owner ? this.client.users.find((u) => u.key === e.owner)?.name ?? `@${e.owner}` : null; const meta = isFolder @@ -303,7 +331,7 @@ export function modal(title, icon, body, footer) { /** Folder picker; resolves to a folder path ("" = common top level) or null. */ export async function pickFolder({ app, client, sync, title, start }) { - const browser = new FilesBrowser({ app, client, sync, mode: "pick", start: start && start !== "@home" ? start : `users/${client.self?.key}` }); + const browser = new FilesBrowser({ app, client, sync, mode: "pick", start }); const choose = h("button", { class: "rs-btn rs-btn-primary" }, "Move here"); const where = h("span", { class: "rs-muted rs-small rs-grow rs-ellipsis" }); browser.onChange = () => { @@ -319,7 +347,7 @@ export async function pickFolder({ app, client, sync, title, start }) { /** "Open from RigShare": browse and open a workflow. */ export async function openFromWorkspace({ app, client, sync }) { const browser = new FilesBrowser({ - app, client, sync, mode: "open", start: storage.get("rigshare.lastOpenFolder") || "@home", + app, client, sync, mode: "open", start: storage.get("rigshare.lastOpenFolder") ?? undefined, onOpenFile: async (path) => { storage.set("rigshare.lastOpenFolder", browser.path); m.close(true); diff --git a/web/rigshare/panel.js b/web/rigshare/panel.js index 66af471..319ca6a 100644 --- a/web/rigshare/panel.js +++ b/web/rigshare/panel.js @@ -240,7 +240,7 @@ export class RigSharePanel { : h("span", { class: "rs-live rs-muted" }, h("i", { class: "pi pi-spin pi-spinner" }), "Joining…"), h("span", { class: "rs-avatars" }, ...here.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name)))))); body.push(this.accessLine()); - if (this.accessOpen && s.room?.manage) body.push(this.accessEditor()); + if (this.accessOpen && s.room?.manage && s.room.folder) body.push(this.accessEditor(s.room.folder)); if (!s.canEdit) body.push(h("p", { class: "rs-note" }, h("i", { class: "pi pi-lock" }), c.perms.edit ? "View only in this workflow." : "View only — ask an admin for edit access.")); if (s.canEdit) { row.append(h("button", { @@ -269,33 +269,32 @@ export class RigSharePanel { await this.run(() => this.sync.movePath(wf.path, dest ? `workflows/${dest}/${base}` : `workflows/${base}`), "Moved: it is live now"); } + /** Who can open the workflow on screen: that is decided by its folder. */ accessLine() { - const s = this.sync; - const info = s.room; + const info = this.sync.room; if (!info) return null; - const owner = info.owner ? this.client.users.find((u) => u.key === info.owner)?.name ?? `@${info.owner}` : null; + const folder = info.folder?.replace(/^shared\//, ""); return h("div", { class: "rs-access" }, h("i", { class: `pi ${info.restricted ? "pi-lock" : "pi-globe"}` }), - h("span", { class: "rs-grow" }, info.restricted ? "Only people with access" : "Everyone can open it", - owner ? h("span", { class: "rs-muted" }, ` · owner ${owner}`) : null), + h("span", { class: "rs-grow" }, info.restricted ? `Only people with access to "${folder}"` : "Everyone can open it", + folder && !info.restricted ? h("span", { class: "rs-muted" }, ` · in "${folder}"`) : null), info.manage ? h("button", { - class: `rs-btn rs-btn-sm ${this.accessOpen ? "active" : ""}`, + class: `rs-btn rs-btn-sm ${this.accessOpen ? "active" : ""}`, title: `Who can open the folder "${folder}"`, onclick: () => { this.accessOpen = !this.accessOpen; this.accessDraft = null; this.renderBody(); }, - }, "Access") : null); + }, "Folder access") : null); } - accessEditor() { - const key = this.sync.targetKey; + accessEditor(folder) { return this.accessEditorFor({ - load: () => this.client.request("GET", `/rigshare/api/room/acl?key=${encodeURIComponent(key)}`), - save: (restricted, members) => this.sync.setAccess(restricted, members), - everyoneHint: "Anyone with an account can open it, with their usual permissions", + load: () => this.client.request("GET", `/rigshare/api/workspace/folder?path=${encodeURIComponent(folder)}`), + save: (restricted, members) => this.client.request("PATCH", "/rigshare/api/workspace/folders", { path: folder, restricted, members }), + everyoneHint: "Anyone with an account can open its workflows, with their usual permissions", close: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); }, draftKey: "accessDraft", }); } - /** Access editor shared by live workflows and shared folders. */ + /** Access editor for a shared folder (Files tab and the live workflow's folder). */ accessEditorFor({ load, save, everyoneHint, close, draftKey }) { const box = h("div", { class: "rs-access-editor" }, h("p", { class: "rs-muted" }, "Loading…")); Promise.all([this.run(load), this.run(() => this.client.request("GET", "/rigshare/api/people"))]).then(([acl, people]) => { diff --git a/web/rigshare/save-dialog.js b/web/rigshare/save-dialog.js index 7fff1cc..e75e92e 100644 --- a/web/rigshare/save-dialog.js +++ b/web/rigshare/save-dialog.js @@ -52,7 +52,7 @@ export async function chooseLocation(app, api, client, sync, wf) { onChange: () => { save.disabled = browser.saveFolder === null; where.textContent = browser.saveFolder === null - ? (browser.path === "@home" ? "Choose My files or Shared" : "You cannot save in this folder") + ? "You cannot save in this folder" : `Save in ${browser.label}`; }, }); diff --git a/web/rigshare/styles.js b/web/rigshare/styles.js index 11adc70..be36490 100644 --- a/web/rigshare/styles.js +++ b/web/rigshare/styles.js @@ -179,6 +179,12 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; } .rs-files-logo { width: 28px; height: 28px; border-radius: 7px; display: grid; place-items: center; font-size: 15px; background: linear-gradient(135deg, #3b82f6, #a855f7); color: #fff; flex: none; } .rs-fb { display: flex; flex-direction: column; min-height: 0; } +.rs-fb-tabs { display: flex; gap: 2px; margin-bottom: 8px; border-bottom: 1px solid var(--rs-border); } +.rs-fb-tab { flex: 1 1 0; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 6px; border: 0; + border-bottom: 2px solid transparent; margin-bottom: -1px; background: none; color: var(--rs-muted); font: inherit; font-size: 12.5px; cursor: pointer; white-space: nowrap; } +.rs-fb-tab span { overflow: hidden; text-overflow: ellipsis; } +.rs-fb-tab:hover { color: var(--rs-fg); } +.rs-fb-tab.active { color: var(--rs-fg); border-bottom-color: var(--rs-accent); font-weight: 600; } .rs-fb-bar { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } .rs-fb-crumbs { flex: 1; min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; } .rs-fb-crumb { border: 0; background: none; color: var(--rs-muted); font: inherit; font-size: 12.5px; padding: 3px 5px; border-radius: 5px; cursor: pointer; } @@ -197,10 +203,6 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; } .rs-fb-main:disabled { cursor: default; } .rs-fb-icon { font-size: 16px; color: var(--rs-muted); width: 18px; text-align: center; flex: none; } .rs-fb-icon.folder { color: #f5b942; } -.rs-fb-home { width: 100%; padding: 12px 10px; border: 1px solid var(--rs-border); background: color-mix(in srgb, var(--rs-bg2) 45%, transparent); - color: inherit; font: inherit; text-align: left; cursor: pointer; margin-bottom: 6px; } -.rs-fb-home:hover { border-color: var(--rs-accent); } -.rs-fb-home .rs-fb-icon { font-size: 20px; color: var(--rs-accent); width: 24px; } .rs-fb-tools-row { display: none; gap: 3px; padding-right: 6px; } .rs-fb-row:hover .rs-fb-tools-row, .rs-fb-row:focus-within .rs-fb-tools-row { display: flex; } .rs-fb-empty { color: var(--rs-muted); font-size: 12px; text-align: center; padding: 24px 8px; } diff --git a/web/rigshare/sync.js b/web/rigshare/sync.js index 993c18d..002aafc 100644 --- a/web/rigshare/sync.js +++ b/web/rigshare/sync.js @@ -115,7 +115,7 @@ export class RoomSync extends EventTarget { this.pointerDown = false; this.lastEditor = null; this.readOnlyForced = false; - this.room = null; // {role, manage, restricted, owner} of the joined room + this.room = null; // {role, folder, restricted, manage} of the joined room this.denied = new Set(); // rooms we were refused; not retried until access changes this.inFlight = 0; // our patches not yet acknowledged this.crossed = false; // a remote patch arrived while ours were in flight @@ -125,7 +125,7 @@ export class RoomSync extends EventTarget { client.on("room", (msg) => this.onRoom(msg)); client.on("room_info", (msg) => { if (msg.room !== this.targetKey) return; - this.room = { role: msg.role, manage: msg.manage, restricted: msg.restricted, owner: msg.owner }; + this.room = { role: msg.role, folder: msg.folder, restricted: msg.restricted, manage: msg.manage }; this.updateReadOnly(); this.emitState(); }); @@ -250,12 +250,6 @@ export class RoomSync extends EventTarget { return { wf, room: room && this.denied.has(room.key) ? null : room }; } - /** Change who can open the room on screen (owner or admin). */ - async setAccess(restricted, members) { - if (!this.targetKey) return; - return await this.client.request("PUT", "/rigshare/api/room/acl", { key: this.targetKey, restricted, members }); - } - // ----- joining -------------------------------------------------------- tick() { @@ -294,7 +288,7 @@ export class RoomSync extends EventTarget { onRoom(msg) { if (msg.room !== this.targetKey) return; this.version = msg.version; - this.room = { role: msg.role, manage: msg.manage, restricted: msg.restricted, owner: msg.owner }; + this.room = { role: msg.role, folder: msg.folder, restricted: msg.restricted, manage: msg.manage }; if (msg.created || !msg.doc) { this.doc = this.serialize(); this.ready = true;