diff --git a/package-lock.json b/package-lock.json index 7819d1f1a..b3ae0bdd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-ui", - "version": "5.0.2-0", + "version": "5.0.2-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-ui", - "version": "5.0.2-0", + "version": "5.0.2-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -32,7 +32,7 @@ "mime-types": "^3.0.2", "pane-registry": "5.0.2-0", "rdflib": "2.4.1", - "solid-logic": "6.0.1-1", + "solid-logic": "6.0.1-3", "solid-namespace": "^0.5.4", "uuid": "^14.0.0" }, @@ -3911,9 +3911,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.154.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", - "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/oxc-project" @@ -4276,9 +4276,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", - "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", "cpu": [ "arm" ], @@ -4292,9 +4292,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", - "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -4308,9 +4308,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", - "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -4324,9 +4324,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", - "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -4340,9 +4340,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", - "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -4356,9 +4356,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", - "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -4372,9 +4372,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", - "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -4391,9 +4391,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", - "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -4410,9 +4410,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", - "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -4429,9 +4429,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", - "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -4448,9 +4448,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", - "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -4467,9 +4467,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", - "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -4486,9 +4486,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", - "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -4502,9 +4502,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", - "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -4518,9 +4518,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", - "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -4571,9 +4571,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", - "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.5.1.tgz", + "integrity": "sha512-P9ZvnfouRu4MSC+ilERaw6FmZs8zqKhpHxEs4aGDGWQAMzQGXnhvCSvoNwokfNvdt7t3Y5Y4p6gStQgezeIqug==", "dev": true, "license": "MIT", "dependencies": { @@ -6929,9 +6929,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001817", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001817.tgz", - "integrity": "sha512-uZ1JG80BwhtMalIAGPxPfmNhHyd/B/nTdr3hITWlRamSyAdUStP1Gop01e7SD5q6J90ixJvWDbvmiFVfDgn4bw==", + "version": "1.0.30001818", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001818.tgz", + "integrity": "sha512-XJbgo4adscw7R4MZU4/qVFkoMyPaNUAWmdNY4o/k9x7fFwmv4frIlndI9lWuaxviW9ObiZel9LuncnQPn6hQ5A==", "dev": true, "funding": [ { @@ -7542,9 +7542,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.461", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.461.tgz", - "integrity": "sha512-3WZFARz1By+/rYcqQ0OLj4UcEdhd6tz2LEcDtKBUq7ey77EeJ1OtetY/5hjSUxw7dC5r5mA7FfkhRbqO9vnTCg==", + "version": "1.5.463", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.463.tgz", + "integrity": "sha512-nVWNU6ltFjopvOdD44J4zoBkgO0M+WZiFKKFV7EjebOH9z0KNphk6bCEjplXtJofL21ARIuuwWOCsFkBVklm6w==", "dev": true, "license": "ISC" }, @@ -11115,9 +11115,9 @@ } }, "node_modules/napi-postinstall": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.5.tgz", - "integrity": "sha512-NFMjXXkyodBJnRrM0A6qxFO1OLUbrYLDhP1ZLetlXnyx41gKOAmVyROibq85j7I8dda4aZMs9L7kmRvvjNf7Cg==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.6.tgz", + "integrity": "sha512-RzlO2IIhNxWEahiVrwUYvk+PRoP2SdKLTUt7Yfmwjstc2MRULti2uwc9EkBRTMwkYtfITwVzgBLkS4K4esCRGg==", "dev": true, "license": "MIT", "bin": { @@ -11296,9 +11296,9 @@ } }, "node_modules/nock": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/nock/-/nock-15.0.4.tgz", - "integrity": "sha512-quP113SfbjFQCvYCE8eKjEFYqFuHoNTnz+JOfwUnTbqwvH7bB/Z1yLS6xxfI0s+7x86V+EptbHv22CxH065FYw==", + "version": "15.0.5", + "resolved": "https://registry.npmjs.org/nock/-/nock-15.0.5.tgz", + "integrity": "sha512-nVeuZGS2we1PyvqbW082Qj0EB4bd//6pZk/zOPDlaTPU72qcWrqfHg3kx9JcBcH1fJxwJHjmsfuQ4mss42su4w==", "dev": true, "license": "MIT", "dependencies": { @@ -12432,12 +12432,12 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", - "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.154.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -12447,21 +12447,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.14", - "@rolldown/binding-android-arm64": "1.2.14", - "@rolldown/binding-darwin-arm64": "1.2.14", - "@rolldown/binding-darwin-x64": "1.2.14", - "@rolldown/binding-freebsd-x64": "1.2.14", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", - "@rolldown/binding-linux-arm64-gnu": "1.2.14", - "@rolldown/binding-linux-arm64-musl": "1.2.14", - "@rolldown/binding-linux-ppc64-gnu": "1.2.14", - "@rolldown/binding-linux-s390x-gnu": "1.2.14", - "@rolldown/binding-linux-x64-gnu": "1.2.14", - "@rolldown/binding-linux-x64-musl": "1.2.14", - "@rolldown/binding-openharmony-arm64": "1.2.14", - "@rolldown/binding-win32-arm64-msvc": "1.2.14", - "@rolldown/binding-win32-x64-msvc": "1.2.14" + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/run-applescript": { @@ -12776,9 +12776,9 @@ } }, "node_modules/solid-logic": { - "version": "6.0.1-1", - "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-1.tgz", - "integrity": "sha512-k0EB26f3whZWfvePl6qHcHhOa4TWfnjPLphAa++s2ZR7+FvruM9Cc9eyvzJqEF6b3HF72jH8V7ENiRseFI51Ow==", + "version": "6.0.1-3", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-3.tgz", + "integrity": "sha512-4g3Hue5NiuGJZxdrFa2Q6OWGog5L6esBwUOIheCPQvIXxn/Cmd8WvykaIdFYWDwpytMmxqdwW034sdVVzY7bdw==", "license": "MIT", "dependencies": { "@dokieli/web-access-control": "^1.1.0", @@ -12798,9 +12798,9 @@ "license": "MIT" }, "node_modules/solidos-toolkit": { - "version": "1.0.0-dev.c11da40967c5d278451118142650415d688d8d4c", - "resolved": "https://registry.npmjs.org/solidos-toolkit/-/solidos-toolkit-1.0.0-dev.c11da40967c5d278451118142650415d688d8d4c.tgz", - "integrity": "sha512-i5FOuvpBBbXfYpRKBv4ygdccO5EUUpfo2rp1HoOspG+AOKMVeFqizuPokl+RZX6AVn3AulqRfnlo+NUEWkidEA==", + "version": "1.0.0-dev.2fb6be508170f3b28ca79d738aae71cd48f6d87e", + "resolved": "https://registry.npmjs.org/solidos-toolkit/-/solidos-toolkit-1.0.0-dev.2fb6be508170f3b28ca79d738aae71cd48f6d87e.tgz", + "integrity": "sha512-EQMddD3jV0YiPdPuEfBTRc1+QynYiIBwxMs8e3+F/gEP0lfIvSUjFSXCYsJxQxY8rqsEb4YXR4JW/5b1JRtdTQ==", "dev": true, "dependencies": { "@babel/core": "^7.29.0", @@ -12822,7 +12822,7 @@ "pane-registry": "^3.1.1 || ^3.1.2-0 || >=5.0.2-0 <6.0.0", "rdflib": "^2.3.6", "solid-logic": "^4.0.7 || ^4.0.8-0 || >=6.0.1-0 <7.0.0", - "solid-ui": "^3.1.2 || ^3.1.3-0 || >=5.0.1-0 <6.0.0", + "solid-ui": "^3.1.2 || ^3.1.3-0 || >=5.0.1-0 <6.0.0 || ^5.0.2-0", "vite": "^8.0.0", "vitest": "^4.0.0" }, @@ -13940,15 +13940,15 @@ } }, "node_modules/vite": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", - "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.29", - "rolldown": "~1.2.12", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -13965,7 +13965,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index 96f834af6..d58f2e28d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-ui", - "version": "5.0.2-0", + "version": "5.0.2-1", "description": "UI library for Solid applications", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", @@ -114,7 +114,7 @@ "mime-types": "^3.0.2", "pane-registry": "5.0.2-0", "rdflib": "2.4.1", - "solid-logic": "6.0.1-1", + "solid-logic": "6.0.1-3", "solid-namespace": "^0.5.4", "uuid": "^14.0.0" }, diff --git a/src/components/access-control-modal/AccessControlAddAccess.styles.css b/src/components/access-control-modal/AccessControlAddAccess.styles.css index 1331e3136..0257c9f2b 100644 --- a/src/components/access-control-modal/AccessControlAddAccess.styles.css +++ b/src/components/access-control-modal/AccessControlAddAccess.styles.css @@ -17,7 +17,7 @@ .access-grants-form-main { display: flex; - align-items: flex-start; + align-items: center; gap: 10px; align-self: stretch; diff --git a/src/components/access-control-modal/AccessControlAddAccess.ts b/src/components/access-control-modal/AccessControlAddAccess.ts index cda932ee9..4d10ccd9e 100644 --- a/src/components/access-control-modal/AccessControlAddAccess.ts +++ b/src/components/access-control-modal/AccessControlAddAccess.ts @@ -2,12 +2,13 @@ import { customElement, WebComponent } from '@/lib/components' import { html, nothing } from 'lit' import { property } from 'lit/decorators.js' import { ACCESS_ROLES, type AccessRole, type DirectoryEntry } from 'solid-logic' -import type { AsyncComboboxOptionsProvider, ComboboxChangeEvent } from '@/components/combobox' +import type { AsyncComboboxOptionsProvider, ComboboxChangeEvent, ComboboxOptionData } from '@/components/combobox' import '~icons/lucide/book-user' import '~icons/lucide/user-round' import '~icons/lucide/users' import '~icons/lucide/circle-x' +import '~icons/lucide/globe' import '@/components/button' import '@/components/combobox' import '@/components/combobox-option' @@ -56,6 +57,9 @@ export default class AccessControlAddAccess extends WebComponent { @property({ attribute: false }) accessor inputError = '' + @property({ type: Boolean }) + accessor disabled = false + @property({ attribute: false }) accessor asyncOptionsProvider: AsyncComboboxOptionsProvider | null = null @@ -69,7 +73,7 @@ export default class AccessControlAddAccess extends WebComponent { } private onPrincipalSelect (event: ComboboxChangeEvent) { - const option = event.detail.option + const option = event.detail.option as ComboboxOptionData & { targetKind?: 'origin' | 'trustedApp' } if (typeof option.value !== 'string') { return } @@ -77,7 +81,8 @@ export default class AccessControlAddAccess extends WebComponent { this.dispatchEvent(new CustomEvent('target-selected', { detail: { value: option.value, - label: option.label + label: option.label, + targetKind: option.targetKind }, bubbles: true, composed: true @@ -86,12 +91,15 @@ export default class AccessControlAddAccess extends WebComponent { private onRoleChange (event: ComboboxChangeEvent) { const role = event.detail.option.value - if (typeof role !== 'string' || !ACCESS_ROLES.includes(role as AccessRole)) { + const accessRole = typeof role === 'string' + ? ACCESS_ROLES.find(candidate => candidate === role) + : undefined + if (!accessRole) { return } this.dispatchEvent(new CustomEvent('role-changed', { - detail: { role: role as AccessRole }, + detail: { role: accessRole }, bubbles: true, composed: true })) @@ -118,6 +126,7 @@ export default class AccessControlAddAccess extends WebComponent {
@@ -156,11 +166,17 @@ export default class AccessControlAddAccess extends WebComponent {
${this.pendingAccessGrants.map((grant, index) => html`
- ${grant.label} + + ${grant.targetKind === 'trustedApp' + ? html`` + : nothing} + ${grant.label} + this.onPendingGrantRemove(index)} > Remove ${grant.label} diff --git a/src/components/access-control-modal/AccessControlErrors.ts b/src/components/access-control-modal/AccessControlErrors.ts index 4558b7419..fa8041153 100644 --- a/src/components/access-control-modal/AccessControlErrors.ts +++ b/src/components/access-control-modal/AccessControlErrors.ts @@ -25,6 +25,16 @@ export default class AccessControlErrors extends WebComponent { })) } + private getScopeLabel (scope: NonNullable['scope']>) { + return scope === 'folder+contents' + ? 'folder and contents' + : scope === 'container' + ? 'folder' + : scope === 'contents' + ? 'contents' + : 'resource' + } + protected render () { if (!this.errors.length) { return html`` @@ -44,11 +54,14 @@ export default class AccessControlErrors extends WebComponent { ${error.label} ${error.action === 'grant' - ? `Could not grant ${error.role ?? 'access'}` + ? `Could not grant ${error.role ?? 'access'} for ${error.scope ? this.getScopeLabel(error.scope) : 'resource'}` : error.action === 'revoke' - ? 'Could not remove access' + ? `Could not remove access for ${error.scope ? this.getScopeLabel(error.scope) : 'resource'}` + : error.action === 'reset' + ? 'Could not reset sharing' : 'Could not save access changes'} + ${error.message} `)} diff --git a/src/components/access-control-modal/AccessControlGeneralAccess.ts b/src/components/access-control-modal/AccessControlGeneralAccess.ts index ff969a5be..e634187ab 100644 --- a/src/components/access-control-modal/AccessControlGeneralAccess.ts +++ b/src/components/access-control-modal/AccessControlGeneralAccess.ts @@ -27,6 +27,9 @@ export default class AccessControlGeneralAccess extends WebComponent { @property({ attribute: false }) accessor publicAccessRoleValue: AccessRole = 'No Access' + @property({ type: Boolean }) + accessor disabled = false + private onAuthenticatedRoleChange (event: ComboboxChangeEvent) { this.dispatchRoleChange('authenticated-role-changed', event, ACCESS_ROLES) } @@ -83,6 +86,7 @@ export default class AccessControlGeneralAccess extends WebComponent { return html` @@ -95,6 +99,7 @@ export default class AccessControlGeneralAccess extends WebComponent { return html` @@ -149,7 +154,7 @@ export default class AccessControlGeneralAccess extends WebComponent {
${this.renderGeneralShareRow({ - title: 'Share with Anyone Signed In', + title: 'Only logged in users with the link', description: 'Users must sign in to SolidOS to access this shared item using the link.', variant: 'authenticated' })} diff --git a/src/components/access-control-modal/AccessControlModal.styles.css b/src/components/access-control-modal/AccessControlModal.styles.css index 32438b214..8c3109b2a 100644 --- a/src/components/access-control-modal/AccessControlModal.styles.css +++ b/src/components/access-control-modal/AccessControlModal.styles.css @@ -1,4 +1,263 @@ :host { + .access-control-mixed-policy-warning { + display: grid; + gap: 4px; + margin: 0 0 16px; + padding: 12px 14px; + border: 1px solid var(--solid-ui-color-red-300, #FCA5A5); + border-radius: 5px; + background: var(--solid-ui-color-red-50, #FEF2F2); + color: var(--solid-ui-color-red-800, #991B1B); + font-size: var(--solid-ui-font-size-sm, .875rem); + line-height: 1.45; + + strong { + font-weight: 600; + } + } + + .access-control-same-access-toggle { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 12px; + color: var(--solid-ui-color-gray-900, #101828); + line-height: 1.4; + border: 0; + background: transparent; + padding: 0; + text-align: left; + font: inherit; + cursor: pointer; + } + + .access-control-switch-track { + display: inline-flex; + flex: 0 0 auto; + width: 34px; + height: 20px; + margin-top: 1px; + border-radius: 999px; + background: var(--solid-ui-color-slate-300, #CBD5E1); + padding: 2px; + transition: background .15s ease; + } + + .access-control-same-access-toggle[aria-checked='true'] .access-control-switch-track { + background: var(--solid-ui-color-purple-600, #9333EA); + } + + .access-control-switch-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: white; + box-shadow: 0 1px 2px rgb(0 0 0 / 25%); + transition: transform .15s ease; + } + + .access-control-same-access-toggle[aria-checked='true'] .access-control-switch-thumb { + transform: translateX(14px); + } + + .access-control-same-access-toggle > span:last-child { + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + } + + .access-control-sharing-state, + .access-control-contents-prompt { + box-sizing: border-box; + min-width: 0; + max-width: 100%; + padding: 10px; + margin-bottom: 16px; + font-size: var(--solid-ui-font-size-sm, .875rem); + + h2 { + margin: 0 0 10px; + color: var(--solid-ui-color-gray-900, #101828); + font-size: var(--solid-ui-font-size-md, 1rem); + font-weight: 600; + } + + .access-control-advanced-sharing { + box-sizing: border-box; + min-width: 0; + width: 100%; + max-width: 100%; + overflow-wrap: anywhere; + margin-top: 10px; + padding: 10px 15px; + position: relative; + padding-right: 48px; + + h2 { + margin: 0 0 4px; + color: var(--solid-ui-color-gray-900, #101828); + font-size: var(--solid-ui-font-size-md, 1rem); + font-weight: 600; + } + + p { + margin: 0; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + color: var(--solid-ui-color-gray-600, #4A5565); + font-size: var(--solid-ui-font-size-xs, .75rem); + line-height: 1.5; + } + + .access-control-advanced-sharing-description-line { + display: block; + max-width: 100%; + } + } + + .access-control-advanced-sharing-options, + .access-control-advanced-sharing-actions { + display: flex; + flex-wrap: wrap; + min-width: 0; + gap: 8px; + margin-top: 10px; + + solid-ui-button { + --solid-ui-font-size-md: var(--solid-ui-font-size-sm, .875rem); + min-width: 0; + max-width: 100%; + white-space: normal; + } + } + + .access-control-advanced-sharing-actions { + justify-content: flex-end; + position: absolute; + top: 10px; + right: 15px; + margin-top: 0; + + solid-ui-button { + color: var(--solid-ui-color-gray-600, #4A5565); + } + } + + p { + margin: 0; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + color: var(--solid-ui-color-gray-600, #4A5565); + line-height: 1.5; + } + } + + .access-control-sharing-state-panel, + .access-control-contents-prompt { + box-sizing: border-box; + min-width: 0; + max-width: 100%; + border: 1px solid var(--solid-ui-color-slate-200, #E2E8F0); + border-radius: 5px; + background: var(--solid-ui-color-white, #FFF); + padding: 10px 15px; + } + + .access-control-sharing-state-panel { + display: grid; + gap: 10px; + + p { + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + } + + .access-control-sharing-state-custom-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + + p { + flex: 1 1 auto; + } + + solid-ui-button { + flex: 0 0 auto; + --solid-ui-font-size-md: var(--solid-ui-font-size-sm, .875rem); + } + } + } + + .access-control-sharing-guidance { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + gap: 12px; + + p { + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + } + + solid-ui-button { + flex: 0 0 auto; + --solid-ui-font-size-md: var(--solid-ui-font-size-sm, .875rem); + } + } + + .access-control-sharing-state-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 0; + justify-content: flex-end; + margin-top: 10px; + gap: 8px; + + solid-ui-button { + --solid-ui-font-size-md: var(--solid-ui-font-size-sm, .875rem); + max-width: 100%; + } + } + + @media (max-width: 420px) { + .access-control-sharing-guidance { + align-items: flex-start; + flex-direction: column; + gap: 8px; + + solid-ui-button { + align-self: flex-end; + } + } + } + + .access-control-contents-prompt { + .access-control-contents-prompt-actions { + display: flex; + flex-wrap: wrap; + min-width: 0; + gap: 8px; + margin-top: 10px; + } + + solid-ui-button { + --solid-ui-font-size-md: var(--solid-ui-font-size-sm, .875rem); + max-width: 100%; + flex: 0 1 auto; + } + } + + .access-control-contents-prompt h2 { + margin-bottom: 4px; + } + .access-control-footer-actions { display: flex; justify-content: flex-end; diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index a4a634bcd..cc1a5446a 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -4,10 +4,13 @@ import type { PropertyValues } from 'lit' import { property, state } from 'lit/decorators.js' import { label } from '@/utils/label' import * as debug from '@/lib/debug' -import { Authenticated, Public, solidLogicSingleton, type AccessRole, type Authorization } from 'solid-logic' +import { Authenticated, Public, analyzeContainerAccessScope, filterAuthorizationsByContainerScope, solidLogicSingleton, type ACLContext, type AccessRole, type Authorization, type ContainerAccessScope, type ContainerAccessScopeResult } from 'solid-logic' import { sym } from 'rdflib' import '~icons/lucide/circle-x' +import '~icons/lucide/folder' +import '~icons/lucide/folder-open' +import '~icons/lucide/x' import '@/components/dialog' import '@/components/dialog-content' import '@/components/dialog-footer' @@ -19,8 +22,7 @@ import './AccessControlGeneralAccess' import './AccessControlErrors' import styles from './AccessControlModal.styles.css' -import type { AccessChange, AccessControlError, AccessGrantDraft, AccessGrantRoleChangedEvent, AccessSaveError, InputAccessControlError, PendingAccessGrant, PendingGrantRemovedEvent, RoleChangedEvent, TargetInputEvent, TargetSelectedEvent } from './types' -import { dedupeByKey } from './helpers' +import type { AccessChange, AccessControlError, AccessGrantDraft, AccessGrantRoleChangedEvent, AccessSaveError, AccessSaveScope, InputAccessControlError, PendingAccessGrant, PendingGrantRemovedEvent, RoleChangedEvent, TargetInputEvent, TargetSelectedEvent } from './types' import { createAccessGrantDrafts, getAccessGrantIds, getAccessGrantViewModels, getAuthenticatedAccessRole, getAuthorizationSubjectLabel, getChangedAccessGrants, getPublicAccessRole, getSharedAuthorization } from './accessGrantDrafts' import { accessPrincipalOptionsProvider, resolveAccessTarget } from './accessTargetResolver' import { applyAccessChanges } from './applyAccessChanges' @@ -71,10 +73,35 @@ export default class AccessControlModal extends DialogComponent { @state() private accessor successfulChangesRefreshed = true + @state() + private accessor accessContext: ACLContext | undefined + + @state() + private accessor accessContextLoading = false + + @state() + private accessor accessContextError = '' + + // Intentionally has no opener this release: scope selectors and reset are deferred. + @state() + private accessor advancedSharingOpen = false + + @state() + private accessor containerScope: ContainerAccessScope = 'folder+contents' + + @state() + private accessor containerScopeAnalysis: ContainerAccessScopeResult | undefined + + @state() + private accessor protectedTarget = false + private accessGrantLabelRequestVersion = 0 + private accessContextRequestVersion = 0 + private accessContextSubjectUri: string | undefined disconnectedCallback (): void { this.accessGrantLabelRequestVersion++ + this.accessContextRequestVersion++ this.errors = [] super.disconnectedCallback() } @@ -102,26 +129,49 @@ export default class AccessControlModal extends DialogComponent { super.willUpdate(changedProperties) if (changedProperties.has('accessGrants')) { - const accessGrants = this.accessGrants ?? [] - this.accessGrantDrafts = createAccessGrantDrafts(accessGrants) - this.authenticatedAccessRoleValue = getAuthenticatedAccessRole(accessGrants) - this.publicAccessRoleValue = getPublicAccessRole(accessGrants) + const scopedAccessGrants = this.getScopedAccessGrants() + this.accessGrantDrafts = createAccessGrantDrafts(scopedAccessGrants) + this.authenticatedAccessRoleValue = getAuthenticatedAccessRole(scopedAccessGrants) + this.publicAccessRoleValue = getPublicAccessRole(scopedAccessGrants) } } protected updated (changedProperties: PropertyValues) { super.updated(changedProperties) + if (changedProperties.has('subjectUri')) { + this.loadAccessContext().catch(error => { + debug.error('Failed to load access control context', error) + }) + } + if (changedProperties.has('accessGrants')) { this.refreshAccessGrantLabels().catch((error: unknown) => { debug.error('Failed to refresh access grant labels', error) }) } + + if (changedProperties.has('accessContext' as keyof AccessControlModal) && !changedProperties.has('subjectUri')) { + this.refreshContainerScopeAnalysis() + } + } + + private refreshContainerScopeAnalysis () { + if (this.isContainer() && this.accessContext) { + this.containerScopeAnalysis = analyzeContainerAccessScope(this.accessContext) + this.containerScope = this.containerScopeAnalysis.primaryScope + this.refreshScopedAccessGrantDrafts() + } else { + this.containerScopeAnalysis = undefined + } } private async refreshAccessGrantLabels () { const requestVersion = ++this.accessGrantLabelRequestVersion - const grants = this.accessGrants ?? [] + const sourceAccessGrants = this.accessGrants + const subjectUri = this.subjectUri + const scope = this.getSelectedContainerScope() + const grants = this.getScopedAccessGrants(scope) if (!grants.length) { this.accessGrantDrafts = [] return @@ -139,7 +189,12 @@ export default class AccessControlModal extends DialogComponent { return getAuthorizationSubjectLabel(getSharedAuthorization(authorization)) })) - if (requestVersion === this.accessGrantLabelRequestVersion && this.accessGrants === grants) { + if ( + requestVersion === this.accessGrantLabelRequestVersion && + this.accessGrants === sourceAccessGrants && + this.subjectUri === subjectUri && + this.getSelectedContainerScope() === scope + ) { const labelsById = new Map(getAccessGrantIds(grants).map((id, index) => [id, labels[index]])) this.accessGrantDrafts = this.accessGrantDrafts.map(draft => ({ ...draft, @@ -153,10 +208,298 @@ export default class AccessControlModal extends DialogComponent { const subjectLabel = subject ? label(subject).trim() : '' if (!subjectLabel || subjectLabel === 'this resource') { - return 'Share this resource' + return 'Manage Access of "this resource"' + } + return `Manage Access of "${subjectLabel}"` + } + + private isContainer (): boolean { + return Boolean(this.subjectUri && new URL(this.subjectUri).pathname.endsWith('/')) + } + + private async loadAccessContext (): Promise { + const requestVersion = ++this.accessContextRequestVersion + const subjectUri = this.subjectUri + this.accessContext = undefined + this.accessContextSubjectUri = undefined + this.accessContextError = '' + this.accessContextLoading = Boolean(subjectUri) + + if (!subjectUri) { + this.accessContextLoading = false + return } - return `Share "${subjectLabel}"` + try { + const accessContext = await solidLogicSingleton.acl.findEffectiveAcl(subjectUri) + if (requestVersion !== this.accessContextRequestVersion || this.subjectUri !== subjectUri) { + return + } + + this.accessContext = accessContext + this.accessContextSubjectUri = subjectUri + if (this.accessContext?.inherited && !(this.accessGrants?.length)) { + // findAccessGrants only reports ACLs attached directly to the resource. + // Inherited permissions are the effective grants users need to review. + this.accessGrants = this.accessContext.authorizations + } + this.refreshContainerScopeAnalysis() + this.protectedTarget = solidLogicSingleton.acl.isProtectedResource(subjectUri) + if (this.protectedTarget) { + this.refreshScopedAccessGrantDrafts() + } + } catch (error) { + if (requestVersion !== this.accessContextRequestVersion || this.subjectUri !== subjectUri) { + return + } + + this.accessContextError = error instanceof Error && error.message.trim() + ? error.message + : 'Unable to load access settings for this resource.' + debug.error('Failed to load access control context', error) + } finally { + if (requestVersion === this.accessContextRequestVersion && this.subjectUri === subjectUri) { + this.accessContextLoading = false + } + } + } + + private async ensureAccessContext (): Promise { + if (!this.subjectUri) { + return false + } + + // Direct method calls are used by consumers and unit tests before the + // element is connected; the rendered controls are always guarded below. + if (!this.isConnected) { + return true + } + + if (this.accessContextSubjectUri === this.subjectUri && this.accessContext && !this.accessContextError) { + return true + } + + await this.loadAccessContext() + return this.accessContextSubjectUri === this.subjectUri && Boolean(this.accessContext) && !this.accessContextError + } + + private canMutate (): boolean { + if (!this.isConnected) { + return true + } + + if (!this.subjectUri) { + return true + } + + return Boolean( + this.subjectUri && + this.accessContextSubjectUri === this.subjectUri && + this.accessContext && + !this.accessContextLoading && + !this.accessContextError + ) + } + + private async retryAccessContext () { + await this.loadAccessContext() + } + + private renderSharingInformation () { + if (!this.accessContext) { + return html`` + } + + const isCustomSharing = !this.accessContext.inherited + const inheritedContainer = this.accessContext?.effectiveContainer + const inheritedContainerLabel = inheritedContainer + ? label(sym(inheritedContainer)).trim() + : '' + const defaultContainer = inheritedContainerLabel && inheritedContainerLabel !== inheritedContainer + ? html`${inheritedContainerLabel} (${inheritedContainer})` + : html`${inheritedContainer ?? 'the parent container'}` + return html` + + ` + } + + private renderSharingState () { + if (!this.accessContext || !this.advancedSharingOpen) { + return html`` + } + + return html` + + ` + } + + private getSelectedContainerScope (): ContainerAccessScope { + return this.containerScope + } + + private hasMixedContainerPolicy (): boolean { + return this.isContainer() && this.containerScopeAnalysis?.mixed === true + } + + private renderAccessContextState () { + if (this.accessContextLoading) { + return html`

Loading access settings...

` + } + + if (this.accessContextError) { + return html` + + + Retry + + + ` + } + + if (!this.accessContext) { + return html`

Access settings are not ready yet.

` + } + + return html`` + } + + private renderMixedContainerWarning () { + if (!this.hasMixedContainerPolicy()) { + return html`` + } + + return html` + + ` + } + + private getScopedAccessGrants (scope = this.getSelectedContainerScope()): Authorization[] { + const grants = this.accessGrants ?? [] + if (!this.isContainer() || !this.subjectUri || this.protectedTarget) { + return grants + } + return filterAuthorizationsByContainerScope(this.subjectUri, grants, scope, this.accessContext?.inherited ?? false) + } + + private refreshScopedAccessGrantDrafts () { + const scopedAccessGrants = this.getScopedAccessGrants() + this.accessGrantDrafts = createAccessGrantDrafts(scopedAccessGrants) + this.authenticatedAccessRoleValue = getAuthenticatedAccessRole(scopedAccessGrants) + this.publicAccessRoleValue = getPublicAccessRole(scopedAccessGrants) + } + + private selectContainerScope (scope: 'folder+contents' | 'contents' | 'folder') { + this.containerScope = scope + this.refreshScopedAccessGrantDrafts() + } + + private renderAdvancedSharing () { + if (!this.advancedSharingOpen) return html`` + + return html` +
+

Advanced sharing

+

+ Use this option when the folder and its contents have different access requirements. + Choose which access settings to edit. + Save Changes applies your current edits only to that scope. +

+ ${this.isContainer() + ? html` + +
+ { this.selectContainerScope('folder') }} + > + Folder Only + + { this.selectContainerScope('contents') }} + > + Contents Only + +
+ ` + : html``} +
+ + + Close + +
+
+ ` } protected render () { @@ -166,7 +509,10 @@ export default class AccessControlModal extends DialogComponent {
- { @pending-grant-removed=${this.onPendingGrantRemoved} > { .successfulChangesRefreshed=${this.successfulChangesRefreshed} @close=${this.onCancelClick} > + ${this.renderSharingState()} @@ -204,7 +553,7 @@ export default class AccessControlModal extends DialogComponent { Cancel 0} + ?disabled=${!this.canMutate() || !this.hasUnsavedChanges() || this.submitting || this.saveErrors.length > 0 || this.hasMixedContainerPolicy()} ?loading=${this.submitting} type="button" @click=${this.onSaveClick} @@ -221,7 +570,11 @@ export default class AccessControlModal extends DialogComponent { private async onSubmit (e: Event) { e.preventDefault() - if (this.submitting) { + if (this.submitting || this.hasMixedContainerPolicy()) { + return + } + + if (this.isConnected && !await this.ensureAccessContext()) { return } @@ -229,7 +582,11 @@ export default class AccessControlModal extends DialogComponent { } private async onSaveClick () { - if (this.submitting) { + if (this.submitting || this.hasMixedContainerPolicy()) { + return + } + + if (this.isConnected && !await this.ensureAccessContext()) { return } @@ -241,15 +598,47 @@ export default class AccessControlModal extends DialogComponent { } } - await this.saveAccessChanges() + const scope = this.isContainer() && !this.protectedTarget + ? this.getSelectedContainerScope() + : 'file' + await this.saveAccessChanges(scope === 'folder' ? 'container' : scope) } private onCancelClick () { this.close() } - private async saveAccessChanges () { - if (this.submitting) { + private closeAdvancedSharing () { + this.advancedSharingOpen = false + } + + private async onResetToDefault () { + if (!this.subjectUri || !this.canMutate() || !this.accessContext || this.accessContext.inherited || this.submitting || this.hasMixedContainerPolicy()) return + + this.submitting = true + this.errors = this.errors.filter(error => error.action === 'input') + try { + await solidLogicSingleton.acl.resetToDefault(this.subjectUri) + await this.loadAccessContext() + const directAccessGrants = await solidLogicSingleton.acl.findAccessGrants(this.subjectUri) + this.accessGrants = this.accessContext?.inherited + ? this.accessContext.authorizations + : directAccessGrants + } catch (error) { + this.errors = [...this.errors, { + action: 'reset', + targetUri: this.subjectUri, + label: 'Custom sharing', + message: error instanceof Error ? error.message : 'Unable to reset sharing.' + }] + debug.error('Failed to reset access control', error) + } finally { + this.submitting = false + } + } + + private async saveAccessChanges (scope: AccessSaveScope = 'file') { + if (this.submitting || !this.canMutate() || this.hasMixedContainerPolicy()) { return } @@ -275,7 +664,9 @@ export default class AccessControlModal extends DialogComponent { changedAccessGrants }) - const saveErrors = await applyAccessChanges(this.subjectUri, accessChanges) + const saveErrors = await applyAccessChanges(this.subjectUri, accessChanges, { + containerScope: scope + }) if (saveErrors.length) { this.errors = [...this.errors, ...saveErrors] this.principalInputValue = '' @@ -324,12 +715,14 @@ export default class AccessControlModal extends DialogComponent { } private onPrincipalInput (event: TargetInputEvent) { + if (!this.canMutate() || this.hasMixedContainerPolicy()) return this.principalInputValue = event.detail.value this.clearInputError() } private onPrincipalSelect (event: TargetSelectedEvent) { - this.queuePendingPrincipal(event.detail.value, this.addAccessRoleValue, event.detail.label) + if (!this.canMutate() || this.hasMixedContainerPolicy()) return + this.queuePendingPrincipal(event.detail.value, this.addAccessRoleValue, event.detail.label, undefined, event.detail.targetKind) } private getGeneralAccessChanges (): AccessChange[] { @@ -337,7 +730,7 @@ export default class AccessControlModal extends DialogComponent { return [] } - const accessGrants = this.accessGrants ?? [] + const accessGrants = this.getScopedAccessGrants() const currentAuthenticatedRole = getAuthenticatedAccessRole(accessGrants) const currentPublicRole = getPublicAccessRole(accessGrants) @@ -361,6 +754,7 @@ export default class AccessControlModal extends DialogComponent { } private onAddAccessRoleInput (event: RoleChangedEvent) { + if (!this.canMutate() || this.hasMixedContainerPolicy()) return this.addAccessRoleValue = event.detail.role this.pendingAccessGrants = this.pendingAccessGrants.map(grant => ({ ...grant, @@ -369,14 +763,17 @@ export default class AccessControlModal extends DialogComponent { } private onAuthenticatedAccessRoleInput (event: RoleChangedEvent) { + if (!this.canMutate() || this.hasMixedContainerPolicy()) return this.authenticatedAccessRoleValue = event.detail.role } private onPublicAccessRoleInput (event: RoleChangedEvent) { + if (!this.canMutate() || this.hasMixedContainerPolicy()) return this.publicAccessRoleValue = event.detail.role } private onAccessGrantRoleInput (event: AccessGrantRoleChangedEvent) { + if (!this.canMutate() || this.hasMixedContainerPolicy()) return this.accessGrantDrafts = this.accessGrantDrafts.map(draft => draft.id === event.detail.id ? { ...draft, role: event.detail.role } @@ -385,6 +782,7 @@ export default class AccessControlModal extends DialogComponent { } private onPendingGrantRemoved (event: PendingGrantRemovedEvent) { + if (!this.canMutate() || this.hasMixedContainerPolicy()) return this.pendingAccessGrants = this.pendingAccessGrants.filter((_, pendingIndex) => pendingIndex !== event.detail.index) } @@ -398,6 +796,10 @@ export default class AccessControlModal extends DialogComponent { } private async commitPrincipalFromInput (): Promise { + if (!this.canMutate() || this.hasMixedContainerPolicy()) { + return false + } + const rawValue = this.principalInputValue.trim() if (!rawValue) { return false @@ -432,10 +834,11 @@ export default class AccessControlModal extends DialogComponent { principal: string, role: AccessRole = this.addAccessRoleValue, preferredLabel?: string, - inputSnapshot?: string + inputSnapshot?: string, + targetKind?: 'origin' | 'trustedApp' ): Promise { const snapshot = inputSnapshot ?? this.principalInputValue.trim() - const result = await this.createPendingAccessGrant(principal, role, preferredLabel) + const result = await this.createPendingAccessGrant(principal, role, preferredLabel, targetKind) if ('error' in result) { if (this.principalInputValue.trim() === snapshot) { @@ -459,7 +862,8 @@ export default class AccessControlModal extends DialogComponent { private async createPendingAccessGrant ( principal: string, role: AccessRole = this.addAccessRoleValue, - preferredLabel?: string + preferredLabel?: string, + targetKind?: 'origin' | 'trustedApp' ): Promise { try { const normalizedPrincipal = principal.trim() @@ -480,7 +884,8 @@ export default class AccessControlModal extends DialogComponent { subjectType: resolvedTarget.subjectType, subjectValue: resolvedTarget.subjectValue, role, - label: resolvedTarget.label + label: resolvedTarget.label, + targetKind: targetKind ?? resolvedTarget.targetKind } } } catch (error) { @@ -498,6 +903,10 @@ export default class AccessControlModal extends DialogComponent { } private dedupePendingAccessGrants (draftGrants: PendingAccessGrant[]): PendingAccessGrant[] { - return dedupeByKey(draftGrants, draftGrant => `${draftGrant.subjectType}:${draftGrant.subjectValue}`) + const grantsBySubject = new Map() + for (const draftGrant of draftGrants) { + grantsBySubject.set(`${draftGrant.subjectType}:${draftGrant.subjectValue}`, draftGrant) + } + return [...grantsBySubject.values()] } } diff --git a/src/components/access-control-modal/AccessControlSharedAccess.ts b/src/components/access-control-modal/AccessControlSharedAccess.ts index ce5f2828e..1217f0938 100644 --- a/src/components/access-control-modal/AccessControlSharedAccess.ts +++ b/src/components/access-control-modal/AccessControlSharedAccess.ts @@ -8,6 +8,7 @@ import { sym } from 'rdflib' import '@/components/combobox' import '@/components/combobox-option' +import '~icons/lucide/globe' import styles from './AccessControlSharedAccess.styles.css' import type { AccessGrantRoleChangedDetail, AccessGrantViewModel } from './types' @@ -19,6 +20,9 @@ export default class AccessControlSharedAccess extends WebComponent { @property({ attribute: false }) accessor accessGrantViewModels: AccessGrantViewModel[] = [] + @property({ type: Boolean }) + accessor disabled = false + @state() private accessor searchValue = '' @@ -38,13 +42,16 @@ export default class AccessControlSharedAccess extends WebComponent { private onAccessGrantRoleChange (id: string, event: ComboboxChangeEvent) { const value = event.detail.option.value - if (typeof value !== 'string' || !ACCESS_ROLES.includes(value as AccessRole)) { + const accessRole = typeof value === 'string' + ? ACCESS_ROLES.find(candidate => candidate === value) + : undefined + if (!accessRole) { return } const detail: AccessGrantRoleChangedDetail = { id, - role: value as AccessRole + role: accessRole } this.dispatchEvent(new CustomEvent('access-grant-role-changed', { @@ -83,6 +90,8 @@ export default class AccessControlSharedAccess extends WebComponent {
${badge.image ? html`` + : badge.kind === 'origin' + ? html`` : html``}
` @@ -123,7 +132,8 @@ export default class AccessControlSharedAccess extends WebComponent { if (authorization.origin[0]) { return { kind: 'origin' as const, - text: 'O' + text: 'O', + image: '' } } @@ -143,6 +153,7 @@ export default class AccessControlSharedAccess extends WebComponent { return html` this.onAccessGrantRoleChange(id, event)} > @@ -163,7 +174,7 @@ export default class AccessControlSharedAccess extends WebComponent { return html`
-

Share with

+

Shared with

`${subject.type}:${subject.iri}`) .sort() @@ -93,13 +97,17 @@ export function getChangedAccessGrants (drafts: AccessGrantDraft[]): ChangedAcce } export function getAuthenticatedAccessRole (accessGrants: AccessAuthorization[]): AccessRole { - const authorization = accessGrants.find(item => item.agentClass.includes(Authenticated.iri)) - return authorization ? getAuthorizationRole(authorization) : 'No Access' + const modes = accessGrants + .filter(item => item.agentClass.includes(Authenticated.iri)) + .flatMap(item => item.mode) + return modes.length ? solidLogicSingleton.acl.roleFromModes(modes) : 'No Access' } export function getPublicAccessRole (accessGrants: AccessAuthorization[]): AccessRole { - const authorization = accessGrants.find(item => item.agentClass.includes(Public.iri)) - return authorization ? solidLogicSingleton.acl.publicRoleFromModes(authorization.mode) : 'No Access' + const modes = accessGrants + .filter(item => item.agentClass.includes(Public.iri)) + .flatMap(item => item.mode) + return modes.length ? solidLogicSingleton.acl.publicRoleFromModes(modes) : 'No Access' } export function getAccessSubjectLabel (subject: AccessSubject): string { diff --git a/src/components/access-control-modal/accessTargetResolver.ts b/src/components/access-control-modal/accessTargetResolver.ts index 398431477..2ad4a7382 100644 --- a/src/components/access-control-modal/accessTargetResolver.ts +++ b/src/components/access-control-modal/accessTargetResolver.ts @@ -3,19 +3,33 @@ import { directoryEntryOptionTemplate } from './AccessControlAddAccess' import { label } from '@/utils/label' import { DEFAULT_DIRECTORY_SOURCES, solidLogicSingleton, type AccessControlSubjectKind, type DirectoryEntry } from 'solid-logic' import { sym } from 'rdflib' +import { html } from 'lit' +import ns from '@/lib/ns' +import '~icons/lucide/globe' import { dedupeByKey, dedupeComboboxOptions, isAbsoluteHttpUri, isOriginUrl, normalizeOriginInput, type StringComboboxOptionData } from './helpers' export type ResolvedAccessTarget = { subjectType: AccessControlSubjectKind subjectValue: string label: string + targetKind?: 'origin' | 'trustedApp' +} + +type AccessTargetOption = ComboboxOptionData & { + targetKind?: 'origin' | 'trustedApp' } async function classifyAccessTarget (principal: string) { const normalizedOrigin = normalizeOriginInput(principal) - return normalizedOrigin - ? { kind: 'origin' as const, subjectValue: normalizedOrigin } - : await solidLogicSingleton.acl.classifyAccessControlSubject(principal) + if (normalizedOrigin) { + return { kind: 'origin' as const, subjectValue: normalizedOrigin } + } + + if (isOriginUrl(principal)) { + return { kind: 'origin' as const, subjectValue: new URL(principal).origin } + } + + return await solidLogicSingleton.acl.classifyAccessControlSubject(principal) } async function resolveAccessTargetLabel ( @@ -30,7 +44,7 @@ async function resolveAccessTargetLabel ( await solidLogicSingleton.store.fetcher.load(subject.doc()) const resolvedLabel = preferredLabel?.trim() || label(subject).trim() if (!resolvedLabel || resolvedLabel === resolvedPrincipal.subjectValue) { - return undefined + throw new Error('The access target has no usable display label.') } return resolvedLabel @@ -53,7 +67,8 @@ export async function resolveAccessTarget ( return { subjectType: resolvedPrincipal.kind, subjectValue: resolvedPrincipal.subjectValue, - label: resolvedLabel + label: resolvedLabel, + targetKind: resolvedPrincipal.kind === 'origin' ? 'origin' : undefined } } @@ -72,12 +87,14 @@ function directoryEntryToOption (entry: DirectoryEntry, labelValue: string): Com } } -async function getPreferredAccessPrincipalOptions (query: string): Promise { +async function getPreferredAccessPrincipalOptions (query: string): Promise { const normalizedOrigin = normalizeOriginInput(query) if (normalizedOrigin) { return [{ label: `Use origin ${normalizedOrigin}`, - value: normalizedOrigin + value: normalizedOrigin, + targetKind: 'origin', + template: html`Use origin ${normalizedOrigin}` }] } @@ -88,7 +105,9 @@ async function getPreferredAccessPrincipalOptions (query: string): PromiseUse origin ${new URL(query).origin}` }] } @@ -96,6 +115,30 @@ async function getPreferredAccessPrincipalOptions (query: string): Promise { + try { + const me = solidLogicSingleton.authn.currentUser() + if (!me) return [] + await solidLogicSingleton.profile.loadProfile(me) + const store = solidLogicSingleton.store + const apps = store.each(me, ns.acl('trustedApp')) as Array> + return apps.flatMap(app => { + const origin = store.any(app, ns.acl('origin')) + if (!origin || !isOriginUrl(origin.value)) return [] + const normalizedOrigin = new URL(origin.value).origin + if (query && !`${normalizedOrigin} trusted app`.toLowerCase().includes(query.toLowerCase())) return [] + return [{ + label: `Trusted app: ${normalizedOrigin}`, + value: normalizedOrigin, + targetKind: 'trustedApp' as const, + template: html`Trusted app: ${normalizedOrigin}` + }] + }) + } catch { + return [] + } +} + function getShortQueryOptions (preferredOptions: ComboboxOptionData[]): ComboboxOptionData[] { if (preferredOptions.length) { return preferredOptions @@ -112,9 +155,18 @@ export const accessPrincipalOptionsProvider: AsyncComboboxOptionsProvider = defineAsyncComboboxOptionsProvider(async (filter: string) => { const query = filter.trim() const preferredOptions = await getPreferredAccessPrincipalOptions(query) + const trustedAppOptions = await getTrustedAppOptions(query) if (query.length < 2) { - return getShortQueryOptions(preferredOptions) + return dedupeByKey( + [...preferredOptions, ...trustedAppOptions, ...getShortQueryOptions(preferredOptions)], + option => { + const targetOption = option as AccessTargetOption + return typeof targetOption.value === 'string' + ? `${targetOption.value}:${targetOption.targetKind ?? 'default'}` + : undefined + } + ) } let entries: DirectoryEntry[] = [] @@ -125,7 +177,7 @@ export const accessPrincipalOptionsProvider: AsyncComboboxOptionsProvider = sources: DEFAULT_DIRECTORY_SOURCES }) } catch (error) { - if (!preferredOptions.length) { + if (!preferredOptions.length && !trustedAppOptions.length) { throw error } } @@ -142,7 +194,12 @@ export const accessPrincipalOptionsProvider: AsyncComboboxOptionsProvider = .filter((entry): entry is { entry: DirectoryEntry, label: string } => entry !== undefined) .map(({ entry, label: entryLabel }) => directoryEntryToOption(entry, entryLabel)) return dedupeByKey( - [...preferredOptions, ...options], - option => typeof option.value === 'string' ? option.value : undefined + [...preferredOptions, ...trustedAppOptions, ...options], + option => { + const targetOption = option as AccessTargetOption + return typeof targetOption.value === 'string' + ? `${targetOption.value}:${targetOption.targetKind ?? 'default'}` + : undefined + } ) }) diff --git a/src/components/access-control-modal/applyAccessChanges.ts b/src/components/access-control-modal/applyAccessChanges.ts index 084514195..685303ba2 100644 --- a/src/components/access-control-modal/applyAccessChanges.ts +++ b/src/components/access-control-modal/applyAccessChanges.ts @@ -1,19 +1,28 @@ import { PUBLIC_ACCESS_ROLES, Public, solidLogicSingleton } from 'solid-logic' -import type { AccessChange, AccessSaveError } from './types' +import type { AccessChange, AccessSaveError, AccessSaveScope } from './types' export async function applyAccessChanges ( subjectUri: string, - accessChanges: AccessChange[] + accessChanges: AccessChange[], + options: { containerScope?: AccessSaveScope } = {} ): Promise { const errors: AccessSaveError[] = [] + const scope = options.containerScope ?? 'file' for (const { subject, label, role } of accessChanges) { try { + const grantPlanner = scope === 'folder+contents' + ? solidLogicSingleton.acl.planGrantContainerAndContents + : scope === 'container' + ? solidLogicSingleton.acl.planGrantContainer + : scope === 'contents' + ? solidLogicSingleton.acl.planGrantContainerContents + : solidLogicSingleton.acl.planGrant const plan = role === 'No Access' - ? await solidLogicSingleton.acl.planRevoke(subjectUri, subject) - : await solidLogicSingleton.acl.planGrant(subjectUri, subject, subject.type === 'agentClass' && subject.iri === Public.iri - ? solidLogicSingleton.acl.modesFromPublicRole(role as (typeof PUBLIC_ACCESS_ROLES)[number]) - : solidLogicSingleton.acl.modesFromRole(role)) + ? await (scope === 'contents' + ? solidLogicSingleton.acl.planRevokeContainerContents(subjectUri, subject) + : solidLogicSingleton.acl.planRevoke(subjectUri, subject)) + : await grantPlanner(subjectUri, subject, getModes(subject, role)) await solidLogicSingleton.acl.applyPlan(plan) } catch (error) { @@ -22,6 +31,7 @@ export async function applyAccessChanges ( targetUri: subject.iri, label, role, + scope, message: error instanceof Error ? error.message : 'Unable to apply access change.' @@ -31,3 +41,15 @@ export async function applyAccessChanges ( return errors } + +function getModes (subject: AccessChange['subject'], role: AccessChange['role']) { + if (subject.type === 'agentClass' && subject.iri === Public.iri) { + const publicRole = PUBLIC_ACCESS_ROLES.find(candidate => candidate === role) + if (!publicRole) { + throw new Error(`Public access does not support the ${role} role.`) + } + return solidLogicSingleton.acl.modesFromPublicRole(publicRole) + } + + return solidLogicSingleton.acl.modesFromRole(role) +} diff --git a/src/components/access-control-modal/types.ts b/src/components/access-control-modal/types.ts index 43b376834..def7c1835 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -4,6 +4,7 @@ export type AccessAuthorization = Omit< Pick, 'mode' > & { + id?: string mode: readonly AccessMode[] } @@ -14,6 +15,7 @@ export type TargetInputDetail = { export type TargetSelectedDetail = { value: string label: string + targetKind?: 'origin' | 'trustedApp' } export type RoleChangedDetail = { @@ -41,6 +43,7 @@ export type PendingAccessGrant = { subjectValue: string role: AccessRole label: string + targetKind?: 'origin' | 'trustedApp' } export type AccessGrantEntry = { @@ -74,6 +77,8 @@ export type AccessChange = { label: string } +export type AccessSaveScope = 'folder+contents' | 'container' | 'contents' | 'file' + export type InputAccessControlError = { action: 'input' targetUri: string @@ -86,9 +91,10 @@ export type AccessSaveError = { targetUri: string label: string role: AccessRole + scope: AccessSaveScope message: string } | { - action: 'save' + action: 'save' | 'reset' targetUri: string label: string message: string diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index 864e79468..3793e4bdc 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -154,6 +154,7 @@ export default class Combobox extends FormControlComponent { aria-required=${this.required ? 'true' : nothing} autocomplete="off" spellcheck="false" + ?disabled=${this.disabled} ?required=${this.required} .value=${this.displayValue} @keydown=${this.onInputKeyDown} diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index 3e365ef8d..026bcdd4d 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -6,11 +6,17 @@ import type { AccessMode } from 'solid-logic' import { isAbsoluteHttpUri, isOriginUrl } from '../../src/components/access-control-modal/helpers' const planGrant = vi.fn() +const planGrantContainer = vi.fn() +const planGrantContainerContents = vi.fn() +const planGrantContainerAndContents = vi.fn() const planRevoke = vi.fn() const planPublicRead = vi.fn() const applyPlan = vi.fn() const classifyAccessControlSubject = vi.fn() const findAccessGrants = vi.fn() +const findEffectiveAcl = vi.fn() +const resetToDefault = vi.fn() +const isProtectedResource = vi.fn() const load = vi.fn() vi.mock('@/components/combobox', () => ({ @@ -30,10 +36,16 @@ vi.mock('solid-logic', async (importOriginal) => { ...actual.solidLogicSingleton.acl, classifyAccessControlSubject, planGrant, + planGrantContainer, + planGrantContainerContents, + planGrantContainerAndContents, planPublicRead, planRevoke, applyPlan, - findAccessGrants + findAccessGrants, + findEffectiveAcl, + resetToDefault, + isProtectedResource }, store: { ...actual.solidLogicSingleton.store, @@ -53,20 +65,32 @@ vi.mock('solid-logic', async (importOriginal) => { describe('AccessControlModal submit', () => { beforeEach(async () => { planGrant.mockReset() + planGrantContainer.mockReset() + planGrantContainerContents.mockReset() + planGrantContainerAndContents.mockReset() planRevoke.mockReset() planPublicRead.mockReset() applyPlan.mockReset() classifyAccessControlSubject.mockReset() findAccessGrants.mockReset() + findEffectiveAcl.mockReset() + resetToDefault.mockReset() + isProtectedResource.mockReset() load.mockReset() classifyAccessControlSubject.mockImplementation(async (principal: string) => ({ kind: 'agent' as const, subjectValue: principal })) planGrant.mockResolvedValue({ target: 'https://example.com/resource.ttl.acl', deletes: [], inserts: [] }) + planGrantContainer.mockResolvedValue({ target: 'https://example.com/container/.acl', deletes: [], inserts: [] }) + planGrantContainerContents.mockResolvedValue({ target: 'https://example.com/container/.acl', deletes: [], inserts: [] }) + planGrantContainerAndContents.mockResolvedValue({ target: 'https://example.com/container/.acl', deletes: [], inserts: [] }) planPublicRead.mockResolvedValue({ target: 'https://example.com/resource.ttl.acl', deletes: [], inserts: [] }) applyPlan.mockResolvedValue(new Response('ok', { status: 200 })) findAccessGrants.mockResolvedValue([]) + findEffectiveAcl.mockResolvedValue({ inherited: true, authorizations: [] }) + resetToDefault.mockResolvedValue(new Response(null, { status: 204 })) + isProtectedResource.mockReturnValue(false) load.mockResolvedValue(undefined) await import('../../src/components/access-control-modal/AccessControlModal') await import('../../src/components/access-control-modal/AccessControlErrors') @@ -311,6 +335,73 @@ describe('AccessControlModal submit', () => { expect(element.pendingAccessGrants).toEqual([]) }) + it('does not apply changes while the effective ACL context is loading', async () => { + let resolveContext!: (context: { inherited: boolean, authorizations: [] }) => void + findEffectiveAcl.mockImplementationOnce(() => new Promise(resolve => { + resolveContext = resolve + })) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://alice.example.com/profile/card.ttl#me', + role: 'Editor', + label: 'Alice' + }] + document.body.appendChild(element) + await element.updateComplete + + const saveButton = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .find((button: Element) => button.textContent?.includes('Save Changes')) as HTMLElement + expect(saveButton.hasAttribute('disabled')).toBe(true) + + const savePromise = element.onSaveClick() + await Promise.resolve() + expect(applyPlan).not.toHaveBeenCalled() + + resolveContext({ inherited: true, authorizations: [] }) + await savePromise + + expect(applyPlan).toHaveBeenCalledTimes(1) + document.body.removeChild(element) + }) + + it('shows a retry action when the effective ACL context fails to load', async () => { + findEffectiveAcl.mockRejectedValueOnce(new Error('ACL service unavailable')) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://alice.example.com/profile/card.ttl#me', + role: 'Editor', + label: 'Alice' + }] + document.body.appendChild(element) + await element.updateComplete + await vi.waitFor(() => { + expect(element.accessContextError).toBe('ACL service unavailable') + }) + + const errorState = element.shadowRoot.querySelector('solid-ui-error-state') as any + expect(errorState.message).toBe('ACL service unavailable') + const retryButton = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .find((button: Element) => button.textContent?.includes('Retry')) as HTMLElement + expect(retryButton).not.toBeUndefined() + expect(applyPlan).not.toHaveBeenCalled() + + findEffectiveAcl.mockResolvedValueOnce({ inherited: true, authorizations: [] }) + retryButton.click() + await vi.waitFor(() => { + expect(element.accessContext).toBeDefined() + }) + + expect(element.accessContextError).toBe('') + expect(element.accessContext).toBeDefined() + document.body.removeChild(element) + }) + it('passes a queued origin grant to planGrant on save', async () => { const element = document.createElement('solid-ui-access-control-modal') as any element.subjectUri = 'https://example.com/resource.ttl' @@ -787,7 +878,7 @@ describe('AccessControlModal submit', () => { it('uses the default share title when no subject uri is present', async () => { const element = document.createElement('solid-ui-access-control-modal') as any - expect(element.getDialogTitle()).toBe('Share this resource') + expect(element.getDialogTitle()).toBe('Manage Access of "this resource"') }) it('hides no access in the add selector, keeps it in the authenticated selector, and exposes dokieli public roles', async () => { @@ -969,6 +1060,78 @@ describe('AccessControlModal submit', () => { document.body.removeChild(element) }) + it('refreshes labels for a filtered container grant', async () => { + const folder = 'https://example.com/folder/' + const authorization = { + id: `${folder}.acl#group`, + agent: [], + agentGroup: ['https://example.com/groups/team'], + agentClass: [], + origin: [], + mode: ['Read'], + accessTo: [folder], + default: [folder] + } + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = folder + element.accessContext = { resource: folder, inherited: false, authorizations: [authorization] } + element.accessGrants = [authorization] + document.body.appendChild(element) + await element.updateComplete + element.accessGrantDrafts = [{ + id: authorization.id, + authorization, + role: 'Viewer', + initialRole: 'Viewer', + subjectLabel: 'stale label', + subjects: [{ + type: 'agentGroup', + iri: authorization.agentGroup[0] + }] + }] + + await element.refreshAccessGrantLabels() + + expect(element.accessGrantDrafts[0].subjectLabel).not.toBe('stale label') + document.body.removeChild(element) + }) + + it('ignores an access context that resolves after the resource changes', async () => { + let resolveFirst!: (context: any) => void + let resolveSecond!: (context: any) => void + findEffectiveAcl + .mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve })) + .mockImplementationOnce(() => new Promise(resolve => { resolveSecond = resolve })) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/first.ttl' + const firstLoad = element.loadAccessContext() + element.subjectUri = 'https://example.com/second.ttl' + const secondLoad = element.loadAccessContext() + + resolveSecond({ resource: 'https://example.com/second.ttl', inherited: true, authorizations: [] }) + await secondLoad + resolveFirst({ resource: 'https://example.com/first.ttl', inherited: false, authorizations: [] }) + await firstLoad + + expect(element.accessContext.resource).toBe('https://example.com/second.ttl') + }) + + it('replaces a duplicate pending principal with its latest role', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + + await element.queuePendingPrincipal('https://alice.example.com/profile/card.ttl#me', 'Viewer') + await element.queuePendingPrincipal('https://alice.example.com/profile/card.ttl#me', 'Editor') + + expect(element.pendingAccessGrants).toEqual([ + expect.objectContaining({ + subjectValue: 'https://alice.example.com/profile/card.ttl#me', + role: 'Editor' + }) + ]) + }) + it('persists a changed shared access grant on save', async () => { const element = document.createElement('solid-ui-access-control-modal') as any element.subjectUri = 'https://example.com/resource.ttl' @@ -1066,4 +1229,542 @@ describe('AccessControlModal submit', () => { document.body.removeChild(element) }) + + it('saves a file directly without an apply-to-contents prompt', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://example.com/alice#me', + role: 'Viewer', + label: 'Alice' + }] + + await element.onSaveClick() + + expect(planGrant).toHaveBeenCalledWith( + element.subjectUri, + { type: 'agent', iri: 'https://example.com/alice#me' }, + ['Read'] + ) + }) + + it('uses the common container-and-contents scope by default', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://example.com/alice#me', + role: 'Viewer', + label: 'Alice' + }] + + await element.onSaveClick() + + expect(planGrant).not.toHaveBeenCalled() + expect(planGrantContainer).not.toHaveBeenCalled() + expect(planGrantContainerContents).not.toHaveBeenCalled() + expect(planGrantContainerAndContents).toHaveBeenCalledWith( + element.subjectUri, + { type: 'agent', iri: 'https://example.com/alice#me' }, + ['Read'] + ) + }) + + it('uses the advanced folder-access scope when selected', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://example.com/alice#me', + role: 'Viewer', + label: 'Alice' + }] + + element.advancedSharingOpen = true + element.containerScope = 'folder' + await element.onSaveClick() + + expect(planGrantContainer).toHaveBeenCalledWith( + element.subjectUri, + { type: 'agent', iri: 'https://example.com/alice#me' }, + ['Read'] + ) + expect(planGrantContainerContents).not.toHaveBeenCalled() + }) + + it('uses the advanced contents-defaults scope when selected', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://example.com/alice#me', + role: 'Viewer', + label: 'Alice' + }] + + element.advancedSharingOpen = true + element.containerScope = 'contents' + await element.onSaveClick() + + expect(planGrantContainerContents).toHaveBeenCalledWith( + element.subjectUri, + { type: 'agent', iri: 'https://example.com/alice#me' }, + ['Read'] + ) + expect(planGrantContainer).not.toHaveBeenCalled() + }) + + it('offers advanced folder-versus-contents sharing for containers', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + element.accessContext = { inherited: true, effectiveContainer: 'https://example.com/' } + document.body.appendChild(element) + await element.updateComplete + + element.advancedSharingOpen = true + await element.updateComplete + expect(element.shadowRoot.textContent).toContain('different access requirements') + expect(element.shadowRoot.textContent).toContain('Folder Only') + expect(element.shadowRoot.textContent).toContain('Contents Only') + + document.body.removeChild(element) + }) + + it('defaults containers to folder-and-contents without a scope summary', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + document.body.appendChild(element) + await element.updateComplete + + expect(element.containerScope).toBe('folder+contents') + expect(element.shadowRoot.querySelector('.access-control-scope-summary')).toBeNull() + + document.body.removeChild(element) + }) + + it.each([ + ['folder+contents', ['https://example.com/both#me']], + ['folder', ['https://example.com/folder#me']], + ['contents', ['https://example.com/contents#me']] + ] as const)('shows only %s authorizations in Shared with', async (scope, expectedSubjects) => { + const folder = 'https://example.com/folder/' + const grants = [ + { agent: ['https://example.com/folder#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [folder], default: [] }, + { agent: ['https://example.com/contents#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [], default: [folder] }, + { agent: ['https://example.com/both#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [folder], default: [folder] } + ] + findEffectiveAcl.mockResolvedValue({ + resource: folder, + inherited: false, + effectiveContainer: folder, + authorizations: grants + }) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = folder + element.accessGrants = grants + document.body.appendChild(element) + await element.updateComplete + await Promise.resolve() + await element.updateComplete + element.selectContainerScope(scope) + await element.updateComplete + const sharedAccess = element.shadowRoot.querySelector('solid-ui-access-control-shared-access') as any + await sharedAccess.updateComplete + + expect(sharedAccess.accessGrantViewModels.map((entry: any) => entry.authorization.agent[0])) + .toEqual(expectedSubjects) + + document.body.removeChild(element) + }) + + it('recomputes Shared with entries immediately when Advanced changes scope', async () => { + const folder = 'https://example.com/folder/' + const grants = [ + { agent: ['https://example.com/folder#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [folder], default: [] }, + { agent: ['https://example.com/contents#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [], default: [folder] }, + { agent: ['https://example.com/both#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [folder], default: [folder] } + ] + findEffectiveAcl.mockResolvedValue({ resource: folder, inherited: false, effectiveContainer: folder, authorizations: grants }) + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = folder + element.accessGrants = grants + element.advancedSharingOpen = true + document.body.appendChild(element) + await element.updateComplete + const sharedAccess = () => element.shadowRoot.querySelector('solid-ui-access-control-shared-access') as any + await sharedAccess().updateComplete + expect(sharedAccess().accessGrantViewModels).toHaveLength(1) + + const contentsOnly = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .find((button: HTMLElement) => button.textContent?.includes('Contents Only')) as HTMLElement + contentsOnly.click() + await element.updateComplete + await sharedAccess().updateComplete + expect(sharedAccess().accessGrantViewModels).toHaveLength(1) + expect(sharedAccess().accessGrantViewModels[0].authorization.agent).toEqual(['https://example.com/contents#me']) + + document.body.removeChild(element) + }) + + it('updates the summary and save planner when a scope card is selected', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://example.com/alice#me', + role: 'Viewer', + label: 'Alice' + }] + document.body.appendChild(element) + await element.updateComplete + + element.advancedSharingOpen = true + await element.updateComplete + const folderOnly = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .find((button: HTMLElement) => button.textContent?.includes('Folder Only')) as HTMLElement + folderOnly.click() + await element.updateComplete + + await element.onSaveClick() + expect(planGrantContainer).toHaveBeenCalledWith( + element.subjectUri, + { type: 'agent', iri: 'https://example.com/alice#me' }, + ['Read'] + ) + + document.body.removeChild(element) + }) + + it('supports the same-access toggle in advanced mode', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + document.body.appendChild(element) + await element.updateComplete + + element.advancedSharingOpen = true + await element.updateComplete + const toggle = element.shadowRoot.querySelector('.access-control-same-access-toggle') as HTMLButtonElement + expect(toggle.getAttribute('aria-checked')).toBe('true') + toggle.click() + await element.updateComplete + + expect(element.containerScope).toBe('folder') + document.body.removeChild(element) + }) + + it('closes advanced sharing with an accessible ghost X button', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + document.body.appendChild(element) + await element.updateComplete + + element.advancedSharingOpen = true + await element.updateComplete + const closeButton = element.shadowRoot.querySelector('.access-control-advanced-sharing-actions solid-ui-button') as HTMLElement + expect(closeButton?.getAttribute('variant')).toBe('ghost') + expect(closeButton?.getAttribute('aria-label')).toBe('Close advanced sharing') + expect(closeButton?.getAttribute('title')).toBe('Close advanced sharing') + expect(closeButton?.querySelector('icon-lucide-x')).not.toBeNull() + expect(closeButton?.querySelector('.sr-only')?.textContent).toBe('Close') + + closeButton.click() + await element.updateComplete + expect(element.shadowRoot.querySelector('.access-control-advanced-sharing')).toBeNull() + + document.body.removeChild(element) + }) + + it('places sharing details inside Advanced', async () => { + const inherited = document.createElement('solid-ui-access-control-modal') as any + inherited.subjectUri = 'https://example.com/inherited-folder/' + inherited.accessContext = { + inherited: true, + effectiveContainer: 'https://example.com/' + } + document.body.appendChild(inherited) + await inherited.updateComplete + + inherited.advancedSharingOpen = true + await inherited.updateComplete + expect(inherited.shadowRoot.querySelector('.access-control-advanced-sharing')).not.toBeNull() + document.body.removeChild(inherited) + + const custom = document.createElement('solid-ui-access-control-modal') as any + custom.subjectUri = 'https://example.com/custom-folder/' + custom.accessContext = { + inherited: false, + effectiveContainer: 'https://example.com/', + authorizations: [] + } + document.body.appendChild(custom) + await custom.updateComplete + + custom.advancedSharingOpen = true + await custom.updateComplete + const advancedActions = custom.shadowRoot.querySelector('.access-control-advanced-sharing-actions') + expect(advancedActions).not.toBeNull() + document.body.removeChild(custom) + }) + + it.each([ + { subjectUri: 'https://example.com/file.ttl', inherited: false, protectedTarget: false }, + { subjectUri: 'https://example.com/file.ttl', inherited: true, protectedTarget: false }, + { subjectUri: 'https://example.com/folder/', inherited: false, protectedTarget: false }, + { subjectUri: 'https://example.com/folder/', inherited: true, protectedTarget: false }, + { subjectUri: 'https://example.com/protected/', inherited: false, protectedTarget: true }, + { subjectUri: 'https://example.com/protected/', inherited: true, protectedTarget: true } + ])('keeps Advanced and reset unreleased for $subjectUri (inherited: $inherited)', async ({ + subjectUri, inherited, protectedTarget + }) => { + findEffectiveAcl.mockResolvedValueOnce({ + resource: subjectUri, + inherited, + effectiveContainer: 'https://example.com/', + authorizations: [] + }) + isProtectedResource.mockReturnValue(protectedTarget) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = subjectUri + document.body.appendChild(element) + + try { + await vi.waitFor(() => { + expect(element.accessContext?.resource).toBe(subjectUri) + expect(element.accessContextLoading).toBe(false) + }) + await element.updateComplete + + expect(element.shadowRoot.querySelector('.access-control-sharing-state')).toBeNull() + expect(element.shadowRoot.querySelector('.access-control-advanced-sharing')).toBeNull() + expect(element.shadowRoot.querySelector('.access-control-same-access-toggle')).toBeNull() + expect(element.shadowRoot.querySelector('[aria-label="Reset custom sharing to default"]')).toBeNull() + expect(element.shadowRoot.textContent).not.toMatch(/Advanced sharing|Folder Only|Contents Only|Reset to default/) + expect(resetToDefault).not.toHaveBeenCalled() + } finally { + element.remove() + } + }) + + it('hides Advanced for files and protected containers without offering reset controls', async () => { + const file = document.createElement('solid-ui-access-control-modal') as any + file.subjectUri = 'https://example.com/file.ttl' + file.accessContext = { inherited: true, effectiveContainer: 'https://example.com/' } + document.body.appendChild(file) + await file.updateComplete + expect(file.shadowRoot.querySelector('.access-control-sharing-state')).toBeNull() + expect(file.shadowRoot.querySelector('solid-ui-button')?.textContent ?? '').not.toContain('Advanced') + document.body.removeChild(file) + + const protectedContainer = document.createElement('solid-ui-access-control-modal') as any + protectedContainer.subjectUri = 'https://example.com/protected/' + protectedContainer.accessContext = { inherited: true, effectiveContainer: 'https://example.com/' } + document.body.appendChild(protectedContainer) + await protectedContainer.updateComplete + protectedContainer.protectedTarget = true + await protectedContainer.updateComplete + expect(protectedContainer.shadowRoot.querySelector('.access-control-sharing-state')).toBeNull() + expect(protectedContainer.shadowRoot.querySelector('solid-ui-button')?.textContent ?? '').not.toContain('Advanced') + expect(protectedContainer.shadowRoot.querySelector('[aria-label="Reset custom sharing to default"]')).toBeNull() + document.body.removeChild(protectedContainer) + }) + + it('keeps resource sharing details inside Advanced', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/file.ttl' + element.accessContext = { + inherited: false, + effectiveContainer: 'https://example.com/folder/', + authorizations: [] + } + document.body.appendChild(element) + await element.updateComplete + element.advancedSharingOpen = true + await element.updateComplete + + const generalAccess = element.shadowRoot.querySelector('solid-ui-access-control-general-access') + const customSharing = element.shadowRoot.querySelector('.access-control-sharing-state') + expect(generalAccess).not.toBeNull() + expect(customSharing).not.toBeNull() + expect(generalAccess.compareDocumentPosition(customSharing) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(element.shadowRoot.querySelector('.access-control-advanced-sharing')).not.toBeNull() + const panel = element.shadowRoot.querySelector('.access-control-sharing-state-panel') + expect(panel).not.toBeNull() + expect(panel?.querySelector('p')).not.toBeNull() + document.body.removeChild(element) + }) + + it('shows inherited sharing details inside Advanced', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { + inherited: true, + effectiveContainer: 'https://example.com/folder/', + authorizations: [] + } + document.body.appendChild(element) + await element.updateComplete + element.advancedSharingOpen = true + await element.updateComplete + + const sharingState = element.shadowRoot.querySelector('.access-control-sharing-state') + expect(sharingState).not.toBeNull() + expect(sharingState?.querySelector('.access-control-advanced-sharing')).not.toBeNull() + expect(sharingState?.textContent).not.toContain('Reset to default') + document.body.removeChild(element) + }) + + it('keeps the sharing state inside Advanced when no inherited container is known', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { + inherited: false, + authorizations: [] + } + document.body.appendChild(element) + await element.updateComplete + element.advancedSharingOpen = true + await element.updateComplete + + const sharingState = element.shadowRoot.querySelector('.access-control-sharing-state') + expect(sharingState?.querySelector('.access-control-advanced-sharing')).not.toBeNull() + document.body.removeChild(element) + }) + + it('explains when an inherited resource has no default container', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { + inherited: true, + authorizations: [] + } + document.body.appendChild(element) + await element.updateComplete + element.advancedSharingOpen = true + await element.updateComplete + + const sharingState = element.shadowRoot.querySelector('.access-control-sharing-state') + expect(sharingState?.textContent).toContain('no inherited default is available') + expect(sharingState?.textContent).not.toContain('sharing from') + expect(sharingState?.textContent).not.toContain('Reset to default') + document.body.removeChild(element) + }) + + it('blocks edits and explains mixed container policies until Advanced is released', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + findEffectiveAcl.mockResolvedValueOnce({ + resource: element.subjectUri, + inherited: false, + authorizations: [ + { + agent: ['https://example.com/folder-agent#me'], + agentGroup: [], + agentClass: [], + origin: [], + accessTo: [element.subjectUri], + default: [], + mode: ['Read'] + }, + { + agent: ['https://example.com/contents-agent#me'], + agentGroup: [], + agentClass: [], + origin: [], + accessTo: [], + default: [element.subjectUri], + mode: ['Read'] + } + ] + }) + document.body.appendChild(element) + await element.updateComplete + await Promise.resolve() + await element.updateComplete + + const warning = element.shadowRoot.querySelector('.access-control-mixed-policy-warning') + expect(warning?.getAttribute('role')).toBe('alert') + expect(warning?.textContent).toContain('different access settings for the folder and its contents') + expect(warning?.textContent).toContain('available in the next release') + expect(element.shadowRoot.querySelector('solid-ui-access-control-add-access')?.disabled).toBe(true) + expect(element.shadowRoot.querySelector('solid-ui-access-control-shared-access')?.disabled).toBe(true) + expect(element.shadowRoot.querySelector('solid-ui-access-control-general-access')?.disabled).toBe(true) + const saveButton = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .find((button: Element) => button.textContent?.includes('Save Changes')) as HTMLElement + expect(saveButton.hasAttribute('disabled')).toBe(true) + + await element.onSaveClick() + expect(planGrant).not.toHaveBeenCalled() + document.body.removeChild(element) + }) + + it('resets custom sharing and refreshes context and grants', async () => { + findEffectiveAcl + .mockResolvedValueOnce({ inherited: true, authorizations: [{ agent: ['https://example.com/owner#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] }] }) + findAccessGrants.mockResolvedValueOnce([]) + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { inherited: false, effectiveContainer: 'https://example.com/folder/', authorizations: [] } + + await element.onResetToDefault() + + expect(resetToDefault).toHaveBeenCalledWith(element.subjectUri) + expect(findEffectiveAcl).toHaveBeenCalled() + expect(findAccessGrants).toHaveBeenCalledWith(element.subjectUri) + expect(element.accessContext.inherited).toBe(true) + expect(element.accessGrants[0].agent).toEqual(['https://example.com/owner#me']) + }) + + it('shows inherited effective authorizations when the resource has no direct ACL', async () => { + const inheritedAuthorization = { + agent: ['https://example.com/owner#me'], + agentGroup: [], + agentClass: [], + origin: [], + mode: ['Read'] + } + findEffectiveAcl.mockResolvedValueOnce({ + inherited: true, + effectiveContainer: 'https://example.com/folder/', + authorizations: [inheritedAuthorization] + }) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + document.body.appendChild(element) + await element.updateComplete + await Promise.resolve() + await element.updateComplete + + expect(element.accessGrants).toEqual([inheritedAuthorization]) + const sharedAccess = element.shadowRoot.querySelector('solid-ui-access-control-shared-access') + await sharedAccess.updateComplete + expect(sharedAccess.shadowRoot.querySelectorAll('.access-grants-list h3')).toHaveLength(1) + document.body.removeChild(element) + }) + + it('reports reset errors without changing the custom state', async () => { + resetToDefault.mockRejectedValueOnce(new Error('reset failed')) + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { inherited: false, authorizations: [] } + + await element.onResetToDefault() + + expect(element.accessContext.inherited).toBe(false) + expect(element.saveErrors[0]).toEqual(expect.objectContaining({ action: 'reset', message: 'reset failed' })) + }) + + it('does not offer reset for an inherited resource', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { inherited: true, effectiveContainer: 'https://example.com/folder/', authorizations: [] } + document.body.appendChild(element) + await element.updateComplete + + expect(element.shadowRoot.textContent).not.toContain('Reset to default') + document.body.removeChild(element) + }) }) diff --git a/test/components/access-control-modal/accessGrantDrafts.test.ts b/test/components/access-control-modal/accessGrantDrafts.test.ts index 11e839c62..a1d5a3d8f 100644 --- a/test/components/access-control-modal/accessGrantDrafts.test.ts +++ b/test/components/access-control-modal/accessGrantDrafts.test.ts @@ -5,7 +5,9 @@ import { getAccessGrantId, getAccessGrantIds, getChangedAccessGrants, - getSharedAuthorization + getSharedAuthorization, + getAuthenticatedAccessRole, + getPublicAccessRole } from '../../../src/components/access-control-modal/accessGrantDrafts' describe('access-control grant drafts', () => { @@ -58,6 +60,52 @@ describe('access-control grant drafts', () => { ]) }) + it('uses authorization ids instead of subject order for draft identity', () => { + const first: AccessAuthorization = { + id: 'https://example.com/.acl#authorization-1', + agent: ['https://alice.example/profile#me'], + agentGroup: [], + agentClass: [], + origin: [], + mode: ['Read'] as AccessMode[] + } + const second = { ...first, id: 'https://example.com/.acl#authorization-2', mode: ['Write'] as AccessMode[] } + + expect(getAccessGrantIds([second, first])).toEqual([second.id, first.id]) + }) + + it('aggregates modes from all matching general-access authorizations', () => { + const authorizations: AccessAuthorization[] = [ + { + id: 'auth-read', + agent: [], + agentGroup: [], + agentClass: [Authenticated.iri], + origin: [], + mode: ['Read'] as AccessMode[] + }, + { + id: 'auth-write', + agent: [], + agentGroup: [], + agentClass: [Authenticated.iri], + origin: [], + mode: ['Write'] as AccessMode[] + }, + { + id: 'public-read', + agent: [], + agentGroup: [], + agentClass: [Public.iri], + origin: [], + mode: ['Read'] as AccessMode[] + } + ] + + expect(getAuthenticatedAccessRole(authorizations)).toBe('Editor') + expect(getPublicAccessRole(authorizations)).toBe('Viewer') + }) + it('returns only changed drafts with real shared subjects', () => { const changed = { id: 'agent:https://alice.example/profile#me', diff --git a/test/components/access-control-modal/accessTargetResolver.test.ts b/test/components/access-control-modal/accessTargetResolver.test.ts index 12eb048b9..e5aec9027 100644 --- a/test/components/access-control-modal/accessTargetResolver.test.ts +++ b/test/components/access-control-modal/accessTargetResolver.test.ts @@ -4,13 +4,25 @@ import { resolveAccessTarget } from '../../../src/components/access-control-modal/accessTargetResolver' import { solidLogicSingleton } from 'solid-logic' +import { sym } from 'rdflib' +import ns from '../../../src/lib/ns' describe('access-control target resolver', () => { it('normalizes a bare origin without requiring directory lookup', async () => { await expect(resolveAccessTarget('example.test')).resolves.toEqual({ subjectType: 'origin', subjectValue: 'https://example.test', - label: 'https://example.test' + label: 'https://example.test', + targetKind: 'origin' + }) + }) + + it('resolves a selected absolute origin without classifying it as an agent', async () => { + await expect(resolveAccessTarget('https://example.test/')).resolves.toEqual({ + subjectType: 'origin', + subjectValue: 'https://example.test', + label: 'https://example.test', + targetKind: 'origin' }) }) @@ -22,6 +34,48 @@ describe('access-control target resolver', () => { }]) }) + it('suggests only the current user’s trusted-app origins from the profile RDF', async () => { + const store = solidLogicSingleton.store + const me = sym('https://trusted-app-options.example/profile/card#me') + const profile = me.doc() + const trustedApp = sym('https://trusted-app-options.example/apps/trusted') + const invalidApp = sym('https://trusted-app-options.example/apps/invalid') + const otherUser = sym('https://other-user.example/profile/card#me') + const otherProfile = otherUser.doc() + const unrelatedApp = sym('https://other-user.example/apps/unrelated') + + store.add(me, ns.acl('trustedApp'), trustedApp, profile) + store.add(trustedApp, ns.acl('origin'), sym('https://trusted.example.test/'), profile) + store.add(me, ns.acl('trustedApp'), invalidApp, profile) + store.add(invalidApp, ns.acl('origin'), sym('https://invalid.example.test/app'), profile) + store.add(otherUser, ns.acl('trustedApp'), unrelatedApp, otherProfile) + store.add(unrelatedApp, ns.acl('origin'), sym('https://unrelated.example.test/'), otherProfile) + + const currentUserSpy = vi.spyOn(solidLogicSingleton.authn, 'currentUser').mockReturnValue(me) + const loadProfileSpy = vi.spyOn(solidLogicSingleton.profile, 'loadProfile').mockResolvedValue(profile) + const directorySearchSpy = vi.spyOn(solidLogicSingleton.directory, 'search') + .mockRejectedValue(new Error('directory unavailable')) + + try { + const options = await accessPrincipalOptionsProvider('trusted') + + expect(loadProfileSpy).toHaveBeenCalledWith(me) + expect(directorySearchSpy).toHaveBeenCalled() + expect(options).toHaveLength(1) + expect(options[0]).toEqual(expect.objectContaining({ + label: 'Trusted app: https://trusted.example.test', + value: 'https://trusted.example.test', + targetKind: 'trustedApp' + })) + } finally { + currentUserSpy.mockRestore() + loadProfileSpy.mockRestore() + directorySearchSpy.mockRestore() + store.removeMatches(null, null, null, profile) + store.removeMatches(null, null, null, otherProfile) + } + }) + it('preserves related document lookup failures for direct resolution', async () => { const classifySpy = vi.spyOn(solidLogicSingleton.acl, 'classifyAccessControlSubject') .mockResolvedValueOnce({