From 0bdeb2eb31f3dc0a3044744381c20feb46c33a19 Mon Sep 17 00:00:00 2001 From: Jannes Klee Date: Mon, 6 Apr 2026 13:22:26 +0200 Subject: [PATCH 01/10] WIP: show permission chips for ACL/WAC (with some flaws) --- app/components/FileItem.tsx | 156 ++++++++++++++ app/components/FileList.tsx | 69 ++++++- app/components/FileManager.tsx | 3 + app/components/ShareDialog.tsx | 37 ++-- app/components/shared/Toolbar.tsx | 23 ++- app/lib/helpers/acpUtils.ts | 326 ++++++++++++++++++++++++++---- package-lock.json | 9 +- 7 files changed, 554 insertions(+), 69 deletions(-) diff --git a/app/components/FileItem.tsx b/app/components/FileItem.tsx index 2d99afe..6a6ebc1 100644 --- a/app/components/FileItem.tsx +++ b/app/components/FileItem.tsx @@ -3,6 +3,27 @@ import { useState, useRef } from "react"; import { getFileIcon, formatFileSize, formatDate, type FileType } from "../lib/helpers"; import FileItemMenu from "./FileItemMenu"; +import type { AccessEntry, AccessResult } from "./FileList"; + +/** + * Extracts a short readable label from a WebID URL. + * e.g. "http://localhost:3000/alice/profile/card#me" → "alice" + */ +function extractShortLabel(agent: string): string { + if (agent === "PUBLIC") return "Anyone"; + if (agent === "AUTHENTICATED") return "Authenticated"; + try { + const url = new URL(agent); + const segments = url.pathname.split("/").filter(Boolean); + if (segments.length > 0) { + return segments[0]; + } + return url.hostname; + } catch { + const parts = agent.split(/[/#]/).filter(Boolean); + return parts[parts.length - 1] || agent; + } +} export type { FileType }; @@ -30,6 +51,7 @@ interface FileItemProps { onShare?: (file: FileItemData) => void; isSelected?: boolean; onContextMenu?: (file: FileItemData, event: React.MouseEvent) => void; + accessResult?: AccessResult | null | "loading"; } export default function FileItem({ @@ -46,6 +68,7 @@ export default function FileItem({ onShare, isSelected = false, onContextMenu, + accessResult, }: FileItemProps) { const [isHovered, setIsHovered] = useState(false); const clickCountRef = useRef(0); @@ -116,6 +139,134 @@ export default function FileItem({ lastTapRef.current = currentTime; }; + const [expandedCategory, setExpandedCategory] = useState(null); + + const renderPermissions = () => { + if (accessResult === undefined) return null; + if (accessResult === "loading") { + return ( +
+
+
+ ); + } + if (accessResult === null) { + return error; + } + + const entries = accessResult.entries; + + if (entries.length === 0) { + return ( + + Private + + ); + } + + // Categorize entries + const publicEntries = entries.filter(e => e.isPublic); + const authenticatedEntries = entries.filter(e => e.isAuthenticated); + const agentEntries = entries.filter(e => !e.isPublic && !e.isAuthenticated); + + const allInherited = entries.every(e => e.inherited); + const someInherited = entries.some(e => e.inherited); + + // Build category chips + type Category = { key: string; label: string; chipClass: string; entries: AccessEntry[]; title: string }; + const categories: Category[] = []; + + if (publicEntries.length > 0) { + const modes = [...new Set(publicEntries.flatMap(e => e.modes))]; + categories.push({ + key: "public", + label: `Public: ${modes.join(", ")}`, + chipClass: "bg-orange-50 text-orange-800 hover:bg-orange-100 border border-orange-200", + entries: publicEntries, + title: `Accessible by anyone on the internet\nModes: ${modes.join(", ")}`, + }); + } + + if (authenticatedEntries.length > 0) { + const modes = [...new Set(authenticatedEntries.flatMap(e => e.modes))]; + categories.push({ + key: "authenticated", + label: `Authenticated: ${modes.join(", ")}`, + chipClass: "bg-blue-50 text-blue-800 hover:bg-blue-100 border border-blue-200", + entries: authenticatedEntries, + title: `Accessible by any logged-in user\nModes: ${modes.join(", ")}`, + }); + } + + if (agentEntries.length > 0) { + // Group: if there's only 1 agent, show it directly; otherwise show count + if (agentEntries.length === 1) { + const entry = agentEntries[0]; + const shortLabel = extractShortLabel(entry.agent); + categories.push({ + key: "agents", + label: `${shortLabel}: ${entry.modes.join(", ")}`, + chipClass: "bg-purple-50 text-purple-800 hover:bg-purple-100 border border-purple-200", + entries: agentEntries, + title: `${entry.agent}\nModes: ${entry.modes.join(", ")}`, + }); + } else { + categories.push({ + key: "agents", + label: `${agentEntries.length} users shared`, + chipClass: "bg-purple-50 text-purple-800 hover:bg-purple-100 border border-purple-200", + entries: agentEntries, + title: agentEntries.map(e => `${e.agent}: ${e.modes.join(", ")}`).join("\n"), + }); + } + } + + return ( +
+ {categories.map((cat) => { + const isExpanded = expandedCategory === cat.key; + + return ( + + + + ); + })} + {someInherited && ( + + {allInherited ? "(inherited)" : "(partly inherited)"} + + )} +
+ ); + }; + if (view === "grid") { return (

{file.name}

+ {accessResult !== undefined && ( +
+ {renderPermissions()} +
+ )}
{file.lastModified && formatDate(file.lastModified)}
diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx index 74362ef..6c9dc30 100644 --- a/app/components/FileList.tsx +++ b/app/components/FileList.tsx @@ -1,9 +1,13 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import FileItem, { FileItemData } from "./FileItem"; import Toolbar from "./shared/Toolbar"; import EmptyState from "./shared/EmptyState"; +import { getResourceAccessList, type AccessEntry, type AccessResult } from "../lib/helpers/acpUtils"; + +export type { AccessEntry, AccessResult }; +type PermissionsMap = Record; interface FileListProps { files: FileItemData[]; @@ -19,6 +23,8 @@ interface FileListProps { onFileShare?: (file: FileItemData) => void; selectedFileIds: string[]; onFileContextMenu?: (file: FileItemData, event: React.MouseEvent) => void; + showPermissions?: boolean; + onTogglePermissions?: () => void; } const VIEW_STORAGE_KEY = "solid-file-manager-view"; @@ -37,6 +43,8 @@ export default function FileList({ onFileShare, selectedFileIds, onFileContextMenu, + showPermissions, + onTogglePermissions, }: FileListProps) { const [view, setView] = useState<"grid" | "list">(() => { if (typeof window === "undefined") return "list"; @@ -44,6 +52,53 @@ export default function FileList({ return (stored === "grid" || stored === "list") ? stored : "list"; }); + const [permissionsState, setPermissionsState] = useState<{ path: string; map: PermissionsMap }>({ path: currentPath, map: {} }); + const fetchedUrlsRef = useRef>(new Set()); + + // Derive the current map, resetting if path changed + const permissionsMap = permissionsState.path === currentPath ? permissionsState.map : {}; + + // Fetch permissions for all visible files when toggle is on + useEffect(() => { + fetchedUrlsRef.current = new Set(); + + if (!showPermissions || files.length === 0) return; + + let cancelled = false; + + const fetchAll = async () => { + const toFetch = files.filter(f => !fetchedUrlsRef.current.has(f.url)); + if (toFetch.length === 0) return; + + // Mark all as loading + const loadingMap: PermissionsMap = {}; + for (const f of toFetch) { + loadingMap[f.url] = "loading"; + fetchedUrlsRef.current.add(f.url); + } + if (!cancelled) setPermissionsState({ path: currentPath, map: loadingMap }); + + // Fetch in parallel + await Promise.all( + toFetch.map(async (file) => { + try { + const resourceUrl = file.type === "folder" && !file.url.endsWith("/") + ? file.url + "/" + : file.url; + const list = await getResourceAccessList(resourceUrl); + if (!cancelled) setPermissionsState(prev => ({ ...prev, map: { ...prev.map, [file.url]: list } })); + } catch { + if (!cancelled) setPermissionsState(prev => ({ ...prev, map: { ...prev.map, [file.url]: null } })); + } + }) + ); + }; + + fetchAll(); + + return () => { cancelled = true; }; + }, [showPermissions, currentPath, files]); + useEffect(() => { localStorage.setItem(VIEW_STORAGE_KEY, view); }, [view]); @@ -54,6 +109,8 @@ export default function FileList({ view={view} onViewChange={setView} itemCount={files.length} + showPermissions={showPermissions} + onTogglePermissions={onTogglePermissions} /> {/* File List/Grid */} @@ -83,6 +140,15 @@ export default function FileList({ ) : (
+ {showPermissions && ( +
+
+
Name
+
Permissions
+
Modified
+
Size
+
+ )} {files.map((file) => ( ))}
diff --git a/app/components/FileManager.tsx b/app/components/FileManager.tsx index 554af85..9d333df 100644 --- a/app/components/FileManager.tsx +++ b/app/components/FileManager.tsx @@ -99,6 +99,7 @@ export default function FileManager() { const [showShareSuccessModal, setShowShareSuccessModal] = useState(false); const [sharedResourceUrl, setSharedResourceUrl] = useState(""); const [sharedResourceName, setSharedResourceName] = useState(""); + const [showPermissions, setShowPermissions] = useState(false); const closeContextMenu = () => setContextMenuState(null); @@ -882,6 +883,8 @@ export default function FileManager() { onFileShare={handleShare} selectedFileIds={selectedFileIds} onFileContextMenu={handleFileContextMenu} + showPermissions={showPermissions} + onTogglePermissions={() => setShowPermissions(prev => !prev)} />
)} diff --git a/app/components/ShareDialog.tsx b/app/components/ShareDialog.tsx index 79cd11c..e58f08a 100644 --- a/app/components/ShareDialog.tsx +++ b/app/components/ShareDialog.tsx @@ -7,7 +7,7 @@ import UrlCombobox, { ComboboxOption } from "./shared/UrlCombobox"; import { FileItemData } from "./FileItem"; import { fetchUserContacts, Contact, addContactToProfile } from "../lib/helpers/contactUtils"; import { fetchAndParseProfile } from "../lib/helpers/profileUtils"; -import { getResourceAccessList, removeAccessFromResource } from "../lib/helpers/acpUtils"; +import { getResourceAccessList, removeAccessFromResource, type AccessResult } from "../lib/helpers/acpUtils"; import { UserIcon, MagnifyingGlassIcon, LockClosedIcon, XMarkIcon, CheckCircleIcon, TrashIcon } from "@heroicons/react/24/outline"; import LoadingSpinner from "./shared/LoadingSpinner"; @@ -39,7 +39,7 @@ export default function ShareDialog({ const [peopleChips, setPeopleChips] = useState([]); const [isAddingWebId, setIsAddingWebId] = useState(false); const [isSharing, setIsSharing] = useState(false); - const [accessList, setAccessList] = useState | null>(null); + const [accessResult, setAccessResult] = useState(null); const [isLoadingAccessList, setIsLoadingAccessList] = useState(false); const [removingWebId, setRemovingWebId] = useState(null); @@ -61,8 +61,8 @@ export default function ShareDialog({ setIsLoadingAccessList(true); const resourceUrl = file.type === "folder" && !file.url.endsWith("/") ? file.url + "/" : file.url; getResourceAccessList(resourceUrl) - .then((list) => { - setAccessList(list); + .then((result) => { + setAccessResult(result); setIsLoadingAccessList(false); }) .catch((error) => { @@ -74,7 +74,7 @@ export default function ShareDialog({ setWebIdInput(""); setSelectedAccessLevel("Editor"); setPeopleChips([]); - setAccessList(null); + setAccessResult(null); setRemovingWebId(null); } }, [isOpen, file]); @@ -88,8 +88,8 @@ export default function ShareDialog({ await removeAccessFromResource(resourceUrl, webIdToRemove); // Refresh the access list - const updatedList = await getResourceAccessList(resourceUrl); - setAccessList(updatedList); + const updatedResult = await getResourceAccessList(resourceUrl); + setAccessResult(updatedResult); } catch (error) { console.error("Failed to remove access:", error); } finally { @@ -212,8 +212,8 @@ export default function ShareDialog({ // Refresh access list after sharing if (file) { const resourceUrl = file.type === "folder" && !file.url.endsWith("/") ? file.url + "/" : file.url; - const updatedList = await getResourceAccessList(resourceUrl); - setAccessList(updatedList); + const updatedResult = await getResourceAccessList(resourceUrl); + setAccessResult(updatedResult); } onClose(); @@ -333,7 +333,7 @@ export default function ShareDialog({ {/* People with access section */} - {accessList && accessList.length > 0 && ( + {accessResult && accessResult.entries.length > 0 && (

People with access

@@ -342,30 +342,31 @@ export default function ShareDialog({
) : ( - accessList.map((access, index) => { - const hasWrite = access.accessModes.some((mode) => mode.includes("Write")); + accessResult.entries.map((access, index) => { + const hasWrite = access.modes.some((mode) => mode === "Write"); const accessLevel = hasWrite ? "Editor" : "Viewer"; - const isRemoving = removingWebId === access.webId; - + const displayLabel = access.isPublic ? "Anyone (Public)" : access.isAuthenticated ? "Authenticated users" : access.agent; + const isRemoving = removingWebId === access.agent; + return (
- {access.webId} + {displayLabel}
{accessLevel} + {view === "list" && onTogglePermissions && ( + + )} {actions &&
{actions}
}
diff --git a/app/lib/helpers/acpUtils.ts b/app/lib/helpers/acpUtils.ts index 1cff865..ae0ada6 100644 --- a/app/lib/helpers/acpUtils.ts +++ b/app/lib/helpers/acpUtils.ts @@ -388,73 +388,311 @@ export async function verifyResourceAccess(resourceUrl: string): Promise<{ } } +export interface AccessEntry { + /** WebID URI, or special values: "PUBLIC" for anyone, "AUTHENTICATED" for any logged-in agent */ + agent: string; + /** Normalized mode names: "Read", "Write", "Append", "Control" */ + modes: string[]; + /** True if this grants access to everyone (foaf:Agent / acp public matcher) */ + isPublic: boolean; + /** True if this grants access to any authenticated agent */ + isAuthenticated: boolean; + /** True if these permissions are inherited from a parent container (not set directly on this resource) */ + inherited: boolean; +} + +/** Result of getResourceAccessList */ +export interface AccessResult { + entries: AccessEntry[]; + /** URL of the ACL/ACR document that was resolved (may be from a parent if inherited) */ + sourceUrl?: string; +} + +// Well-known URIs for public/authenticated agent classes +const FOAF_AGENT = "http://xmlns.com/foaf/0.1/Agent"; +const ACL_AUTHENTICATED_AGENT = "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"; + +// WAC predicates +const WAC = { + Authorization: "http://www.w3.org/ns/auth/acl#Authorization", + agent: "http://www.w3.org/ns/auth/acl#agent", + agentClass: "http://www.w3.org/ns/auth/acl#agentClass", + mode: "http://www.w3.org/ns/auth/acl#mode", + accessTo: "http://www.w3.org/ns/auth/acl#accessTo", + default_: "http://www.w3.org/ns/auth/acl#default", +}; + +/** + * Normalizes a mode URI to a short human-readable name. + * Handles both WAC (http://www.w3.org/ns/auth/acl#Read) and ACP URIs. + */ +function normalizeMode(modeUri: string): string { + const fragment = modeUri.includes("#") ? modeUri.split("#").pop() : modeUri.split("/").pop(); + if (!fragment) return modeUri; + + const lower = fragment.toLowerCase(); + if (lower === "read") return "Read"; + if (lower === "write") return "Write"; + if (lower === "append") return "Append"; + if (lower === "control") return "Control"; + // ACP uses "mode" as a generic predicate value — not a real mode name + if (lower === "mode") return "Read"; + return fragment; +} + /** - * Fetches the ACR for a resource to see who has access + * Discovers the ACL/ACR URL from Link headers. Returns the URL as-is from the server + * (no .acl→.acr conversion, since that breaks WAC servers). */ -export async function getResourceAccessList(resourceUrl: string): Promise | null> { +function discoverAclUrl(linkHeader: string): { url: string; isAcp: boolean } | null { + const aclMatch = linkHeader.match(/<([^>]+)>;\s*rel=["']acl["']/i); + if (!aclMatch || !aclMatch[1]) return null; + + const url = aclMatch[1]; + const isAcp = url.includes(".acr") || linkHeader.includes("http://www.w3.org/ns/solid/acp#AccessControlResource"); + return { url, isAcp }; +} + +/** + * Computes the parent container URL. + * "http://ex.com/pod/folder/file.txt" → "http://ex.com/pod/folder/" + * "http://ex.com/pod/folder/" → "http://ex.com/pod/" + */ +function getParentContainerUrl(url: string): string | null { try { - const { fetch } = getAuthenticatedSession(); - const acrUrl = await getAcrUrl(resourceUrl, fetch); + const u = new URL(url); + // Remove trailing slash for containers so we can go up + let path = u.pathname; + if (path.endsWith("/") && path.length > 1) { + path = path.slice(0, -1); + } + const lastSlash = path.lastIndexOf("/"); + if (lastSlash <= 0) return null; // Already at root + u.pathname = path.slice(0, lastSlash + 1); + return u.toString(); + } catch { + return null; + } +} - const response = await fetch(acrUrl, { - method: "GET", - headers: { - Accept: "text/turtle", - }, +/** + * Collects agents from a set of WAC Authorization quads. + * scope: "accessTo" = direct rules for the resource, "default" = inherited rules for children. + */ +function collectWacAgents( + dataset: Store, + scope: "accessTo" | "default", + targetResourceUrl: string, + inherited: boolean, +): AccessEntry[] { + const { namedNode } = DataFactory; + const scopePredicate = scope === "accessTo" ? WAC.accessTo : WAC.default_; + + // Find all Authorization subjects + const authSubjects = dataset.getSubjects( + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode(WAC.Authorization), + null + ); + + const agentMap = new Map; isPublic: boolean; isAuthenticated: boolean }>(); + + for (const authSubject of authSubjects) { + // Check if this authorization applies to our scope + const scopeObjects = dataset.getObjects(authSubject, namedNode(scopePredicate), null); + // For accessTo: must reference our exact resource. For default: must reference the container. + const applies = scopeObjects.some(o => { + if (scope === "accessTo") { + return o.value === targetResourceUrl; + } + // For default, the object is the container itself — we accept it + return true; }); + if (scopeObjects.length > 0 && !applies) continue; + // If no scope predicate at all, skip (don't assume it applies) + if (scopeObjects.length === 0) continue; + + const modeQuads = dataset.getObjects(authSubject, namedNode(WAC.mode), null); + const modes = modeQuads.map(m => normalizeMode(m.value)); + + // Direct agents + const agentQuads = dataset.getObjects(authSubject, namedNode(WAC.agent), null); + for (const agent of agentQuads) { + const key = agent.value; + if (!agentMap.has(key)) { + agentMap.set(key, { modes: new Set(), isPublic: false, isAuthenticated: false }); + } + for (const mode of modes) agentMap.get(key)!.modes.add(mode); + } - if (!response.ok) { - if (response.status === 404) { - return []; + // Agent classes + const classQuads = dataset.getObjects(authSubject, namedNode(WAC.agentClass), null); + for (const cls of classQuads) { + if (cls.value === FOAF_AGENT) { + if (!agentMap.has("PUBLIC")) { + agentMap.set("PUBLIC", { modes: new Set(), isPublic: true, isAuthenticated: false }); + } + for (const mode of modes) agentMap.get("PUBLIC")!.modes.add(mode); + } else if (cls.value === ACL_AUTHENTICATED_AGENT) { + if (!agentMap.has("AUTHENTICATED")) { + agentMap.set("AUTHENTICATED", { modes: new Set(), isPublic: false, isAuthenticated: true }); + } + for (const mode of modes) agentMap.get("AUTHENTICATED")!.modes.add(mode); } - throw new Error(`Failed to fetch ACR: ${response.statusText}`); } + } - const turtle = await response.text(); + return [...agentMap.entries()].map(([agent, data]) => ({ + agent, + modes: [...data.modes], + isPublic: data.isPublic, + isAuthenticated: data.isAuthenticated, + inherited, + })); +} - const dataset = new Store(); - dataset.addQuads(new Parser().parse(turtle)); - - const acr = new AccessControlResource(DataFactory.namedNode(acrUrl), dataset, DataFactory) - - const rawModesByWebId = - [...acr.accessControl].flatMap(ac => - [...ac.apply].flatMap(p => - [...p.anyOf].flatMap(m => - [...m.agent].flatMap(a => - ({ - webId: a, - accessModes: p.allow - }))))) - - const grouped = rawModesByWebId.reduce(groupByWebId, new Map) - return [...grouped].map(shape) +/** + * Fetches the access list for a resource, supporting both ACP and WAC. + * + * WAC inheritance: if the resource has no own .acl, walks up parent containers + * to find the nearest .acl with acl:default rules. Results are flagged inherited=true. + * + * ACP: fetches the ACR directly. If empty, walks up to find inherited policies. + */ +export async function getResourceAccessList(resourceUrl: string): Promise { + try { + const { fetch: fetchFn } = getAuthenticatedSession(); + return await resolveAccessList(resourceUrl, fetchFn, false); } catch (error) { console.error("Failed to get resource access list:", error); return null; } } -function groupByWebId(previous: Map>, current: { webId: string, accessModes: Set }) { - if (!previous.has(current.webId)) { - previous.set(current.webId, new Set) +/** + * Core recursive resolver. Tries to fetch the ACL/ACR for resourceUrl; + * if it doesn't exist (404) and isInheriting=false, walks up parent containers. + */ +async function resolveAccessList( + resourceUrl: string, + fetchFn: typeof fetch, + isInheriting: boolean, + depth: number = 0, +): Promise { + if (depth > 10) return { entries: [] }; // Safety limit + + // HEAD the resource to discover ACL URL + const headResponse = await fetchFn(resourceUrl, { + method: "HEAD", + headers: { Accept: "*/*" }, + }); + + const linkHeader = headResponse.headers.get("Link") || ""; + const discovered = discoverAclUrl(linkHeader); + if (!discovered) { + // No ACL link at all — try parent + return walkUpForInherited(resourceUrl, fetchFn, depth); + } + + const { url: aclUrl, isAcp } = discovered; + + // Fetch the ACL/ACR document + const response = await fetchFn(aclUrl, { + method: "GET", + headers: { Accept: "text/turtle" }, + }); + + if (!response.ok) { + if (response.status === 404) { + // No ACL exists for this resource — walk up to find inherited + return walkUpForInherited(resourceUrl, fetchFn, depth); + } + throw new Error(`Failed to fetch ${isAcp ? "ACR" : "ACL"}: ${response.statusText}`); + } + + const turtle = await response.text(); + + if (isAcp) { + return parseAcpForEntries(turtle, aclUrl, isInheriting); + } + + // WAC: parse and check for direct (accessTo) or default rules + const dataset = new Store(); + dataset.addQuads(new Parser({ baseIRI: aclUrl }).parse(turtle)); + + if (!isInheriting) { + // Try direct accessTo rules for this exact resource + const directEntries = collectWacAgents(dataset, "accessTo", resourceUrl, false); + if (directEntries.length > 0) { + return { entries: directEntries, sourceUrl: aclUrl }; + } + // No direct rules — this ACL might only have acl:default for children. + // Walk up to find inherited rules for this resource. + return walkUpForInherited(resourceUrl, fetchFn, depth); + } else { + // We're looking for acl:default rules (inherited from parent) + const defaultEntries = collectWacAgents(dataset, "default", resourceUrl, true); + if (defaultEntries.length > 0) { + return { entries: defaultEntries, sourceUrl: aclUrl }; + } + // Also check accessTo on the container itself — some servers set both + const directOnContainer = collectWacAgents(dataset, "accessTo", resourceUrl, true); + if (directOnContainer.length > 0) { + return { entries: directOnContainer, sourceUrl: aclUrl }; + } + // Keep walking up + return walkUpForInherited(resourceUrl, fetchFn, depth); } +} + +function parseAcpForEntries(turtle: string, acrUrl: string, inherited: boolean): AccessResult { + const dataset = new Store(); + dataset.addQuads(new Parser().parse(turtle)); + const acr = new AccessControlResource(DataFactory.namedNode(acrUrl), dataset, DataFactory); - for (const mode of current.accessModes) { - previous.get(current.webId)!.add(mode) + const rawEntries = [...acr.accessControl].flatMap(ac => + [...ac.apply].flatMap(p => + [...p.anyOf].flatMap(m => + [...m.agent].map(a => ({ + agent: a, + modes: [...p.allow].map(normalizeMode), + })) + ) + ) + ); + + // Group by agent + const grouped = new Map>(); + for (const entry of rawEntries) { + if (!grouped.has(entry.agent)) { + grouped.set(entry.agent, new Set()); + } + for (const mode of entry.modes) { + grouped.get(entry.agent)!.add(mode); + } } - return previous + const entries: AccessEntry[] = [...grouped.entries()].map(([agent, modes]) => ({ + agent, + modes: [...modes], + isPublic: agent === FOAF_AGENT, + isAuthenticated: agent === ACL_AUTHENTICATED_AGENT, + inherited, + })); + + return { entries, sourceUrl: acrUrl }; } -function shape(item: [string, Set]) { - return { - webId: item[0], - accessModes: [...item[1]] +async function walkUpForInherited( + resourceUrl: string, + fetchFn: typeof fetch, + depth: number, +): Promise { + const parentUrl = getParentContainerUrl(resourceUrl); + if (!parentUrl) { + return { entries: [] }; } + return resolveAccessList(parentUrl, fetchFn, true, depth + 1); } /** diff --git a/package-lock.json b/package-lock.json index 1e21177..acb447a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "react": "19.2.1", "react-dom": "19.2.1", "react-hot-toast": "^2.6.0", - "solid-react-component": "^0.2.1" + "solid-react-component": "^0.2.2" }, "devDependencies": { "@solid/community-server": "^8.0.0-alpha.1", @@ -6986,7 +6986,6 @@ "resolved": "https://registry.npmjs.org/@ldo/solid-react/-/solid-react-1.0.0-alpha.33.tgz", "integrity": "sha512-H7GN2SGWHsX1N5NF2c/lSbaPK3MLBZXtK6GvrXdWkR3I6kuUaZE6PsYMgx38CmJuIfh9dK4Vbz2+a36J30YMcg==", "license": "MIT", - "peer": true, "dependencies": { "@inrupt/solid-client-authn-browser": "^3.0.0", "@ldo/connected": "^1.0.0-alpha.32", @@ -16670,9 +16669,9 @@ } }, "node_modules/solid-react-component": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/solid-react-component/-/solid-react-component-0.2.1.tgz", - "integrity": "sha512-gENFQeJLeXqPo51UD+PRP8YTbflSsxwceiFAbv0c4DQM3fvbq6OtiHejWZsZoGrqHwd+Sh5h8SR4wGhN976rcg==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/solid-react-component/-/solid-react-component-0.2.8.tgz", + "integrity": "sha512-Tn3oA1ctUsmOKH1TLNzyRpRt/w8URZpG5W+8rS9AdLsBQOY1/7pJ/zoJXnrRxWIXSplgk/F/4rRQffzFE5230g==", "license": "MIT", "peerDependencies": { "@ldo/solid-react": ">=1.0.0-alpha.33", From 5d3f9e8cd9b4b72ab12a0db7aadcc7ef4c43e865 Mon Sep 17 00:00:00 2001 From: Jannes Klee Date: Mon, 6 Apr 2026 13:41:23 +0200 Subject: [PATCH 02/10] WIP: correctly identify public or authenticated agents --- app/lib/helpers/acpUtils.ts | 238 +++++++++++++++++++++++++----------- 1 file changed, 169 insertions(+), 69 deletions(-) diff --git a/app/lib/helpers/acpUtils.ts b/app/lib/helpers/acpUtils.ts index ae0ada6..d5717d5 100644 --- a/app/lib/helpers/acpUtils.ts +++ b/app/lib/helpers/acpUtils.ts @@ -408,9 +408,25 @@ export interface AccessResult { sourceUrl?: string; } -// Well-known URIs for public/authenticated agent classes -const FOAF_AGENT = "http://xmlns.com/foaf/0.1/Agent"; -const ACL_AUTHENTICATED_AGENT = "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"; +// Well-known URIs for public/authenticated agent classes (WAC + ACP variants) +const PUBLIC_AGENTS = new Set([ + "http://xmlns.com/foaf/0.1/Agent", // WAC: foaf:Agent + "http://www.w3.org/ns/solid/acp#PublicAgent", // ACP (ESS) + "http://www.w3.org/ns/solid/acp/acp#PublicAgent", // ACP alternate namespace +]); +const AUTHENTICATED_AGENTS = new Set([ + "http://www.w3.org/ns/auth/acl#AuthenticatedAgent", // WAC + "http://www.w3.org/ns/solid/acp#AuthenticatedAgent", // ACP (ESS) + "http://www.w3.org/ns/solid/acp/acp#AuthenticatedAgent", // ACP alternate namespace +]); + +function isPublicAgent(agent: string): boolean { + return PUBLIC_AGENTS.has(agent); +} + +function isAuthenticatedAgent(agent: string): boolean { + return AUTHENTICATED_AGENTS.has(agent); +} // WAC predicates const WAC = { @@ -441,16 +457,45 @@ function normalizeMode(modeUri: string): string { } /** - * Discovers the ACL/ACR URL from Link headers. Returns the URL as-is from the server - * (no .acl→.acr conversion, since that breaks WAC servers). + * Discovers the ACL/ACR URL from Link headers. Returns the URL as-is from the server. + * Does NOT guess ACP vs WAC from the URL — that is determined from the document content. */ -function discoverAclUrl(linkHeader: string): { url: string; isAcp: boolean } | null { +function discoverAclUrl(linkHeader: string): string | null { const aclMatch = linkHeader.match(/<([^>]+)>;\s*rel=["']acl["']/i); - if (!aclMatch || !aclMatch[1]) return null; + return aclMatch ? aclMatch[1] : null; +} + +/** + * Detects whether a parsed RDF dataset contains ACP or WAC triples. + */ +function detectAuthType(dataset: Store): "acp" | "wac" | "unknown" { + const { namedNode } = DataFactory; - const url = aclMatch[1]; - const isAcp = url.includes(".acr") || linkHeader.includes("http://www.w3.org/ns/solid/acp#AccessControlResource"); - return { url, isAcp }; + // Check for ACP: look for acp:AccessControlResource type or acp:accessControl predicate + const acpTypeQuads = dataset.getQuads( + null, + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode(ACP.AccessControlResource), + null, + ); + if (acpTypeQuads.length > 0) return "acp"; + + const acpControlQuads = dataset.getQuads(null, namedNode(ACP.accessControl), null, null); + if (acpControlQuads.length > 0) return "acp"; + + const acpMemberControlQuads = dataset.getQuads(null, namedNode(ACP.memberAccessControl), null, null); + if (acpMemberControlQuads.length > 0) return "acp"; + + // Check for WAC: look for acl:Authorization type + const wacTypeQuads = dataset.getQuads( + null, + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode(WAC.Authorization), + null, + ); + if (wacTypeQuads.length > 0) return "wac"; + + return "unknown"; } /** @@ -528,12 +573,12 @@ function collectWacAgents( // Agent classes const classQuads = dataset.getObjects(authSubject, namedNode(WAC.agentClass), null); for (const cls of classQuads) { - if (cls.value === FOAF_AGENT) { + if (isPublicAgent(cls.value)) { if (!agentMap.has("PUBLIC")) { agentMap.set("PUBLIC", { modes: new Set(), isPublic: true, isAuthenticated: false }); } for (const mode of modes) agentMap.get("PUBLIC")!.modes.add(mode); - } else if (cls.value === ACL_AUTHENTICATED_AGENT) { + } else if (isAuthenticatedAgent(cls.value)) { if (!agentMap.has("AUTHENTICATED")) { agentMap.set("AUTHENTICATED", { modes: new Set(), isPublic: false, isAuthenticated: true }); } @@ -571,7 +616,10 @@ export async function getResourceAccessList(resourceUrl: string): Promise 0) { - return { entries: directEntries, sourceUrl: aclUrl }; + // Detect ACP vs WAC from the document content + const authType = detectAuthType(dataset); + + if (authType === "acp") { + // ACP: parse accessControl + memberAccessControl + const result = parseAcpFromDataset(dataset, aclUrl, isInheriting); + if (result.entries.length > 0) { + return result; } - // No direct rules — this ACL might only have acl:default for children. - // Walk up to find inherited rules for this resource. + // Empty ACP ACR — walk up for inherited return walkUpForInherited(resourceUrl, fetchFn, depth); - } else { - // We're looking for acl:default rules (inherited from parent) - const defaultEntries = collectWacAgents(dataset, "default", resourceUrl, true); - if (defaultEntries.length > 0) { - return { entries: defaultEntries, sourceUrl: aclUrl }; - } - // Also check accessTo on the container itself — some servers set both - const directOnContainer = collectWacAgents(dataset, "accessTo", resourceUrl, true); - if (directOnContainer.length > 0) { - return { entries: directOnContainer, sourceUrl: aclUrl }; + } + + if (authType === "wac") { + // WAC: check for direct (accessTo) or default rules + if (!isInheriting) { + const directEntries = collectWacAgents(dataset, "accessTo", resourceUrl, false); + if (directEntries.length > 0) { + return { entries: directEntries, sourceUrl: aclUrl }; + } + // No direct rules — walk up to find inherited + return walkUpForInherited(resourceUrl, fetchFn, depth); + } else { + // Looking for acl:default rules (inherited from parent) + const defaultEntries = collectWacAgents(dataset, "default", resourceUrl, true); + if (defaultEntries.length > 0) { + return { entries: defaultEntries, sourceUrl: aclUrl }; + } + // Fallback: check accessTo on the container itself + const directOnContainer = collectWacAgents(dataset, "accessTo", resourceUrl, true); + if (directOnContainer.length > 0) { + return { entries: directOnContainer, sourceUrl: aclUrl }; + } + return walkUpForInherited(resourceUrl, fetchFn, depth); } - // Keep walking up - return walkUpForInherited(resourceUrl, fetchFn, depth); } + + // Unknown auth type — document was fetched but contained neither ACP nor WAC triples + // This can happen with empty ACR documents on some servers + return walkUpForInherited(resourceUrl, fetchFn, depth); } -function parseAcpForEntries(turtle: string, acrUrl: string, inherited: boolean): AccessResult { - const dataset = new Store(); - dataset.addQuads(new Parser().parse(turtle)); - const acr = new AccessControlResource(DataFactory.namedNode(acrUrl), dataset, DataFactory); +/** + * Parses ACP entries from a dataset. Reads both acp:accessControl (direct) + * and acp:memberAccessControl (inherited by container members, used by ESS). + */ +function parseAcpFromDataset(dataset: Store, acrUrl: string, inherited: boolean): AccessResult { + const { namedNode } = DataFactory; - const rawEntries = [...acr.accessControl].flatMap(ac => - [...ac.apply].flatMap(p => - [...p.anyOf].flatMap(m => - [...m.agent].map(a => ({ - agent: a, - modes: [...p.allow].map(normalizeMode), - })) - ) - ) - ); + // Collect AccessControl nodes from both predicates + const directControlQuads = dataset.getQuads(null, namedNode(ACP.accessControl), null, null); + const memberControlQuads = dataset.getQuads(null, namedNode(ACP.memberAccessControl), null, null); + + type RawEntry = { agent: string; modes: string[]; inherited: boolean }; + const rawEntries: RawEntry[] = []; + + const extractFromControls = (controlQuads: ReturnType, isInherited: boolean) => { + for (const cq of controlQuads) { + const controlNode = cq.object; + + // Get policies via acp:apply + const policyQuads = dataset.getQuads(controlNode, namedNode(ACP.apply), null, null); + for (const pq of policyQuads) { + const policyNode = pq.object; + + // Get allowed modes from the policy + const allowQuads = dataset.getQuads(policyNode, namedNode(ACP.allow), null, null); + const modes = allowQuads.map(aq => normalizeMode(aq.object.value)); + + // Get matchers via acp:anyOf + const matcherQuads = dataset.getQuads(policyNode, namedNode(ACP.anyOf), null, null); + for (const mq of matcherQuads) { + const matcherNode = mq.object; + + // Get agents from matcher + const agentQuads = dataset.getQuads(matcherNode, namedNode(ACP.agent), null, null); + for (const aq of agentQuads) { + rawEntries.push({ agent: aq.object.value, modes, inherited: isInherited }); + } + } + + // Some ACP implementations put acp:agent directly on the policy (without matchers) + const directAgentQuads = dataset.getQuads(policyNode, namedNode(ACP.agent), null, null); + for (const aq of directAgentQuads) { + rawEntries.push({ agent: aq.object.value, modes, inherited: isInherited }); + } + } + } + }; + + extractFromControls(directControlQuads, inherited); + extractFromControls(memberControlQuads, true); // memberAccessControl is always inherited // Group by agent - const grouped = new Map>(); + const grouped = new Map; inherited: boolean }>(); for (const entry of rawEntries) { if (!grouped.has(entry.agent)) { - grouped.set(entry.agent, new Set()); + grouped.set(entry.agent, { modes: new Set(), inherited: entry.inherited }); } + const group = grouped.get(entry.agent)!; for (const mode of entry.modes) { - grouped.get(entry.agent)!.add(mode); + group.modes.add(mode); + } + // If any rule is direct (not inherited), mark the whole entry as direct + if (!entry.inherited) { + group.inherited = false; } } - const entries: AccessEntry[] = [...grouped.entries()].map(([agent, modes]) => ({ - agent, - modes: [...modes], - isPublic: agent === FOAF_AGENT, - isAuthenticated: agent === ACL_AUTHENTICATED_AGENT, - inherited, - })); + const entries: AccessEntry[] = [...grouped.entries()].map(([agent, data]) => { + const pub = isPublicAgent(agent); + const auth = isAuthenticatedAgent(agent); + return { + // Normalize special agents to canonical keys so the UI treats them uniformly + agent: pub ? "PUBLIC" : auth ? "AUTHENTICATED" : agent, + modes: [...data.modes], + isPublic: pub, + isAuthenticated: auth, + inherited: data.inherited, + }; + }); return { entries, sourceUrl: acrUrl }; } From f8ab58b0f62109f9ceb39514126a7649d37e3b07 Mon Sep 17 00:00:00 2001 From: Jannes Klee Date: Mon, 6 Apr 2026 13:53:34 +0200 Subject: [PATCH 03/10] ENH: show URI when hovering over chip --- app/components/FileItem.tsx | 55 ++++++++++++++++--------------------- app/lib/helpers/acpUtils.ts | 33 +++++++++++----------- 2 files changed, 40 insertions(+), 48 deletions(-) diff --git a/app/components/FileItem.tsx b/app/components/FileItem.tsx index 6a6ebc1..44baa9a 100644 --- a/app/components/FileItem.tsx +++ b/app/components/FileItem.tsx @@ -139,7 +139,7 @@ export default function FileItem({ lastTapRef.current = currentTime; }; - const [expandedCategory, setExpandedCategory] = useState(null); + const [hoveredCategory, setHoveredCategory] = useState(null); const renderPermissions = () => { if (accessResult === undefined) return null; @@ -224,37 +224,30 @@ export default function FileItem({ return (
- {categories.map((cat) => { - const isExpanded = expandedCategory === cat.key; - - return ( - - + {categories.map((cat) => ( + setHoveredCategory(cat.key)} + onMouseLeave={() => setHoveredCategory(null)} + > + + {cat.label} - ); - })} + {hoveredCategory === cat.key && ( +
+ {cat.entries.map((entry) => ( +
+ {entry.rawAgent || entry.agent} + — {entry.modes.join(", ")} +
+ ))} +
+ )} +
+ ))} {someInherited && ( ; isPublic: boolean; isAuthenticated: boolean }>(); + const agentMap = new Map; isPublic: boolean; isAuthenticated: boolean }>(); for (const authSubject of authSubjects) { // Check if this authorization applies to our scope const scopeObjects = dataset.getObjects(authSubject, namedNode(scopePredicate), null); - // For accessTo: must reference our exact resource. For default: must reference the container. const applies = scopeObjects.some(o => { if (scope === "accessTo") { return o.value === targetResourceUrl; } - // For default, the object is the container itself — we accept it return true; }); if (scopeObjects.length > 0 && !applies) continue; - // If no scope predicate at all, skip (don't assume it applies) if (scopeObjects.length === 0) continue; const modeQuads = dataset.getObjects(authSubject, namedNode(WAC.mode), null); @@ -565,7 +564,7 @@ function collectWacAgents( for (const agent of agentQuads) { const key = agent.value; if (!agentMap.has(key)) { - agentMap.set(key, { modes: new Set(), isPublic: false, isAuthenticated: false }); + agentMap.set(key, { rawAgent: key, modes: new Set(), isPublic: false, isAuthenticated: false }); } for (const mode of modes) agentMap.get(key)!.modes.add(mode); } @@ -575,12 +574,12 @@ function collectWacAgents( for (const cls of classQuads) { if (isPublicAgent(cls.value)) { if (!agentMap.has("PUBLIC")) { - agentMap.set("PUBLIC", { modes: new Set(), isPublic: true, isAuthenticated: false }); + agentMap.set("PUBLIC", { rawAgent: cls.value, modes: new Set(), isPublic: true, isAuthenticated: false }); } for (const mode of modes) agentMap.get("PUBLIC")!.modes.add(mode); } else if (isAuthenticatedAgent(cls.value)) { if (!agentMap.has("AUTHENTICATED")) { - agentMap.set("AUTHENTICATED", { modes: new Set(), isPublic: false, isAuthenticated: true }); + agentMap.set("AUTHENTICATED", { rawAgent: cls.value, modes: new Set(), isPublic: false, isAuthenticated: true }); } for (const mode of modes) agentMap.get("AUTHENTICATED")!.modes.add(mode); } @@ -589,6 +588,7 @@ function collectWacAgents( return [...agentMap.entries()].map(([agent, data]) => ({ agent, + rawAgent: data.rawAgent, modes: [...data.modes], isPublic: data.isPublic, isAuthenticated: data.isAuthenticated, @@ -751,28 +751,27 @@ function parseAcpFromDataset(dataset: Store, acrUrl: string, inherited: boolean) extractFromControls(directControlQuads, inherited); extractFromControls(memberControlQuads, true); // memberAccessControl is always inherited - // Group by agent - const grouped = new Map; inherited: boolean }>(); + // Group by agent URI + const grouped = new Map; inherited: boolean }>(); for (const entry of rawEntries) { if (!grouped.has(entry.agent)) { - grouped.set(entry.agent, { modes: new Set(), inherited: entry.inherited }); + grouped.set(entry.agent, { rawAgent: entry.agent, modes: new Set(), inherited: entry.inherited }); } const group = grouped.get(entry.agent)!; for (const mode of entry.modes) { group.modes.add(mode); } - // If any rule is direct (not inherited), mark the whole entry as direct if (!entry.inherited) { group.inherited = false; } } - const entries: AccessEntry[] = [...grouped.entries()].map(([agent, data]) => { - const pub = isPublicAgent(agent); - const auth = isAuthenticatedAgent(agent); + const entries: AccessEntry[] = [...grouped.entries()].map(([, data]) => { + const pub = isPublicAgent(data.rawAgent); + const auth = isAuthenticatedAgent(data.rawAgent); return { - // Normalize special agents to canonical keys so the UI treats them uniformly - agent: pub ? "PUBLIC" : auth ? "AUTHENTICATED" : agent, + agent: pub ? "PUBLIC" : auth ? "AUTHENTICATED" : data.rawAgent, + rawAgent: data.rawAgent, modes: [...data.modes], isPublic: pub, isAuthenticated: auth, From 01bd5f83baa9d20c04d9db820e628d44f7fca52f Mon Sep 17 00:00:00 2001 From: Jannes Klee Date: Mon, 6 Apr 2026 14:06:22 +0200 Subject: [PATCH 04/10] WIP: update layout of the table --- app/components/FileItem.tsx | 52 ++++++++++++++++++++++--------------- app/components/FileList.tsx | 14 +++++----- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/app/components/FileItem.tsx b/app/components/FileItem.tsx index 44baa9a..1d243d3 100644 --- a/app/components/FileItem.tsx +++ b/app/components/FileItem.tsx @@ -304,12 +304,20 @@ export default function FileItem({ ); } - // List view + // List view — always use grid layout for consistent alignment + const hasPermissions = accessResult !== undefined; + return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onClick={handleClick} @@ -323,35 +331,37 @@ export default function FileItem({ onContextMenu?.(file, event); }} > -
+
{getFileIcon(file.type, file.mimeType)}
-
+

{file.name}

- {accessResult !== undefined && ( -
+ {hasPermissions && ( +
{renderPermissions()}
)} -
+
{file.lastModified && formatDate(file.lastModified)}
-
+
{file.size && formatFileSize(file.size)}
{isHovered && ( - +
+ +
)}
); diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx index 6c9dc30..90609fd 100644 --- a/app/components/FileList.tsx +++ b/app/components/FileList.tsx @@ -141,12 +141,14 @@ export default function FileList({ ) : (
{showPermissions && ( -
-
-
Name
-
Permissions
-
Modified
-
Size
+
+
+
Name
+
Permissions
+
Modified
+
Size
)} {files.map((file) => ( From 1e071e2fe8bbb61ef26da2d2a78c4c38a54a8b3b Mon Sep 17 00:00:00 2001 From: Jannes Klee Date: Mon, 6 Apr 2026 14:19:53 +0200 Subject: [PATCH 05/10] WIP: change coloring and naming of chips --- app/components/FileItem.tsx | 52 +++++++------------------------------ 1 file changed, 10 insertions(+), 42 deletions(-) diff --git a/app/components/FileItem.tsx b/app/components/FileItem.tsx index 1d243d3..f1b1114 100644 --- a/app/components/FileItem.tsx +++ b/app/components/FileItem.tsx @@ -5,26 +5,6 @@ import { getFileIcon, formatFileSize, formatDate, type FileType } from "../lib/h import FileItemMenu from "./FileItemMenu"; import type { AccessEntry, AccessResult } from "./FileList"; -/** - * Extracts a short readable label from a WebID URL. - * e.g. "http://localhost:3000/alice/profile/card#me" → "alice" - */ -function extractShortLabel(agent: string): string { - if (agent === "PUBLIC") return "Anyone"; - if (agent === "AUTHENTICATED") return "Authenticated"; - try { - const url = new URL(agent); - const segments = url.pathname.split("/").filter(Boolean); - if (segments.length > 0) { - return segments[0]; - } - return url.hostname; - } catch { - const parts = agent.split(/[/#]/).filter(Boolean); - return parts[parts.length - 1] || agent; - } -} - export type { FileType }; export interface FileItemData { @@ -182,7 +162,7 @@ export default function FileItem({ categories.push({ key: "public", label: `Public: ${modes.join(", ")}`, - chipClass: "bg-orange-50 text-orange-800 hover:bg-orange-100 border border-orange-200", + chipClass: "bg-red-50 text-red-800 hover:bg-red-100 border border-red-200", entries: publicEntries, title: `Accessible by anyone on the internet\nModes: ${modes.join(", ")}`, }); @@ -193,33 +173,21 @@ export default function FileItem({ categories.push({ key: "authenticated", label: `Authenticated: ${modes.join(", ")}`, - chipClass: "bg-blue-50 text-blue-800 hover:bg-blue-100 border border-blue-200", + chipClass: "bg-orange-50 text-orange-800 hover:bg-orange-100 border border-orange-200", entries: authenticatedEntries, title: `Accessible by any logged-in user\nModes: ${modes.join(", ")}`, }); } if (agentEntries.length > 0) { - // Group: if there's only 1 agent, show it directly; otherwise show count - if (agentEntries.length === 1) { - const entry = agentEntries[0]; - const shortLabel = extractShortLabel(entry.agent); - categories.push({ - key: "agents", - label: `${shortLabel}: ${entry.modes.join(", ")}`, - chipClass: "bg-purple-50 text-purple-800 hover:bg-purple-100 border border-purple-200", - entries: agentEntries, - title: `${entry.agent}\nModes: ${entry.modes.join(", ")}`, - }); - } else { - categories.push({ - key: "agents", - label: `${agentEntries.length} users shared`, - chipClass: "bg-purple-50 text-purple-800 hover:bg-purple-100 border border-purple-200", - entries: agentEntries, - title: agentEntries.map(e => `${e.agent}: ${e.modes.join(", ")}`).join("\n"), - }); - } + const modes = [...new Set(agentEntries.flatMap(e => e.modes))]; + categories.push({ + key: "agents", + label: `Shared: ${modes.join(", ")}`, + chipClass: "bg-purple-50 text-purple-800 hover:bg-purple-100 border border-purple-200", + entries: agentEntries, + title: agentEntries.map(e => `${e.agent}: ${e.modes.join(", ")}`).join("\n"), + }); } return ( From f32a1fd8e16a9a2836ad04109dcdf2db53dae47f Mon Sep 17 00:00:00 2001 From: Jannes Klee Date: Mon, 6 Apr 2026 18:58:35 +0200 Subject: [PATCH 06/10] ENH: remove own permissions for ACL in view, because there is no benefit to see it --- app/lib/helpers/acpUtils.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/lib/helpers/acpUtils.ts b/app/lib/helpers/acpUtils.ts index bcbeb82..f5fc826 100644 --- a/app/lib/helpers/acpUtils.ts +++ b/app/lib/helpers/acpUtils.ts @@ -606,8 +606,18 @@ function collectWacAgents( */ export async function getResourceAccessList(resourceUrl: string): Promise { try { - const { fetch: fetchFn } = getAuthenticatedSession(); - return await resolveAccessList(resourceUrl, fetchFn, false); + const { session, fetch: fetchFn } = getAuthenticatedSession(); + const result = await resolveAccessList(resourceUrl, fetchFn, false); + if (!result) return null; + + // Filter out the current user's own WebID — it's standard for ACLs to include + // the owner with full access, but this is noise in the UI. + const currentWebId = session.info.webId; + if (currentWebId) { + result.entries = result.entries.filter(e => e.agent !== currentWebId); + } + + return result; } catch (error) { console.error("Failed to get resource access list:", error); return null; From 2246a18bacdd7d51a17e6659bbad7fc2fb537678 Mon Sep 17 00:00:00 2001 From: Jannes Klee Date: Mon, 6 Apr 2026 19:06:35 +0200 Subject: [PATCH 07/10] ENH: switch to toggle for Permissions --- app/components/shared/Toolbar.tsx | 87 +++++++++++++++++-------------- 1 file changed, 49 insertions(+), 38 deletions(-) diff --git a/app/components/shared/Toolbar.tsx b/app/components/shared/Toolbar.tsx index f1d913a..bb8c897 100644 --- a/app/components/shared/Toolbar.tsx +++ b/app/components/shared/Toolbar.tsx @@ -2,7 +2,7 @@ import { ReactNode } from "react"; import Button from "./Button"; -import { ListBulletIcon, Squares2X2Icon, ShieldCheckIcon } from "@heroicons/react/24/outline"; +import { ListBulletIcon, Squares2X2Icon } from "@heroicons/react/24/outline"; interface ToolbarProps { view: "list" | "grid"; @@ -23,54 +23,65 @@ export default function Toolbar({ }: ToolbarProps) { return (
- +
{itemCount} {itemCount === 1 ? "item" : "items"}
From 4ffab1b00fb302e11012f62e902f4423b04afc6c Mon Sep 17 00:00:00 2001 From: Jannes Klee Date: Mon, 6 Apr 2026 19:09:28 +0200 Subject: [PATCH 08/10] BUG: add 403 to responses that case to look for inherited permissions --- app/lib/helpers/acpUtils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/lib/helpers/acpUtils.ts b/app/lib/helpers/acpUtils.ts index f5fc826..0533ab7 100644 --- a/app/lib/helpers/acpUtils.ts +++ b/app/lib/helpers/acpUtils.ts @@ -658,10 +658,14 @@ async function resolveAccessList( }); if (!response.ok) { - if (response.status === 404) { + // 404: no ACL/ACR exists, walk up for inherited. + // 403/other: user may not have permission to read the ACL — treat as unavailable. + if (response.status === 404 || response.status === 403) { return walkUpForInherited(resourceUrl, fetchFn, depth); } - throw new Error(`Failed to fetch ACL/ACR: ${response.statusText}`); + // For other unexpected errors, log and return null rather than crashing the UI. + console.warn(`Could not fetch ACL/ACR (${response.status} ${response.statusText}) for ${aclUrl}`); + return null; } const turtle = await response.text(); From b5b042a66129f54ccd8d1864032938f49f9c12db Mon Sep 17 00:00:00 2001 From: Jannes Klee Date: Mon, 6 Apr 2026 19:46:47 +0200 Subject: [PATCH 09/10] ENH: add permissions to the grid view --- app/components/FileItem.tsx | 130 +++++++++++++++++++++++------- app/components/FileList.tsx | 1 + app/components/shared/Toolbar.tsx | 2 +- 3 files changed, 102 insertions(+), 31 deletions(-) diff --git a/app/components/FileItem.tsx b/app/components/FileItem.tsx index f1b1114..9cb36f9 100644 --- a/app/components/FileItem.tsx +++ b/app/components/FileItem.tsx @@ -121,40 +121,17 @@ export default function FileItem({ const [hoveredCategory, setHoveredCategory] = useState(null); - const renderPermissions = () => { - if (accessResult === undefined) return null; - if (accessResult === "loading") { - return ( -
-
-
- ); - } - if (accessResult === null) { - return error; - } + type Category = { key: string; label: string; dotColor: string; chipClass: string; entries: AccessEntry[]; }; + const buildCategories = (): { categories: Category[]; allInherited: boolean; someInherited: boolean } | null => { + if (accessResult === undefined || accessResult === "loading" || accessResult === null) return null; const entries = accessResult.entries; + if (entries.length === 0) return { categories: [], allInherited: false, someInherited: false }; - if (entries.length === 0) { - return ( - - Private - - ); - } - - // Categorize entries const publicEntries = entries.filter(e => e.isPublic); const authenticatedEntries = entries.filter(e => e.isAuthenticated); const agentEntries = entries.filter(e => !e.isPublic && !e.isAuthenticated); - const allInherited = entries.every(e => e.inherited); - const someInherited = entries.some(e => e.inherited); - - // Build category chips - type Category = { key: string; label: string; chipClass: string; entries: AccessEntry[]; title: string }; const categories: Category[] = []; if (publicEntries.length > 0) { @@ -162,9 +139,9 @@ export default function FileItem({ categories.push({ key: "public", label: `Public: ${modes.join(", ")}`, + dotColor: "bg-red-400", chipClass: "bg-red-50 text-red-800 hover:bg-red-100 border border-red-200", entries: publicEntries, - title: `Accessible by anyone on the internet\nModes: ${modes.join(", ")}`, }); } @@ -173,9 +150,9 @@ export default function FileItem({ categories.push({ key: "authenticated", label: `Authenticated: ${modes.join(", ")}`, + dotColor: "bg-orange-400", chipClass: "bg-orange-50 text-orange-800 hover:bg-orange-100 border border-orange-200", entries: authenticatedEntries, - title: `Accessible by any logged-in user\nModes: ${modes.join(", ")}`, }); } @@ -184,12 +161,45 @@ export default function FileItem({ categories.push({ key: "agents", label: `Shared: ${modes.join(", ")}`, + dotColor: "bg-purple-400", chipClass: "bg-purple-50 text-purple-800 hover:bg-purple-100 border border-purple-200", entries: agentEntries, - title: agentEntries.map(e => `${e.agent}: ${e.modes.join(", ")}`).join("\n"), }); } + return { + categories, + allInherited: entries.every(e => e.inherited), + someInherited: entries.some(e => e.inherited), + }; + }; + + const renderPermissions = () => { + if (accessResult === undefined) return null; + if (accessResult === "loading") { + return ( +
+
+
+ ); + } + if (accessResult === null) { + return error; + } + + const result = buildCategories(); + if (!result) return null; + const { categories, allInherited, someInherited } = result; + + if (categories.length === 0) { + return ( + + Private + + ); + } + return (
{categories.map((cat) => ( @@ -228,6 +238,65 @@ export default function FileItem({ ); }; + const renderPermissionDots = () => { + if (accessResult === undefined) return null; + if (accessResult === "loading") { + return ( +
+
+
+ ); + } + if (accessResult === null) return null; + + const result = buildCategories(); + if (!result) return null; + const { categories } = result; + + if (categories.length === 0) { + return ( +
setHoveredCategory("private")} + onMouseLeave={() => setHoveredCategory(null)} + > + + {hoveredCategory === "private" && ( +
+ Private — only the owner +
+ )} +
+ ); + } + + return ( +
+ {categories.map((cat) => ( + setHoveredCategory(cat.key)} + onMouseLeave={() => setHoveredCategory(null)} + > + + {hoveredCategory === cat.key && ( +
+
{cat.label}
+ {cat.entries.map((entry) => ( +
+ {entry.rawAgent || entry.agent} + — {entry.modes.join(", ")} +
+ ))} +
+ )} +
+ ))} +
+ ); + }; + if (view === "grid") { return (
{file.name}

+ {renderPermissionDots()}
); } diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx index 90609fd..e531267 100644 --- a/app/components/FileList.tsx +++ b/app/components/FileList.tsx @@ -135,6 +135,7 @@ export default function FileList({ onShare={onFileShare} isSelected={selectedFileIds.includes(file.id)} onContextMenu={onFileContextMenu} + accessResult={showPermissions ? permissionsMap[file.url] : undefined} /> ))}
diff --git a/app/components/shared/Toolbar.tsx b/app/components/shared/Toolbar.tsx index bb8c897..482e28d 100644 --- a/app/components/shared/Toolbar.tsx +++ b/app/components/shared/Toolbar.tsx @@ -54,7 +54,7 @@ export default function Toolbar({ - {view === "list" && onTogglePermissions && ( + {onTogglePermissions && ( <>