diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 000155ac8..7e0f4ca2e 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -370,3 +370,45 @@ jobs: # deliberate). Floating both halves keeps caller and callee in step, # which is the only configuration in which a pinned path cannot expire. enable-hydra-gates: true + # ── Cost controls (see ConductionNL/.github#596, #599) ─────────────── + # Run PHPUnit and Playwright only when the diff could change their + # verdict. Fails safe TOWARDS running: an unreadable diff, a force-push, + # a branch creation or a dispatch all run everything, and a filtered + # skip is a declared state in the Quality Report, not a missing one. + enable-path-filtering: true + # PR-time PHPUnit runs the primary PHP against the newest declared + # Nextcloud; the full matrix still runs on every push to a default + # branch and on the release PR into beta. Breadth moves from + # per-commit to per-merge, it is not dropped. + reduce-pr-matrix: true + + # ── l10n parity, as a WARNING ───────────────────────────────────────────── + # tests/l10n/check-l10n-parity.js ran in no CI lane, so #559 left 35 of the + # 36 required locales without "Admin settings" and nothing noticed for six + # days: gate-102 only guards nl.json, and only for manifest strings. + # + # It lives HERE, in this repo's own workflow, and not in the shared + # ConductionNL/.github quality.yml, whose changes reach the whole fleet the + # minute they merge. And it ships as a warning first, the way every new check + # does: a failure raises an annotation and marks this step, but + # `continue-on-error` keeps the job and the run green. Make it blocking once + # it has held for a while, by deleting `continue-on-error`. + # + # Dependency-free Node, so it needs no `npm ci`. + l10n-parity: + name: l10n parity (warning only) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Every required locale carries every source key + continue-on-error: true + run: | + if ! npm run --silent check:l10n-parity; then + echo "::warning title=l10n parity::A required locale is missing a translation. Warning only, not blocking: see this step's log for the locale and the key." + exit 1 + fi diff --git a/appinfo/info.xml b/appinfo/info.xml index c050e2d66..fd5cc1b17 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -10,49 +10,49 @@ LaunchPad Drag-and-drop dashboards with templates, tiles, and smart visibility Drag-and-drop dashboards met templates, tegels en slimme zichtbaarheid - - - 1.0.25-unstable.20260831161142 + 1.1.4-unstable.20260910141110 EUPL-1.2 Conduction LaunchPad diff --git a/appinfo/routes.php b/appinfo/routes.php index d16a23be7..73c709ada 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -21,6 +21,24 @@ ['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'], ['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'], + // Store plane (ADR-080). LaunchPad declares a `type: "store"` page, which + // renders CnStorePage, which calls the item routes on mount and on an install + // click. Without them that page is in the navigation and 404s. + // + // Discovery is OpenRegister's `GenericStoreService`; install is ours, + // because a dashboard lives in LaunchPad's tables and no engine install + // op can write one. `store#*` therefore resolves to + // `OCA\LaunchPad\Controller\StoreController`, which is also what keeps + // the engine's alias off this app (see that class). + // + // The slug requirement matches the engine's, so a malformed slug fails + // at the router rather than inside a registry URL. + ['name' => 'store#search', 'url' => '/api/store/items', 'verb' => 'GET'], + ['name' => 'store#getConfig', 'url' => '/api/store/config', 'verb' => 'GET'], + ['name' => 'store#updateConfig', 'url' => '/api/store/config', 'verb' => 'PUT'], + ['name' => 'store#install', 'url' => '/api/store/items/{slug}/install', 'verb' => 'POST', + 'requirements' => ['slug' => '[a-z0-9][a-z0-9\-]*[a-z0-9]']], + // Generic per-user preferences (used by shared nextcloud-vue widgets, e.g. CnSupportDialog). ['name' => 'preferences#getPreference', 'url' => '/api/preferences/{key}', 'verb' => 'GET'], ['name' => 'preferences#setPreference', 'url' => '/api/preferences/{key}', 'verb' => 'PUT'], diff --git a/composer.json b/composer.json index 2662f4cf8..566e4d6ad 100644 --- a/composer.json +++ b/composer.json @@ -47,10 +47,10 @@ "phpcs": "./vendor/bin/phpcs --standard=phpcs.xml", "phpcs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml", "phpcs:output": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ 2>/dev/null | tail -1 > phpcs-output.json", - "phpmd": "E=0; ./vendor/bin/phpmd lib text phpmd.xml || E=$?; ./vendor/bin/phpmd lib text vendor/conduction/hydra-gates/quality-config/phpmd-unusedparams.xml --baseline-file phpmd.baseline.xml || E=$?; exit $E", + "phpmd": "E=0; php -d memory_limit=2G ./vendor/bin/phpmd lib text phpmd.xml || E=$?; php -d memory_limit=2G ./vendor/bin/phpmd lib text vendor/conduction/hydra-gates/quality-config/phpmd-unusedparams.xml --baseline-file phpmd.baseline.xml || E=$?; exit $E", "phpmetrics": "./vendor/bin/phpmetrics --report-html=phpmetrics lib/", "phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/", - "psalm": "./vendor/bin/psalm --threads=1 --no-cache", + "psalm": "./vendor/bin/psalm --threads=1 --no-cache --memory-limit=2G", "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G", "test": "phpunit --configuration phpunit.xml", "test:unit": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always", diff --git a/composer.lock b/composer.lock index 5847ff418..33f42c653 100644 --- a/composer.lock +++ b/composer.lock @@ -512,16 +512,16 @@ }, { "name": "conduction/hydra-gates", - "version": "v1.15.0", + "version": "v1.18.0", "source": { "type": "git", "url": "https://github.com/ConductionNL/.github.git", - "reference": "0bc214023be78aac94142035a9988986cfccccbc" + "reference": "477f930e84f1b3e709d2fb645d7c303d8d39a994" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ConductionNL/.github/zipball/0bc214023be78aac94142035a9988986cfccccbc", - "reference": "0bc214023be78aac94142035a9988986cfccccbc", + "url": "https://api.github.com/repos/ConductionNL/.github/zipball/477f930e84f1b3e709d2fb645d7c303d8d39a994", + "reference": "477f930e84f1b3e709d2fb645d7c303d8d39a994", "shasum": "" }, "require": { @@ -560,9 +560,9 @@ "support": { "docs": "https://github.com/ConductionNL/.github/blob/main/hydra-gates/README.md", "issues": "https://github.com/ConductionNL/.github/issues", - "source": "https://github.com/ConductionNL/.github/tree/v1.15.0" + "source": "https://github.com/ConductionNL/.github/tree/v1.18.0" }, - "time": "2026-09-04T16:24:04+00:00" + "time": "2026-09-10T10:15:53+00:00" }, { "name": "consolidation/annotated-command", @@ -3220,11 +3220,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.12", + "version": "2.2.13", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/174b0d88710f00a42598886504dd7a146f91ace5", - "reference": "174b0d88710f00a42598886504dd7a146f91ace5", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", + "reference": "9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", "shasum": "" }, "require": { @@ -3280,7 +3280,7 @@ "type": "github" } ], - "time": "2026-08-31T19:09:43+00:00" + "time": "2026-09-03T20:38:19+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/data/demo-showcases/case-handler/case-handler.zip b/data/demo-showcases/case-handler/case-handler.zip new file mode 100644 index 000000000..9f0f9460b Binary files /dev/null and b/data/demo-showcases/case-handler/case-handler.zip differ diff --git a/img/showcases/case-handler.png b/img/showcases/case-handler.png new file mode 100644 index 000000000..248fb0977 Binary files /dev/null and b/img/showcases/case-handler.png differ diff --git a/l10n/be.js b/l10n/be.js index ae8629fc1..b0e434211 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Налады адміністравання", "Features & roadmap" : "Функцыі і дарожная карта", "Maximum dashboards per user" : "Максімальная колькасць дашбордаў на карыстальніка", "Maximum widgets per dashboard" : "Максімальная колькасць віджэтаў на дашборд", @@ -1113,7 +1114,6 @@ OC.L10N.register( "Kind": "Від", "Version": "Версія", "How many dashboards there are, and who they belong to.": "Колькі ёсць панэляў і каму яны належаць.", - "Store": "Крама", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Усталюйце рэестры, схемы і патокі, апублікаваныя іншымі арганізацыямі." + "Store": "Крама" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);"); diff --git a/l10n/be.json b/l10n/be.json index f6ef36c15..28429c50b 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Налады адміністравання", "Maximum dashboards per user": "Максімальная колькасць дашбордаў на карыстальніка", "Maximum widgets per dashboard": "Максімальная колькасць віджэтаў на дашборд", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = без абмежаванняў. Зніжэнне ліміту ніколі не выдаляе існуючыя дашборды; яно толькі блакуе стварэнне новых, пакуль карыстальнікі не вернуцца пад ліміт.", @@ -1090,7 +1091,6 @@ "Kind": "Від", "Version": "Версія", "How many dashboards there are, and who they belong to.": "Колькі ёсць панэляў і каму яны належаць.", - "Store": "Крама", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Усталюйце рэестры, схемы і патокі, апублікаваныя іншымі арганізацыямі." + "Store": "Крама" } } diff --git a/l10n/bg.js b/l10n/bg.js index 80ffa6906..880d1d362 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Административни настройки", "Features & roadmap" : "Функции и пътна карта", "Maximum dashboards per user" : "Максимален брой табла на потребител", "Maximum widgets per dashboard" : "Максимален брой джаджи на табло", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Вид", "Version": "Версия", "How many dashboards there are, and who they belong to.": "Колко табла има и на кого принадлежат.", - "Store": "Магазин", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Инсталирайте регистри, схеми и потоци, публикувани от други организации." + "Store": "Магазин" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/bg.json b/l10n/bg.json index c25bf273f..cf72a1c8f 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Административни настройки", "Maximum dashboards per user": "Максимален брой табла на потребител", "Maximum widgets per dashboard": "Максимален брой джаджи на табло", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = неограничено. Намаляването на ограничение никога не изтрива съществуващи табла; то само блокира новите, докато потребителите не се върнат под ограничението.", @@ -1089,7 +1090,6 @@ "Kind": "Вид", "Version": "Версия", "How many dashboards there are, and who they belong to.": "Колко табла има и на кого принадлежат.", - "Store": "Магазин", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Инсталирайте регистри, схеми и потоци, публикувани от други организации." + "Store": "Магазин" } } diff --git a/l10n/bs.js b/l10n/bs.js index 220f00d1b..06ceff758 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Admin settings", "Features & roadmap" : "Funkcije i plan razvoja", "Maximum dashboards per user" : "Maksimalan broj nadzornih ploča po korisniku", "Maximum widgets per dashboard" : "Maksimalan broj widgeta po nadzornoj ploči", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Vrsta", "Version": "Verzija", "How many dashboards there are, and who they belong to.": "Koliko ima kontrolnih tabli i kome pripadaju.", - "Store": "Trgovina", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instalirajte registre, šeme i tokove koje su objavile druge organizacije." + "Store": "Trgovina" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/bs.json b/l10n/bs.json index 5616784b5..f8c906f49 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Admin settings", "Maximum dashboards per user": "Maksimalan broj nadzornih ploča po korisniku", "Maximum widgets per dashboard": "Maksimalan broj widgeta po nadzornoj ploči", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = neograničeno. Snižavanje ograničenja nikada ne briše postojeće nadzorne ploče; samo blokira nove dok se korisnici ne vrate ispod ograničenja.", @@ -1090,7 +1091,6 @@ "Kind": "Vrsta", "Version": "Verzija", "How many dashboards there are, and who they belong to.": "Koliko ima kontrolnih tabli i kome pripadaju.", - "Store": "Trgovina", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instalirajte registre, šeme i tokove koje su objavile druge organizacije." + "Store": "Trgovina" } } diff --git a/l10n/ca.js b/l10n/ca.js index 91f3d38bb..2bd88f34e 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Paràmetres d'administració", "Features & roadmap" : "Funcions i full de ruta", "Maximum dashboards per user" : "Màxim de taulers per usuari", "Maximum widgets per dashboard" : "Màxim de ginys per tauler", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Tipus", "Version": "Versió", "How many dashboards there are, and who they belong to.": "Quants taulers hi ha i a qui pertanyen.", - "Store": "Botiga", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instal·leu registres, esquemes i fluxos publicats per altres organitzacions." + "Store": "Botiga" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/ca.json b/l10n/ca.json index e398926f3..577c887a7 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Paràmetres d'administració", "Maximum dashboards per user": "Màxim de taulers per usuari", "Maximum widgets per dashboard": "Màxim de ginys per tauler", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = il·limitat. Reduir un límit mai no suprimeix els taulers existents; només bloqueja els nous fins que els usuaris tornin a estar per sota del límit.", @@ -1089,7 +1090,6 @@ "Kind": "Tipus", "Version": "Versió", "How many dashboards there are, and who they belong to.": "Quants taulers hi ha i a qui pertanyen.", - "Store": "Botiga", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instal·leu registres, esquemes i fluxos publicats per altres organitzacions." + "Store": "Botiga" } } diff --git a/l10n/cs.js b/l10n/cs.js index 772d8d852..dcef39851 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Nastavení pro správu", "Features & roadmap" : "Funkce a plán vývoje", "Maximum dashboards per user" : "Maximální počet dashboardů na uživatele", "Maximum widgets per dashboard" : "Maximální počet widgetů na dashboard", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Druh", "Version": "Verze", "How many dashboards there are, and who they belong to.": "Kolik je přehledů a komu patří.", - "Store": "Obchod", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Nainstalujte registry, schémata a toky zveřejněné jinými organizacemi." + "Store": "Obchod" }, "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;" ); diff --git a/l10n/cs.json b/l10n/cs.json index 964024b54..a5e38c6e3 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Nastavení pro správu", "Maximum dashboards per user": "Maximální počet dashboardů na uživatele", "Maximum widgets per dashboard": "Maximální počet widgetů na dashboard", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = neomezeno. Snížení limitu nikdy neodstraní stávající dashboardy; pouze zablokuje nové, dokud se uživatelé nevrátí pod limit.", @@ -1090,7 +1091,6 @@ "Kind": "Druh", "Version": "Verze", "How many dashboards there are, and who they belong to.": "Kolik je přehledů a komu patří.", - "Store": "Obchod", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Nainstalujte registry, schémata a toky zveřejněné jinými organizacemi." + "Store": "Obchod" } } diff --git a/l10n/da.js b/l10n/da.js index 703b7d4cd..57cebacd9 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Systemindstillinger", "Features & roadmap" : "Funktioner og køreplan", "Maximum dashboards per user" : "Maksimalt antal dashboards pr. bruger", "Maximum widgets per dashboard" : "Maksimalt antal widgets pr. dashboard", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Type", "Version": "Version", "How many dashboards there are, and who they belong to.": "Hvor mange dashboards der er, og hvem de tilhører.", - "Store": "Butik", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installer registre, skemaer og flows, som andre organisationer har udgivet." + "Store": "Butik" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/da.json b/l10n/da.json index b5b01f1df..7bdf63425 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Systemindstillinger", "Maximum dashboards per user": "Maksimalt antal dashboards pr. bruger", "Maximum widgets per dashboard": "Maksimalt antal widgets pr. dashboard", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = ubegrænset. Sænkning af en grænse sletter aldrig eksisterende dashboards; den blokerer kun nye, indtil brugerne igen er under grænsen.", @@ -1089,7 +1090,6 @@ "Kind": "Type", "Version": "Version", "How many dashboards there are, and who they belong to.": "Hvor mange dashboards der er, og hvem de tilhører.", - "Store": "Butik", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installer registre, skemaer og flows, som andre organisationer har udgivet." + "Store": "Butik" } } diff --git a/l10n/de.js b/l10n/de.js index 076fefe4c..c07f0e248 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Administrationseinstellungen", "Features & roadmap" : "Funktionen und Roadmap", "Maximum dashboards per user" : "Maximale Dashboards pro Benutzer", "Maximum widgets per dashboard" : "Maximale Widgets pro Dashboard", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Art", "Version": "Version", "How many dashboards there are, and who they belong to.": "Wie viele Dashboards es gibt und wem sie gehören.", - "Store": "Store", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installieren Sie Register, Schemata und Flows, die andere Organisationen veröffentlicht haben." + "Store": "Store" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/de.json b/l10n/de.json index b9aee2771..8132dff65 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Administrationseinstellungen", "Maximum dashboards per user": "Maximale Dashboards pro Benutzer", "Maximum widgets per dashboard": "Maximale Widgets pro Dashboard", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = unbegrenzt. Das Verringern eines Limits löscht niemals vorhandene Dashboards; es blockiert nur neue, bis Benutzer wieder unter dem Limit sind.", @@ -1089,7 +1090,6 @@ "Kind": "Art", "Version": "Version", "How many dashboards there are, and who they belong to.": "Wie viele Dashboards es gibt und wem sie gehören.", - "Store": "Store", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installieren Sie Register, Schemata und Flows, die andere Organisationen veröffentlicht haben." + "Store": "Store" } } diff --git a/l10n/el.js b/l10n/el.js index 0f2a2d448..1e9ede047 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Ρυθμίσεις διαχείρισης", "Features & roadmap" : "Λειτουργίες και οδικός χάρτης", "Maximum dashboards per user" : "Μέγιστος αριθμός πινάκων ανά χρήστη", "Maximum widgets per dashboard" : "Μέγιστος αριθμός widget ανά πίνακα", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Είδος", "Version": "Έκδοση", "How many dashboards there are, and who they belong to.": "Πόσοι πίνακες υπάρχουν και σε ποιον ανήκουν.", - "Store": "Κατάστημα", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Εγκαταστήστε μητρώα, σχήματα και ροές που έχουν δημοσιεύσει άλλοι οργανισμοί." + "Store": "Κατάστημα" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/el.json b/l10n/el.json index 39d7a422f..2692ca19e 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Ρυθμίσεις διαχείρισης", "Maximum dashboards per user": "Μέγιστος αριθμός πινάκων ανά χρήστη", "Maximum widgets per dashboard": "Μέγιστος αριθμός widget ανά πίνακα", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = απεριόριστο. Η μείωση ενός ορίου δεν διαγράφει ποτέ υπάρχοντες πίνακες· απλώς μπλοκάρει τη δημιουργία νέων μέχρι οι χρήστες να επανέλθουν κάτω από το όριο.", @@ -1089,7 +1090,6 @@ "Kind": "Είδος", "Version": "Έκδοση", "How many dashboards there are, and who they belong to.": "Πόσοι πίνακες υπάρχουν και σε ποιον ανήκουν.", - "Store": "Κατάστημα", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Εγκαταστήστε μητρώα, σχήματα και ροές που έχουν δημοσιεύσει άλλοι οργανισμοί." + "Store": "Κατάστημα" } } diff --git a/l10n/en.js b/l10n/en.js index 0bf06cfaa..bb3cf1c5b 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Admin settings", "Features & roadmap" : "Features & roadmap", "share_not_found" : "Share not found", "share_expired" : "This share has expired", diff --git a/l10n/es.js b/l10n/es.js index 6243efa2e..57429e95e 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Configuraciones de administración", "Features & roadmap" : "Funciones y hoja de ruta", "Maximum dashboards per user" : "Máximo de paneles por usuario", "Maximum widgets per dashboard" : "Máximo de widgets por panel", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Tipo", "Version": "Versión", "How many dashboards there are, and who they belong to.": "Cuántos paneles hay y a quién pertenecen.", - "Store": "Tienda", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instale registros, esquemas y flujos publicados por otras organizaciones." + "Store": "Tienda" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/es.json b/l10n/es.json index d91406652..7e5e8a942 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Configuraciones de administración", "Maximum dashboards per user": "Máximo de paneles por usuario", "Maximum widgets per dashboard": "Máximo de widgets por panel", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = sin límite. Reducir un límite nunca elimina los paneles existentes; solo bloquea los nuevos hasta que los usuarios vuelvan a estar por debajo del límite.", @@ -1089,7 +1090,6 @@ "Kind": "Tipo", "Version": "Versión", "How many dashboards there are, and who they belong to.": "Cuántos paneles hay y a quién pertenecen.", - "Store": "Tienda", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instale registros, esquemas y flujos publicados por otras organizaciones." + "Store": "Tienda" } } diff --git a/l10n/et.js b/l10n/et.js index a6c0a3acf..c72bc77cb 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Administreerimise seaded", "Features & roadmap" : "Funktsioonid ja teekaart", "Maximum dashboards per user" : "Töölaudade maksimumarv kasutaja kohta", "Maximum widgets per dashboard" : "Vidinate maksimumarv töölaua kohta", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Liik", "Version": "Versioon", "How many dashboards there are, and who they belong to.": "Kui palju töölaudu on ja kellele need kuuluvad.", - "Store": "Pood", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Paigalda registrid, skeemid ja voog, mille teised organisatsioonid on avaldanud." + "Store": "Pood" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/et.json b/l10n/et.json index 85440907e..876ff0165 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Administreerimise seaded", "Maximum dashboards per user": "Töölaudade maksimumarv kasutaja kohta", "Maximum widgets per dashboard": "Vidinate maksimumarv töölaua kohta", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = piiramatu. Piirangu vähendamine ei kustuta kunagi olemasolevaid töölaudu; see üksnes blokeerib uute loomise, kuni kasutajad on jälle piirist allpool.", @@ -1089,7 +1090,6 @@ "Kind": "Liik", "Version": "Versioon", "How many dashboards there are, and who they belong to.": "Kui palju töölaudu on ja kellele need kuuluvad.", - "Store": "Pood", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Paigalda registrid, skeemid ja voog, mille teised organisatsioonid on avaldanud." + "Store": "Pood" } } diff --git a/l10n/fi.js b/l10n/fi.js index 9afdecf86..6e355f626 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Ylläpitäjän asetukset", "Features & roadmap" : "Ominaisuudet ja tiekartta", "Maximum dashboards per user" : "Koontinäyttöjen enimmäismäärä käyttäjää kohden", "Maximum widgets per dashboard" : "Pienoisohjelmien enimmäismäärä koontinäyttöä kohden", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Laji", "Version": "Versio", "How many dashboards there are, and who they belong to.": "Kuinka monta koontinäyttöä on ja kenelle ne kuuluvat.", - "Store": "Kauppa", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Asenna muiden organisaatioiden julkaisemia rekistereitä, skeemoja ja vuokaavioita." + "Store": "Kauppa" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/fi.json b/l10n/fi.json index d8874af76..d8a67eb5a 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Ylläpitäjän asetukset", "Maximum dashboards per user": "Koontinäyttöjen enimmäismäärä käyttäjää kohden", "Maximum widgets per dashboard": "Pienoisohjelmien enimmäismäärä koontinäyttöä kohden", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = rajoittamaton. Rajan alentaminen ei koskaan poista olemassa olevia koontinäyttöjä; se vain estää uusien luomisen, kunnes käyttäjät ovat jälleen rajan alapuolella.", @@ -1089,7 +1090,6 @@ "Kind": "Laji", "Version": "Versio", "How many dashboards there are, and who they belong to.": "Kuinka monta koontinäyttöä on ja kenelle ne kuuluvat.", - "Store": "Kauppa", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Asenna muiden organisaatioiden julkaisemia rekistereitä, skeemoja ja vuokaavioita." + "Store": "Kauppa" } } diff --git a/l10n/fr.js b/l10n/fr.js index 93e86e612..51d66638f 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Paramètres d'administration", "Features & roadmap" : "Fonctionnalités et feuille de route", "Maximum dashboards per user" : "Nombre maximal de tableaux de bord par utilisateur", "Maximum widgets per dashboard" : "Nombre maximal de widgets par tableau de bord", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Type", "Version": "Version", "How many dashboards there are, and who they belong to.": "Combien de tableaux de bord il y a, et à qui ils appartiennent.", - "Store": "Boutique", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installez des registres, schémas et flux publiés par d'autres organisations." + "Store": "Boutique" }, "nplurals=2; plural=(n > 1);" ); diff --git a/l10n/fr.json b/l10n/fr.json index fe423b618..caee966ce 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Paramètres d'administration", "Maximum dashboards per user": "Nombre maximal de tableaux de bord par utilisateur", "Maximum widgets per dashboard": "Nombre maximal de widgets par tableau de bord", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = illimité. Réduire une limite ne supprime jamais les tableaux de bord existants ; cela bloque uniquement les nouveaux jusqu’à ce que les utilisateurs repassent sous la limite.", @@ -1089,7 +1090,6 @@ "Kind": "Type", "Version": "Version", "How many dashboards there are, and who they belong to.": "Combien de tableaux de bord il y a, et à qui ils appartiennent.", - "Store": "Boutique", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installez des registres, schémas et flux publiés par d'autres organisations." + "Store": "Boutique" } } diff --git a/l10n/ga.js b/l10n/ga.js index dbf5623b9..5ce064b10 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Socruithe riaracháin", "Features & roadmap" : "Gnéithe agus treochlár", "Maximum dashboards per user" : "Uaslíon deais-chláir in aghaidh an úsáideora", "Maximum widgets per dashboard" : "Uaslíon giuirléidí in aghaidh an deais-chláir", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Cineál", "Version": "Leagan", "How many dashboards there are, and who they belong to.": "Cé mhéad painéal atá ann, agus cé leis iad.", - "Store": "Siopa", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Suiteáil cláir, scéimeanna agus sruthanna a d'fhoilsigh eagraíochtaí eile." + "Store": "Siopa" }, "nplurals=5; plural=(n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4);" ); diff --git a/l10n/ga.json b/l10n/ga.json index 2df121449..ca50e5947 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Socruithe riaracháin", "Maximum dashboards per user": "Uaslíon deais-chláir in aghaidh an úsáideora", "Maximum widgets per dashboard": "Uaslíon giuirléidí in aghaidh an deais-chláir", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = gan teorainn. Ní scriosann ísliú teorann deais-chláir atá ann cheana; ní dhéanann sé ach cinn nua a bhac go dtí go mbeidh úsáideoirí faoin teorainn arís.", @@ -1092,7 +1093,6 @@ "Kind": "Cineál", "Version": "Leagan", "How many dashboards there are, and who they belong to.": "Cé mhéad painéal atá ann, agus cé leis iad.", - "Store": "Siopa", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Suiteáil cláir, scéimeanna agus sruthanna a d'fhoilsigh eagraíochtaí eile." + "Store": "Siopa" } } diff --git a/l10n/hr.js b/l10n/hr.js index 38b1fb360..0e1ce400a 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Admin settings", "Features & roadmap" : "Značajke i plan razvoja", "Maximum dashboards per user" : "Najveći broj nadzornih ploča po korisniku", "Maximum widgets per dashboard" : "Najveći broj widgeta po nadzornoj ploči", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Vrsta", "Version": "Verzija", "How many dashboards there are, and who they belong to.": "Koliko nadzornih ploča ima i kome pripadaju.", - "Store": "Trgovina", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instalirajte registre, sheme i tokove koje su objavile druge organizacije." + "Store": "Trgovina" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/hr.json b/l10n/hr.json index 954d1e411..9ae1c8436 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Admin settings", "Maximum dashboards per user": "Najveći broj nadzornih ploča po korisniku", "Maximum widgets per dashboard": "Najveći broj widgeta po nadzornoj ploči", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = neograničeno. Snižavanje ograničenja nikada ne briše postojeće nadzorne ploče; samo blokira nove dok se korisnici ne vrate ispod ograničenja.", @@ -1090,7 +1091,6 @@ "Kind": "Vrsta", "Version": "Verzija", "How many dashboards there are, and who they belong to.": "Koliko nadzornih ploča ima i kome pripadaju.", - "Store": "Trgovina", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instalirajte registre, sheme i tokove koje su objavile druge organizacije." + "Store": "Trgovina" } } diff --git a/l10n/hu.js b/l10n/hu.js index 97af73a64..373fa5bd4 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Rendszergazdai beállítások", "Features & roadmap" : "Funkciók és ütemterv", "Maximum dashboards per user" : "Felhasználónkénti maximális irányítópultok száma", "Maximum widgets per dashboard" : "Irányítópultonkénti maximális modulok száma", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Fajta", "Version": "Verzió", "How many dashboards there are, and who they belong to.": "Hány irányítópult van, és kihez tartoznak.", - "Store": "Áruház", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Telepítsen más szervezetek által közzétett nyilvántartásokat, sémákat és folyamatokat." + "Store": "Áruház" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/hu.json b/l10n/hu.json index b63a6ee64..0a50f14f6 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Rendszergazdai beállítások", "Maximum dashboards per user": "Felhasználónkénti maximális irányítópultok száma", "Maximum widgets per dashboard": "Irányítópultonkénti maximális modulok száma", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = korlátlan. A korlát csökkentése soha nem törli a meglévő irányítópultokat; csak az újak létrehozását tiltja, amíg a felhasználók ismét a korlát alá nem kerülnek.", @@ -1089,7 +1090,6 @@ "Kind": "Fajta", "Version": "Verzió", "How many dashboards there are, and who they belong to.": "Hány irányítópult van, és kihez tartoznak.", - "Store": "Áruház", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Telepítsen más szervezetek által közzétett nyilvántartásokat, sémákat és folyamatokat." + "Store": "Áruház" } } diff --git a/l10n/is.js b/l10n/is.js index a5f931d47..147f95504 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Stillingar stjórnunar", "Features & roadmap" : "Eiginleikar og vegvísir", "Maximum dashboards per user" : "Hámarksfjöldi mælaborða á notanda", "Maximum widgets per dashboard" : "Hámarksfjöldi viðmótshluta á mælaborð", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Tegund", "Version": "Útgáfa", "How many dashboards there are, and who they belong to.": "Hversu mörg mælaborð eru til og hverjum þau tilheyra.", - "Store": "Verslun", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Settu upp skrár, skemu og flæði sem aðrar stofnanir hafa birt." + "Store": "Verslun" }, "nplurals=2; plural=(n%10!=1 || n%100==11);" ); diff --git a/l10n/is.json b/l10n/is.json index a2ccedfe4..1b99975db 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Stillingar stjórnunar", "Maximum dashboards per user": "Hámarksfjöldi mælaborða á notanda", "Maximum widgets per dashboard": "Hámarksfjöldi viðmótshluta á mælaborð", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = ótakmarkað. Lækkun á mörkum eyðir aldrei núverandi mælaborðum; hún hindrar aðeins ný þar til notendur eru aftur undir mörkunum.", @@ -1089,7 +1090,6 @@ "Kind": "Tegund", "Version": "Útgáfa", "How many dashboards there are, and who they belong to.": "Hversu mörg mælaborð eru til og hverjum þau tilheyra.", - "Store": "Verslun", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Settu upp skrár, skemu og flæði sem aðrar stofnanir hafa birt." + "Store": "Verslun" } } diff --git a/l10n/it.js b/l10n/it.js index eacd46bf1..5f58b50f8 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Impostazioni di amministrazione", "Features & roadmap" : "Funzionalità e roadmap", "Maximum dashboards per user" : "Numero massimo di dashboard per utente", "Maximum widgets per dashboard" : "Numero massimo di widget per dashboard", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Tipo", "Version": "Versione", "How many dashboards there are, and who they belong to.": "Quante dashboard ci sono e a chi appartengono.", - "Store": "Store", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installa registri, schemi e flussi pubblicati da altre organizzazioni." + "Store": "Store" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/it.json b/l10n/it.json index 4adb4eb72..262f00b62 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Impostazioni di amministrazione", "Maximum dashboards per user": "Numero massimo di dashboard per utente", "Maximum widgets per dashboard": "Numero massimo di widget per dashboard", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = illimitato. La riduzione di un limite non elimina mai le dashboard esistenti; blocca solo quelle nuove finché gli utenti non tornano sotto il limite.", @@ -1089,7 +1090,6 @@ "Kind": "Tipo", "Version": "Versione", "How many dashboards there are, and who they belong to.": "Quante dashboard ci sono e a chi appartengono.", - "Store": "Store", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installa registri, schemi e flussi pubblicati da altre organizzazioni." + "Store": "Store" } } diff --git a/l10n/lb.js b/l10n/lb.js index ced45303e..887dff034 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Admin settings", "Features & roadmap" : "Funktiounen a Roadmap", "Maximum dashboards per user" : "Maximal Dashboards pro Benotzer", "Maximum widgets per dashboard" : "Maximal Widgets pro Dashboard", @@ -1113,8 +1114,7 @@ OC.L10N.register( "Kind": "Aart", "Version": "Versioun", "How many dashboards there are, and who they belong to.": "Wéi vill Dashboards et gëtt a wiem se gehéieren.", - "Store": "Buttek", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installéiert Registeren, Schemaen a Flows déi aner Organisatiounen publizéiert hunn." + "Store": "Buttek" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/lb.json b/l10n/lb.json index 14d24506f..40fe64d6c 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Admin settings", "Maximum dashboards per user": "Maximal Dashboards pro Benotzer", "Maximum widgets per dashboard": "Maximal Widgets pro Dashboard", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = onbegrenzt. D'Erofsetze vun enger Limitt läscht ni existéierend Dashboards; et blockéiert nëmme nei bis d'Benotzer erëm ënner der Limitt sinn.", @@ -1089,7 +1090,6 @@ "Kind": "Aart", "Version": "Versioun", "How many dashboards there are, and who they belong to.": "Wéi vill Dashboards et gëtt a wiem se gehéieren.", - "Store": "Buttek", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installéiert Registeren, Schemaen a Flows déi aner Organisatiounen publizéiert hunn." + "Store": "Buttek" } } diff --git a/l10n/lt.js b/l10n/lt.js index 0cc0ad989..d0691af5c 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Administravimo nustatymai", "Features & roadmap" : "Funkcijos ir planas", "Maximum dashboards per user" : "Didžiausias skydelių skaičius vienam naudotojui", "Maximum widgets per dashboard" : "Didžiausias valdiklių skaičius viename skydelyje", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Rūšis", "Version": "Versija", "How many dashboards there are, and who they belong to.": "Kiek yra skydelių ir kam jie priklauso.", - "Store": "Parduotuvė", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Įdiekite registrus, schemas ir srautus, kuriuos paskelbė kitos organizacijos." + "Store": "Parduotuvė" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/lt.json b/l10n/lt.json index 053ac15f3..46e3713ec 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Administravimo nustatymai", "Maximum dashboards per user": "Didžiausias skydelių skaičius vienam naudotojui", "Maximum widgets per dashboard": "Didžiausias valdiklių skaičius viename skydelyje", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = neribota. Apribojimo sumažinimas niekada netrina esamų skydelių; jis tik blokuoja naujų kūrimą, kol naudotojai vėl bus mažiau už ribą.", @@ -1090,7 +1091,6 @@ "Kind": "Rūšis", "Version": "Versija", "How many dashboards there are, and who they belong to.": "Kiek yra skydelių ir kam jie priklauso.", - "Store": "Parduotuvė", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Įdiekite registrus, schemas ir srautus, kuriuos paskelbė kitos organizacijos." + "Store": "Parduotuvė" } } diff --git a/l10n/lv.js b/l10n/lv.js index 229881a28..717c0eb7a 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Pārvaldīšanas iestatījumi", "Features & roadmap" : "Funkcijas un ceļvedis", "Maximum dashboards per user" : "Maksimālais informācijas paneļu skaits uz lietotāju", "Maximum widgets per dashboard" : "Maksimālais logrīku skaits uz informācijas paneli", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Veids", "Version": "Versija", "How many dashboards there are, and who they belong to.": "Cik ir infopaneļu un kam tie pieder.", - "Store": "Veikals", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instalējiet reģistrus, shēmas un plūsmas, ko publicējušas citas organizācijas." + "Store": "Veikals" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2);" ); diff --git a/l10n/lv.json b/l10n/lv.json index 1f8d229a7..9bb4873f0 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Pārvaldīšanas iestatījumi", "Maximum dashboards per user": "Maksimālais informācijas paneļu skaits uz lietotāju", "Maximum widgets per dashboard": "Maksimālais logrīku skaits uz informācijas paneli", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = neierobežots. Ierobežojuma samazināšana nekad nedzēš esošos informācijas paneļus; tā tikai bloķē jaunus, līdz lietotāji atkal ir zem ierobežojuma.", @@ -1090,7 +1091,6 @@ "Kind": "Veids", "Version": "Versija", "How many dashboards there are, and who they belong to.": "Cik ir infopaneļu un kam tie pieder.", - "Store": "Veikals", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instalējiet reģistrus, shēmas un plūsmas, ko publicējušas citas organizācijas." + "Store": "Veikals" } } diff --git a/l10n/mk.js b/l10n/mk.js index f4db1155a..fa0853271 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Административни параметри", "Features & roadmap" : "Функции и патоказ", "Maximum dashboards per user" : "Максимален број контролни табли по корисник", "Maximum widgets per dashboard" : "Максимален број виџети по контролна табла", @@ -1113,8 +1114,7 @@ OC.L10N.register( "Kind": "Вид", "Version": "Верзија", "How many dashboards there are, and who they belong to.": "Колку контролни табли има и кому припаѓаат.", - "Store": "Продавница", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Инсталирајте регистри, шеми и текови објавени од други организации." + "Store": "Продавница" }, "nplurals=2; plural=(n%10==1 && n%100!=11 ? 0 : 1);" ); diff --git a/l10n/mk.json b/l10n/mk.json index 7537697cb..a36b82daa 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Административни параметри", "Maximum dashboards per user": "Максимален број контролни табли по корисник", "Maximum widgets per dashboard": "Максимален број виџети по контролна табла", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = неограничено. Намалувањето на ограничувањето никогаш не ги брише постоечките контролни табли; само ги блокира новите додека корисниците не се вратат под ограничувањето.", @@ -1089,7 +1090,6 @@ "Kind": "Вид", "Version": "Верзија", "How many dashboards there are, and who they belong to.": "Колку контролни табли има и кому припаѓаат.", - "Store": "Продавница", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Инсталирајте регистри, шеми и текови објавени од други организации." + "Store": "Продавница" } } diff --git a/l10n/mt.js b/l10n/mt.js index 32ec35bbe..51925453c 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Admin settings", "Features & roadmap" : "Funzjonijiet u pjan direzzjonali", "Maximum dashboards per user" : "Numru massimu ta' dashboards għal kull utent", "Maximum widgets per dashboard" : "Numru massimu ta' widgets għal kull dashboard", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Tip", "Version": "Verżjoni", "How many dashboards there are, and who they belong to.": "Kemm hemm dashboards, u ta' min huma.", - "Store": "Ħanut", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installa reġistri, skemi u flussi ppubblikati minn organizzazzjonijiet oħra." + "Store": "Ħanut" }, "nplurals=4; plural=(n==1 ? 0 : n==0 || (n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20) ? 2 : 3);" ); diff --git a/l10n/mt.json b/l10n/mt.json index 09f78ccaa..c5ecfac41 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Admin settings", "Maximum dashboards per user": "Numru massimu ta' dashboards għal kull utent", "Maximum widgets per dashboard": "Numru massimu ta' widgets għal kull dashboard", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = bla limitu. It-tnaqqis ta' limitu qatt ma jħassar dashboards eżistenti; jimblokka biss oħrajn ġodda sakemm l-utenti jerġgħu jkunu taħt il-limitu.", @@ -1091,7 +1092,6 @@ "Kind": "Tip", "Version": "Verżjoni", "How many dashboards there are, and who they belong to.": "Kemm hemm dashboards, u ta' min huma.", - "Store": "Ħanut", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installa reġistri, skemi u flussi ppubblikati minn organizzazzjonijiet oħra." + "Store": "Ħanut" } } diff --git a/l10n/nb.js b/l10n/nb.js index 0bbcf9d01..9e85b8e82 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Administrasjonsinnstillinger", "Features & roadmap" : "Funksjoner og veikart", "Maximum dashboards per user" : "Maksimalt antall dashbord per bruker", "Maximum widgets per dashboard" : "Maksimalt antall widgeter per dashbord", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Type", "Version": "Versjon", "How many dashboards there are, and who they belong to.": "Hvor mange dashbord det er, og hvem de tilhører.", - "Store": "Butikk", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installer registre, skjemaer og flyter som andre organisasjoner har publisert." + "Store": "Butikk" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/nb.json b/l10n/nb.json index d7b4db265..07254e51a 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Administrasjonsinnstillinger", "Maximum dashboards per user": "Maksimalt antall dashbord per bruker", "Maximum widgets per dashboard": "Maksimalt antall widgeter per dashbord", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = ubegrenset. Å senke en grense sletter aldri eksisterende dashbord; den blokkerer kun nye til brukerne igjen er under grensen.", @@ -1089,7 +1090,6 @@ "Kind": "Type", "Version": "Versjon", "How many dashboards there are, and who they belong to.": "Hvor mange dashbord det er, og hvem de tilhører.", - "Store": "Butikk", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installer registre, skjemaer og flyter som andre organisasjoner har publisert." + "Store": "Butikk" } } diff --git a/l10n/nl.js b/l10n/nl.js index 0cbcdeaed..8ecad5470 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Beheerinstellingen", "Features & roadmap" : "Functies en roadmap", "share_not_found" : "Gedeeld dashboard niet gevonden", "share_expired" : "Dit gedeelde dashboard is verlopen", @@ -1150,7 +1151,22 @@ OC.L10N.register( "Version": "Versie", "How many dashboards there are, and who they belong to.": "Hoeveel dashboards er zijn, en van wie ze zijn.", "Store": "Store", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installeer registers, schema's en flows die andere organisaties hebben gepubliceerd.", + "Install a dashboard another organisation published. Point LaunchPad at a registry and browse what it offers.": "Installeer een dashboard dat een andere organisatie publiceerde. Wijs LaunchPad naar een register en bekijk het aanbod.", + "Dashboard registry": "Dashboardregister", + "Connect a registry to install dashboards that other organisations published.": "Koppel een register om dashboards te installeren die andere organisaties publiceerden.", + "Enable OpenRegister so you can reach a registry.": "Schakel OpenRegister in om een register te bereiken.", + "Registry URL": "Register-URL", + "Use the address of the instance that publishes the templates.": "Gebruik het adres van de omgeving die de sjablonen publiceert.", + "Register": "Register", + "Leave empty to use launchpad.": "Laat leeg om launchpad te gebruiken.", + "Access token": "Toegangstoken", + "A token is set. Enter a new one to replace it.": "Er is een token ingesteld. Voer een nieuw token in om het te vervangen.", + "No token is set. Add one if the registry asks for it.": "Er is geen token ingesteld. Voeg er een toe als het register erom vraagt.", + "Save registry": "Register opslaan", + "Remove token": "Token verwijderen", + "Registry saved.": "Register opgeslagen.", + "Token removed.": "Token verwijderd.", + "The registry could not be saved.": "Het register kon niet worden opgeslagen.", "Load example data?": "Voorbeeldgegevens laden?", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.", "Load the example data": "Laad de voorbeeldgegevens", diff --git a/l10n/nl.json b/l10n/nl.json index c651ef281..a4ef4783c 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1135,7 +1135,22 @@ "Version": "Versie", "How many dashboards there are, and who they belong to.": "Hoeveel dashboards er zijn, en van wie ze zijn.", "Store": "Store", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installeer registers, schema's en flows die andere organisaties hebben gepubliceerd.", + "Install a dashboard another organisation published. Point LaunchPad at a registry and browse what it offers.": "Installeer een dashboard dat een andere organisatie publiceerde. Wijs LaunchPad naar een register en bekijk het aanbod.", + "Dashboard registry": "Dashboardregister", + "Connect a registry to install dashboards that other organisations published.": "Koppel een register om dashboards te installeren die andere organisaties publiceerden.", + "Enable OpenRegister so you can reach a registry.": "Schakel OpenRegister in om een register te bereiken.", + "Registry URL": "Register-URL", + "Use the address of the instance that publishes the templates.": "Gebruik het adres van de omgeving die de sjablonen publiceert.", + "Register": "Register", + "Leave empty to use launchpad.": "Laat leeg om launchpad te gebruiken.", + "Access token": "Toegangstoken", + "A token is set. Enter a new one to replace it.": "Er is een token ingesteld. Voer een nieuw token in om het te vervangen.", + "No token is set. Add one if the registry asks for it.": "Er is geen token ingesteld. Voeg er een toe als het register erom vraagt.", + "Save registry": "Register opslaan", + "Remove token": "Token verwijderen", + "Registry saved.": "Register opgeslagen.", + "Token removed.": "Token verwijderd.", + "The registry could not be saved.": "Het register kon niet worden opgeslagen.", "Admin settings": "Beheerinstellingen" } } diff --git a/l10n/pl.js b/l10n/pl.js index a4607ce1d..72d97b692 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Ustawienia administracyjne", "Features & roadmap" : "Funkcje i plan rozwoju", "Maximum dashboards per user" : "Maksymalna liczba pulpitów na użytkownika", "Maximum widgets per dashboard" : "Maksymalna liczba widżetów na pulpit", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Rodzaj", "Version": "Wersja", "How many dashboards there are, and who they belong to.": "Ile jest pulpitów i do kogo należą.", - "Store": "Sklep", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Zainstaluj rejestry, schematy i przepływy opublikowane przez inne organizacje." + "Store": "Sklep" }, "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/pl.json b/l10n/pl.json index 37b14a19a..dae5fe537 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Ustawienia administracyjne", "Maximum dashboards per user": "Maksymalna liczba pulpitów na użytkownika", "Maximum widgets per dashboard": "Maksymalna liczba widżetów na pulpit", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = bez ograniczeń. Obniżenie limitu nigdy nie usuwa istniejących pulpitów; jedynie blokuje nowe, dopóki użytkownicy nie zejdą poniżej limitu.", @@ -1090,7 +1091,6 @@ "Kind": "Rodzaj", "Version": "Wersja", "How many dashboards there are, and who they belong to.": "Ile jest pulpitów i do kogo należą.", - "Store": "Sklep", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Zainstaluj rejestry, schematy i przepływy opublikowane przez inne organizacje." + "Store": "Sklep" } } diff --git a/l10n/pt.js b/l10n/pt.js index 780b18faf..b6fd7c7b9 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Admin settings", "Features & roadmap" : "Funcionalidades e roteiro", "Maximum dashboards per user" : "Máximo de painéis por utilizador", "Maximum widgets per dashboard" : "Máximo de widgets por painel", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Tipo", "Version": "Versão", "How many dashboards there are, and who they belong to.": "Quantos painéis existem e a quem pertencem.", - "Store": "Loja", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instale registos, esquemas e fluxos publicados por outras organizações." + "Store": "Loja" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/pt.json b/l10n/pt.json index cd574a62a..81ac87a7d 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Admin settings", "Maximum dashboards per user": "Máximo de painéis por utilizador", "Maximum widgets per dashboard": "Máximo de widgets por painel", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = ilimitado. Reduzir um limite nunca elimina os painéis existentes; apenas bloqueia os novos até que os utilizadores voltem a estar abaixo do limite.", @@ -1089,7 +1090,6 @@ "Kind": "Tipo", "Version": "Versão", "How many dashboards there are, and who they belong to.": "Quantos painéis existem e a quem pertencem.", - "Store": "Loja", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instale registos, esquemas e fluxos publicados por outras organizações." + "Store": "Loja" } } diff --git a/l10n/rm.js b/l10n/rm.js index ea897b80e..941b0a9fa 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Admin settings", "Features & roadmap" : "Funcziuns e plan da svilup", "Maximum dashboards per user" : "Maximum da dashboards per utilisader", "Maximum widgets per dashboard" : "Maximum da widgets per dashboard", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Gener", "Version": "Versiun", "How many dashboards there are, and who they belong to.": "Quants panels ch'i dat e tgi ch'els appartegnan.", - "Store": "Butia", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installescha registers, schemas e process ch'autras organisaziuns han publitgà." + "Store": "Butia" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/rm.json b/l10n/rm.json index 4b0245b57..f98864d96 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Admin settings", "Maximum dashboards per user": "Maximum da dashboards per utilisader", "Maximum widgets per dashboard": "Maximum da widgets per dashboard", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = senza limita. Sbassar ina limita stizza mai dashboards existents; el bloccha mo ils nums fin che ils utilisaders èn puspè sut la limita.", @@ -1089,7 +1090,6 @@ "Kind": "Gener", "Version": "Versiun", "How many dashboards there are, and who they belong to.": "Quants panels ch'i dat e tgi ch'els appartegnan.", - "Store": "Butia", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installescha registers, schemas e process ch'autras organisaziuns han publitgà." + "Store": "Butia" } } diff --git a/l10n/ro.js b/l10n/ro.js index 72b4c07dd..fe0ad3aa6 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Setări de administrare", "Features & roadmap" : "Funcții și foaie de parcurs", "Maximum dashboards per user" : "Numărul maxim de tablouri de bord per utilizator", "Maximum widgets per dashboard" : "Numărul maxim de widgeturi per tablou de bord", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Fel", "Version": "Versiune", "How many dashboards there are, and who they belong to.": "Câte tablouri de bord există și cui aparțin.", - "Store": "Magazin", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instalați registre, scheme și fluxuri publicate de alte organizații." + "Store": "Magazin" }, "nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100>0 && n%100<20)) ? 1 : 2);" ); diff --git a/l10n/ro.json b/l10n/ro.json index 18070f124..f65fed0f4 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Setări de administrare", "Maximum dashboards per user": "Numărul maxim de tablouri de bord per utilizator", "Maximum widgets per dashboard": "Numărul maxim de widgeturi per tablou de bord", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = nelimitat. Reducerea unei limite nu șterge niciodată tablourile de bord existente; blochează doar pe cele noi până când utilizatorii revin sub limită.", @@ -1090,7 +1091,6 @@ "Kind": "Fel", "Version": "Versiune", "How many dashboards there are, and who they belong to.": "Câte tablouri de bord există și cui aparțin.", - "Store": "Magazin", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instalați registre, scheme și fluxuri publicate de alte organizații." + "Store": "Magazin" } } diff --git a/l10n/ru.js b/l10n/ru.js index cf4c5baad..847bffa6c 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Параметры сервера", "Features & roadmap" : "Функции и дорожная карта", "Maximum dashboards per user" : "Максимальное количество панелей на пользователя", "Maximum widgets per dashboard" : "Максимальное количество виджетов на панель", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Вид", "Version": "Версия", "How many dashboards there are, and who they belong to.": "Сколько есть панелей и кому они принадлежат.", - "Store": "Магазин", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Установите реестры, схемы и потоки, опубликованные другими организациями." + "Store": "Магазин" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/ru.json b/l10n/ru.json index 3b9583a36..a1e718f7f 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Параметры сервера", "Maximum dashboards per user": "Максимальное количество дашбордов на пользователя", "Maximum widgets per dashboard": "Максимальное количество виджетов на дашборд", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = без ограничений. Снижение лимита никогда не удаляет существующие дашборды; оно лишь блокирует создание новых, пока пользователи не вернутся под лимит.", @@ -1090,7 +1091,6 @@ "Kind": "Вид", "Version": "Версия", "How many dashboards there are, and who they belong to.": "Сколько есть панелей и кому они принадлежат.", - "Store": "Магазин", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Установите реестры, схемы и потоки, опубликованные другими организациями." + "Store": "Магазин" } } diff --git a/l10n/sk.js b/l10n/sk.js index 85bc42bbf..eb2bc99c4 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Nastavenia administrácie", "Features & roadmap" : "Funkcie a plán vývoja", "Maximum dashboards per user" : "Maximálny počet dashboardov na používateľa", "Maximum widgets per dashboard" : "Maximálny počet widgetov na dashboard", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Druh", "Version": "Verzia", "How many dashboards there are, and who they belong to.": "Koľko je prehľadov a komu patria.", - "Store": "Obchod", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Nainštalujte registre, schémy a toky zverejnené inými organizáciami." + "Store": "Obchod" }, "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;" ); diff --git a/l10n/sk.json b/l10n/sk.json index f7457867c..3f3e0a653 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Nastavenia administrácie", "Maximum dashboards per user": "Maximálny počet dashboardov na používateľa", "Maximum widgets per dashboard": "Maximálny počet widgetov na dashboard", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = neobmedzené. Zníženie limitu nikdy neodstráni existujúce dashboardy; iba zablokuje nové, kým sa používatelia nevrátia pod limit.", @@ -1090,7 +1091,6 @@ "Kind": "Druh", "Version": "Verzia", "How many dashboards there are, and who they belong to.": "Koľko je prehľadov a komu patria.", - "Store": "Obchod", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Nainštalujte registre, schémy a toky zverejnené inými organizáciami." + "Store": "Obchod" } } diff --git a/l10n/sl.js b/l10n/sl.js index abd2c882e..cec16c49a 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Skrbniške nastavitve", "Features & roadmap" : "Funkcije in načrt razvoja", "Maximum dashboards per user" : "Največje število nadzornih plošč na uporabnika", "Maximum widgets per dashboard" : "Največje število gradnikov na nadzorno ploščo", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Vrsta", "Version": "Različica", "How many dashboards there are, and who they belong to.": "Koliko je nadzornih plošč in komu pripadajo.", - "Store": "Trgovina", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Namestite registre, sheme in tokove, ki so jih objavile druge organizacije." + "Store": "Trgovina" }, "nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);" ); diff --git a/l10n/sl.json b/l10n/sl.json index ca58d393f..a98b91ec8 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Skrbniške nastavitve", "Maximum dashboards per user": "Največje število nadzornih plošč na uporabnika", "Maximum widgets per dashboard": "Največje število gradnikov na nadzorno ploščo", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = neomejeno. Zmanjšanje omejitve nikoli ne izbriše obstoječih nadzornih plošč; le prepreči nove, dokler uporabniki niso ponovno pod omejitvijo.", @@ -1091,7 +1092,6 @@ "Kind": "Vrsta", "Version": "Različica", "How many dashboards there are, and who they belong to.": "Koliko je nadzornih plošč in komu pripadajo.", - "Store": "Trgovina", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Namestite registre, sheme in tokove, ki so jih objavile druge organizacije." + "Store": "Trgovina" } } diff --git a/l10n/sq.js b/l10n/sq.js index 08852df26..250599de3 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Admin settings", "Features & roadmap" : "Veçoritë dhe plani i zhvillimit", "Maximum dashboards per user" : "Numri maksimal i paneleve për përdorues", "Maximum widgets per dashboard" : "Numri maksimal i widget-eve për panel", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Lloji", "Version": "Versioni", "How many dashboards there are, and who they belong to.": "Sa panele ka dhe kujt i përkasin.", - "Store": "Dyqani", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instaloni regjistra, skema dhe rrjedha të publikuara nga organizata të tjera." + "Store": "Dyqani" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/sq.json b/l10n/sq.json index 7853241bb..45088f228 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Admin settings", "Maximum dashboards per user": "Numri maksimal i paneleve për përdorues", "Maximum widgets per dashboard": "Numri maksimal i widget-eve për panel", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = i pakufizuar. Ulja e një kufiri nuk fshin kurrë panelet ekzistuese; ajo vetëm bllokon krijimin e të rejave derisa përdoruesit të kthehen nën kufi.", @@ -1089,7 +1090,6 @@ "Kind": "Lloji", "Version": "Versioni", "How many dashboards there are, and who they belong to.": "Sa panele ka dhe kujt i përkasin.", - "Store": "Dyqani", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Instaloni regjistra, skema dhe rrjedha të publikuara nga organizata të tjera." + "Store": "Dyqani" } } diff --git a/l10n/sr.js b/l10n/sr.js index 55318526f..62349d493 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Административна подешавања", "Features & roadmap" : "Функције и план развоја", "Maximum dashboards per user" : "Максималан број контролних табли по кориснику", "Maximum widgets per dashboard" : "Максималан број виџета по контролној табли", @@ -1113,7 +1114,6 @@ OC.L10N.register( "Kind": "Врста", "Version": "Верзија", "How many dashboards there are, and who they belong to.": "Колико има контролних табли и коме припадају.", - "Store": "Продавница", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Инсталирајте регистре, шеме и токове које су објавиле друге организације." + "Store": "Продавница" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);"); diff --git a/l10n/sr.json b/l10n/sr.json index fb240ca9a..686e1911c 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Административна подешавања", "Maximum dashboards per user": "Максималан број контролних табли по кориснику", "Maximum widgets per dashboard": "Максималан број виџета по контролној табли", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = неограничено. Снижавање ограничења никада не брише постојеће контролне табле; само блокира нове док се корисници не врате испод ограничења.", @@ -1090,7 +1091,6 @@ "Kind": "Врста", "Version": "Верзија", "How many dashboards there are, and who they belong to.": "Колико има контролних табли и коме припадају.", - "Store": "Продавница", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Инсталирајте регистре, шеме и токове које су објавиле друге организације." + "Store": "Продавница" } } diff --git a/l10n/sv.js b/l10n/sv.js index 858518c46..4954a6065 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Administration", "Features & roadmap" : "Funktioner och färdplan", "Maximum dashboards per user" : "Maximalt antal dashboards per användare", "Maximum widgets per dashboard" : "Maximalt antal widgets per dashboard", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Typ", "Version": "Version", "How many dashboards there are, and who they belong to.": "Hur många instrumentpaneler det finns, och vem de tillhör.", - "Store": "Butik", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installera register, scheman och flöden som andra organisationer har publicerat." + "Store": "Butik" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/sv.json b/l10n/sv.json index 990c43d8a..59a4bd689 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Administration", "Maximum dashboards per user": "Maximalt antal dashboards per användare", "Maximum widgets per dashboard": "Maximalt antal widgets per dashboard", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = obegränsat. Att sänka en gräns raderar aldrig befintliga dashboards; den blockerar endast nya tills användarna åter ligger under gränsen.", @@ -1089,7 +1090,6 @@ "Kind": "Typ", "Version": "Version", "How many dashboards there are, and who they belong to.": "Hur många instrumentpaneler det finns, och vem de tillhör.", - "Store": "Butik", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installera register, scheman och flöden som andra organisationer har publicerat." + "Store": "Butik" } } diff --git a/l10n/tr.js b/l10n/tr.js index 9672dbed8..d9e906636 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Yönetim ayarları", "Features & roadmap" : "Özellikler ve yol haritası", "Maximum dashboards per user" : "Kullanıcı başına en fazla pano", "Maximum widgets per dashboard" : "Pano başına en fazla araç", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Tür", "Version": "Sürüm", "How many dashboards there are, and who they belong to.": "Kaç panonun olduğu ve kime ait oldukları.", - "Store": "Mağaza", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Diğer kuruluşların yayımladığı kayıtları, şemaları ve akışları yükleyin." + "Store": "Mağaza" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/tr.json b/l10n/tr.json index 65629878c..3b8fa3299 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Yönetim ayarları", "Maximum dashboards per user": "Kullanıcı başına en fazla pano", "Maximum widgets per dashboard": "Pano başına en fazla araç", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = sınırsız. Bir sınırın düşürülmesi var olan panoları asla silmez; yalnızca kullanıcılar sınırın altına dönene kadar yenilerinin oluşturulmasını engeller.", @@ -1089,7 +1090,6 @@ "Kind": "Tür", "Version": "Sürüm", "How many dashboards there are, and who they belong to.": "Kaç panonun olduğu ve kime ait oldukları.", - "Store": "Mağaza", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Diğer kuruluşların yayımladığı kayıtları, şemaları ve akışları yükleyin." + "Store": "Mağaza" } } diff --git a/l10n/uk.js b/l10n/uk.js index 915d3d481..09e1433b5 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1,6 +1,7 @@ OC.L10N.register( "launchpad", { + "Admin settings" : "Адміністрування", "Features & roadmap" : "Функції та дорожня карта", "Maximum dashboards per user" : "Максимальна кількість дашбордів на користувача", "Maximum widgets per dashboard" : "Максимальна кількість віджетів на дашборд", @@ -1122,8 +1123,7 @@ OC.L10N.register( "Kind": "Вид", "Version": "Версія", "How many dashboards there are, and who they belong to.": "Скільки є дашбордів і кому вони належать.", - "Store": "Магазин", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Встановлюйте реєстри, схеми та потоки, опубліковані іншими організаціями." + "Store": "Магазин" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/uk.json b/l10n/uk.json index a14b9a3e9..f84467e75 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1,5 +1,6 @@ { "translations": { + "Admin settings": "Адміністрування", "Maximum dashboards per user": "Максимальна кількість дашбордів на користувача", "Maximum widgets per dashboard": "Максимальна кількість віджетів на дашборд", "0 = unlimited. Lowering a limit never deletes existing dashboards; it only blocks new ones until users are back under the limit.": "0 = без обмежень. Зниження ліміту ніколи не видаляє наявні дашборди; воно лише блокує створення нових, доки користувачі не повернуться під ліміт.", @@ -1090,7 +1091,6 @@ "Kind": "Вид", "Version": "Версія", "How many dashboards there are, and who they belong to.": "Скільки є дашбордів і кому вони належать.", - "Store": "Магазин", - "Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Встановлюйте реєстри, схеми та потоки, опубліковані іншими організаціями." + "Store": "Магазин" } } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 758f7886c..6a0e7c6f9 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -23,6 +23,7 @@ use OCA\LaunchPad\Controller\MetricsController; use OCA\LaunchPad\Event\DashboardDeletedEvent; use OCA\LaunchPad\Listener\CspListener; +use OCA\LaunchPad\Listener\DashboardSharesListener; use OCA\LaunchPad\Listener\GroupDeletedListener; use OCA\LaunchPad\Listener\LocksListener; use OCA\LaunchPad\Listener\MetadataValuesListener; @@ -36,7 +37,9 @@ use OCA\LaunchPad\Listener\WidgetPlacementsListener; use OCA\LaunchPad\Notification\Notifier; use OCA\LaunchPad\Search\LaunchPadSearchProvider; +use OCA\LaunchPad\Service\ImportService; use OCA\LaunchPad\Service\PublicShareContext; +use OCA\LaunchPad\Service\StoreService; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -92,6 +95,11 @@ public function register(IRegistrationContext $context): void { // runtime app id `launchpad`; the engine reads the manifest under it and // emits the launchpad_ Prometheus prefix, preserving the contract. $this->registerObservability(context: $context); + + // Store plane (ADR-080). Same laziness as above: the discovery client is + // resolved through self::optional(), so an instance without OpenRegister + // gets a store that reports `not_configured` rather than a fatal. + $this->registerStore(context: $context); }//end register() /** @@ -194,6 +202,10 @@ private function registerCascadeListeners(IRegistrationContext $context): void { event: DashboardDeletedEvent::class, listener: PublicSharesListener::class ); + $context->registerEventListener( + event: DashboardDeletedEvent::class, + listener: DashboardSharesListener::class + ); $context->registerEventListener( event: DashboardDeletedEvent::class, listener: MetadataValuesListener::class @@ -234,6 +246,44 @@ private function registerIntegrations(IRegistrationContext $context): void { ); }//end registerIntegrations() + /** + * Wire the store service (ADR-080). + * + * `GenericStoreService` is OpenRegister's, and OpenRegister is optional: + * LaunchPad declares no `` dependency on it, so it can be absent or + * disabled. The class is named as a STRING and resolved through + * {@see self::optional()}, so nothing touches an `OCA\OpenRegister\…` + * symbol at registration time and a missing engine degrades the store to + * `not_configured` instead of fatalling NC bootstrap. + * + * `StoreController` is NOT registered here. Declaring the class is already + * what stops the engine aliasing its own `GenericStoreController` at that + * name, and its remaining collaborators resolve by type. + * + * @param IRegistrationContext $context The registration context. + * + * @return void + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-002-an-absent-openregister-must-degrade-never-fatal + */ + private function registerStore(IRegistrationContext $context): void { + $context->registerService( + StoreService::class, + // @psalm-suppress UnusedClosureParam,TooManyArguments + static function (\Psr\Container\ContainerInterface $c): StoreService { + return new StoreService( + discovery: self::optional( + container: $c, + id: 'OCA\\OpenRegister\\AppHost\\Service\\GenericStoreService' + ), + importService: $c->get(ImportService::class), + appConfig: $c->get(\OCP\IAppConfig::class), + logger: $c->get(\Psr\Log\LoggerInterface::class) + ); + } + ); + }//end registerStore() + /** * Wire the AppHost observability controllers (ADR-040). * diff --git a/lib/Command/DashboardDeleteCommand.php b/lib/Command/DashboardDeleteCommand.php index e930eae23..9ce076fc8 100644 --- a/lib/Command/DashboardDeleteCommand.php +++ b/lib/Command/DashboardDeleteCommand.php @@ -262,7 +262,8 @@ private function applyDeletion( dashboardUuid: $uuid, ownerUserId: (string)($dashboard->getUserId() ?? ''), type: (string)($dashboard->getType() ?? Dashboard::TYPE_USER), - deletedAt: new DateTimeImmutable() + deletedAt: new DateTimeImmutable(), + dashboardId: (int)$dashboard->getId() ) ); }//end applyDeletion() diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 51657756d..c04d59c1e 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -416,6 +416,13 @@ public function getSettings(): JSONResponse { /** * Update admin settings. * + * Five settings are read under one name and written under another: GET + * answers `allowUserDashboards`, this PUT took only `allowUserDash`. An + * unrecognised key binds to null, null means "not supplied", so a caller + * round-tripping GET into PUT wrote nothing and was told `{"status": + * "ok"}`. Both spellings are accepted; the short one wins, since + * AdminSettings.vue sends it. Covered by AdminControllerSettingsAliasTest. + * * @param string|null $defaultPermLevel Default permission level. * @param bool|null $allowUserDash Allow user dashboards. * @param bool|null $allowMultiDash Allow multiple dashboards. @@ -456,6 +463,11 @@ public function getSettings(): JSONResponse { * `{query}`. * tile-quick-search * REQ-QSEARCH-004. + * @param string|null $defaultPermissionLevel Alias of `$defaultPermLevel`. + * @param bool|null $allowUserDashboards Alias of `$allowUserDash`. + * @param bool|null $allowMultipleDashboards Alias of `$allowMultiDash`. + * @param int|null $defaultGridColumns Alias of `$defaultGridCols`. + * @param array|null $linkCreateFileExtensions Alias of `$linkCreateFileExts`. * * @return JSONResponse The update confirmation. * @@ -475,7 +487,19 @@ public function updateSettings( ?int $maxDashboardsPerUser = null, ?int $maxWidgetsPerDashboard = null, ?string $quicksearchFallbackTarget = null, + ?string $defaultPermissionLevel = null, + ?bool $allowUserDashboards = null, + ?bool $allowMultipleDashboards = null, + ?int $defaultGridColumns = null, + ?array $linkCreateFileExtensions = null, ): JSONResponse { + // Read-side aliases, see the docblock. Short form wins. + $defaultPermLevel ??= $defaultPermissionLevel; + $allowUserDash ??= $allowUserDashboards; + $allowMultiDash ??= $allowMultipleDashboards; + $defaultGridCols ??= $defaultGridColumns; + $linkCreateFileExts ??= $linkCreateFileExtensions; + try { $this->settingsService->updateSettings( defaultPermLevel: $defaultPermLevel, diff --git a/lib/Controller/StoreController.php b/lib/Controller/StoreController.php new file mode 100644 index 000000000..e05f08279 --- /dev/null +++ b/lib/Controller/StoreController.php @@ -0,0 +1,215 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT:auto + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\LaunchPad\Controller; + +use OCA\LaunchPad\AppInfo\Application; +use OCA\LaunchPad\Service\StoreService; +use OCA\LaunchPad\Settings\LaunchPadAdmin; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Browse a dashboard registry and install one of its templates. + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md + */ +class StoreController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The HTTP request. + * @param StoreService $storeService Discovery delegation and install. + * @param IUserSession $userSession The current session. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly StoreService $storeService, + private readonly IUserSession $userSession, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * Search the configured registry. + * + * Signed-in rather than admin: browsing is how a user finds out what an + * administrator could install for them, and the response carries only + * normalised cards. The engine drops every remote property outside the + * descriptor's card map, so the registry URL and token never ride back. + * + * The guard is in the body rather than only in the attribute so an + * anonymous caller gets an explicit 401 instead of a login redirect into a + * fetch that expected JSON. + * + * @return JSONResponse 200 with `{outcome, cards}`, or 401 for anonymous. + * + * @no-admin-idor-exempt Addresses no object of this instance. The query and + * kind filter are forwarded to an EXTERNAL registry and the response is + * normalised cards, so there is no local identifier to guess. + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-004-installing-must-require-an-administrator + */ + #[NoAdminRequired] + public function search(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse( + data: ['outcome' => 'unauthenticated', 'cards' => []], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + $result = $this->storeService->search( + query: $this->stringParam(name: 'q'), + kind: $this->stringParam(name: 'kind') + ); + + return new JSONResponse( + data: [ + 'outcome' => $result['outcome'], + 'cards' => $result['cards'], + 'kinds' => [], + 'builtIn' => [], + ], + statusCode: Http::STATUS_OK + ); + }//end search() + + /** + * Install one registry template into this instance. + * + * Administrative. An install writes dashboards from a third-party server + * into the instance, so it is gated exactly like the rest of LaunchPad's + * admin surface (ADR-005). + * + * @param string $slug The remote item slug. + * + * @return JSONResponse 200 with a per-component report, or 400 when the install was refused. + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-004-installing-must-require-an-administrator + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function install(string $slug): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse( + data: ['success' => false, 'message' => 'Sign in to install a template.', 'components' => []], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + $result = $this->storeService->install(slug: $slug, userId: $user->getUID()); + + // A refusal that installed nothing is a 400 so the page shows the + // message rather than an empty success. A PARTIAL install is a 200: the + // dashboards that arrived are real, and the report names the rest. + $status = Http::STATUS_OK; + if ($result['success'] === false) { + $status = Http::STATUS_BAD_REQUEST; + } + + return new JSONResponse(data: $result, statusCode: $status); + }//end install() + + /** + * Read the registry connection. + * + * The token is reported as a boolean and never as a value. + * + * @return JSONResponse 200 with `{registryUrl, registryRegister, tokenConfigured}`. + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-007-the-registry-token-must-not-be-readable-back + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function getConfig(): JSONResponse { + return new JSONResponse( + data: ($this->storeService->getRegistryConfig() + ['available' => $this->storeService->isAvailable()]), + statusCode: Http::STATUS_OK + ); + }//end getConfig() + + /** + * Write the registry connection. + * + * An omitted key leaves the stored value alone, so a form can change the + * URL without resubmitting the token. An empty string clears the key. + * + * @return JSONResponse 200 with the redacted connection. + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-007-the-registry-token-must-not-be-readable-back + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function updateConfig(): JSONResponse { + $this->storeService->updateRegistryConfig( + registryUrl: $this->stringParam(name: 'registryUrl'), + registryToken: $this->stringParam(name: 'registryToken'), + registryRegister: $this->stringParam(name: 'registryRegister') + ); + + return new JSONResponse( + data: $this->storeService->getRegistryConfig(), + statusCode: Http::STATUS_OK + ); + }//end updateConfig() + + /** + * Read one request parameter as a string, or null when it was not sent. + * + * The null is load-bearing: it is what distinguishes "leave this key alone" + * from "clear this key", and the empty string means the second. + * + * @param string $name The parameter name. + * + * @return string|null The value, or null when absent or not a string. + */ + private function stringParam(string $name): ?string { + $value = $this->request->getParam($name); + if (is_string($value) === false) { + return null; + } + + return $value; + }//end stringParam() +}//end class diff --git a/lib/Db/DashboardShareMapper.php b/lib/Db/DashboardShareMapper.php index de5e32bcb..4bbb3abc7 100644 --- a/lib/Db/DashboardShareMapper.php +++ b/lib/Db/DashboardShareMapper.php @@ -235,12 +235,15 @@ public function findForRecipient(string $userId, array $groupIds): array { /** * Delete all shares for a dashboard. * + * Keyed on the integer id, so it works after the dashboard row itself + * has been deleted, which is when `DashboardDeletedEvent` fires. + * * @param int $dashboardId The dashboard ID. * - * @return void + * @return int The number of rows deleted. * @spec openspec/changes/launchpad-legacy-quality-cleanup/tasks.md#task-1 */ - public function deleteByDashboardId(int $dashboardId): void { + public function deleteByDashboardId(int $dashboardId): int { $qb = $this->db->getQueryBuilder(); $qb->delete(delete: $this->getTableName()) ->where( @@ -253,7 +256,7 @@ public function deleteByDashboardId(int $dashboardId): void { ) ); - $qb->executeStatement(); + return $qb->executeStatement(); }//end deleteByDashboardId() /** @@ -460,9 +463,14 @@ public function deleteByOwnerAndRecipient( /** * Delete all shares for a dashboard identified by UUID. * - * Uses a subquery to translate the UUID to an integer dashboard_id so - * the caller (DashboardDeletedEvent) never has to resolve the ID itself. - * Triggered by cascade-event processing (REQ-CSC-003). + * Uses a subquery to translate the UUID to an integer dashboard_id. + * + * ⚠️ ONLY WHILE THE DASHBOARD ROW STILL EXISTS. This used to say it was + * triggered by cascade-event processing. Nothing called it, and it could + * not have worked there: `DashboardDeletedEvent` fires after the row is + * gone, so the subquery matches nothing and zero rows are deleted. + * `DashboardSharesListener` deletes by id and uses this only as the + * fallback for an event that carries no id. * * @param string $dashboardUuid The dashboard UUID. * diff --git a/lib/Event/DashboardDeletedEvent.php b/lib/Event/DashboardDeletedEvent.php index 7a459d0fc..1886e1755 100644 --- a/lib/Event/DashboardDeletedEvent.php +++ b/lib/Event/DashboardDeletedEvent.php @@ -40,6 +40,7 @@ * @see \OCA\LaunchPad\Listener\LocksListener * @see \OCA\LaunchPad\Listener\VersionsListener * @see \OCA\LaunchPad\Listener\PublicSharesListener + * @see \OCA\LaunchPad\Listener\DashboardSharesListener * @see \OCA\LaunchPad\Listener\MetadataValuesListener * @see \OCA\LaunchPad\Listener\TranslationsListener * @see \OCA\LaunchPad\Listener\ViewAnalyticsListener @@ -53,12 +54,15 @@ final class DashboardDeletedEvent extends Event { * @param string $ownerUserId The owner user ID at the time of deletion. * @param string $type The dashboard type (`user`, `group_shared`, `admin_template`). * @param DateTimeImmutable $deletedAt The instant the soft-delete completed. + * @param int|null $dashboardId The integer id of the deleted row, or null + * when the dispatcher did not have it. */ public function __construct( private readonly string $dashboardUuid, private readonly string $ownerUserId, private readonly string $type, private readonly DateTimeImmutable $deletedAt, + private readonly ?int $dashboardId = null, ) { parent::__construct(); }//end __construct() @@ -102,4 +106,26 @@ public function getType(): string { public function getDeletedAt(): DateTimeImmutable { return $this->deletedAt; }//end getDeletedAt() + + /** + * Get the integer id of the deleted dashboard row. + * + * 🔴 WHY THE EVENT CARRIES THE ID AT ALL. Every dispatcher fires this + * event AFTER the dashboard row is gone. A dependent table keyed on + * `dashboard_id` therefore cannot be cleaned by translating the UUID + * through `oc_launchpad_dashboards`: that subquery finds nothing, and + * the delete silently removes zero rows. `oc_launchpad_dashboard_shares` + * is such a table, which is why `DashboardSharesListener` reads this. + * + * Null when a dispatcher did not supply it. A listener that needs it + * must then fall back to the UUID, which only works while the row + * still exists. + * + * @return int|null The dashboard id, or null. + * + * @spec openspec/specs/dashboard-cascade-events/spec.md#requirement-req-csc-001-dashboarddeletedevent-definition + */ + public function getDashboardId(): ?int { + return $this->dashboardId; + }//end getDashboardId() }//end class diff --git a/lib/Listener/DashboardSharesListener.php b/lib/Listener/DashboardSharesListener.php new file mode 100644 index 000000000..7c7dc07ee --- /dev/null +++ b/lib/Listener/DashboardSharesListener.php @@ -0,0 +1,135 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/specs/dashboard-cascade-events/spec.md#requirement-req-csc-003-dependent-data-listener-group + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\LaunchPad\Listener; + +use OCA\LaunchPad\Db\DashboardShareMapper; +use OCA\LaunchPad\Event\DashboardDeletedEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Deletes user and group shares for a deleted dashboard. + * + * @implements IEventListener + * + * @spec openspec/specs/dashboard-cascade-events/spec.md#requirement-req-csc-003-dependent-data-listener-group + */ +class DashboardSharesListener implements IEventListener { + /** + * Constructor. + * + * @param DashboardShareMapper $shareMapper User and group share mapper. + * @param LoggerInterface $logger PSR-3 logger for log-and-continue + * failure handling per REQ-CSC-006. + */ + public function __construct( + private readonly DashboardShareMapper $shareMapper, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Handle the DashboardDeletedEvent by deleting every share of the dashboard. + * + * @param Event $event The event. + * + * @return void + * + * @spec openspec/specs/dashboard-cascade-events/spec.md#requirement-req-csc-003-dependent-data-listener-group + */ + public function handle(Event $event): void { + if (($event instanceof DashboardDeletedEvent) === false) { + return; + } + + $uuid = $event->getDashboardUuid(); + + try { + $deleted = $this->deleteShares( + uuid: $uuid, + dashboardId: $event->getDashboardId() + ); + + $this->logger->debug( + message: sprintf( + 'launchpad DashboardSharesListener: deleted %d share row(s) for dashboard %s', + $deleted, + $uuid + ), + context: ['app' => 'launchpad'] + ); + } catch (Throwable $t) { + // Failure isolation per REQ-CSC-006: log at WARN, do not rethrow, + // so peer listeners still execute. + $this->logger->warning( + message: sprintf( + 'launchpad DashboardSharesListener: failed for dashboard %s: %s', + $uuid, + $t->getMessage() + ), + context: ['app' => 'launchpad'] + ); + }//end try + }//end handle() + + /** + * Delete the shares by id, or by UUID when the event carries no id. + * + * The id path is the one that works after the dashboard row is gone, which + * is when every dispatcher fires. The UUID path translates through + * `oc_launchpad_dashboards` and only works while the row still exists. + * + * @param string $uuid The deleted dashboard's UUID. + * @param int|null $dashboardId The deleted row's id, or null. + * + * @return int The number of share rows deleted. + * + * @spec openspec/specs/dashboard-cascade-events/spec.md#requirement-req-csc-003-dependent-data-listener-group + */ + private function deleteShares(string $uuid, ?int $dashboardId): int { + if ($dashboardId !== null && $dashboardId > 0) { + return $this->shareMapper->deleteByDashboardId(dashboardId: $dashboardId); + } + + return $this->shareMapper->deleteByDashboardUuid(dashboardUuid: $uuid); + }//end deleteShares() +}//end class diff --git a/lib/Listener/TreeListener.php b/lib/Listener/TreeListener.php index afddeb89e..ba4af9fb5 100644 --- a/lib/Listener/TreeListener.php +++ b/lib/Listener/TreeListener.php @@ -100,7 +100,8 @@ public function handle(Event $event): void { dashboardUuid: $childUuid, ownerUserId: $childOwnerId, type: $childType, - deletedAt: $deletedAt + deletedAt: $deletedAt, + dashboardId: $child->getId() ) ); }//end foreach diff --git a/lib/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index e4e27d94d..64f1ac234 100644 --- a/lib/Listener/UserDeletedListener.php +++ b/lib/Listener/UserDeletedListener.php @@ -202,7 +202,8 @@ private function handleOwnedDashboard( dashboardUuid: $deletedUuid, ownerUserId: $deletedUserId, type: (string)($dashboard->getType() ?? Dashboard::TYPE_USER), - deletedAt: new DateTimeImmutable() + deletedAt: new DateTimeImmutable(), + dashboardId: $dashboardId ) ); } diff --git a/lib/Service/AdminTemplateService.php b/lib/Service/AdminTemplateService.php index 99fda313c..7f4d8d896 100644 --- a/lib/Service/AdminTemplateService.php +++ b/lib/Service/AdminTemplateService.php @@ -383,7 +383,8 @@ public function deleteTemplate(int $id): void { dashboardUuid: $deletedUuid, ownerUserId: (string)($template->getUserId() ?? ''), type: Dashboard::TYPE_ADMIN_TEMPLATE, - deletedAt: new DateTimeImmutable() + deletedAt: new DateTimeImmutable(), + dashboardId: $id ) ); } diff --git a/lib/Service/BulkOperationService.php b/lib/Service/BulkOperationService.php index f21177e24..ed02f5ffa 100644 --- a/lib/Service/BulkOperationService.php +++ b/lib/Service/BulkOperationService.php @@ -891,7 +891,8 @@ private function performDelete( dashboardUuid: $rowUuid, ownerUserId: (string)($dashboard->getUserId() ?? $userId), type: (string)($dashboard->getType() ?? Dashboard::TYPE_USER), - deletedAt: new DateTimeImmutable() + deletedAt: new DateTimeImmutable(), + dashboardId: (int)$dashboard->getId() ) ); } diff --git a/lib/Service/ConfluenceImportService.php b/lib/Service/ConfluenceImportService.php index cf4e0579a..49e0b27d9 100644 --- a/lib/Service/ConfluenceImportService.php +++ b/lib/Service/ConfluenceImportService.php @@ -462,6 +462,14 @@ private function buildPlaceholderWidget(int $dashboardId, ParsedPage $page): Wid $placement->setShowTitle(1); $placement->setSortOrder(0); + // `created_at` and `updated_at` are NOT NULL with no default. Without + // these two lines every page's placement insert failed with SQLSTATE + // 23502, the page rolled back, and the import reported it as skipped: + // the same defect #612 fixed in ImportService. + $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); + $placement->setCreatedAt($now); + $placement->setUpdatedAt($now); + $placement->setStyleConfigArray( config: [ 'text' => '', diff --git a/lib/Service/DashboardService.php b/lib/Service/DashboardService.php index a47214489..1d80156d1 100644 --- a/lib/Service/DashboardService.php +++ b/lib/Service/DashboardService.php @@ -937,7 +937,8 @@ private function dispatchDashboardDeleted( dashboardUuid: $uuid, ownerUserId: $resolvedOwnerId, type: (string)($dashboard->getType() ?? Dashboard::TYPE_USER), - deletedAt: new DateTimeImmutable() + deletedAt: new DateTimeImmutable(), + dashboardId: $dashboard->getId() ) ); }//end dispatchDashboardDeleted() @@ -1200,7 +1201,8 @@ public function deleteGroupShared( dashboardUuid: $deletedUuid, ownerUserId: $actorUserId, type: (string)($dashboard->getType() ?? Dashboard::TYPE_GROUP_SHARED), - deletedAt: new DateTimeImmutable() + deletedAt: new DateTimeImmutable(), + dashboardId: $dashboard->getId() ) ); } diff --git a/lib/Service/DashboardTreeService.php b/lib/Service/DashboardTreeService.php index 48f189793..9149a9161 100644 --- a/lib/Service/DashboardTreeService.php +++ b/lib/Service/DashboardTreeService.php @@ -562,7 +562,8 @@ private function deleteDashboardWithPlacements( dashboardUuid: $targetUuid, ownerUserId: (string)($target->getUserId() ?? $fallback->getUserId() ?? ''), type: (string)($target->getType() ?? $fallback->getType() ?? Dashboard::TYPE_USER), - deletedAt: $deletedAt + deletedAt: $deletedAt, + dashboardId: $targetId ) ); } diff --git a/lib/Service/DemoShowcasesService.php b/lib/Service/DemoShowcasesService.php index 5cfb2b9ee..c5b77edb8 100644 --- a/lib/Service/DemoShowcasesService.php +++ b/lib/Service/DemoShowcasesService.php @@ -38,7 +38,6 @@ use OCA\LaunchPad\AppInfo\Application; use OCA\LaunchPad\Db\Dashboard; use OCA\LaunchPad\Db\DashboardMapper; -use OCA\LaunchPad\Db\WidgetPlacement; use OCA\LaunchPad\Db\WidgetPlacementMapper; use OCA\LaunchPad\Event\DashboardDeletedEvent; use OCA\LaunchPad\Exception\ShowcaseNotFoundException; @@ -79,13 +78,22 @@ class DemoShowcasesService { /** * Bundled showcase IDs. * - * The set is fixed at v1 (REQ-DEMO-001). The Dutch fictional - * organisation names mirror the reference source dataset so - * existing copy / screenshots remain reusable. + * Two kinds, and the difference is what a reader is meant to take + * away. The five Dutch fictional ORGANISATIONS mirror the reference + * source dataset so existing copy and screenshots stay reusable; + * they answer "what does an intranet built on this look like". + * + * `case-handler` is the first ROLE showcase and answers a different + * question: what one person's working day looks like on one page. + * It is in English because the widgets it places are the fleet's own + * (a dossiq case list, the Tasks app, a calendar, unread mail) and + * those carry English labels, so a Dutch shell around English + * content would read as a half-translation. * * @var array */ public const BUNDLED_IDS = [ + 'case-handler', 'de-bron', 'de-linden', 'gemeente-duin', @@ -93,6 +101,17 @@ class DemoShowcasesService { 'van-der-berg', ]; + /** + * Where the list of LaunchPad's own widget types lives. + * + * Shared with the frontend: `widgetRegistry.completeness.spec.js` + * asserts the registry's keys equal this file, so the types the + * installer keeps and the types the workspace can render cannot drift. + * + * @var string + */ + private const WIDGET_TYPES_PATH = __DIR__ . '/../widget-types.json'; + /** * Optional data-directory override (test seam). * @@ -100,6 +119,23 @@ class DemoShowcasesService { */ private ?string $dataDirOverride = null; + /** + * LaunchPad widget types, keyed by type, loaded once per instance. + * + * @var array|null + */ + private ?array $launchpadWidgetTypes = null; + + /** + * Builds placements from the archive; see PlacementPayloadHydrator for + * which fields travel and why the importer uses the same one. It holds + * no state and has no dependencies, so it is constructed here rather + * than injected. + * + * @var PlacementPayloadHydrator + */ + private readonly PlacementPayloadHydrator $placementHydrator; + /** * Constructor. * @@ -133,6 +169,7 @@ public function __construct( private readonly IURLGenerator $urlGenerator, private readonly ?IEventDispatcher $eventDispatcher = null, ) { + $this->placementHydrator = new PlacementPayloadHydrator(); }//end __construct() /** @@ -223,15 +260,29 @@ public function describeShowcase(string $showcaseId): ?array { $installedDashboardId = $installedUuid; } + // The imagePath() call THROWS when the image is absent, and this runs once per + // showcase inside the listing, so one showcase without a preview used + // to 500 the whole gallery. A missing thumbnail is a cosmetic gap in + // one card, not a reason to hide the other five. + $thumbnailUrl = null; + try { + $thumbnailUrl = $this->urlGenerator->imagePath( + Application::APP_ID, + 'showcases/' . $showcaseId . '.png' + ); + } catch (\RuntimeException $e) { + $this->logger->warning( + message: 'Showcase ' . $showcaseId . ' has no preview image', + context: ['exception' => $e] + ); + } + return [ 'id' => $showcaseId, 'name' => (string)($manifest['showcaseName'] ?? $showcaseId), 'description' => (string)($manifest['showcaseDescription'] ?? ''), 'language' => (string)($manifest['showcaseLanguage'] ?? 'nl'), - 'thumbnailUrl' => $this->urlGenerator->imagePath( - Application::APP_ID, - 'showcases/' . $showcaseId . '.png' - ), + 'thumbnailUrl' => $thumbnailUrl, 'isInstalled' => $installedUuid !== '', 'installedDashboardUuid' => $installedDashboardId, ]; @@ -344,7 +395,7 @@ private function doInstallShowcase(string $showcaseId, string $lang): array { try { $persisted = $this->dashboardMapper->insert(entity: $dashboard); foreach ($valid as $widgetPayload) { - $placement = $this->buildPlacement( + $placement = $this->placementHydrator->hydrate( dashboardId: (int)$persisted->getId(), payload: $widgetPayload ); @@ -418,7 +469,8 @@ public function uninstallShowcase(string $showcaseId): void { dashboardUuid: $deletedUuid, ownerUserId: (string)($dashboard->getUserId() ?? ''), type: (string)($dashboard->getType() ?? Dashboard::TYPE_GROUP_SHARED), - deletedAt: new DateTimeImmutable() + deletedAt: new DateTimeImmutable(), + dashboardId: $dashId ) ); } @@ -452,13 +504,32 @@ public function getInstalledUuid(string $showcaseId): string { }//end getInstalledUuid() /** - * Cross-reference a widget collection against the registered - * Nextcloud dashboard widget registry, returning valid + skipped - * partitions (REQ-DEMO-005). + * Split a showcase's widgets into the ones to place and the ones to + * skip (REQ-DEMO-005). * - * Tile placements (rows where `tileType` is non-null) are always - * considered valid — tiles are owned by LaunchPad itself and do not - * require a third-party widget registration. + * A widget is kept when LaunchPad can render it. That is true in three + * cases, checked in this order: + * + * 1. it is a tile (`tileType` set), rendered by LaunchPad's tile renderer; + * 2. its `widgetId` is one of LaunchPad's OWN widget types + * (`lib/widget-types.json`: `object-list`, `nc-widget`, `calendar`, + * `text` and the rest), rendered by the workspace's widget registry; + * 3. its `widgetId` is a Nextcloud dashboard widget registered on this + * instance (`IManager::getWidgets()`), rendered through the bridge. + * + * Anything else is skipped, which is what the skip exists for: a bare + * Nextcloud widget id whose app is not installed here. + * + * Case 2 is new. Before it, a LaunchPad type counted as "unknown" + * because it is not in Nextcloud's registry, so the `case-handler` + * showcase, built entirely from LaunchPad types, installed as an empty + * dashboard and still reported success. + * + * An `nc-widget` is kept whatever widget it proxies. The check is on the + * widget's TYPE, as REQ-DEMO-005 words it; whether the proxied app is + * installed decides what the tile shows, not whether it exists. Checking + * the target here would make a showcase's shape depend on which apps an + * instance happens to have. * * @param array $widgets Widget payloads from the * showcase JSON. @@ -473,6 +544,8 @@ public function partitionWidgets(array $widgets): array { $registered[$widget->getId()] = true; } + $ownTypes = $this->getLaunchpadWidgetTypes(); + $valid = []; $skipped = []; foreach ($widgets as $widget) { @@ -490,7 +563,9 @@ public function partitionWidgets(array $widgets): array { continue; } - if (isset($registered[$widgetId]) === true) { + if (isset($ownTypes[$widgetId]) === true + || isset($registered[$widgetId]) === true + ) { $valid[] = $widget; continue; } @@ -503,6 +578,51 @@ public function partitionWidgets(array $widgets): array { return [$valid, $skipped]; }//end partitionWidgets() + /** + * Load LaunchPad's own widget types from `lib/widget-types.json`. + * + * Throws rather than returning an empty set when the file is missing or + * unreadable. An empty set is exactly the defect this list fixes: every + * LaunchPad widget in a showcase would be skipped and the install would + * still report success, which is the failure nobody noticed the first + * time. A packaging error must be loud. + * + * @return array The types, keyed for `isset()` lookups. + * + * @throws RuntimeException When the list is missing or malformed. + * + * @spec openspec/specs/demo-data-showcases/spec.md + */ + private function getLaunchpadWidgetTypes(): array { + if ($this->launchpadWidgetTypes !== null) { + return $this->launchpadWidgetTypes; + } + + $decoded = null; + if (is_readable(filename: self::WIDGET_TYPES_PATH) === true) { + $raw = file_get_contents(filename: self::WIDGET_TYPES_PATH); + if (is_string($raw) === true) { + $decoded = json_decode(json: $raw, associative: true); + } + } + + if (is_array($decoded) === false || is_array($decoded['types'] ?? null) === false) { + throw new RuntimeException( + message: 'LaunchPad widget type list missing or malformed: ' . self::WIDGET_TYPES_PATH + ); + } + + $types = []; + foreach ($decoded['types'] as $type) { + if (is_string($type) === true && $type !== '') { + $types[$type] = true; + } + } + + $this->launchpadWidgetTypes = $types; + return $types; + }//end getLaunchpadWidgetTypes() + /** * Resolve the on-disk ZIP path for a showcase ID. * @@ -675,89 +795,6 @@ private function buildDashboardEntity( return $dashboard; }//end buildDashboardEntity() - /** - * Hydrate a WidgetPlacement entity from a payload. - * - * Mirrors {@see ImportService::buildPlacement} so the showcase - * format and the export-import format stay byte-compatible. - * - * @param int $dashboardId The freshly-inserted - * dashboard ID. - * @param array $payload The widget payload. - * - * @return WidgetPlacement The placement entity. - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * Field-by-field guards mirror the export-import format and - * are clearer than a map-driven setter. - * @SuppressWarnings(PHPMD.NPathComplexity) - * Consequence of the same field-by-field guards: each optional - * payload key contributes an independent present/absent branch, so - * the acyclic-path count multiplies across fields even though the - * method is a flat sequence with no nesting. - */ - private function buildPlacement( - int $dashboardId, - array $payload, - ): WidgetPlacement { - $placement = new WidgetPlacement(); - $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); - - // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setDashboardId($dashboardId); - $placement->setWidgetId((string)($payload['widgetId'] ?? '')); - $placement->setGridX((int)($payload['gridX'] ?? 0)); - $placement->setGridY((int)($payload['gridY'] ?? 0)); - $placement->setGridWidth((int)($payload['gridWidth'] ?? 4)); - $placement->setGridHeight((int)($payload['gridHeight'] ?? 4)); - $placement->setIsVisible((int)($payload['isVisible'] ?? 1)); - $placement->setShowTitle((int)($payload['showTitle'] ?? 1)); - $placement->setSortOrder((int)($payload['sortOrder'] ?? 0)); - $placement->setCreatedAt($now); - $placement->setUpdatedAt($now); - - if (isset($payload['styleConfig']) === true && is_array($payload['styleConfig']) === true) { - $placement->setStyleConfigArray(config: $payload['styleConfig']); - } - - if (isset($payload['customTitle']) === true) { - $placement->setCustomTitle((string)$payload['customTitle']); - } - - // Tile fields — see WidgetPlacement::jsonSerialize(). - if (isset($payload['tileType']) === true) { - $placement->setTileType((string)$payload['tileType']); - $placement->setTileTitle((string)($payload['tileTitle'] ?? '')); - if (isset($payload['tileIcon']) === true) { - $placement->setTileIcon((string)$payload['tileIcon']); - } - - if (isset($payload['tileIconType']) === true) { - $placement->setTileIconType((string)$payload['tileIconType']); - } - - if (isset($payload['tileBackgroundColor']) === true) { - $placement->setTileBackgroundColor((string)$payload['tileBackgroundColor']); - } - - if (isset($payload['tileTextColor']) === true) { - $placement->setTileTextColor((string)$payload['tileTextColor']); - } - - if (isset($payload['tileLinkType']) === true) { - $placement->setTileLinkType((string)$payload['tileLinkType']); - } - - if (isset($payload['tileLinkValue']) === true) { - $placement->setTileLinkValue((string)$payload['tileLinkValue']); - } - }//end if - - // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters - - return $placement; - }//end buildPlacement() - /** * Persist the per-showcase install marker. * diff --git a/lib/Service/ImportService.php b/lib/Service/ImportService.php index 1e2eb3255..09f3d5169 100644 --- a/lib/Service/ImportService.php +++ b/lib/Service/ImportService.php @@ -25,10 +25,10 @@ namespace OCA\LaunchPad\Service; +use DateTime; use InvalidArgumentException; use OCA\LaunchPad\Db\Dashboard; use OCA\LaunchPad\Db\DashboardMapper; -use OCA\LaunchPad\Db\WidgetPlacement; use OCA\LaunchPad\Db\WidgetPlacementMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IDBConnection; @@ -80,6 +80,27 @@ class ImportService { */ public const ERR_INVALID_DASHBOARD = 'invalidDashboard'; + /** + * Internal payload key holding the UUID an archive gave a dashboard. + * + * Set by {@see self::remapUuids()} when it mints a fresh UUID, and read + * only when reporting an error, so the admin is told which file in their + * archive went wrong rather than a UUID this import invented. + * + * @var string + */ + private const KEY_SOURCE_UUID = '__sourceUuid__'; + + /** + * Builds placements from their exported form; see PlacementPayloadHydrator + * for which fields travel and why this is one builder and not two. It + * holds no state and has no dependencies, so it is constructed here rather + * than injected. + * + * @var PlacementPayloadHydrator + */ + private readonly PlacementPayloadHydrator $placementHydrator; + /** * Constructor. * @@ -94,6 +115,7 @@ public function __construct( private readonly IDBConnection $db, private readonly LoggerInterface $logger, ) { + $this->placementHydrator = new PlacementPayloadHydrator(); }//end __construct() /** @@ -208,6 +230,14 @@ public function validateZipStructure(ZipArchive $zip): array { if (is_int($version) === false || $version !== self::SCHEMA_VERSION) { $head = 'Unsupported manifest schema version: ' . (string)$version . '.'; $tail = ' Only version ' . (string)self::SCHEMA_VERSION . ' is supported.'; + // An archive from a NEWER LaunchPad is the one case the admin can + // act on, and REQ-EXIM-009 asks for them to be told how. A version + // below 1 is not an older format, it is a broken manifest, so it + // gets no such advice. + if (is_int($version) === true && $version > self::SCHEMA_VERSION) { + $tail .= ' Upgrade LaunchPad to import archives of version ' . (string)$version . '.'; + } + throw new InvalidArgumentException(message: ($head . $tail)); } @@ -244,6 +274,11 @@ public function remapUuids(array $dashboards, bool $preserveUuids): array { $original = (string)($dashboard['uuid'] ?? ''); if ($original !== '' && isset($uuidMap[$original]) === true) { $dashboard['uuid'] = $uuidMap[$original]; + // Keep the UUID the archive used. A dashboard that is then + // SKIPPED never exists under the new one, so reporting the new + // UUID names a row nobody can look up and a file the admin + // cannot find. The source UUID is the file's own name. + $dashboard[self::KEY_SOURCE_UUID] = $original; } $parent = $dashboard['parentUuid'] ?? null; @@ -280,15 +315,13 @@ private function importDashboardBatch( $errors = []; foreach ($dashboards as $payload) { - $uuid = (string)($payload['uuid'] ?? ''); + // The archive's UUID, not the one a remap may have minted: it is + // what the admin can find in their file. + $uuid = $this->reportableUuid(payload: $payload); $missing = $this->validateDashboardPayload(payload: $payload); if ($missing !== null) { $skipped++; - $errors[] = [ - 'type' => self::ERR_INVALID_DASHBOARD, - 'uuid' => $uuid, - 'message' => 'Missing required field: ' . $missing, - ]; + $errors[] = $this->invalidDashboardError(payload: $payload, missing: $missing); continue; } @@ -309,7 +342,7 @@ private function importDashboardBatch( continue; } - $placement = $this->buildPlacement( + $placement = $this->placementHydrator->hydrate( dashboardId: (int)$persisted->getId(), payload: $widgetPayload ); @@ -324,7 +357,7 @@ private function importDashboardBatch( $skipped++; $errors[] = [ 'type' => self::ERR_INVALID_DASHBOARD, - 'uuid' => $uuid, + 'uuid' => $this->reportableUuid(payload: $payload), 'message' => 'Failed to import dashboard: ' . $e->getMessage(), ]; $this->logger->warning( @@ -455,6 +488,55 @@ private function validateDashboardPayload(array $payload): ?string { return null; }//end validateDashboardPayload() + /** + * The error entry for a dashboard the import has to skip. + * + * A dashboard file that is not valid JSON has no `uuid` to report, so it + * used to come back as `uuid: ""` with the message "Missing required + * field: corrupt JSON payload", which names neither the file nor what is + * wrong with it. REQ-EXIM-004 asks for the corrupt dashboard to be + * identified; the archive entry name is the only identity it has, and the + * file name is the exported UUID. + * + * @param array $payload The payload that failed validation. + * @param string $missing What validation reported missing. + * + * @return array The entry for the `errors` array. + */ + private function invalidDashboardError(array $payload, string $missing): array { + if (isset($payload['__corrupt__']) === true) { + $entry = (string)($payload['__entry__'] ?? ''); + return [ + 'type' => self::ERR_INVALID_DASHBOARD, + 'uuid' => basename(path: $entry, suffix: '.json'), + 'entry' => $entry, + 'message' => $entry . ' is not valid JSON', + ]; + } + + return [ + 'type' => self::ERR_INVALID_DASHBOARD, + 'uuid' => $this->reportableUuid(payload: $payload), + 'message' => 'Missing required field: ' . $missing, + ]; + }//end invalidDashboardError() + + /** + * The UUID to name in an error: the archive's, not the remapped one. + * + * @param array $payload The dashboard payload. + * + * @return string The UUID the archive used, or the current one. + */ + private function reportableUuid(array $payload): string { + $source = (string)($payload[self::KEY_SOURCE_UUID] ?? ''); + if ($source !== '') { + return $source; + } + + return (string)($payload['uuid'] ?? ''); + }//end reportableUuid() + /** * Hydrate a Dashboard entity from a payload. * @@ -486,6 +568,19 @@ private function buildEntity( // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters $dashboard->setIsDefault(0); + // `created_at` and `updated_at` are NOT NULL with no default + // (DashboardTableBuilder). Nothing here set them, so on every database + // that enforces NOT NULL the insert failed, the dashboard was reported + // as skipped, and no import had landed a dashboard at all: measured on + // PostgreSQL as SQLSTATE 23502 on created_at. The unit tests mock the + // mapper, so none of them could see it. An import creates a new row, + // so it gets the time of the import rather than the exported one. + $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $dashboard->setCreatedAt($now); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $dashboard->setUpdatedAt($now); + return $dashboard; }//end buildEntity() @@ -642,50 +737,6 @@ private function applyEntityPlacement( } }//end applyEntityPlacement() - /** - * Hydrate a WidgetPlacement entity from a payload. - * - * @param int $dashboardId The freshly-inserted dashboard ID. - * @param array $payload The widget payload. - * - * @return WidgetPlacement The placement entity (not yet persisted). - */ - private function buildPlacement( - int $dashboardId, - array $payload, - ): WidgetPlacement { - $placement = new WidgetPlacement(); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setDashboardId($dashboardId); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setWidgetId((string)($payload['widgetId'] ?? '')); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setGridX((int)($payload['gridX'] ?? 0)); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setGridY((int)($payload['gridY'] ?? 0)); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setGridWidth((int)($payload['gridWidth'] ?? 4)); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setGridHeight((int)($payload['gridHeight'] ?? 4)); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setIsVisible((int)($payload['isVisible'] ?? 1)); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setShowTitle((int)($payload['showTitle'] ?? 1)); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setSortOrder((int)($payload['sortOrder'] ?? 0)); - - if (isset($payload['styleConfig']) === true && is_array($payload['styleConfig']) === true) { - $placement->setStyleConfigArray(config: $payload['styleConfig']); - } - - if (isset($payload['customTitle']) === true) { - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setCustomTitle((string)$payload['customTitle']); - } - - return $placement; - }//end buildPlacement() - /** * Generate a v4 UUID for re-mapped imports. * diff --git a/lib/Service/LiveTileService.php b/lib/Service/LiveTileService.php index 87f3e7934..3b4c65b5f 100644 --- a/lib/Service/LiveTileService.php +++ b/lib/Service/LiveTileService.php @@ -6,14 +6,17 @@ * Resolves a `livetile` widget placement's configured data source to a * value, server-side (REQ-LIVETILE-003). Two source modes: * - * - `connector` — OpenConnector's `dashboard-http-datasource` capability - * is called through its documented runtime source-run API, ONLY when a + * - `connector` — Integriq's `dashboard-http-datasource` capability is + * called through its documented runtime resolve API, ONLY when a * capability probe confirms the app is installed AND the expected - * service is resolvable. This file never statically imports an - * OpenConnector class (REQ-LIVETILE-005 "No direct class dependency") — - * the FQCN is referenced only as a string, exactly mirroring + * service is resolvable. This file never statically imports an Integriq + * class (REQ-LIVETILE-005 "No direct class dependency") — the FQCN is + * referenced only as a string, exactly mirroring * {@see WeatherService::WEATHER_STATUS_SERVICE_CLASS}'s reuse of the - * optional `weather_status` app. + * optional `weather_status` app. Both the id and the namespace are + * resolved through {@see FleetAppId} rather than hardcoded, because the + * fleet rename moved both and an instance in the field may still be on + * either name. * - `url` — a server-side allow-listed HTTP GET, extracting a value via a * JSONPath-lite expression (`$.a.b`, `$.a[0].b`). The allow-list * (`livetile_allowed_hosts`, IAppConfig) is enforced FAIL-CLOSED: an @@ -48,6 +51,7 @@ use DateTime; use OCA\LaunchPad\AppInfo\Application; use OCA\LaunchPad\Db\WidgetPlacementMapper; +use OCA\LaunchPad\Support\FleetAppId; use OCP\App\IAppManager; use OCP\Http\Client\IClientService; use OCP\IAppConfig; @@ -62,12 +66,12 @@ * readings. * * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Combines dual-source - * resolution (OpenConnector leaf + allow-listed direct GET), JSONPath- + * resolution (Integriq leaf + allow-listed direct GET), JSONPath- * lite extraction, formatting, badge thresholding, caching, and * stale-fallback in one cohesive unit — mirrors WeatherService's shape * for the same class of capability. * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Same cause as the complexity - * above: the dual-source resolution necessarily reaches OpenConnector, the + * above: the dual-source resolution necessarily reaches Integriq, the * HTTP client, the cache and the config. The collaborators are the feature. * @spec openspec/specs/live-data-tile-widget/spec.md */ @@ -113,31 +117,42 @@ class LiveTileService { public const CONFIG_KEY_ALLOWED_HOSTS = 'livetile_allowed_hosts'; /** - * App id of the optional OpenConnector leaf. + * Canonical name of the optional connector leaf. Never used as a literal + * app id: {@see FleetAppId} maps it onto whichever of `integriq` / + * `openconnector` this instance actually registered, so the probe works + * on a migrated instance and on one still running an older release. * * @var string */ - private const OPENCONNECTOR_APP_ID = 'openconnector'; + private const CONNECTOR_APP = 'integriq'; /** - * FQCN of OpenConnector's dashboard data-source resolver, referenced - * only as a string so this file never hard-requires the class to - * exist (REQ-LIVETILE-005 "No direct class dependency") — resolved - * through the container only when the capability probe passes. + * Class name of the connector's dashboard data-source resolver, RELATIVE + * to the app's PSR-4 root and referenced only as a string so this file + * never hard-requires the class to exist (REQ-LIVETILE-005 "No direct + * class dependency"). {@see FleetAppId::classCandidates()} prefixes it + * with `OCA\Integriq` and then `OCA\OpenConnector`. + * + * Verified against integriq `development` (2026-09-09): the class lives + * at `lib/Service/Datasource/DashboardDatasourceService.php` and shipped + * under `OCA\OpenConnector\Service\Datasource` before the rename. The + * previous value here named `Service\DashboardDataSourceService` — an + * extra namespace segment short and a capital `S` out — which has never + * existed under either name. * * @var string */ - private const OPENCONNECTOR_DATASOURCE_SERVICE_CLASS = 'OCA\\OpenConnector\\Service\\DashboardDataSourceService'; + private const CONNECTOR_DATASOURCE_SERVICE_CLASS = 'Service\\Datasource\\DashboardDatasourceService'; /** - * Method OpenConnector's data-source resolver is expected to expose: - * `resolveDashboardValue(string $sourceId, string $valueExpr): array{value: mixed}`. + * Method the connector's data-source resolver exposes: + * `resolve(string $sourceId, string $valueExpr, array $params = [], ?int $ttl = null): array{value: mixed, fetchedAt: string, stale: bool}`. * Guarded with `method_exists()` before every call — a shape mismatch * degrades to "source unavailable" rather than a fatal error. * * @var string */ - private const OPENCONNECTOR_DATASOURCE_METHOD = 'resolveDashboardValue'; + private const CONNECTOR_DATASOURCE_METHOD = 'resolve'; /** * Badge threshold states, in priority order for icon/label fallback. @@ -156,9 +171,10 @@ class LiveTileService { /** * Constructor. * - * @param IAppManager $appManager Detects whether `openconnector` is enabled. + * @param IAppManager $appManager Detects whether the connector leaf is enabled, + * under whichever id it registered. * @param ContainerInterface $container App container used to optionally resolve - * OpenConnector's data-source service + * Integriq's data-source service * (REQ-LIVETILE-005 capability probe). * @param IClientService $clientService HTTP client factory for the direct-URL fetch. * @param ICacheFactory $cacheFactory Backing factory for the distributed value cache. @@ -283,24 +299,34 @@ public function validateSourceConfig(array $config): array { }//end validateSourceConfig() /** - * Whether the OpenConnector `dashboard-http-datasource` capability is + * Whether Integriq's `dashboard-http-datasource` capability is * currently resolvable — app enabled AND the expected service present * in the container. Never throws (REQ-LIVETILE-005). * * @return boolean * + * @SuppressWarnings(PHPMD.StaticAccess) FleetAppId is a stateless resolver. + * * @spec openspec/specs/live-data-tile-widget/spec.md */ public function isConnectorAvailable(): bool { try { - if ($this->appManager->isEnabledForUser(appId: self::OPENCONNECTOR_APP_ID) === false) { + $enabled = FleetAppId::isEnabledForUser( + appManager: $this->appManager, + canonical: self::CONNECTOR_APP + ); + if ($enabled === false) { return false; } - return $this->container->has(id: self::OPENCONNECTOR_DATASOURCE_SERVICE_CLASS); + return FleetAppId::hasService( + container: $this->container, + canonical: self::CONNECTOR_APP, + relative: self::CONNECTOR_DATASOURCE_SERVICE_CLASS + ); } catch (Throwable $exception) { $this->logger->info( - message: 'LiveTileService: OpenConnector capability probe failed, treating as absent', + message: 'LiveTileService: connector capability probe failed, treating as absent', context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] ); return false; @@ -331,7 +357,7 @@ private function fetchFresh(array $config): ?array { }//end fetchFresh() /** - * Resolve via OpenConnector's `dashboard-http-datasource` capability + * Resolve via Integriq's `dashboard-http-datasource` capability * (REQ-LIVETILE-005). Returns `null` — never throws — when the * capability probe fails, the service's expected method is absent, or * the call itself fails; the caller then falls back to a stale cached @@ -343,6 +369,8 @@ private function fetchFresh(array $config): ?array { * @param array $config The placement's resolved config (`sourceId`, `valueExpr`). * * @return array|null `{rawValue: mixed}` or `null`. + * + * @SuppressWarnings(PHPMD.StaticAccess) FleetAppId is a stateless resolver. */ private function fetchFromConnector(array $config): ?array { if ($this->isConnectorAvailable() === false) { @@ -356,15 +384,23 @@ private function fetchFromConnector(array $config): ?array { } try { - $service = $this->container->get(id: self::OPENCONNECTOR_DATASOURCE_SERVICE_CLASS); - if (method_exists(object_or_class: $service, method: self::OPENCONNECTOR_DATASOURCE_METHOD) === false) { + $service = FleetAppId::getService( + container: $this->container, + canonical: self::CONNECTOR_APP, + relative: self::CONNECTOR_DATASOURCE_SERVICE_CLASS + ); + if ($service === null) { + return null; + } + + if (method_exists(object_or_class: $service, method: self::CONNECTOR_DATASOURCE_METHOD) === false) { return null; } - $result = $service->{self::OPENCONNECTOR_DATASOURCE_METHOD}($sourceId, $valueExpr); + $result = $service->{self::CONNECTOR_DATASOURCE_METHOD}($sourceId, $valueExpr); } catch (Throwable $exception) { $this->logger->info( - message: 'LiveTileService: OpenConnector source-run call failed', + message: 'LiveTileService: connector resolve call failed', context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] ); return null; diff --git a/lib/Service/PlacementPayloadHydrator.php b/lib/Service/PlacementPayloadHydrator.php new file mode 100644 index 000000000..f1321f222 --- /dev/null +++ b/lib/Service/PlacementPayloadHydrator.php @@ -0,0 +1,169 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT:auto + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/dashboard-export-import/spec.md + */ + +declare(strict_types=1); + +namespace OCA\LaunchPad\Service; + +use DateTime; +use OCA\LaunchPad\Db\WidgetPlacement; + +/** + * Build a placement entity from its exported array form. + */ +class PlacementPayloadHydrator { + /** + * Tile fields, copied as strings when present. + * + * Mirrors the block `WidgetPlacement::jsonSerialize()` emits when a + * placement is a tile. + * + * @var array + */ + private const TILE_FIELDS = [ + 'tileIcon', + 'tileIconType', + 'tileBackgroundColor', + 'tileTextColor', + 'tileLinkType', + 'tileLinkValue', + ]; + + /** + * Build an unsaved placement on a dashboard from an exported payload. + * + * @param int $dashboardId The dashboard the placement joins. + * @param array $payload One entry of an exported dashboard's + * `widgets` array. + * + * @return WidgetPlacement The placement, not yet persisted. + * + * @spec openspec/specs/dashboard-export-import/spec.md + */ + public function hydrate(int $dashboardId, array $payload): WidgetPlacement { + $placement = new WidgetPlacement(); + $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); + + // Entity setters resolve through __call, which reads $args[0]; named + // arguments would break that forwarding. + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setDashboardId($dashboardId); + $placement->setWidgetId((string)($payload['widgetId'] ?? '')); + $placement->setGridX((int)($payload['gridX'] ?? 0)); + $placement->setGridY((int)($payload['gridY'] ?? 0)); + $placement->setGridWidth((int)($payload['gridWidth'] ?? 4)); + $placement->setGridHeight((int)($payload['gridHeight'] ?? 4)); + $placement->setIsVisible((int)($payload['isVisible'] ?? 1)); + $placement->setShowTitle((int)($payload['showTitle'] ?? 1)); + $placement->setSortOrder((int)($payload['sortOrder'] ?? 0)); + $placement->setCreatedAt($now); + $placement->setUpdatedAt($now); + + if (isset($payload['customTitle']) === true) { + $placement->setCustomTitle((string)$payload['customTitle']); + } + + if (isset($payload['customIcon']) === true) { + $placement->setCustomIcon((string)$payload['customIcon']); + } + + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + + $this->applyTile(placement: $placement, payload: $payload); + $this->applyBlobs(placement: $placement, payload: $payload); + + return $placement; + }//end hydrate() + + /** + * Copy a tile's type and fields, when the payload is a tile. + * + * @param WidgetPlacement $placement The placement being built. + * @param array $payload The exported payload. + * + * @return void + */ + private function applyTile(WidgetPlacement $placement, array $payload): void { + if (isset($payload['tileType']) === false) { + return; + } + + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setTileType((string)$payload['tileType']); + // A tile always gets a title, empty if the payload has none. + $placement->setTileTitle((string)($payload['tileTitle'] ?? '')); + foreach (self::TILE_FIELDS as $field) { + if (isset($payload[$field]) === true) { + $placement->{'set' . ucfirst($field)}((string)$payload[$field]); + } + } + + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + }//end applyTile() + + /** + * Copy the two JSON blobs: `styleConfig` and `content`. + * + * A registry widget's configuration lives in `content`: which register an + * object-list reads, which Nextcloud widget an nc-widget proxies, what a + * text widget says. Without it the placement lands and renders as an + * unconfigured widget, which nothing on the way in can notice. Export + * writes an empty blob as `{}`, which decodes to `[]`; that carries + * nothing, so the column is left NULL rather than set to `[]`. + * + * @param WidgetPlacement $placement The placement being built. + * @param array $payload The exported payload. + * + * @return void + */ + private function applyBlobs(WidgetPlacement $placement, array $payload): void { + if (isset($payload['styleConfig']) === true && is_array($payload['styleConfig']) === true) { + $placement->setStyleConfigArray(config: $payload['styleConfig']); + } + + if (isset($payload['content']) === true + && is_array($payload['content']) === true + && $payload['content'] !== [] + ) { + $placement->setContentArray(content: $payload['content']); + } + }//end applyBlobs() +}//end class diff --git a/lib/Service/RoleFeaturePermissionService.php b/lib/Service/RoleFeaturePermissionService.php index b43fb2831..12868fee5 100644 --- a/lib/Service/RoleFeaturePermissionService.php +++ b/lib/Service/RoleFeaturePermissionService.php @@ -148,6 +148,7 @@ public function savePermission(array $data): RoleFeaturePermission { } $entity = $this->resolvePermissionEntity(groupId: $groupId); + $this->requireNameForNewRow(isNew: ($entity->getId() === null), data: $data); $this->applyPermissionCopy(entity: $entity, data: $data); $this->applyPermissionWidgetLists(entity: $entity, data: $data); @@ -315,6 +316,7 @@ public function saveLayoutDefault(array $data): RoleLayoutDefault { groupId: $groupId, widgetId: $widgetId ); + $this->requireNameForNewRow(isNew: ($entity->getId() === null), data: $data); $this->applyLayoutDefaultCopy(entity: $entity, data: $data); $this->applyLayoutDefaultGeometry(entity: $entity, data: $data); @@ -745,4 +747,28 @@ private function groupIdsForUser(string $userId): array { return $this->adminTemplateService->getUserGroupIdsFor(userId: $userId); }//end groupIdsForUser() + /** + * Refuse a new row that carries no `name`. + * + * `name` is NOT NULL with no default in both tables, and the spec lists it + * as required. The copy helpers set it only when the payload carries it, so + * a new row without it used to reach the database with the column missing + * and fail there with SQLSTATE 23502, which the API reported as a bare + * "Operation failed". An existing row keeps its stored name, and an empty + * string is still accepted, as the admin editors send one. + * + * @param bool $isNew Whether the save will INSERT. + * @param array $data The request payload. + * + * @return void + * + * @throws InvalidArgumentException When a new row carries no name. + * + * @spec openspec/specs/role-feature-permissions/spec.md + */ + private function requireNameForNewRow(bool $isNew, array $data): void { + if ($isNew === true && array_key_exists(key: 'name', array: $data) === false) { + throw new InvalidArgumentException(message: 'name is required'); + } + }//end requireNameForNewRow() }//end class diff --git a/lib/Service/StoreService.php b/lib/Service/StoreService.php new file mode 100644 index 000000000..1feee8d4a --- /dev/null +++ b/lib/Service/StoreService.php @@ -0,0 +1,509 @@ +` + * dependency on OpenRegister, so it can run without it. Every OpenRegister + * class here is named as a STRING and instantiated only once the injected + * client proved non-null, which keeps NC bootstrap fatal-free when OpenRegister + * is disabled or absent. The degraded state is `not_configured`, the same + * outcome an unconfigured registry produces, so the store page falls back to + * its built-in items either way. + * + * Implements the `dashboard-store` capability (REQ-STORE-002..007). + * + * @category Service + * @package OCA\LaunchPad\Service + * @author Conduction b.v. + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT:auto + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\LaunchPad\Service; + +use OCA\LaunchPad\AppInfo\Application; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; +use ZipArchive; + +/** + * Browse a remote dashboard registry and install one of its templates. + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md + */ +class StoreService { + /** + * Remote schema slug this store reads. + * + * A registry publishes dashboard templates as objects of this schema. It is + * deliberately NOT one of the `openregister.configset` types: a non-empty + * `types` list selects the engine's federated configuration path, which + * trades registers, schemas and flows rather than dashboards. + */ + public const REMOTE_SCHEMA = 'dashboard-template'; + + /** + * Register segment used when `registry_register` is unset or empty. + */ + public const DEFAULT_REGISTER = 'launchpad'; + + /** + * Card field name to remote object property. + * + * The engine flattens a remote object to a card using ONLY this map plus + * `kind`, so anything a registry sends outside it — a manifest, a + * credential-shaped field — never reaches the browser. + * + * @var array + */ + public const CARD_FIELDS = [ + 'slug' => 'slug', + 'title' => 'title', + 'description' => 'description', + 'category' => 'category', + 'version' => 'version', + 'publisher' => 'publisher', + ]; + + /** + * Fully-qualified name of the engine's descriptor value object. + * + * Held as a string so no `OCA\OpenRegister\…` symbol is touched at load + * time. Instantiated only after the injected client proved non-null. + */ + private const DESCRIPTOR_CLASS = 'OCA\\OpenRegister\\AppHost\\Service\\StoreDescriptor'; + + /** + * Outcome reported when there is nothing to talk to. + */ + public const OUTCOME_NOT_CONFIGURED = 'not_configured'; + + /** + * Config key holding the registry base URL. + */ + public const CONFIG_URL = 'registry_url'; + + /** + * Config key holding the registry Bearer token. + */ + public const CONFIG_TOKEN = 'registry_token'; + + /** + * Config key holding the remote register segment. + */ + public const CONFIG_REGISTER = 'registry_register'; + + /** + * Constructor. + * + * @param object|null $discovery OpenRegister's `GenericStoreService`, or null when + * OpenRegister is unavailable. Untyped on purpose: a + * typed parameter would load the class at resolution + * time and fatal on an instance without OpenRegister. + * @param ImportService $importService LaunchPad's one and only dashboard importer. + * @param IAppConfig $appConfig Where the engine reads the registry connection. + * @param LoggerInterface $logger PSR logger — server-side diagnostics only. + * + * @return void + */ + public function __construct( + private readonly ?object $discovery, + private readonly ImportService $importService, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Whether the discovery engine is present at all. + * + * @return bool True when OpenRegister supplied a store client. + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-002-an-absent-openregister-must-degrade-never-fatal + */ + public function isAvailable(): bool { + return $this->discovery !== null; + }//end isAvailable() + + /** + * Search the configured registry for dashboard templates. + * + * The engine's outcome is passed through VERBATIM. `store_unreachable` and + * `store_invalid_response` are deliberately not collapsed: a misconfigured + * registry would otherwise read as an offline one, and the two need + * different fixes. + * + * @param string|null $query Optional free-text search term. + * @param string|null $kind Optional kind filter. + * + * @return array{outcome: string, cards: array>} + * + * @psalm-suppress MixedMethodCall The discovery client is untyped on purpose. + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-003-the-engines-outcome-must-reach-the-caller-unchanged + */ + public function search(?string $query = null, ?string $kind = null): array { + if ($this->discovery === null) { + return ['outcome' => self::OUTCOME_NOT_CONFIGURED, 'cards' => []]; + } + + $result = $this->discovery->search( + descriptor: $this->descriptor(), + query: $query, + kind: $kind + ); + + if (is_array($result) === false) { + return ['outcome' => self::OUTCOME_NOT_CONFIGURED, 'cards' => []]; + } + + $cards = ($result['cards'] ?? []); + if (is_array($cards) === false) { + $cards = []; + } + + return [ + 'outcome' => (string)($result['outcome'] ?? self::OUTCOME_NOT_CONFIGURED), + 'cards' => $cards, + ]; + }//end search() + + /** + * Install one remote dashboard template into this instance. + * + * The resolved payload is materialised into a `launchpad-export-v1` archive + * and handed to {@see ImportService::import()}. LaunchPad gains no second + * path from a dashboard payload to rows: the importer that validates an + * administrator's uploaded ZIP is the importer that validates this one. + * + * `preserveUuids` is FALSE, which is what makes a store install additive. A + * template published by a foreign instance carries that instance's UUIDs and + * one of them can collide with a live local dashboard. + * + * @param string $slug The remote item slug. + * @param string $userId The installing administrator's UID. + * + * @return array{success: bool, message: string, components: array>} + * + * @psalm-suppress MixedMethodCall The discovery client is untyped on purpose. + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-005-an-install-must-reuse-importservice-not-reimplement-it + */ + public function install(string $slug, string $userId): array { + if ($this->discovery === null) { + return $this->failure(message: 'No dashboard registry is available on this instance.'); + } + + $item = $this->discovery->resolve(descriptor: $this->descriptor(), slug: $slug); + if (is_array($item) === false) { + return $this->failure(message: 'That template could not be found in the registry.'); + } + + $dashboards = $this->dashboardsFrom(item: $item); + if ($dashboards === []) { + return $this->failure(message: 'That template carries no dashboard.'); + } + + try { + $archivePath = $this->materialise(dashboards: $dashboards, slug: $slug, userId: $userId); + } catch (Throwable $e) { + $this->logger->error( + message: 'Could not build a store archive for ' . $slug . ': ' . $e->getMessage(), + context: ['app' => Application::APP_ID] + ); + return $this->failure(message: 'That template could not be prepared for import.'); + } + + try { + $report = $this->importService->import( + zipPath: $archivePath, + preserveUuids: false, + currentUserId: $userId + ); + } catch (Throwable $e) { + // Detail to the log, a plain sentence to the browser. A registry's + // malformed payload is not the administrator's business to debug. + $this->logger->error( + message: 'Store install failed for ' . $slug . ': ' . $e->getMessage(), + context: ['app' => Application::APP_ID] + ); + return $this->failure(message: 'That template could not be installed.'); + } finally { + // Runs on the throwing path too, so a failed install leaves nothing + // behind in the temp directory. + $this->discard(path: $archivePath); + }//end try + + return $this->report(report: $report, dashboards: $dashboards); + }//end install() + + /** + * The registry connection, with the token redacted. + * + * 🔴 THE TOKEN NEVER LEAVES THE SERVER. The read reports only WHETHER one + * is set. Returning it so an admin form could pre-fill would put a + * credential in a browser response, and the form does not need it: an empty + * submission clears the token and a non-empty one replaces it. + * + * @return array{registryUrl: string, registryRegister: string, tokenConfigured: bool} + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-007-the-registry-token-must-not-be-readable-back + */ + public function getRegistryConfig(): array { + return [ + 'registryUrl' => $this->config(key: self::CONFIG_URL), + 'registryRegister' => $this->config(key: self::CONFIG_REGISTER), + 'tokenConfigured' => ($this->config(key: self::CONFIG_TOKEN) !== ''), + ]; + }//end getRegistryConfig() + + /** + * Write the registry connection. + * + * Each value is optional: a null leaves the stored key untouched, which is + * what lets a form change the URL without resubmitting the token. An empty + * string is a real value and CLEARS the key. + * + * @param string|null $registryUrl Registry base URL, or null to leave it. + * @param string|null $registryToken Bearer token, or null to leave it. + * @param string|null $registryRegister Remote register segment, or null to leave it. + * + * @return void + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-007-the-registry-token-must-not-be-readable-back + */ + public function updateRegistryConfig( + ?string $registryUrl = null, + ?string $registryToken = null, + ?string $registryRegister = null, + ): void { + if ($registryUrl !== null) { + $this->appConfig->setValueString(Application::APP_ID, self::CONFIG_URL, trim($registryUrl)); + } + + if ($registryToken !== null) { + $this->appConfig->setValueString(Application::APP_ID, self::CONFIG_TOKEN, trim($registryToken)); + } + + if ($registryRegister !== null) { + $this->appConfig->setValueString(Application::APP_ID, self::CONFIG_REGISTER, trim($registryRegister)); + } + }//end updateRegistryConfig() + + /** + * Build the engine descriptor for this app's store. + * + * Only ever called once `$this->discovery` proved non-null, which is what + * guarantees the OpenRegister class is loadable here. + * + * @return object The engine's StoreDescriptor. + * + * @throws RuntimeException When OpenRegister supplied a client but not a descriptor class. + * + * @psalm-suppress MixedMethodCall Instantiated from a class string by design. + */ + private function descriptor(): object { + $class = self::DESCRIPTOR_CLASS; + if (class_exists($class) === false) { + throw new RuntimeException(message: 'OpenRegister store descriptor is unavailable.'); + } + + return new $class( + appId: Application::APP_ID, + schema: self::REMOTE_SCHEMA, + defaultRegister: self::DEFAULT_REGISTER, + cardFields: self::CARD_FIELDS, + types: [] + ); + }//end descriptor() + + /** + * Pull the dashboard payloads out of a resolved registry item. + * + * A registry may store the payload as a nested list or as a JSON string, so + * both are accepted. Anything else yields an empty list, and the caller + * refuses the install rather than importing an empty archive. + * + * @param array $item The resolved remote object. + * + * @return array> The dashboard payloads. + */ + private function dashboardsFrom(array $item): array { + $raw = ($item['dashboards'] ?? null); + if (is_string($raw) === true) { + $raw = json_decode(json: $raw, associative: true); + } + + if (is_array($raw) === false) { + return []; + } + + $dashboards = []; + foreach ($raw as $payload) { + if (is_array($payload) === true && (string)($payload['uuid'] ?? '') !== '') { + $dashboards[] = $payload; + } + } + + return $dashboards; + }//end dashboardsFrom() + + /** + * Write the payloads into a `launchpad-export-v1` archive. + * + * The shape mirrors {@see ExportService} exactly, because + * {@see ImportService::validateZipStructure()} is what reads it back. + * + * @param array> $dashboards The dashboard payloads. + * @param string $slug The remote item slug, for the manifest. + * @param string $userId The installing UID, for the manifest. + * + * @return string Path to the temporary archive. + * + * @throws RuntimeException When the archive cannot be allocated or opened. + */ + private function materialise(array $dashboards, string $slug, string $userId): string { + $tempPath = tempnam(directory: sys_get_temp_dir(), prefix: 'launchpad-store-'); + if ($tempPath === false) { + throw new RuntimeException(message: 'Could not allocate a temporary file for the install.'); + } + + $zip = new ZipArchive(); + if ($zip->open(filename: $tempPath, flags: ZipArchive::OVERWRITE) !== true) { + unlink(filename: $tempPath); + throw new RuntimeException(message: 'Could not open the store archive for writing.'); + } + + $manifest = [ + 'schemaVersion' => ImportService::SCHEMA_VERSION, + 'exportedAt' => gmdate(format: 'Y-m-d\TH:i:s\Z'), + 'exportedBy' => $userId, + 'launchpadVersion' => 'launchpad/v1', + 'scope' => 'dashboard', + 'dashboardCount' => count($dashboards), + 'includedAssets' => [], + 'sourceSlug' => $slug, + ]; + + $zip->addFromString(name: 'manifest.json', content: $this->encode(payload: $manifest)); + + foreach ($dashboards as $payload) { + $zip->addFromString( + name: 'dashboards/' . (string)$payload['uuid'] . '.json', + content: $this->encode(payload: $payload) + ); + } + + $zip->addFromString(name: 'metadata-fields.json', content: $this->encode(payload: [])); + $zip->close(); + + return $tempPath; + }//end materialise() + + /** + * Turn the importer's result into the store page's per-component report. + * + * `CnStorePage` reads `components[].status` and names every entry that is + * not `installed`, so a partial install states which dashboard did not + * arrive instead of reporting a flat failure. + * + * @param array $report The importer's result. + * @param array> $dashboards The payloads that were offered. + * + * @return array{success: bool, message: string, components: array>} + */ + private function report(array $report, array $dashboards): array { + $imported = (int)($report['importedDashboardCount'] ?? 0); + + $components = []; + $index = 0; + foreach ($dashboards as $payload) { + $name = (string)($payload['name'] ?? $payload['uuid'] ?? 'dashboard'); + + $status = 'refused'; + if ($index < $imported) { + $status = 'installed'; + } + + $components[] = ['schema' => $name, 'status' => $status]; + $index++; + } + + $message = 'Installed ' . (string)$imported . ' of ' . (string)count($dashboards) . '.'; + + return [ + 'success' => ($imported > 0), + 'message' => $message, + 'components' => $components, + ]; + }//end report() + + /** + * Remove a temporary archive, tolerating one that is already gone. + * + * @param string $path The archive path. + * + * @return void + */ + private function discard(string $path): void { + if (file_exists(filename: $path) === true) { + unlink(filename: $path); + } + }//end discard() + + /** + * Read one registry config value. + * + * @param string $key The config key. + * + * @return string The trimmed value, or the empty string. + */ + private function config(string $key): string { + return trim($this->appConfig->getValueString(Application::APP_ID, $key, '')); + }//end config() + + /** + * JSON-encode an archive entry. + * + * @param array $payload The payload. + * + * @return string The encoded JSON. + */ + private function encode(array $payload): string { + $encoded = json_encode(value: $payload, flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + if ($encoded === false) { + return '{}'; + } + + return $encoded; + }//end encode() + + /** + * A refusal carrying no components. + * + * @param string $message The sentence the administrator reads. + * + * @return array{success: bool, message: string, components: array>} + */ + private function failure(string $message): array { + return ['success' => false, 'message' => $message, 'components' => []]; + }//end failure() +}//end class diff --git a/lib/Support/FleetAppId.php b/lib/Support/FleetAppId.php new file mode 100644 index 000000000..17346165c --- /dev/null +++ b/lib/Support/FleetAppId.php @@ -0,0 +1,353 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT:auto + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\LaunchPad\Support; + +use OCP\App\IAppManager; +use Psr\Container\ContainerInterface; +use Throwable; + +/** + * Resolves fleet app ids and namespaces across the in-flight rename. + * + * @spec exclude infrastructure utility with no feature requirement of its own; it is + * exercised through the features that call it + */ +final class FleetAppId { + + /** + * Candidate ids per canonical app, NEWEST FIRST. + * + * Order is the contract: the first entry that is installed wins, so a + * fully migrated instance resolves to the new id and a beta/main instance + * falls back to the old one. Adding a new rename means prepending, never + * replacing — dropping the old id here is what silently breaks the + * integrations this class exists to protect. + * + * @var array> + */ + private const CANDIDATES = [ + 'integriq' => ['integriq', 'openconnector'], + 'filinq' => ['filinq', 'docudesk'], + 'thematiq' => ['thematiq', 'nldesign'], + 'stackiq' => ['stackiq', 'softwarecatalog'], + 'larpinq' => ['larpinq', 'larpingapp'], + 'dossiq' => ['dossiq', 'procest'], + 'learniq' => ['learniq', 'scholiq'], + 'decidiq' => ['decidiq', 'decidesk'], + 'buildiq' => ['buildiq', 'openbuild'], + 'keepiq' => ['keepiq', 'doriath'], + ]; + + /** + * Candidate PHP namespaces per canonical app, NEWEST FIRST. + * + * The rename moved each app's PSR-4 root as well as its id, and the two + * halves break differently. A stale id makes `isInstalled()` answer false; + * a stale namespace makes `class_exists()` answer false and + * `ContainerInterface::get()` throw. Both fail into the same silent no-op, + * because every cross-app binding in the fleet is guarded. + * + * Every pair below was read out of that app's own composer.json history, + * not inferred from its id — `openbuild` shipped `OCA\OpenBuilt`, which no + * naming rule would have produced. + * + * @var array> + */ + private const NAMESPACES = [ + 'integriq' => ['OCA\Integriq', 'OCA\OpenConnector'], + 'filinq' => ['OCA\Filinq', 'OCA\DocuDesk'], + 'thematiq' => ['OCA\Thematiq', 'OCA\NLDesign'], + 'stackiq' => ['OCA\Stackiq', 'OCA\SoftwareCatalog'], + 'larpinq' => ['OCA\Larpinq', 'OCA\LarpingApp'], + 'dossiq' => ['OCA\Dossiq', 'OCA\Procest'], + 'learniq' => ['OCA\Learniq', 'OCA\Scholiq'], + 'decidiq' => ['OCA\Decidiq', 'OCA\Decidesk'], + 'buildiq' => ['OCA\Buildiq', 'OCA\OpenBuilt'], + 'keepiq' => ['OCA\Keepiq', 'OCA\Doriath'], + ]; + + /** + * The id this instance actually has installed, or null if none is. + * + * @param IAppManager $appManager The Nextcloud app manager. + * @param string $canonical Canonical (new) app name, e.g. 'integriq'. + * + * @return string|null The installed id, or null when the app is absent. + * + * @spec exclude infrastructure utility with no feature requirement of its own; it is + * exercised through the features that call it + */ + public static function resolve(IAppManager $appManager, string $canonical): ?string { + foreach ((self::CANDIDATES[$canonical] ?? [$canonical]) as $candidate) { + try { + if ($appManager->isInstalled($candidate) === true) { + return $candidate; + } + } catch (Throwable $exception) { + // An app manager that cannot answer for one candidate must not + // abort the search — the next candidate may still resolve. + continue; + } + } + + return null; + }//end resolve() + + /** + * Whether any candidate id for this app is installed. + * + * @param IAppManager $appManager The Nextcloud app manager. + * @param string $canonical Canonical (new) app name, e.g. 'integriq'. + * + * @return boolean True when the app is present under some id. + * + * @spec exclude infrastructure utility with no feature requirement of its own; it is + * exercised through the features that call it + */ + public static function isInstalled(IAppManager $appManager, string $canonical): bool { + return self::resolve(appManager: $appManager, canonical: $canonical) !== null; + }//end isInstalled() + + /** + * Whether the app is installed AND enabled for the current user. + * + * Resolves the id first so the enabled check runs against the same id the + * instance actually has, rather than against a name it never registered. + * + * @param IAppManager $appManager The Nextcloud app manager. + * @param string $canonical Canonical (new) app name, e.g. 'integriq'. + * + * @return boolean True when present and enabled for the current user. + * + * @spec exclude infrastructure utility with no feature requirement of its own; it is + * exercised through the features that call it + */ + public static function isEnabledForUser(IAppManager $appManager, string $canonical): bool { + $id = self::resolve(appManager: $appManager, canonical: $canonical); + if ($id === null) { + return false; + } + + try { + return $appManager->isEnabledForUser($id); + } catch (Throwable $exception) { + return false; + } + }//end isEnabledForUser() + + /** + * Build an app-scoped path using the id the instance actually has. + * + * A URL like `/apps/openconnector/api/sources` is a routing key: Nextcloud + * mounts routes under the REGISTERED app id, so the path is only valid for + * the id that is really installed. Hardcoding either name yields a 404 on + * half the fleet. + * + * @param IAppManager $appManager The Nextcloud app manager. + * @param string $canonical Canonical (new) app name, e.g. 'integriq'. + * @param string $suffix Path after the app segment, no leading slash. + * + * @return string|null The path, or null when the app is not installed. + * + * @spec exclude infrastructure utility with no feature requirement of its own; it is + * exercised through the features that call it + */ + public static function appPath(IAppManager $appManager, string $canonical, string $suffix = ''): ?string { + $id = self::resolve(appManager: $appManager, canonical: $canonical); + if ($id === null) { + return null; + } + + $path = '/apps/' . $id; + if ($suffix !== '') { + $path .= '/' . ltrim($suffix, '/'); + } + + return $path; + }//end appPath() + + /** + * Every fully-qualified name a class could have, newest namespace first. + * + * Use this rather than {@see self::resolveClass()} when the answer is + * needed BEFORE the other app's autoloader is certain to be registered — + * most importantly when registering an event listener during + * `Application::register()`. Registering the listener under every candidate + * name is safe: dispatch matches on the concrete event class, so the names + * that never materialise simply never fire. + * + * @param string $canonical Canonical (new) app name, e.g. 'integriq'. + * @param string $relative Class name below the app root, e.g. 'Service\Datasource\DashboardDatasourceService'. + * + * @return list Candidate FQCNs, newest first; empty when unknown. + * + * @spec exclude infrastructure utility with no feature requirement of its own; it is + * exercised through the features that call it + */ + public static function classCandidates(string $canonical, string $relative): array { + $relative = ltrim($relative, '\\'); + $candidates = []; + + foreach ((self::NAMESPACES[$canonical] ?? []) as $namespace) { + $candidates[] = $namespace . '\\' . $relative; + } + + return $candidates; + }//end classCandidates() + + /** + * The fully-qualified name this instance actually has, or null. + * + * @param string $canonical Canonical (new) app name, e.g. 'integriq'. + * @param string $relative Class name below the app root. + * + * @return string|null The FQCN that exists, or null when none does. + * + * @spec exclude infrastructure utility with no feature requirement of its own; it is + * exercised through the features that call it + */ + public static function resolveClass(string $canonical, string $relative): ?string { + foreach (self::classCandidates(canonical: $canonical, relative: $relative) as $fqcn) { + if (class_exists($fqcn) === true || interface_exists($fqcn) === true) { + return $fqcn; + } + } + + return null; + }//end resolveClass() + + /** + * Fetch a service from another fleet app, whatever namespace it ships under. + * + * Replaces `$container->get('OCA\SomeOldName\Service\Thing')`, which throws + * when that app has renamed and — because every call site of that shape is + * wrapped in a try/catch that degrades gracefully — turns a rename into a + * feature that quietly stops working rather than an error anybody sees. + * + * @param ContainerInterface $container The service container. + * @param string $canonical Canonical (new) app name, e.g. 'integriq'. + * @param string $relative Class name below the app root. + * + * @return object|null The service, or null when no candidate resolves. + * + * @spec exclude infrastructure utility with no feature requirement of its own; it is + * exercised through the features that call it + */ + public static function getService(ContainerInterface $container, string $canonical, string $relative): ?object { + foreach (self::classCandidates(canonical: $canonical, relative: $relative) as $fqcn) { + try { + $service = $container->get($fqcn); + if (is_object($service) === true) { + return $service; + } + } catch (Throwable $exception) { + // This candidate is not registered — try the next name before + // concluding the app is absent. + continue; + } + } + + return null; + }//end getService() + + /** + * Whether the container can resolve the class under any candidate namespace. + * + * The capability-probe counterpart to {@see self::getService()}: answers + * "is this binding present?" without constructing the service. + * + * @param ContainerInterface $container The service container. + * @param string $canonical Canonical (new) app name, e.g. 'integriq'. + * @param string $relative Class name below the app root. + * + * @return boolean True when some candidate FQCN is resolvable. + * + * @spec exclude infrastructure utility with no feature requirement of its own; it is + * exercised through the features that call it + */ + public static function hasService(ContainerInterface $container, string $canonical, string $relative): bool { + foreach (self::classCandidates(canonical: $canonical, relative: $relative) as $fqcn) { + try { + if ($container->has($fqcn) === true) { + return true; + } + } catch (Throwable $exception) { + continue; + } + } + + return false; + }//end hasService() + + /** + * Whether a value is an instance of the named class under any candidate namespace. + * + * The listener-side counterpart to {@see self::classCandidates()}: a + * listener registered under both names receives whichever event the other + * app actually dispatches, so the type check has to accept both too. + * + * @param mixed $value The value to test, normally a dispatched event. + * @param string $canonical Canonical (new) app name, e.g. 'integriq'. + * @param string $relative Class name below the app root. + * + * @return boolean True when $value is an instance of some candidate. + * + * @spec exclude infrastructure utility with no feature requirement of its own; it is + * exercised through the features that call it + */ + public static function isInstanceOf(mixed $value, string $canonical, string $relative): bool { + foreach (self::classCandidates(canonical: $canonical, relative: $relative) as $fqcn) { + if ($value instanceof $fqcn) { + return true; + } + } + + return false; + }//end isInstanceOf() + +}//end class diff --git a/lib/widget-types.json b/lib/widget-types.json new file mode 100644 index 000000000..8425bdb6f --- /dev/null +++ b/lib/widget-types.json @@ -0,0 +1,42 @@ +{ + "_comment": [ + "The widget types LaunchPad renders itself, keyed by the `widgetId` a placement carries.", + "One list, two readers. src/constants/__tests__/widgetRegistry.completeness.spec.js asserts", + "the frontend widgetRegistry's keys EQUAL this list, and DemoShowcasesService::partitionWidgets()", + "keeps a showcase widget whose type is on it. Before this file the PHP side knew only tiles and", + "Nextcloud dashboard ids, so the case-handler showcase installed with none of its four widgets.", + "Add or remove a type in the registry and here in the same commit; the vitest names the drift." + ], + "types": [ + "calendar", + "chart", + "clock", + "container", + "delta", + "divider", + "files", + "gauge", + "header", + "iframe", + "image", + "label", + "link", + "links", + "livetile", + "menu", + "nc-widget", + "news", + "object-list", + "people", + "quicklinks", + "search", + "spend-analytics", + "stat", + "stats-block", + "table", + "text", + "tile", + "video", + "weather" + ] +} diff --git a/openspec/changes/e2e-coverage-programme/.openspec.yaml b/openspec/changes/e2e-coverage-programme/.openspec.yaml new file mode 100644 index 000000000..515eaae39 --- /dev/null +++ b/openspec/changes/e2e-coverage-programme/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-11 diff --git a/openspec/changes/e2e-coverage-programme/proposal.md b/openspec/changes/e2e-coverage-programme/proposal.md new file mode 100644 index 000000000..139571700 --- /dev/null +++ b/openspec/changes/e2e-coverage-programme/proposal.md @@ -0,0 +1,61 @@ +# Give every LaunchPad scenario its own e2e verdict + +## Why + +LaunchPad's specs describe 2,834 scenarios across 78 capabilities. On 2026-09-11, 147 of them (5%) were cited by a Playwright test. 46 specs covered every scenario with one spec-level `@e2e exclude` line, which hides 1,862 scenarios from gate-19. + +A blanket exclusion says nothing about any single scenario. It cannot tell a scenario nobody can test from one nobody has tried. The first spec to lose its blanket shows what that costs. + +`dashboard-export-import` had 48 scenarios under one exclusion. Covering them one by one (#616) found three real defects. A corrupt file was reported with a blank ID. A skipped dashboard was reported under an invented ID. The admin page replaced the server's refusal reason with "Import failed. Please try again." Before that, #612 found that no import had ever landed a dashboard. The importer never set two required columns, and the unit tests mocked the database, so nothing noticed. + +Every blanket still in place may hide the same kind of defect. + +## What changes + +Each spec in LaunchPad ends with one verdict per scenario and no spec-level blanket. A scenario gets exactly one of three verdicts: + +- **Cited.** A Playwright test carries `// @e2e ::` and asserts every THEN and AND clause of that scenario. A test that only loads a page does not count. +- **New e2e.** Someone writes the test. It drives what a person does through the product's own controls, and uses the API only to seed and tear down. +- **Excluded, with a reason.** The scenario carries `@e2e exclude `. The reason names the unit or Newman test that pins the behaviour, or says plainly that none exists or the feature was never built. Check `git log --all` before writing "never built". + +When a test shows the product disagreeing with its scenario, that is a defect. It gets fixed with a unit test. The e2e is not weakened to match the product. + +This change adds no requirements. It changes tests, the `@e2e` annotations in `openspec/specs/`, and product code wherever a test exposes a defect. + +## Order of work + +Specs are worked in five tiers. The tier decides the order, not the size. + +1. **Tier 0, safety (8 specs, 343 scenarios).** Who may see, change or share what. A false green here reads as proof that a protection works. Started on 2026-09-11. +2. **Tier 1, data integrity (11 specs, 341 scenarios).** Deletes, cascades, versions, locks, quotas and bulk writes. #612 shows why: write paths tested only against a mocked database. + Since #617, CI's PHPUnit boots a real Nextcloud, so a write path can be pinned by a database test as well as an e2e. #617 checked all 33 insert sites against the 73 required columns and found two more silent failures: Confluence import never saved a dashboard, and a role permission saved without a name hit the database constraint. +3. **Tier 2, core surfaces (19 specs, 823 scenarios).** Dashboards, widgets, the grid, tiles, templates and the setup wizard. +4. **Tier 3, widgets (24 specs, 817 scenarios).** One spec per widget type. +5. **Tier 4, admin, operations and integrations (14 specs, 419 scenarios).** CLI commands, background jobs, metrics and search providers. Many of these have no browser surface, so expect honest exclusions naming the PHPUnit or Newman test, rather than new e2e. + +## Rules for safety specs + +These apply in tier 0 and to any refusal in a later tier. + +- Probe a refusal with the least privileged principal that should be refused, logged in. An ordinary account proves the rule. A superuser success proves almost nothing, and an anonymous probe says nothing about a logged-in user. +- Assert the exact status the spec names, not "not 200". Then re-read as the owner to confirm that nothing was written. +- Prove the allowed principal can do it, in the same test. Otherwise a broken endpoint passes as a refusal. +- Create test accounts over basic auth with an explicit header. Nextcloud expires password confirmation 30 minutes after login, so accounts created through the admin session fail when a run starts late. + +## How a spec is done + +- Every scenario has one verdict, and the spec-level blanket is gone. +- Every new guard has been seen to fail. Break the rule in product code, watch the right assertion go red, then restore the file. Turn PHP opcache revalidation off in the test container first. On #616, two mutations read green because the container still ran the cached, unbroken code. +- `composer check:strict` exits 0, and so do `npm run lint` and `npm run format`. The scoped hydra gates exit 0, including gate-19. +- The PR's CI shows the new tests ran. A spec that did not run looks exactly like one that passed. + +## Capacity + +One spec of about 50 scenarios took one agent roughly an hour, plus a CI run of 20 to 30 minutes. At that rate the whole programme is about 60 agent-hours. With three agents in parallel it is two to three working days of wall time, bounded by CI queues rather than writing. + +A PR per spec keeps each review small, and lets one spec's defect ship without waiting for the others. + +## Out of scope + +- dossiq and nextcloud-vue. dossiq already runs its own programme (`e2e-citation-integrity`, dossiq #2461 and its follow-ups). nextcloud-vue gets its own change when this one is past tier 1. +- Rewriting scenarios. Where a scenario is wrong, fix the scenario text in the same PR and say why. Do not delete a scenario to make the count look better. diff --git a/openspec/changes/e2e-coverage-programme/tasks.md b/openspec/changes/e2e-coverage-programme/tasks.md new file mode 100644 index 000000000..0ba96cdc6 --- /dev/null +++ b/openspec/changes/e2e-coverage-programme/tasks.md @@ -0,0 +1,117 @@ +# Tasks + +One PR per spec, or per requirement group for the two largest. Each task is done when the spec meets "How a spec is done" in `proposal.md`. + +Counts are scenarios, and scenarios cited by a Playwright test, measured on `development` on 2026-09-11. "Blanket" means the spec hides every scenario behind one spec-level `@e2e exclude`. + +## Done + +- [x] `dashboard-export-import`: 48 scenarios, each with its own verdict. Three import defects fixed (#616, with #612 before it). +- [x] `demo-data-showcases`: per-scenario verdicts replaced the stale "no UI surface" exclusion (#606). + +## 1. Tier 0, safety + +8 specs, 343 scenarios. + +- [ ] 1.1 `admin-settings`: 83 scenarios, 2 cited, blanket. Access requirements in progress 2026-09-11. Its settings-behaviour scenarios stay open under this task. +- [ ] 1.2 `admin-roles`: 55 scenarios, 0 cited, blanket. In progress 2026-09-11. +- [ ] 1.3 `conditional-visibility`: 49 scenarios, 0 cited, blanket. In progress 2026-09-11. +- [ ] 1.4 `permissions`: 42 scenarios, 0 cited, blanket. In progress 2026-09-11, with `admin-roles` and the access requirements of `admin-settings`. +- [ ] 1.5 `dashboard-public-share`: 38 scenarios, 25 cited. In progress 2026-09-11. +- [ ] 1.6 `dashboard-sharing`: 34 scenarios, 10 cited. In progress 2026-09-11. Supersedes `add-dashboard-sharing-e2e-coverage`. +- [ ] 1.7 `role-feature-permissions`: 27 scenarios, 0 cited. Queued 2026-09-11, after the permissions PR. +- [ ] 1.8 `launchpad-enterprise-security-access`: 15 scenarios, 0 cited, blanket. Queued 2026-09-11, after the permissions PR. + +## 2. Tier 1, data integrity + +11 specs, 341 scenarios. + +- [ ] 2.1 `dashboard-bulk-operations`: 50 scenarios, 0 cited, blanket. +- [ ] 2.2 `resource-uploads`: 46 scenarios, 0 cited, blanket. +- [ ] 2.3 `dashboard-versioning`: 38 scenarios, 0 cited, blanket. +- [ ] 2.4 `orphaned-data-cleanup`: 36 scenarios, 0 cited, blanket. +- [ ] 2.5 `dashboard-cascade-events`: 35 scenarios, 1 cited, blanket. +- [ ] 2.6 `dashboard-locking`: 29 scenarios, 0 cited, blanket. +- [ ] 2.7 `dashboard-metadata-fields`: 28 scenarios, 0 cited, blanket. +- [ ] 2.8 `dashboard-quota-limits`: 24 scenarios, 0 cited, blanket. +- [ ] 2.9 `legacy-widget-bridge`: 21 scenarios, 0 cited, blanket. +- [ ] 2.10 `launchpad-compliance-audit-panel`: 18 scenarios, 0 cited, blanket. +- [ ] 2.11 `dashboard-acknowledgements`: 16 scenarios, 3 cited. + +## 3. Tier 2, core surfaces + +19 specs, 823 scenarios. + +- [ ] 3.1 `dashboards`: 167 scenarios, 0 cited, blanket. The largest spec. Split it by requirement across several PRs. +- [ ] 3.2 `widgets`: 110 scenarios, 0 cited, blanket. Split it by requirement across several PRs. +- [ ] 3.3 `admin-templates`: 86 scenarios, 0 cited, blanket. +- [ ] 3.4 `grid-layout`: 56 scenarios, 16 cited. +- [ ] 3.5 `setup-wizard`: 55 scenarios, 0 cited, blanket. +- [ ] 3.6 `footer-customization`: 47 scenarios, 0 cited, blanket. +- [ ] 3.7 `navigation-editor-org`: 45 scenarios, 0 cited, blanket. +- [ ] 3.8 `tiles`: 38 scenarios, 0 cited. +- [ ] 3.9 `dashboard-language-content`: 35 scenarios, 0 cited, blanket. +- [ ] 3.10 `dashboard-reactions`: 31 scenarios, 0 cited, blanket. +- [ ] 3.11 `tile-quick-search`: 28 scenarios, 8 cited. +- [ ] 3.12 `dashboard-icons`: 25 scenarios, 0 cited, blanket. +- [ ] 3.13 `conditional-visibility-editor`: 23 scenarios, 13 cited. +- [ ] 3.14 `dashboard-kiosk-mode`: 23 scenarios, 0 cited. +- [ ] 3.15 `dashboard-switcher`: 20 scenarios, 11 cited. +- [ ] 3.16 `runtime-shell`: 16 scenarios, 5 cited. +- [ ] 3.17 `dashboard-deeplinking`: 7 scenarios, 0 cited. +- [ ] 3.18 `default-widget-bundle`: 6 scenarios, 0 cited. +- [ ] 3.19 `effective-default-marker`: 5 scenarios, 0 cited. + +## 4. Tier 3, widgets + +24 specs, 817 scenarios. + +- [ ] 4.1 `text-display-widget`: 90 scenarios, 9 cited. +- [ ] 4.2 `news-widget`: 58 scenarios, 0 cited, blanket. +- [ ] 4.3 `files-widget`: 57 scenarios, 0 cited, blanket. +- [ ] 4.4 `header-widget`: 56 scenarios, 0 cited. +- [ ] 4.5 `calendar-widget`: 52 scenarios, 0 cited, blanket. +- [ ] 4.6 `links-widget`: 52 scenarios, 0 cited. +- [ ] 4.7 `video-widget`: 51 scenarios, 0 cited. +- [ ] 4.8 `link-button-widget`: 50 scenarios, 0 cited. +- [ ] 4.9 `people-widget`: 49 scenarios, 0 cited, blanket. +- [ ] 4.10 `image-widget`: 45 scenarios, 8 cited. +- [ ] 4.11 `menu-widget`: 37 scenarios, 0 cited. +- [ ] 4.12 `quicklinks-widget`: 36 scenarios, 0 cited. +- [ ] 4.13 `divider-widget`: 29 scenarios, 3 cited. +- [ ] 4.14 `clock-weather-widgets`: 21 scenarios, 0 cited. +- [ ] 4.15 `launchpad-spend-analytics-widget`: 21 scenarios, 0 cited, blanket. +- [ ] 4.16 `launchpad-ai-dashboard-assistant`: 18 scenarios, 0 cited, blanket. +- [ ] 4.17 `iframe-embed-widget`: 15 scenarios, 0 cited. +- [ ] 4.18 `launchpad-mobile-remote-access`: 15 scenarios, 0 cited, blanket. +- [ ] 4.19 `live-data-tile-widget`: 15 scenarios, 0 cited. +- [ ] 4.20 `launchpad-meeting-calendar-actions`: 14 scenarios, 0 cited, blanket. +- [ ] 4.21 `label-widget`: 12 scenarios, 11 cited. +- [ ] 4.22 `launchpad-file-access-widget`: 12 scenarios, 0 cited, blanket. +- [ ] 4.23 `container-widget`: 8 scenarios, 0 cited, blanket. +- [ ] 4.24 `nc-dashboard-widget-proxy`: 4 scenarios, 0 cited, blanket. + +## 5. Tier 4, admin, operations and integrations + +14 specs, 419 scenarios. + +- [ ] 5.1 `dashboard-view-analytics`: 65 scenarios, 0 cited, blanket. +- [ ] 5.2 `background-job-feed-refresh`: 56 scenarios, 0 cited, blanket. +- [ ] 5.3 `cli-commands`: 53 scenarios, 0 cited, blanket. +- [ ] 5.4 `activity-feed-integration`: 52 scenarios, 0 cited, blanket. +- [ ] 5.5 `nc-unified-search-integration`: 48 scenarios, 0 cited, blanket. +- [ ] 5.6 `confluence-html-import`: 41 scenarios, 0 cited, blanket. +- [ ] 5.7 `prometheus-metrics`: 23 scenarios, 0 cited, blanket. +- [ ] 5.8 `launchpad-adopt-or-abstractions`: 20 scenarios, 1 cited. +- [ ] 5.9 `infrastructure-helpers`: 17 scenarios, 0 cited, blanket. +- [ ] 5.10 `service-health-ping`: 15 scenarios, 0 cited. +- [ ] 5.11 `initial-state-contract`: 11 scenarios, 0 cited, blanket. +- [ ] 5.12 `runtime-or-consumption`: 11 scenarios, 0 cited, blanket. +- [ ] 5.13 `license-header-consistency`: 7 scenarios, 0 cited, blanket. +- [ ] 5.14 `groupfolder-storage-backend`: 0 scenarios, 0 cited. Has no scenarios. Decide whether the spec is still wanted. + +## 6. Close the programme + +- [ ] 6.1 Re-measure every spec with the same count, and publish the before and after totals in this change. +- [ ] 6.2 Confirm no spec-level `@e2e exclude` remains in `openspec/specs/`. +- [ ] 6.3 Archive `add-dashboard-sharing-e2e-coverage` once tier 0 covers it, repointing any citation into it first. diff --git a/openspec/changes/store-plane-dashboard-sharing/design.md b/openspec/changes/store-plane-dashboard-sharing/design.md new file mode 100644 index 000000000..c18d54538 --- /dev/null +++ b/openspec/changes/store-plane-dashboard-sharing/design.md @@ -0,0 +1,195 @@ +# Design: cross-instance dashboard sharing over the AppHost store plane + +## Phase 1 findings + +This is the investigation that produced the proposal. It is written down because +three of its findings are defects that exist on `development` today, and one of +them contradicts the brief this work started from. + +### Finding 1: LaunchPad already renders a Store page that cannot answer + +`src/manifest.json` declares a page `{"id": "Store", "type": "store"}`. That type +dispatches to `CnStorePage` in `@conduction/nextcloud-vue`, which fetches +`/apps/launchpad/api/store/items` on mount. + +`appinfo/routes.php` declares no such route. LaunchPad hand-writes its route +table rather than adopting `OCA\OpenRegister\AppHost\Routes::standard()`, so the +two store routes never register. The page is in the navigation and its only +request 404s. + +This is a variant of a failure OpenRegister already recorded on 2026-09-03, where +decidiq, filinq and planninq each returned HTTP 500 on `/api/store/items`. Those +apps took the route table and skipped the controller binding. LaunchPad took +neither, so it 404s instead of 500ing. Same cause: the store surface is wired in +one place and not the other. + +### Finding 2: the declared store block describes somebody else's store + +The `store` block on `development` is: + +```json +{ "types": ["openregister.configset", "openregister.flows"], "localRegister": "launchpad" } +``` + +`types` being non-empty selects FEDERATED discovery in +`StoreDescriptor::isFederated()`, which routes both search and install to +`FederatedStoreCatalog`. That catalogue trades OpenRegister configuration sets: +registers, schemas, views, flows, sources and mappings. + +A LaunchPad dashboard is none of those. The block, its `_note` included, is +decidiq's block with the app name changed. Had the routes existed, the Store page +would have offered configuration sets to somebody looking for a dashboard. + +### Finding 3: the engine cannot install a LaunchPad dashboard, and that is fine + +The store plane offers two install paths and neither reaches a dashboard. + +`GenericStoreInstaller` supports two ops, `writeObject` and `setAppConfig`. The +first writes rows into OpenRegister schemas named by the `installable` allowlist. +`FederatedStoreCatalog` applies configuration bundles through their owning type. + +LaunchPad dashboards live in LaunchPad's own tables. `ImportService` takes a +`DashboardMapper`, a `WidgetPlacementMapper` and an `IDBConnection`. No +OpenRegister schema holds a dashboard, so no `writeObject` component can create +one. + +The engine anticipates this. `Bootstrap::aliasControllerUnlessLeafDefinesIt()` +is a no-op when the leaf ships its own `Controller\StoreController`, and the +comment beside the store alias names dossiq as an app that keeps winning the +alias until its own class is deleted. A leaf-owned store controller is a +supported state, not a workaround. + +### Finding 4: the brief and the spec disagree, and the spec moved most recently + +The brief describes ADR-080 Decision 3: discovery is engine-owned, install stays +per app, the leaf builds a `StoreDescriptor` and three routes. + +`openspec/specs/apphost-store-plane/spec.md` carries a later requirement, "A leaf +app MUST declare its store rather than implement one", which amends Decision 3. +Under it a leaf declares a `store` block and aliases the engine controller. + +We follow the brief's split for LaunchPad, and the reason is the amendment's own +reason. The amendment says install semantics were made data because "the only +thing that actually varied was which schemas an install may write". For LaunchPad +that is not what varies. The install is a ZIP import against LaunchPad's own +tables, expressible as neither `writeObject` nor `setAppConfig`. The amendment +holds for apps whose install is a schema write. This is not one. + +### What the plane gives LaunchPad for free + +Everything on the discovery half, and it is the half with the security surface: + +- SSRF guarding through `SecurityService::assertSafeFetchUrl()`, fail-closed, on + every built URL before any request is issued. +- `allow_redirects => false`, so a public host cannot bounce the Bearer token to + a private, link-local or metadata address. +- Bearer-only token transport. The token never enters the URL or query string and + never returns to a caller. +- Outcome mapping that separates `store_unreachable` from + `store_invalid_response`, so a misconfigured registry does not read as offline. +- Card normalisation against `cardFields`, which drops every remote property + outside the map. A registry cannot smuggle a field onto a card. +- Slug verification in `resolve()`, so a registry that ignores an unknown query + parameter cannot return an arbitrary first row. +- 10 second connect and request timeouts, and a 50 item search cap. + +All of that was read from `lib/AppHost/Service/GenericStoreService.php`, not +assumed from the spec. + +### What LaunchPad must build + +- The two routes, and the controller behind them. +- Install: resolve the item, then hand its payload to `ImportService`. +- The registry configuration surface, because of the finding below. +- A correct `store` block, replacing the configuration-set one. + +### Finding 5: the registry config has no admin surface in LaunchPad + +`GenericStoreService` reads `registry_url`, `registry_token` and +`registry_register` from `IAppConfig` under the calling app's id. LaunchPad's +admin settings do not go there: `AdminSettingsService` writes through +`AdminSettingMapper` into a LaunchPad table. + +So the keys the engine reads are reachable only by `occ config:app:set`. We add a +small admin-gated config endpoint that reads and writes those three keys in +`IAppConfig`, and a form for it on Beheer ▸ Sharing. The token is write-only +across both: the read returns whether a token is set, never the token, and the +form's token field always starts empty. + +## The install mechanism + +`ImportService::import()` takes a path to a ZIP. The registry hands back a JSON +object. Bridging them has three candidate shapes and only one is honest. + +Writing a payload-shaped importer beside the ZIP-shaped one gives LaunchPad two +import paths that must agree forever. Fetching a ZIP URL named by the remote +payload adds a second outbound fetch that the plane does not guard, which throws +away the SSRF property that is the main reason to adopt the plane at all. + +So we materialise. The resolved payload's dashboards are written into a +`launchpad-export-v1` ZIP in a temporary directory, and that path goes to +`ImportService::import()`. The importer is unchanged and remains the only code +that turns a dashboard payload into rows. `ExportService` already writes exactly +this container, so the two stay symmetrical by construction. + +`preserveUuids` is false for a store install. A template arriving from a foreign +instance carrying a UUID that collides with a local dashboard must become a new +dashboard, never replace one. This is the same class of defect the plane records +under "An install MUST create a new object, never replace one", reached by a +different route: `saveObject()` resolves its target from the payload, and +`ImportService` with `preserveUuids: true` fails closed on collision rather than +overwriting, so passing false is what makes an install additive. + +## Publishing is not solved here, and cannot be + +The plane is a read-only client. Nothing in it writes to a registry, and no +requirement in its spec describes a publish. So the answer to "how does a +dashboard get into a registry" is: not through this change. + +What publishing would actually take, honestly: + +1. A schema on the registry instance holding a dashboard template, with the + `cardFields` properties and an inline dashboard payload. +2. An authenticated write from the publishing instance to that schema, which is a + new outbound write path with its own SSRF guarding, its own token scope, and a + token that is no longer read-only. The current `registry_token` grants read. +3. A moderation posture. A registry that accepts writes from every instance that + holds a token is a spam target, and the plane's trust boundary for a bundle is + its publisher. +4. Versioning and withdrawal. A published template that turns out to be wrong + needs a way to be superseded and a way to be pulled. + +Steps 2 and 3 are the expensive ones and neither is a LaunchPad decision alone. +Until they land, a registry is populated out of band: an administrator exports a +dashboard and loads it into the registry instance directly. That is a real +workflow and it is worth saying it is the workflow, rather than implying the +store closes the loop. + +## How this relates to the sharing LaunchPad already has + +LaunchPad's existing sharing is all in-instance and stays untouched. +`DashboardShareService` shares to users and groups, `PublicShareService` publishes +a read-only link, and templates seed new dashboards. Each of those moves a +dashboard between people who already share a Nextcloud. + +Export and import already move a dashboard between instances, by hand, through a +file an administrator carries. The store does not add a capability so much as +remove the carrying: the same ZIP contract, fetched over a guarded HTTP client +instead of downloaded and re-uploaded. + +Nothing here changes who may see a dashboard once it exists. An installed +dashboard is owned by the installing administrator and obeys the same permission +model as one made by hand. + +## Cost + +Roughly 900 lines including tests, in five files plus the manifest and the route +table. The discovery half is a delegation, the install half is the ZIP +materialiser, and the rest is configuration. + +The standing cost is the coupling to two contracts we do not own: the engine's +`GenericStoreService` signatures, and the shape of a remote dashboard-template +object. The first is a normal in-fleet dependency and is resolved optionally, so +an absent OpenRegister degrades to `not_configured` rather than fatal. The second +is the real exposure, and it is bounded by the fact that a malformed payload +fails in `ImportService`'s existing validation rather than in new code. diff --git a/openspec/changes/store-plane-dashboard-sharing/proposal.md b/openspec/changes/store-plane-dashboard-sharing/proposal.md new file mode 100644 index 000000000..21b015881 --- /dev/null +++ b/openspec/changes/store-plane-dashboard-sharing/proposal.md @@ -0,0 +1,74 @@ +# Store plane dashboard sharing + +LaunchPad shares dashboards well inside one instance and not at all between two. +`DashboardShareService` reaches users and groups, `PublicShareService` publishes a +read-only link, and templates seed new dashboards. Every one of them stops at the +instance boundary. Moving a dashboard to another Nextcloud means an administrator +exports a ZIP, carries the file, and imports it on the other side. + +This change adopts OpenRegister's AppHost store plane so that carrying step +disappears. An administrator points LaunchPad at a registry, browses the dashboard +templates it publishes, and installs one. The fetched payload goes to LaunchPad's +existing `ImportService`, so an installed dashboard and an imported ZIP produce +the same rows through the same code. + +It also fixes a surface that is broken on `development`. LaunchPad declares a +`type: "store"` page, which renders `CnStorePage`, which calls +`/apps/launchpad/api/store/items`. That route does not exist. The page is in the +navigation and its only request 404s. The `store` block beside it declares +OpenRegister configuration sets rather than dashboards, so even with routes it +would have offered the wrong thing. + +## Affected code units + +- **NEW** `lib/Service/StoreService.php`: descriptor construction, discovery delegation, payload to ZIP materialisation, install +- **NEW** `lib/Controller/StoreController.php`: the three store endpoints and the registry config endpoints +- **NEW** `tests/Unit/Service/StoreServiceTest.php` +- **NEW** `tests/Unit/Controller/StoreControllerTest.php` +- **MODIFY** `appinfo/routes.php`: register the store routes the page already calls +- **MODIFY** `lib/AppInfo/Application.php`: bind `StoreService` with an optional `GenericStoreService` +- **NEW** `src/components/admin/DashboardRegistrySettings.vue`: the registry form on Beheer ▸ Sharing +- **NEW** `src/components/admin/__tests__/DashboardRegistrySettings.spec.js` +- **NEW** `tests/e2e/dashboard-store.spec.ts` +- **MODIFY** `src/components/admin/tabs/SharingTab.vue`: host the registry form +- **MODIFY** `src/services/api.js`: `getStoreConfig()` and `updateStoreConfig()` +- **MODIFY** `src/manifest.json`: remove the configuration-set store block, which configured an engine controller LaunchPad does not use + +## Capabilities + +### New Capabilities + +- `dashboard-store`: browse a remote registry of dashboard templates and install one into this instance, over the engine's guarded discovery client. + +### Modified Capabilities + +- `dashboard-export-import`: the ZIP container gains a second producer. A store install builds one in memory and imports it, so the container contract now has to hold for payloads that never touched a disk an administrator chose. + +## Why the leaf owns install + +The store plane's spec carries a requirement that a leaf app declares its store +and ships no controller, aliasing the engine's `GenericStoreController` instead. +That requirement amends ADR-080 Decision 3 on the grounds that "the only thing +that actually varied was which schemas an install may write". + +For LaunchPad that is not what varies. The engine offers two install ops, +`writeObject` and `setAppConfig`, plus configuration bundles through +`FederatedStoreCatalog`. A dashboard is none of them: it lives in LaunchPad's own +tables behind `DashboardMapper`, and no OpenRegister schema holds one. So there is +no allowlist entry that makes the engine's installer able to do this. + +The engine treats a leaf-owned controller as a supported state. +`Bootstrap::aliasControllerUnlessLeafDefinesIt()` is a no-op when the leaf ships +its own `Controller\StoreController`, and the comment beside the store alias names +dossiq as an app in exactly this position. Discovery stays engine-owned, which is +where the SSRF guarding, the redirect refusal and the token handling live. + +## What this change does not do + +It does not publish. The plane is a read-only client and nothing in it writes to a +registry. A registry is populated out of band until a publish path exists, which +needs an authenticated outbound write, a wider token scope, and a moderation +posture. `design.md` says what that would take. + +It does not touch in-instance sharing. Nothing about who may see an existing +dashboard changes. diff --git a/openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md b/openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md new file mode 100644 index 000000000..e8da25dc2 --- /dev/null +++ b/openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md @@ -0,0 +1,299 @@ +--- +capability: dashboard-store +status: draft +--- + +# Dashboard store: new capability from change `store-plane-dashboard-sharing` + +## Context + +A registry is another OpenRegister instance publishing dashboard templates as +objects of a `dashboard-template` schema. LaunchPad reaches it through +OpenRegister's AppHost store plane, which owns discovery: the SSRF guard, the +redirect refusal, the Bearer-only token transport, the outcome vocabulary and card +normalisation. LaunchPad owns install, because a dashboard lives in LaunchPad's +own tables and no engine install op can write one. + +Browser coverage lives in `tests/e2e/dashboard-store.spec.ts`: the store page's +not-configured state, the admin form round-trip, the token redaction, and the +admin gate probed with a non-admin account. Scenarios with no browser-visible +effect (degraded discovery with OpenRegister absent, outcome pass-through, +ZIP materialisation, additive install, temp-file cleanup) are PHPUnit-covered +and carry their own reason below. + +A SUCCESSFUL remote install has no e2e test, deliberately. It needs a second +OpenRegister instance publishing a `dashboard-template` object, and CI +provisions one instance. Faking the registry inside the suite would test the +fake. `StoreServiceTest` covers the install path against the engine's real +signatures. + +## NEW Requirements + +### Requirement: REQ-STORE-001 The store routes MUST exist wherever the store page is declared + +LaunchPad's manifest declares a `type: "store"` page, which renders `CnStorePage`, +which calls `GET /apps/launchpad/api/store/items` on mount and +`POST /apps/launchpad/api/store/items/{slug}/install` on an install click. +`appinfo/routes.php` SHALL declare both, and the `{slug}` route SHALL constrain +the slug to `[a-z0-9][a-z0-9-]*[a-z0-9]` so a malformed slug fails at the router +rather than reaching the registry URL. + +#### Scenario: The declared page can reach its endpoint + +- **GIVEN** `src/manifest.json` declares a page of type `store` +- **WHEN** the route table is read +- **THEN** it MUST contain `store#search` at `/api/store/items` +- **AND** it MUST contain `store#install` at `/api/store/items/{slug}/install` + +@e2e exclude route-table shape: asserted by reading appinfo/routes.php in tests/Unit/Support/StoreWiringTest.php; the browser consequence is the next scenario + +#### Scenario: An unconfigured store shows the not-configured state + +- **GIVEN** no `registry_url` is configured +- **WHEN** an administrator opens the Store page +- **THEN** `GET /api/store/items` MUST answer 200 with outcome `not_configured` +- **AND** the page MUST show its not-configured note +- **AND** it MUST NOT show the unreachable note or an empty result grid + +--- + +### Requirement: REQ-STORE-002 An absent OpenRegister MUST degrade, never fatal + +`GenericStoreService` is resolved optionally, exactly as LaunchPad already +resolves the AppHost observability collaborators. When OpenRegister is disabled or +absent the collaborator is null. `search()` MUST then return outcome +`not_configured` with an empty card list, and `install()` MUST refuse, and neither +MUST touch an `OCA\OpenRegister\…` symbol. + +An engine that is present but unconfigured behaves the same way, because +`GenericStoreService::isConfigured()` reports false for an empty `registry_url` +and makes no network call. + +#### Scenario: No OpenRegister yields not_configured + +- **GIVEN** the store service holds a null discovery client +- **WHEN** `search()` is called +- **THEN** the outcome MUST be `not_configured` and the card list MUST be empty + +@e2e exclude requires an instance WITHOUT OpenRegister, and CI installs it; asserted in tests/Unit/Service/StoreServiceTest.php + +#### Scenario: No OpenRegister refuses an install rather than half-running one + +- **GIVEN** the store service holds a null discovery client +- **WHEN** `install()` is called for any slug +- **THEN** it MUST report failure and MUST NOT call `ImportService` + +@e2e exclude requires an instance WITHOUT OpenRegister, and CI installs it; asserted in tests/Unit/Service/StoreServiceTest.php + +--- + +### Requirement: REQ-STORE-003 The engine's outcome MUST reach the caller unchanged + +The plane distinguishes `store_unreachable` from `store_invalid_response` so a +misconfigured registry does not read as an offline one. LaunchPad SHALL pass the +outcome through verbatim and MUST NOT collapse the two, and MUST NOT substitute +its own. Upstream error detail MUST NOT reach the caller; the engine already keeps +it server-side. + +#### Scenario: An invalid response is not reported as unreachable + +- **GIVEN** the engine returns outcome `store_invalid_response` +- **WHEN** the service returns +- **THEN** the outcome MUST be `store_invalid_response` + +@e2e exclude needs a registry that answers malformed JSON, and CI has no registry; asserted in tests/Unit/Service/StoreServiceTest.php + +--- + +### Requirement: REQ-STORE-004 Installing MUST require an administrator + +An install writes dashboards into the instance from a third-party server. The +install endpoint SHALL carry `#[AuthorizedAdminSetting(LaunchPadAdmin::class)]`, +the posture LaunchPad already uses for its other administrative endpoints. + +Search SHALL carry `#[NoAdminRequired]`, because browsing a registry addresses no +object of this instance. It forwards a query to an external registry and returns +normalised cards, so there is no local identifier to guess and no IDOR to create. + +#### Scenario: Every store route declares a posture + +- **GIVEN** the store controller +- **WHEN** its public methods are read +- **THEN** `search` MUST carry `#[NoAdminRequired]` +- **AND** `install` MUST carry `#[AuthorizedAdminSetting]` + +@e2e exclude attribute presence: asserted by reflection in tests/Unit/Controller/StoreControllerTest.php; the two scenarios below prove the attributes hold at runtime + +#### Scenario: A non-admin is refused the registry config + +- **GIVEN** a signed-in account that is not an administrator, holding a valid request token +- **WHEN** it reads or writes `/api/store/config` +- **THEN** both requests MUST answer 403 +- **AND** the stored registry MUST be unchanged afterwards + +#### Scenario: A non-admin is refused an install + +- **GIVEN** the same account +- **WHEN** it posts to `/api/store/items/{slug}/install` +- **THEN** the request MUST answer 403 + +--- + +### Requirement: REQ-STORE-005 An install MUST reuse ImportService, not reimplement it + +The resolved payload SHALL be materialised into a `launchpad-export-v1` ZIP in a +temporary directory and handed to `ImportService::import()`. LaunchPad MUST NOT +gain a second code path that turns a dashboard payload into rows. + +The materialised archive MUST carry a `manifest.json` with `schemaVersion` equal +to the importer's supported version and a `scope`, and one +`dashboards/{uuid}.json` entry per dashboard, because that is what +`validateZipStructure()` requires and what `ExportService` writes. + +The temporary archive MUST be removed whether the import succeeds or throws. + +#### Scenario: The payload reaches the existing importer + +- **GIVEN** a resolved item carrying one dashboard payload +- **WHEN** it is installed +- **THEN** `ImportService::import()` MUST be called exactly once +- **AND** the path it receives MUST be an existing ZIP containing `manifest.json` + +@e2e exclude needs a second OpenRegister publishing a template, and CI has one instance; asserted in tests/Unit/Service/StoreServiceTest.php + +#### Scenario: An install with no registry is refused with a reason + +- **GIVEN** no `registry_url` is configured +- **WHEN** an administrator posts an install for any slug +- **THEN** the request MUST answer 400 with `success` false and a message +- **AND** it MUST NOT answer 500 + +#### Scenario: A payload with no dashboards is refused before any import + +- **GIVEN** a resolved item whose dashboards list is empty or absent +- **WHEN** it is installed +- **THEN** the install MUST fail and `ImportService::import()` MUST NOT be called + +@e2e exclude needs a registry serving a malformed item, and CI has no registry; asserted in tests/Unit/Service/StoreServiceTest.php + +#### Scenario: The temporary archive does not survive a failing import + +- **GIVEN** `ImportService::import()` throws +- **WHEN** the install returns +- **THEN** no temporary archive from that install MUST remain on disk + +@e2e exclude a temp-directory effect with no browser surface; asserted in tests/Unit/Service/StoreServiceTest.php + +--- + +### Requirement: REQ-STORE-006 A store install MUST create dashboards, never replace them + +The install SHALL call `ImportService::import()` with `preserveUuids` false. A +template published by a foreign instance carries that instance's UUIDs, and one of +them can collide with a live local dashboard. Re-mapping makes the install +additive. + +This mirrors the plane's own rule that an install creates rather than replaces, +reached by a different route: the identity that would do the damage is the +dashboard UUID rather than an object `uuid`. + +#### Scenario: A colliding UUID still creates + +- **GIVEN** a resolved item whose dashboard UUID matches a local dashboard +- **WHEN** it is installed +- **THEN** `import()` MUST be called with `preserveUuids` false + +@e2e exclude needs a second OpenRegister publishing a template, and CI has one instance; asserted in tests/Unit/Service/StoreServiceTest.php + +--- + +### Requirement: REQ-STORE-007 The registry token MUST NOT be readable back + +`registry_url`, `registry_token` and `registry_register` live in `IAppConfig` +under `launchpad`, because that is where `GenericStoreService` reads them. +LaunchPad's own admin settings write to a different store, so the change adds an +admin-gated endpoint for these three keys. + +The read endpoint SHALL return the URL and the register, and for the token SHALL +return only whether one is set. The token value MUST NOT appear in any response. + +#### Scenario: Reading the config never returns the token + +- **GIVEN** `registry_token` holds a value +- **WHEN** the config is read +- **THEN** the response MUST report that a token is set +- **AND** the response MUST NOT contain the token value + +#### Scenario: An empty token submission clears rather than blanks around + +- **GIVEN** a stored token +- **WHEN** the config is written with an empty token +- **THEN** the stored token MUST be cleared + +@e2e exclude the clear-versus-keep distinction is a request-payload property the browser cannot see; asserted in tests/Unit/Service/StoreServiceTest.php and src/components/admin/__tests__/DashboardRegistrySettings.spec.js + +--- + +### Requirement: REQ-STORE-008 The manifest MUST NOT declare a store block + +`src/manifest.json` MUST NOT carry a `store` key. That block exists to configure +OpenRegister's `GenericStoreController`, which LaunchPad does not use, so a block +here would declare a schema and card fields that nothing reads while +`StoreService` declares the ones that are used. + +A non-empty `types` list is the specific harm, because it selects the engine's +federated configuration path. The block on `development` declared +`openregister.configset` and `openregister.flows`, which trade registers, schemas +and flows. Had the routes existed, the store page would have offered +configuration sets to somebody looking for a dashboard. + +#### Scenario: No store block is declared + +- **GIVEN** `src/manifest.json` +- **WHEN** it is decoded +- **THEN** it MUST NOT contain a `store` key + +@e2e exclude a source-file property; asserted in tests/Unit/Support/StoreWiringTest.php + +#### Scenario: The store page still declares itself + +- **GIVEN** the same manifest +- **WHEN** its pages are read +- **THEN** exactly one page MUST carry `type` of `store` + + +@e2e exclude a source-file property; asserted in tests/Unit/Support/StoreWiringTest.php + +--- + +### Requirement: REQ-STORE-009 An administrator MUST be able to connect a registry without a shell + +The three registry keys live in `IAppConfig`, which LaunchPad's own admin +settings do not write. So Beheer ▸ Sharing SHALL carry a form for +`registry_url`, `registry_register` and the token, backed by +`/api/store/config`. + +The token field SHALL be write-only. It MUST start empty on every load, and +the page MUST say whether a token is set without showing it. Saving with an +empty token field MUST leave the stored token alone; removing it is a +separate action. + +#### Scenario: An administrator saves a registry and reads it back + +- **GIVEN** an administrator on Beheer ▸ Sharing +- **WHEN** they enter a registry URL, a register and a token, and save +- **THEN** after a reload the URL and register fields MUST show what they entered +- **AND** the page MUST say a token is set + +#### Scenario: The token is never shown back + +- **GIVEN** a stored token +- **WHEN** the form loads +- **THEN** the token field MUST be empty +- **AND** neither the page nor the config response MUST contain the token + +#### Scenario: The saved registry is the one the store reads + +- **GIVEN** an administrator saved a registry URL whose host does not resolve +- **WHEN** they open the Store page +- **THEN** the page MUST show the unreachable note rather than the not-configured one diff --git a/openspec/changes/store-plane-dashboard-sharing/tasks.md b/openspec/changes/store-plane-dashboard-sharing/tasks.md new file mode 100644 index 000000000..f623125c5 --- /dev/null +++ b/openspec/changes/store-plane-dashboard-sharing/tasks.md @@ -0,0 +1,35 @@ +# Tasks: store-plane-dashboard-sharing + +## Backend + +- [x] Task 1: Add `lib/Service/StoreService.php`: descriptor construction, discovery delegation against an optional `GenericStoreService`, payload to ZIP materialisation, install through `ImportService`, registry config read and write (REQ-STORE-002, 003, 005, 006, 007) +- [x] Task 2: Add `lib/Controller/StoreController.php`: `search`, `install`, `getConfig`, `updateConfig` with explicit auth attributes (REQ-STORE-004) +- [x] Task 3: Register the store routes in `appinfo/routes.php`, with the slug requirement (REQ-STORE-001) +- [x] Task 4: Bind `StoreService` in `lib/AppInfo/Application.php`, resolving `GenericStoreService` optionally (REQ-STORE-002) + +## Manifest + +- [x] Task 5: Remove the configuration-set `store` block, which configured an engine controller LaunchPad does not use (REQ-STORE-008) + +## Tests + +- [x] Task 6: `tests/Unit/Service/StoreServiceTest.php`: degraded discovery, outcome pass-through, ZIP shape, additive install, temp cleanup, token redaction +- [x] Task 7: `tests/Unit/Controller/StoreControllerTest.php`: auth attributes, outcome pass-through, install reporting + +## Admin form + +- [x] Task 8: `src/components/admin/DashboardRegistrySettings.vue` on Beheer ▸ Sharing, with a write-only token field (REQ-STORE-009) +- [x] Task 9: `src/components/admin/__tests__/DashboardRegistrySettings.spec.js`: the token is never pre-filled, an empty field on save leaves the stored token alone, removing it is explicit + +## End to end + +- [x] Task 10: `tests/e2e/dashboard-store.spec.ts`: the not-configured state paired with the route's answer, the form round-trip, token redaction, the form's registry reaching the engine, and the admin gate probed with a fresh non-admin account + +## Before archiving + +- The `@e2e` anchors in `tests/e2e/dashboard-store.spec.ts` name this change directory. When the spec is synced to `openspec/specs/dashboard-store/spec.md`, repoint them there, or archiving this change leaves them resolving to nothing. + +## Not in this change + +- Publishing a dashboard to a registry. See `design.md`; it needs an outbound write path, a wider token scope and a moderation posture. +- An e2e test of a SUCCESSFUL remote install. It needs a second OpenRegister publishing a template, and CI provisions one instance. diff --git a/openspec/specs/admin-settings/spec.md b/openspec/specs/admin-settings/spec.md index bcd795201..ea1406cf2 100644 --- a/openspec/specs/admin-settings/spec.md +++ b/openspec/specs/admin-settings/spec.md @@ -10,7 +10,7 @@ Admin settings provide Nextcloud administrators with global configuration option ## Data Model -@e2e exclude all scenarios test REST/service/config API — admin UI reads settings from API; no dedicated Playwright-testable UI flow shipped in v1.0.5 +@e2e exclude Most scenarios here are REST, service or config contracts, covered by PHPUnit and Newman rather than a browser. The browser-observable parts are still exercised: the two REQ-ASET-002 round-trip scenarios are cited by tests/e2e/admin-settings-key-roundtrip.spec.ts, and the personal-dashboard flag's sidebar effect is covered by tests/e2e/allow-personal-dashboards-flag.spec.ts. ### Admin Settings (oc_launchpad_admin_settings) Settings are stored as key-value pairs: @@ -27,24 +27,40 @@ Settings are stored as key-value pairs: | `allow_multiple_dashboards` | `allowMultipleDashboards` | boolean | `true` | Whether users can have more than one dashboard | | `default_permission_level` | `defaultPermissionLevel` | string | `add_only` | Default permission level for user-created dashboards | | `default_grid_columns` | `defaultGridColumns` | integer | `12` | Default number of grid columns for new dashboards | +| `link_create_file_extensions` | `linkCreateFileExtensions` | `string[]` | `["txt","md","docx","xlsx","csv","odt"]` | Extensions the link-button widget's "create file" action may create (REQ-LBN-004). An empty stored list resolves to the default. | +| `content_storage` | `launchpad.content_storage` | string | `database` | Active content storage backend (REQ-GFSB-006). The response key is dotted, unlike the others. | +| `default_share_permission_level` | `defaultSharePermissionLevel` | string | `add_only` | Org-wide default permission for a new share (dashboard-sharing spec) | +| `forced_share_groups` | `forcedShareGroups` | `string[]` | `[]` | Groups every new dashboard is force-shared with (dashboard-sharing spec) | +| `legacy_widget_bridge_enabled` | `legacyWidgetBridgeEnabled` | boolean | `false` | Whether the workspace reads Nextcloud's widget registry (legacy-widget-bridge spec) | +| `max_dashboards_per_user` | `maxDashboardsPerUser` | integer | `0` | Personal dashboard quota per user, `0` = unlimited (REQ-QUOTA-001) | +| `max_widgets_per_dashboard` | `maxWidgetsPerDashboard` | integer | `0` | Placement quota per dashboard, `0` = unlimited (REQ-QUOTA-001) | +| `quicksearch_fallback_target` | `quicksearchFallbackTarget` | string | `none` | Quick-search no-match fallback: `none`, `unified-search` or an https URL template (REQ-QSEARCH-004). A stored value that no longer validates reads as `none`. | | `group_order` | n/a (separate `/api/admin/groups` endpoints) | `string[]` (JSON) | `[]` | Ordered list of Nextcloud group IDs that are "active" for LaunchPad workspace routing (REQ-ASET-012). Read via `AdminSettingsService::getGroupOrder()`; written via `setGroupOrder()`. Corrupt JSON resolves to `[]`. | -NOTE: The DB stores settings with snake_case keys, but the API response returns camelCase keys. The factory default for `defaultPermissionLevel` is `add_only` (Dashboard::PERMISSION_ADD_ONLY), NOT `full`. The API update endpoint accepts abbreviated camelCase parameter names: `defaultPermLevel`, `allowUserDash`, `allowMultiDash`, `defaultGridCols`. +NOTE: The DB stores settings with snake_case keys, but the API response returns camelCase keys. The factory default for `defaultPermissionLevel` is `add_only` (Dashboard::PERMISSION_ADD_ONLY), NOT `full`. The API update endpoint accepts each setting under the key the GET response uses (`defaultPermissionLevel`, `allowUserDashboards`, `allowMultipleDashboards`, `defaultGridColumns`, `linkCreateFileExtensions`) and under its short parameter name (`defaultPermLevel`, `allowUserDash`, `allowMultiDash`, `defaultGridCols`, `linkCreateFileExts`). When both arrive in one body the short name wins. See REQ-ASET-002. ## Requirements ### Requirement: Retrieve Admin Settings (REQ-ASET-001) -Administrators MUST be able to retrieve all current admin settings via the API. The endpoint returns a flat JSON object with all four settings using camelCase keys. +Administrators MUST be able to retrieve all current admin settings via the API. The endpoint returns a flat JSON object with the twelve settings listed under Defined Settings, each under its API response key. `group_order` is not among them; it has its own endpoints (REQ-ASET-012). #### Scenario: Get all settings with defaults - GIVEN no admin settings have been explicitly configured (fresh installation) - WHEN the admin sends GET /api/admin/settings -- THEN the system MUST return HTTP 200 with all settings at their default values: +- THEN the system MUST return HTTP 200 with all twelve settings at their default values: ```json { "defaultPermissionLevel": "add_only", "allowUserDashboards": false, "allowMultipleDashboards": true, - "defaultGridColumns": 12 + "defaultGridColumns": 12, + "linkCreateFileExtensions": ["txt", "md", "docx", "xlsx", "csv", "odt"], + "launchpad.content_storage": "database", + "defaultSharePermissionLevel": "add_only", + "forcedShareGroups": [], + "legacyWidgetBridgeEnabled": false, + "maxDashboardsPerUser": 0, + "maxWidgetsPerDashboard": 0, + "quicksearchFallbackTarget": "none" } ``` - NOTE: `allowUserDashboards` defaults to `false` (REQ-ASET-003) — admins MUST opt in to personal dashboard creation. @@ -52,15 +68,8 @@ Administrators MUST be able to retrieve all current admin settings via the API. #### Scenario: Get settings after modification - GIVEN the admin has set `allowUserDashboards` to `false` - WHEN the admin sends GET /api/admin/settings -- THEN the system MUST return the updated value: - ```json - { - "defaultPermissionLevel": "add_only", - "allowUserDashboards": false, - "allowMultipleDashboards": true, - "defaultGridColumns": 12 - } - ``` +- THEN the response MUST carry `"allowUserDashboards": false` +- AND the other eleven keys MUST still be present with their current values #### Scenario: Non-admin user retrieves settings - GIVEN a regular user "alice" @@ -77,8 +86,9 @@ Administrators MUST be able to retrieve all current admin settings via the API. #### Scenario: Settings response format consistency - GIVEN the admin has configured various settings at different times - WHEN GET /api/admin/settings is called -- THEN the response MUST always return exactly four keys: `defaultPermissionLevel`, `allowUserDashboards`, `allowMultipleDashboards`, `defaultGridColumns` +- THEN the response MUST always return exactly the twelve keys listed under Defined Settings: `defaultPermissionLevel`, `allowUserDashboards`, `allowMultipleDashboards`, `defaultGridColumns`, `linkCreateFileExtensions`, `launchpad.content_storage`, `defaultSharePermissionLevel`, `forcedShareGroups`, `legacyWidgetBridgeEnabled`, `maxDashboardsPerUser`, `maxWidgetsPerDashboard`, `quicksearchFallbackTarget` - AND no additional keys MUST be present in the response +- NOTE: This scenario said "exactly four keys" long after the response had grown to twelve. The list above is taken from `AdminSettingsService::getSettings()`; a key added there MUST be added here and to the Defined Settings table. - AND the response MUST be a flat JSON object (no nesting) ### Requirement: Update Admin Settings (REQ-ASET-002) @@ -90,7 +100,21 @@ Administrators MUST be able to update individual or multiple admin settings in a - WHEN they send PUT /api/admin/settings with body `{"allowUserDash": false}` - THEN the system MUST update the `allowUserDashboards` setting to `false` - AND the response MUST return HTTP 200 with `{"status": "ok"}` -- NOTE: The API update endpoint accepts abbreviated camelCase parameter names (`defaultPermLevel`, `allowUserDash`, `allowMultiDash`, `defaultGridCols`), NOT the full response key names. The response returns `{"status": "ok"}`, NOT the full settings object. +- NOTE: The update endpoint accepts a setting under the key GET returns (`allowUserDashboards`, `allowMultipleDashboards`, `defaultGridColumns`, `linkCreateFileExtensions`, `defaultPermissionLevel`) as well as under its short parameter name (`allowUserDash`, `allowMultiDash`, `defaultGridCols`, `linkCreateFileExts`, `defaultPermLevel`). The response returns `{"status": "ok"}`, NOT the full settings object. +- NOTE: Until launchpad#605 this endpoint accepted ONLY the short names. Nextcloud leaves a parameter it cannot bind at its default, every one of these defaults to null, and null means "not supplied", so a caller that sent the GET keys wrote nothing and was still answered `{"status": "ok"}`. This note used to describe that as the contract. + +#### Scenario: A settings object read from GET can be written back through PUT +- GIVEN the admin reads GET /api/admin/settings +- WHEN they change one value and send it back under the same key, for example PUT with body `{"allowUserDashboards": false}` or `{"defaultGridColumns": 8}` +- THEN the system MUST store the new value +- AND a following GET MUST return it +- AND this MUST hold for each of `allowUserDashboards`, `allowMultipleDashboards`, `defaultGridColumns`, `linkCreateFileExtensions` and `defaultPermissionLevel` + +#### Scenario: Both spellings in one body resolve to the short one +- GIVEN the admin sends PUT /api/admin/settings with body `{"allowUserDash": true, "allowUserDashboards": false}` +- WHEN the settings are updated +- THEN `allowUserDashboards` MUST be `true` +- NOTE: The short name is the documented parameter and the one the admin UI sends, so an alias MUST NOT override it. #### Scenario: Update multiple settings at once - GIVEN the admin wants to change several settings diff --git a/openspec/specs/dashboard-cascade-events/spec.md b/openspec/specs/dashboard-cascade-events/spec.md index 8ba995cf7..b79a45c8c 100644 --- a/openspec/specs/dashboard-cascade-events/spec.md +++ b/openspec/specs/dashboard-cascade-events/spec.md @@ -25,6 +25,10 @@ The system MUST define a `DashboardDeletedEvent` class at `lib/Event/DashboardDe - AND `getOwnerUserId()` MUST return `'alice'` - AND `getType()` MUST return `'user'` - AND `getDeletedAt()` MUST return a `\DateTimeImmutable` equal to `2026-05-01T10:00:00Z` +- AND `getDashboardId()` MUST return the deleted row's integer id when the dispatcher supplied it, and `null` otherwise +- NOTE: every dispatcher fires after the dashboard row is gone, so a listener for a table keyed on `dashboard_id` cannot recover the id from the UUID. It MUST read `getDashboardId()`. Every dispatcher in LaunchPad supplies it. + +@e2e exclude event payload shape with no HTTP surface; asserted in tests/Unit/Event/DashboardDeletedEventTest.php #### Scenario: Event is dispatched after soft-delete and before response @@ -64,7 +68,9 @@ Every listener MUST be registered in `Application` via `IEventDispatcher::addLis - GIVEN LaunchPad is bootstrapped - WHEN `Application::register()` runs -- THEN `IEventDispatcher` MUST have listeners registered for `DashboardDeletedEvent::class` covering: `WidgetPlacementsListener`, `CommentsListener`, `ReactionsListener`, `LocksListener`, `VersionsListener`, `PublicSharesListener`, `MetadataValuesListener`, `TranslationsListener`, `ViewAnalyticsListener`, `TreeListener` +- THEN `IEventDispatcher` MUST have listeners registered for `DashboardDeletedEvent::class` covering: `WidgetPlacementsListener`, `CommentsListener`, `ReactionsListener`, `LocksListener`, `VersionsListener`, `PublicSharesListener`, `DashboardSharesListener`, `MetadataValuesListener`, `TranslationsListener`, `ViewAnalyticsListener`, `TreeListener` + +@e2e exclude registration is wiring with no HTTP surface; asserted by running Application::register() in tests/Unit/Listener/DashboardSharesListenerTest.php #### Scenario: Lifecycle listeners are registered for NC events @@ -84,8 +90,8 @@ Every listener MUST be registered in `Application` via `IEventDispatcher::addLis A set of listeners MUST clean up every dependent table when `DashboardDeletedEvent` fires. Listeners for disjoint data MUST execute independently and MUST NOT interfere with each other. -- NOTE: Of the ten listeners in this group, only `CommentsListener` is derived from the reference implementation's pattern, which uses `ICommentsManager::deleteCommentsAtObject()` as its sole cleanup mechanism for a single listener registered on the delete event. -- NOTE: The remaining nine listeners — `WidgetPlacementsListener`, `ReactionsListener`, `LocksListener`, `VersionsListener`, `PublicSharesListener`, `MetadataValuesListener`, `TranslationsListener`, `ViewAnalyticsListener`, and `TreeListener` — are LaunchPad-specific additions. The reference implementation cleans those targets (if they exist at all) either inline in the service layer or not at all; LaunchPad's richer schema warrants dedicated listeners. The table list MUST be validated against LaunchPad's actual migration files before implementation. +- NOTE: Of the eleven listeners in this group, only `CommentsListener` is derived from the reference implementation's pattern, which uses `ICommentsManager::deleteCommentsAtObject()` as its sole cleanup mechanism for a single listener registered on the delete event. +- NOTE: The remaining ten listeners: `WidgetPlacementsListener`, `ReactionsListener`, `LocksListener`, `VersionsListener`, `PublicSharesListener`, `DashboardSharesListener`, `MetadataValuesListener`, `TranslationsListener`, `ViewAnalyticsListener`, and `TreeListener` — are LaunchPad-specific additions. The reference implementation cleans those targets (if they exist at all) either inline in the service layer or not at all; LaunchPad's richer schema warrants dedicated listeners. The table list MUST be validated against LaunchPad's actual migration files before implementation. - NOTE: `TreeListener` (see REQ-CSC-010) is a LaunchPad improvement over the reference implementation. The reference silently deletes child pages via a recursive filesystem operation without firing the delete event for each child, meaning child-level dependent data (locks, analytics, etc.) is never cleaned up. LaunchPad's `TreeListener` corrects this gap by explicitly dispatching `DashboardDeletedEvent` for each child so that the full listener stack runs for every node in the tree. #### Scenario: Widget placements are deleted on dashboard delete @@ -112,6 +118,15 @@ A set of listeners MUST clean up every dependent table when `DashboardDeletedEve - AND neither row MUST be hard-deleted (audit trail is preserved) - NOTE: LaunchPad-specific listener — the reference implementation has no public-shares table. +#### Scenario: User and group shares are deleted with their dashboard + +- GIVEN dashboard `D1` is shared with user `bob` and with group `sales`, two rows in `oc_launchpad_dashboard_shares` +- WHEN `D1` is deleted and `DashboardDeletedEvent` fires +- THEN `DashboardSharesListener` MUST delete both rows, keyed on the event's `getDashboardId()` +- AND neither `bob` nor a member of `sales` MUST see `D1` any longer +- AND no share row pointing at `D1` MUST remain for the orphan sweep to find +- NOTE: until this listener existed, the rows stayed behind until `OrphanedSharesCategory` swept them. That sweep stays, for rows orphaned before the fix. + #### Scenario: Versions file is deleted in GroupFolder mode - GIVEN the instance uses GroupFolder storage backend diff --git a/openspec/specs/dashboard-export-import/spec.md b/openspec/specs/dashboard-export-import/spec.md index a82451d17..be95cc360 100644 --- a/openspec/specs/dashboard-export-import/spec.md +++ b/openspec/specs/dashboard-export-import/spec.md @@ -11,8 +11,6 @@ Dashboard export and import allow LaunchPad administrators to create versioned s ## Data Model -@e2e exclude pure backend — all scenarios are PHP/service/API/data-layer; no UI surface - ### ZIP Container Format: `launchpad-export-v1.zip` ``` @@ -68,12 +66,14 @@ The export output MUST be a ZIP container with a versioned schema that includes - AND the manifest MUST declare `scope: "dashboard"` and `dashboardCount: 1` - AND all file paths inside the ZIP MUST be relative (no leading `/`) +@e2e exclude The admin page exports the whole site; there is no control for exporting one dashboard, so only a direct API caller reaches this. The archive layout is pinned by ExportServiceTest::testExportDashboardProducesValidArchive. Its metadata-fields and assets clauses are REQ-EXIM-006 and REQ-EXIM-007, which were deferred and never built. + #### Scenario: Site export includes all dashboards - GIVEN an admin exports the entire site via `POST /api/admin/export?scope=site` - WHEN the export completes - THEN the ZIP MUST contain one JSON file per dashboard in `dashboards/` (personal, admin_template, group_shared) - AND the manifest MUST declare `scope: "site"` and `dashboardCount: N` matching the actual count -- AND a single `metadata-fields.json` MUST be present listing all metadata field definitions in the instance +- AND a single `metadata-fields.json` MUST be present (what it should contain is REQ-EXIM-006, which is not implemented: the file is always an empty list) #### Scenario: Manifest schema version enforces forward compatibility - GIVEN the export ZIP is created with `manifest.json` @@ -87,8 +87,12 @@ The export output MUST be a ZIP container with a versioned schema that includes - THEN all file references MUST use relative asset paths (e.g., `"assets/icons/dashboard-icon.png"` or `"assets/widgets//image.png"`) - AND these paths MUST correspond to actual files in the ZIP archive +@e2e exclude Not implemented. The export reserves empty `assets/icons/` and `assets/widgets//` directories and writes no asset bytes or paths (change task 1.5), and asset import was deferred with task 2.6. See REQ-EXIM-007. + ### Requirement: REQ-EXIM-002 Dashboard Scope Export +@e2e exclude Single-dashboard export has no control on the admin page, which offers "Download all dashboards" only, so none of these is reachable from a browser. They are pinned by AdminControllerExportImportTest: testExportDashboardNotFoundReturns404, testExportDashboardRejectsInvalidUuidFormat and testExportDashboardRequiresUuid (each asserting the message, not just the status), and by ExportServiceTest::testSerializeDashboardEmbedsWidgets and testExportDashboardProducesValidArchive. The metadata-fields clause is REQ-EXIM-006 and not implemented. + An admin MUST be able to export a single dashboard with all its configuration, widgets, and associated assets. The export MUST include only the selected dashboard and its dependencies (metadata fields and assets). #### Scenario: Export single dashboard with widgets @@ -117,11 +121,13 @@ An admin MUST be able to export a single dashboard with all its configuration, w An admin MUST be able to export all dashboards in the instance in a single operation. The export MUST include all personal, admin_template, and group_shared dashboards, plus all metadata field definitions and assets. #### Scenario: Site export includes all dashboard types -- GIVEN the instance contains 5 personal dashboards, 2 admin templates, and 1 group_shared dashboard -- WHEN a Nextcloud admin calls `POST /api/admin/export?scope=site` -- THEN the response MUST be HTTP 200 with a ZIP containing all 8 `dashboards/.json` files -- AND the manifest `dashboardCount` MUST be `8` -- AND the `metadata-fields.json` MUST contain all metadata field definitions used across all 8 dashboards (deduplicated by key) +- GIVEN the instance contains personal dashboards, admin templates and group_shared dashboards +- WHEN a Nextcloud admin exports the site, from the admin page or `POST /api/admin/export?scope=site` +- THEN the archive MUST contain one `dashboards/.json` for every dashboard in the instance, of every one of those three types +- AND the manifest `dashboardCount` MUST equal the number of dashboard files in the archive +- AND every dashboard file MUST be readable and carry its uuid, name and widgets +- NOTE: This scenario used to read "5 personal, 2 admin templates and 1 group_shared, so dashboardCount MUST be 8". No test can arrange exactly that on an instance it shares with other suites' fixtures, and demanding it would measure those fixtures rather than the export. What it is about is that a site export leaves no dashboard out and that the manifest agrees with the archive, which is what is now written and tested. +- NOTE: The metadata field definitions this scenario also asked for are REQ-EXIM-006 and are not implemented. #### Scenario: Empty instance site export - GIVEN an instance with no dashboards @@ -130,6 +136,8 @@ An admin MUST be able to export all dashboards in the instance in a single opera - AND the manifest MUST declare `dashboardCount: 0` - AND the `dashboards/` directory MUST be empty (or absent) +@e2e exclude Needs an instance with no dashboards at all; the e2e suite shares its instance with every other spec's fixtures, and emptying it would destroy their data. Pinned by ExportServiceTest::testEmptySiteExportIsAValidArchiveWithNoDashboards. + #### Scenario: Memory efficiency for large site exports - GIVEN an instance with 1000+ dashboards - WHEN an admin calls `POST /api/admin/export?scope=site` @@ -138,10 +146,31 @@ An admin MUST be able to export all dashboards in the instance in a single opera - AND peak memory usage MUST NOT exceed 100 MB (independent of dashboard count) - NOTE: Implementation MUST use streaming writes via ZipArchive or equivalent +@e2e exclude A load measurement, not a browser behaviour: it needs 1000+ dashboards and a memory profile. The archive is built on disk with ZipArchive and streamed (change tasks 8.1 and 8.3), but the 100 MB bound has never been measured — task 8.2 was deferred and is still open. + ### Requirement: REQ-EXIM-004 Import Endpoint An admin MUST be able to import a previously exported ZIP archive into the same or a different LaunchPad instance. The import process MUST validate the ZIP structure, create or update dashboards, handle collisions, and return a summary of imported/skipped records. +Each imported widget MUST arrive as it was exported, apart from what names the exporting instance. Its `content` (a text widget's text, the register an object-list reads, the widget an nc-widget proxies), its `customTitle`, `customIcon`, `styleConfig`, grid position, and for a tile its `tileType` and every tile field MUST be carried. Fields that point at a row or a workflow on the exporting instance MUST NOT be carried: `id`, `dashboardId`, `templatePlacementId`, `isCompulsory`, the `acknowledgement*` fields and `announcementKey`. One builder (`PlacementPayloadHydrator`) does this for every path that reads an archive: this import, the store install that feeds it, and the bundled showcase installer. + +An imported dashboard and each imported placement MUST be written with `created_at` and `updated_at` set to the time of the import. Both columns are NOT NULL on both tables. + +**Why this was written down.** Until the fix the importer set neither timestamp, so on any database that enforces NOT NULL the dashboard insert failed and the dashboard was reported as skipped: no import had landed a dashboard at all (measured on PostgreSQL, SQLSTATE 23502 on `created_at`). Behind that, the importer kept only each widget's grid, style and title, so even a landed dashboard would have lost every widget's configuration and every tile's identity. The store install path hands its payload to this importer, so store installs failed the same way. The unit tests mocked the mapper and saw neither. + +#### Scenario: An exported dashboard imported again keeps every widget's configuration +- GIVEN a dashboard whose widgets are configured: a text widget with text, an object-list with a register and schema, an nc-widget proxying `tasks`, and a tile with its type, icon, colours and link +- WHEN an admin exports it and imports the archive through the admin page +- THEN every imported widget MUST carry the content, title, icon, style and tile fields it was exported with +- AND the imported text widget MUST render its text + +#### Scenario: A dashboard installed from a store arrives with its widgets configured +- GIVEN a registry template whose dashboard carries configured widgets +- WHEN an admin installs it from the store +- THEN each placement MUST carry the `content` the template declared + +@e2e exclude Needs a second, publishing instance to install from, which neither CI nor the dev environment has; pinned by StoreServiceTest::testAStoreInstallArrivesWithItsWidgetsConfigured, which runs the real importer behind the store. + #### Scenario: Import valid ZIP creates new dashboards - GIVEN a valid `launchpad-export-v1.zip` with 3 dashboards - WHEN an admin calls `POST /api/admin/import` with the ZIP file (multipart/form-data, file field) @@ -166,7 +195,7 @@ An admin MUST be able to import a previously exported ZIP archive into the same - WHEN admin imports the ZIP - THEN the system MUST return HTTP 200 (partial success) - AND the response MUST include `importedDashboardCount: 2, skippedDashboardCount: 1` -- AND the `errors` array MUST contain an error message identifying the corrupt dashboard +- AND the `errors` array MUST identify the corrupt dashboard by the archive entry that failed, for example `dashboards/.json is not valid JSON`, with its `uuid` taken from that file name - AND the other 2 valid dashboards MUST be imported #### Scenario: Import multipart file upload @@ -218,10 +247,21 @@ When importing dashboards with preserved UUIDs, the system MUST detect and repor - WHEN the import includes dashboards with existing UUIDs - THEN the system MUST behave as if `preserveUuids=false` (assign fresh UUIDs) +@e2e exclude The admin page always sends the switch's state, so a browser cannot omit the parameter; only a direct caller can. The default lives on the controller signature and is pinned by AdminControllerExportImportTest::testImportDefaultsToFreshUuids. + ### Requirement: REQ-EXIM-006 Metadata Field Collision Handling +@e2e exclude Not implemented, so there is nothing to drive. The export writes `metadata-fields.json` as an empty list and every dashboard's `metadataFieldAssignments` as `[]`; the import never reads that file; `ImportService::ERR_FIELD_TYPE_MISMATCH` has been declared and unused since the first commit. Change tasks 2.4, 2.5, 3.1, 9.6 and 9.7 were deferred and never picked up. Their stated reason (no MetadataFieldMapper on the parent stack) is now stale: lib/Db/MetadataFieldMapper.php exists. + When importing dashboards that reference metadata fields, the system MUST detect field collisions by key. If a collision occurs with the same field type, reuse the existing field; if types differ, skip the affected dashboard and report the mismatch. +#### Scenario: An export carries the metadata field definitions its dashboards use +- GIVEN dashboards that reference metadata fields +- WHEN an admin exports them, at either scope +- THEN `metadata-fields.json` MUST list every metadata field definition those dashboards use, deduplicated by key +- AND each dashboard's `metadataFieldAssignments` MUST name the fields it uses +- NOTE: This is where REQ-EXIM-001, REQ-EXIM-002 and REQ-EXIM-003 used to state the same promise in passing. It is written once, here, beside the import half that has to read it back. + #### Scenario: Reuse existing field on key collision (same type) - GIVEN the instance has a metadata field `{key: "department", type: "string"}` - AND the imported ZIP contains a dashboard that references `{fieldKey: "department", type: "string"}` @@ -255,6 +295,8 @@ When importing dashboards that reference metadata fields, the system MUST detect ### Requirement: REQ-EXIM-007 Asset Import and Collision Handling +@e2e exclude Not implemented, so there is nothing to drive. The export reserves empty `assets/icons/` and `assets/widgets//` directories and writes no bytes (change task 1.5); asset import was deferred with task 2.6 and the collision-rename test with 9.8, pending a read/write-by-uuid surface on the dashboard-icons and resource-uploads capabilities. + When importing dashboards with asset references (icons, widget uploads), the system MUST extract and restore those assets to Nextcloud storage. On filename collision, assets MUST be renamed with a collision-suffix rather than overwriting existing files. #### Scenario: Icons imported to Nextcloud storage @@ -294,7 +336,8 @@ The import process MUST validate the ZIP structure, manifest schema, and per-das #### Scenario: Reject unsupported schema version - GIVEN a ZIP with `manifest.json` containing `schemaVersion: 2` - WHEN admin calls `POST /api/admin/import` -- THEN the system MUST return HTTP 400 with error message `"Unsupported manifest schema version: 2. Only version 1 is supported."` +- THEN the system MUST return HTTP 400 with error message `"Unsupported manifest schema version: 2. Only version 1 is supported. Upgrade LaunchPad to import archives of version 2."` +- AND a version BELOW 1 MUST be refused too, without the upgrade advice: it is a broken manifest, not an older format - AND no dashboards MUST be imported #### Scenario: Reject manifest with invalid JSON @@ -312,6 +355,7 @@ The import process MUST validate the ZIP structure, manifest schema, and per-das - WHEN admin imports - THEN the system MUST skip that dashboard - AND report in `errors` array: `{type: "invalidDashboard", uuid: "", message: "Missing required field: name"}` +- AND that `uuid` MUST be the one the ARCHIVE used, not a UUID the import minted while remapping - AND other valid dashboards in the batch MUST still be imported #### Scenario: ZIP file is not a valid ZIP archive @@ -335,6 +379,8 @@ The export format MUST support forward-compatible versioning. Only `schemaVersio - AND the importer MUST refuse `schemaVersion: 2` on v1-only instances with HTTP 400 - AND migration tooling (if needed) MUST be provided in a separate change proposal +@e2e exclude A documentation and process requirement: it asks for a future ADR and for migration tooling in a separate change proposal, neither of which a test can observe. Its one behavioural clause, that a v2 archive is refused with HTTP 400, is covered under REQ-EXIM-008. + #### Scenario: Version mismatch does not corrupt existing data - GIVEN an instance running export/import for `schemaVersion: 1` - WHEN an unsupported `schemaVersion: 2` ZIP is encountered @@ -344,6 +390,8 @@ The export format MUST support forward-compatible versioning. Only `schemaVersio ### Requirement: REQ-EXIM-010 CLI Commands for Export and Import +@e2e exclude `occ` commands have no browser surface. They are implemented in lib/Command/ExportCommand.php and lib/Command/ImportCommand.php and have no tests of their own, so their output strings and exit codes are unverified. + Administrators MUST be able to export and import dashboards via command-line interface for automation, backup scripting, and disaster recovery workflows. #### Scenario: Export all dashboards via CLI @@ -388,6 +436,8 @@ Each dashboard import MUST be wrapped in a database transaction to ensure consis - AND the dashboard MUST NOT partially exist in the database - AND the error MUST be reported in the `errors` array +@e2e exclude Needs a widget insert to fail in the middle of a dashboard, which no uploaded archive can force: whatever the file contains, the importer builds rows the database accepts. Pinned by ImportServiceTest::testPartialFailureRollsBackOneDashboard. + #### Scenario: Partial import on multi-dashboard failure - GIVEN a ZIP with 10 dashboards, where dashboard #5 has corrupt widget JSON - WHEN import processes the batch @@ -402,9 +452,13 @@ Each dashboard import MUST be wrapped in a database transaction to ensure consis - AND the ZIP output MUST be streamed to the HTTP response in chunks - AND peak memory usage MUST remain under 100 MB +@e2e exclude The same load measurement as REQ-EXIM-003's memory scenario: 1000 dashboards and a memory profile, which change task 8.2 deferred and nobody has measured since. + #### Scenario: Streaming preserves response stream integrity - GIVEN an export of 500 dashboards is being streamed - WHEN the response body is transmitted to the client - THEN the resulting ZIP file on disk MUST be valid and extractable - AND the manifest and all dashboard files MUST be intact +@e2e exclude Needs 500 dashboards. That the streamed archive arrives valid and extractable, with its manifest and every dashboard file intact, is asserted at this instance's size by tests/e2e/dashboard-site-export.spec.ts. + diff --git a/openspec/specs/demo-data-showcases/spec.md b/openspec/specs/demo-data-showcases/spec.md index b8bdf298a..da67b713b 100644 --- a/openspec/specs/demo-data-showcases/spec.md +++ b/openspec/specs/demo-data-showcases/spec.md @@ -118,33 +118,60 @@ The system maintains a registry of installed showcases by querying existing dash ## Requirements -@e2e exclude pure backend — all scenarios are PHP/service/API/data-layer; no UI surface - ### Requirement: REQ-DEMO-001 Bundled showcase ZIP archives -The system MUST ship with exactly 5 showcase ZIP archives under `showcases/{id}/{id}.zip`. Each ZIP MUST contain a valid `export.json` manifest plus a `nl/` locale directory tree. Each `export.json` MUST contain: -- `exportVersion`: Schema version string -- `schemaVersion`: Schema version string -- `requiresMinVersion`: Minimum app version required to install -- `language`: Locale code (`'nl'` for all v1 showcases) -- `pages`: Array of page objects with `_exportPath`, `uniqueId`, `title`, and `content` -- `navigation`: Navigation configuration object -- `footer`: Footer configuration object - -The 5 bundled showcases MUST be: -- `de-bron` — healthcare/nursing organization -- `de-linden` — university -- `gemeente-duin` — municipality -- `horizon-labs` — tech startup -- `van-der-berg` — law firm - -All 5 showcases are NL-only in v1. Multi-locale support (EN, DE, FR variants) is a v2 goal. +The system MUST ship a showcase ZIP archive per bundled id under +`data/demo-showcases/{id}/{id}.zip`, and `DemoShowcasesService::BUNDLED_IDS` is the +list of record. Each ZIP MUST carry a `manifest.json` plus one +`dashboards/{uuid}.json`, and MAY carry `metadata-fields.json` and an `assets/` +tree. Each `manifest.json` MUST contain: +- `schemaVersion`: schema version integer +- `scope`: `"dashboard"` +- `dashboardCount`: number of dashboards in the archive +- `showcaseId`: the bundled id, matching the directory and file name +- `showcaseName`: the name an admin sees in the gallery +- `showcaseDescription`: one sentence saying what the dashboard shows +- `showcaseLanguage`: locale code of the dashboard's own copy + +Each `dashboards/{uuid}.json` MUST declare `type: "group_shared"`, a null `userId`, +`permissionLevel: "view_only"` and a `showcase-{id}` slug, so an installed showcase +is a read-only group dashboard rather than someone's personal one. + +**This paragraph previously described an `export.json` plus a `nl/` locale tree, and +neither has ever been in any bundled ZIP.** The archives have always been +`manifest.json` + `dashboards/`, which is what `ExportService` writes and +`ImportService` reads. The spec described a format the code does not produce, so a +reader implementing against it would have built something no bundled archive matches. + +Two kinds of showcase are bundled, and the difference is what a reader takes away. + +**Organisation showcases** answer "what does an intranet built on this look like". +They are Dutch, and they mirror the reference source dataset so existing copy and +screenshots stay reusable: +- `de-bron`, a healthcare and nursing organisation +- `de-linden`, a university +- `gemeente-duin`, a municipality +- `horizon-labs`, a tech startup +- `van-der-berg`, a law firm + +**Role showcases** answer a different question: what one person's working day looks +like on one page. +- `case-handler`, a case handler's cases, tasks, agenda and unread mail + +A role showcase MAY be in English. `case-handler` places the fleet's own widgets (a +dossiq case list, the Tasks app, a calendar, unread mail) and those carry English +labels, so a Dutch shell around English content would read as a half-translation. +The five organisation showcases stay NL-only. Multi-locale variants of any showcase +remain a v2 goal. #### Scenario: Showcase ZIP archives exist and load without error - **GIVEN** LaunchPad is installed and enabled - **WHEN** the system initializes -- **THEN** all 5 showcase ZIP archives MUST be readable from `showcases/{id}/{id}.zip` -- **AND** each ZIP MUST contain a valid `export.json` parseable as JSON with required fields +- **THEN** every id in `BUNDLED_IDS` MUST be readable from `data/demo-showcases/{id}/{id}.zip` +- **AND** each ZIP MUST contain a `manifest.json` parseable as JSON, carrying the required fields +- **AND** each `manifest.json` `showcaseId` MUST equal the directory and file name it was read from + +@e2e exclude Archive shape is a packaging contract with no browser surface; pinned for every bundled id by DemoShowcasesServiceTest::testEveryBundledIdShipsAReadableArchive. #### Scenario: export.json version is checked against minimum app version - **GIVEN** showcase `de-bron` with `"requiresMinVersion": "0.8.11"` in `export.json` @@ -152,21 +179,33 @@ All 5 showcases are NL-only in v1. Multi-locale support (EN, DE, FR variants) is - **THEN** the system MUST reject the install with HTTP 422 and a clear version mismatch message - **AND** the showcase MUST still appear in the list endpoint with `isInstalled: false` -#### Scenario: All showcases are NL-only in v1 -- **GIVEN** showcase `gemeente-duin` -- **WHEN** admin requests install with any language parameter -- **THEN** the system MUST always resolve to the `nl/` locale tree -- **AND** the installed dashboard MUST carry `metadata.sourceLanguage = 'nl'` +@e2e exclude Not implemented: the archives carry a manifest.json with no requiresMinVersion, and the install path has no version check or 422 response. + +#### Scenario: A showcase declares the language of its own copy +- **GIVEN** showcase `gemeente-duin` and showcase `case-handler` +- **WHEN** an admin lists the available showcases +- **THEN** `gemeente-duin` MUST declare `showcaseLanguage: "nl"` +- **AND** `case-handler` MUST declare `showcaseLanguage: "en"`, because a role showcase places widgets whose labels are English + +#### Scenario: A role showcase installs as a read-only group dashboard +- **GIVEN** showcase `case-handler` +- **WHEN** an admin installs it +- **THEN** the installed dashboard MUST carry `type: "group_shared"` and `permissionLevel: "view_only"` +- **AND** it MUST place exactly four widgets: the case list, tasks, today's agenda and unread mail #### Scenario: Invalid showcase ZIP is rejected -- **GIVEN** a showcase ZIP whose `export.json` is malformed or missing +- **GIVEN** a showcase ZIP whose `manifest.json` is malformed or missing - **WHEN** the system attempts to load it - **THEN** the system MUST log an error - **AND** the showcase MUST NOT appear in the available list - **AND** installation attempts MUST return HTTP 500 +@e2e exclude The bundled archives are all valid, so a browser run cannot arrange a broken one; a missing archive is pinned by DemoShowcasesServiceTest::testDescribeShowcaseReturnsNullWhenZipMissing, a malformed one by no test yet. + ### Requirement: REQ-DEMO-002 List available showcases endpoint +@e2e exclude Endpoint auth and install-state flags are API contracts: 401/403 and the list shape are pinned by AdminDemoShowcasesControllerTest and Newman's GET /api/admin/demo-showcases. The case-handler gallery entry is covered in the browser under REQ-DEMO-001. + The system MUST expose `GET /api/admin/demo-showcases` returning a JSON array of available showcases. Response format: ```json @@ -210,6 +249,8 @@ The endpoint MUST be admin-only (HTTP 403 for non-admin users). Response MUST in ### Requirement: REQ-DEMO-003 Install showcase endpoint +@e2e exclude The endpoint contract (201, 200 when already installed, 404, 403, surfaced skips) is pinned by AdminDemoShowcasesControllerTest and Newman's unknown-id 404; the group_shared result is covered in the browser for case-handler under REQ-DEMO-001. The nl/ locale tree, metadata.sourceLanguage and media extraction scenarios describe behaviour the archive format does not have. + The system MUST expose `POST /api/admin/demo-showcases/{id}/install` creating an installed showcase as a `group_shared` dashboard with `groupId = 'default'`. An optional `?lang=` query parameter is accepted for forward compatibility but always resolves to `nl` in v1. The created dashboard MUST be visible to all users (via REQ-DASH-012). Response format: ```json @@ -272,6 +313,8 @@ If any widget types are unknown at install time, they MUST be silently skipped a ### Requirement: REQ-DEMO-004 Idempotent installation +@e2e exclude Not observable in the browser beyond the gallery's installed state; pinned by DemoShowcasesServiceTest::testInstallIsIdempotent and AdminDemoShowcasesControllerTest::testInstallReturns200WhenAlreadyInstalled. Installation is tracked by an app-config marker, not by dashboard metadata as written. + Reinstalling an already-installed showcase MUST return the existing dashboard's UUID without creating a duplicate. The system MUST track installation state by querying existing `group_shared` dashboards with matching `showcaseId` in metadata. > **NOTE**: The reference implementation tracked idempotency using a single app-wide boolean flag, which only supports one demo dataset. LaunchPad's per-showcase `metadata.showcaseId` approach is more granular and is the correct design for a multi-showcase system. This is a LaunchPad improvement, not a port. @@ -291,21 +334,54 @@ Reinstalling an already-installed showcase MUST return the existing dashboard's ### Requirement: REQ-DEMO-005 Widget type validation and skip-on-missing -At install time, the system MUST validate each widget type in the showcase against the registered widget registry. If a widget type is not registered (unknown), it MUST be silently skipped, recorded in the response, and logged; the installation MUST succeed with valid widgets only. The 8 widget types known to ship in bundled showcases are: `heading`, `text`, `divider`, `links`, `image`, `file`, `news`, `video`, `people`. +At install time the system MUST decide, per widget, whether LaunchPad can render it, and place only those. A widget MUST be kept when any of these holds: -#### Scenario: All known widget types are installed -- **GIVEN** a showcase with widgets of types `heading`, `text`, and `image` (all registered) -- **WHEN** the showcase is installed -- **THEN** all 3 widgets MUST be created +- it is a tile (`tileType` set), rendered by LaunchPad's tile renderer; +- its `widgetId` is one of LaunchPad's own widget types, listed in `lib/widget-types.json` (`object-list`, `nc-widget`, `calendar`, `text` and the rest); +- its `widgetId` is a Nextcloud dashboard widget registered on this instance (`IManager::getWidgets()`). + +Anything else MUST be skipped, recorded in `skippedWidgets` and logged, and the installation MUST still succeed with the widgets that were kept. The case this exists for is a bare Nextcloud widget id whose app is not installed. + +`lib/widget-types.json` is the one list of LaunchPad's own types. The frontend widget registry MUST equal it (`widgetRegistry.completeness.spec.js` reads the same file), so the types the installer keeps and the types the workspace renders cannot drift. A missing or malformed list MUST fail the install loudly rather than skip every LaunchPad widget. + +An `nc-widget` MUST be kept whatever Nextcloud widget it proxies. The check is on the widget's type; whether the proxied app is installed decides what the tile shows, not whether it is placed. Checking the target would make a showcase's shape depend on which apps an instance happens to have. + +A kept widget MUST keep its configuration: its `content` (the register an object-list reads, the widget an nc-widget proxies, a calendar's layout) MUST be copied onto the placement. + +**Why this requirement was rewritten.** It used to say widgets were validated against "the registered widget registry", and the code read that as Nextcloud's registry only. LaunchPad's own types are not in it, so the `case-handler` showcase, built entirely from them, installed as an empty dashboard and still reported success. Behind that, the installer did not copy `content`, so a kept widget would have landed unconfigured. The first was caught by the e2e in `tests/e2e/demo-showcase-case-handler.spec.ts`, not by the unit suite, which opened the archive but never ran the install. The second was found while fixing the first, and the same e2e's configuration assertions would have been its next failure. + +#### Scenario: A LaunchPad widget type is installed although Nextcloud does not register it +- **GIVEN** showcase `case-handler`, whose widgets are `object-list`, `nc-widget`, `calendar` and `nc-widget` +- **AND** none of those ids is in Nextcloud's dashboard registry +- **WHEN** an admin installs it +- **THEN** all four widgets MUST be placed - **AND** `skippedWidgets` MUST be empty +#### Scenario: A kept widget keeps its configuration +- **GIVEN** showcase `case-handler` +- **WHEN** an admin installs it +- **THEN** the `object-list` placement MUST read register `dossiq`, schema `case` +- **AND** the two `nc-widget` placements MUST proxy `tasks` and `mail-unread` +- **AND** the `calendar` placement MUST carry its `internalCalendars` setting + +#### Scenario: A Nextcloud widget whose app is not installed is skipped +- **GIVEN** a showcase with a `text` widget, a `recommendations` widget and a `mail-unread` widget +- **AND** Nextcloud registers `recommendations` but not `mail-unread`, because the Mail app is not installed +- **WHEN** the showcase is installed +- **THEN** `text` and `recommendations` MUST be placed +- **AND** `skippedWidgets` MUST be `["mail-unread"]` + +@e2e exclude Needs an instance where a Nextcloud widget's app is deliberately absent, and no bundled showcase carries such a widget in a stable way; pinned by DemoShowcasesServiceTest::testInstallStillSkipsANextcloudWidgetWhoseAppIsMissing. + #### Scenario: Unknown widget type is skipped -- **GIVEN** a showcase with widget type `future-timeline` (not registered) +- **GIVEN** a showcase with widget type `future-timeline` (neither a LaunchPad type nor registered) - **WHEN** the showcase is installed - **THEN** that widget MUST NOT be created - **AND** `skippedWidgets` MUST include `"future-timeline"` - **AND** other widgets in the showcase MUST still be created +@e2e exclude The bundled archives contain no unknown widget type, so no browser run can arrange one; pinned by DemoShowcasesServiceTest::testInstallSkipsUnknownWidgetTypes and testPartitionWidgetsTreatsTilesAsValid. + #### Scenario: Mixed valid and invalid widgets - **GIVEN** a showcase with 5 widgets: 3 known, 2 unknown - **WHEN** the showcase is installed @@ -314,14 +390,27 @@ At install time, the system MUST validate each widget type in the showcase again - **AND** `skippedWidgets` MUST be `["unknown-1", "unknown-2"]` - **AND** the response MUST warn the admin +@e2e exclude Same limit as the scenario above, the bundled archives carry no unknown types; the partition is pinned by DemoShowcasesServiceTest and the surfaced list by AdminDemoShowcasesControllerTest::testInstallSurfacesSkippedWidgets. + +#### Scenario: The list of LaunchPad types is the list the workspace renders +- **GIVEN** `lib/widget-types.json` and the frontend widget registry +- **WHEN** the frontend unit tests run +- **THEN** the registry's keys MUST equal the types in `lib/widget-types.json` + +@e2e exclude A build-time contract with no runtime surface, enforced by src/constants/__tests__/widgetRegistry.completeness.spec.js, which reads lib/widget-types.json. + #### Scenario: Skip is logged for audit - **GIVEN** a showcase installation with skipped widgets - **WHEN** the installation completes - **THEN** the system MUST log the event with showcase ID, widget types skipped, and admin user ID - **AND** logs MUST be queryable for audit purposes +@e2e exclude Log output has no browser surface. Note the install log today carries the showcase id, dashboard uuid, skipped list and language, but not the admin user id this scenario asks for. + ### Requirement: REQ-DEMO-006 Uninstall showcase endpoint +@e2e exclude An API contract pinned by the DemoShowcasesServiceTest uninstall tests, the AdminDemoShowcasesControllerTest destroy tests and Newman's DELETE. The case-handler e2e uninstalls in afterAll but asserts nothing about it. + The system MUST expose `DELETE /api/admin/demo-showcases/{id}` soft-removing an installed showcase (delete the dashboard and cascade to widget placements). The endpoint MUST be idempotent: calling it twice MUST both return HTTP 204, whether the showcase was installed or not. #### Scenario: Uninstall deletes the dashboard @@ -358,21 +447,38 @@ All v1 bundled showcases are NL-only. Each showcase ZIP contains a single `nl/` - **THEN** the system MUST load and install from the `nl/` locale tree - **AND** the dashboard metadata MUST record `sourceLanguage: 'nl'` +@e2e exclude Describes an nl/ locale tree the archive format does not have; each showcase declares its language in its manifest instead (see REQ-DEMO-001). + #### Scenario: Language parameter accepted for forward compatibility - **GIVEN** admin sends `POST /api/admin/demo-showcases/de-linden/install?lang=en` - **WHEN** no `en/` locale exists in the ZIP - **THEN** the system MUST fall back to `nl/` (the only available locale) - **AND** MUST NOT return an error — graceful fallback is required +@e2e exclude An API parameter with no browser surface; the ?lang= value is passed through as the source language and has no test of its own. + #### Scenario: List endpoint includes language code - **GIVEN** showcase list response - **WHEN** items are returned - **THEN** each item MUST include a `language` field (e.g. `'nl'`) - **AND** the frontend MUST display the language to the admin +@e2e exclude Only partly covered: the case-handler e2e asserts the language field on two entries (case-handler and gemeente-duin), not every item, and nothing checks that the gallery displays it. + ### Requirement: REQ-DEMO-008 Read-only showcase source files -Bundled showcase ZIP archives under `showcases/` are read-only template definitions and MUST NOT be edited or deleted by admins via the admin UI. Only the installed dashboard (a copy in `oc_launchpad_dashboards` table) is mutable. The admin UI MUST display showcase templates as non-editable and non-deletable, with "Install" and "Uninstall" buttons for managing installations only. +@e2e exclude No browser test covers this requirement. The edit rule is pinned by PermissionServiceGroupSharedTest (a member reads, an Editor or a LaunchPad admin edits) and forking by DashboardServiceForkTest; nothing yet asserts the gallery offers only Install and Uninstall. + +Bundled showcase archives under `data/demo-showcases/` are read-only source definitions and MUST NOT be edited or deleted through the admin UI, which offers "Install" and "Uninstall" only. + +Installing a showcase creates a `group_shared` dashboard in the `default` group with `permissionLevel: view_only` (REQ-DEMO-001). Who may change it follows the rule for every group-shared dashboard (`PermissionService::getEffectivePermissionLevel()`), not the stored level alone: + +- Nextcloud admins, LaunchPad admins and users with the Editor role or higher get `full` and MUST be able to edit it: widgets, layout, name. +- Everyone else MUST see it read-only. + +Anyone who wants an editable version of their own MUST be able to fork it. The sidebar's "Add dashboard" button forks the dashboard currently shown into a personal copy (REQ-DASH-020, `POST /api/dashboards/{uuid}/fork`), and the copy carries every widget with its `content` and tile fields. Forking is refused while personal dashboards are switched off (REQ-ASET-003) and is bound by the per-user dashboard quota. + +**Why this was rewritten.** It used to say "Installed dashboard is fully editable", which read as a contradiction of the `view_only` the showcases install with. Both were half the rule: `view_only` is what the audience gets, and admins and Editors can still edit. #### Scenario: Showcase source files are not listed in editable templates - **GIVEN** admin views the template management or dashboard list @@ -380,11 +486,23 @@ Bundled showcase ZIP archives under `showcases/` are read-only template definiti - **THEN** showcase source files MUST NOT appear - **AND** showcase installations (installed dashboards) MUST appear as regular group-shared dashboards -#### Scenario: Installed dashboard is fully editable -- **GIVEN** admin has installed showcase `horizon-labs` -- **WHEN** they view the installed dashboard -- **THEN** it MUST be editable like any other group-shared dashboard -- **AND** they MUST be able to modify widgets, rename, reorder, etc. +#### Scenario: An installed showcase is read-only for its audience +- **GIVEN** showcase `case-handler` is installed +- **AND** user "alice" holds no admin or Editor role +- **WHEN** she opens it +- **THEN** she MUST be able to see it +- **AND** she MUST NOT be able to change its widgets, layout or name + +#### Scenario: Admins and Editors can edit an installed showcase +- **GIVEN** showcase `case-handler` is installed +- **WHEN** a LaunchPad admin, a Nextcloud admin or a user with the Editor role opens it +- **THEN** they MUST be able to edit it like any other group-shared dashboard + +#### Scenario: A user makes an editable copy of an installed showcase +- **GIVEN** showcase `case-handler` is installed and personal dashboards are allowed +- **WHEN** a user viewing it presses "Add dashboard" in the sidebar +- **THEN** a personal copy MUST be created that they own and can edit +- **AND** the copy MUST carry every widget with its configuration #### Scenario: Admin UI prevents editing showcase source - **GIVEN** the admin UI displaying a showcase card @@ -394,6 +512,8 @@ Bundled showcase ZIP archives under `showcases/` are read-only template definiti ### Requirement: REQ-DEMO-009 CLI commands for operations +@e2e exclude occ commands have no browser surface. DemoShowcasesInstallCommand and DemoShowcasesListCommand have no tests yet. + The system MUST expose two Symfony console commands for showcase management. 1. `php occ launchpad:demo-showcases:install [--lang=nl] [--force]` — installs the specified showcase. The `--force` flag bypasses the idempotency guard and reinstalls even if the showcase is already installed (creating a new dashboard). Without `--force`, reinstall returns the existing UUID. Output MUST include the installed dashboard UUID and any skipped widgets. diff --git a/package-lock.json b/package-lock.json index 5d89498db..cd4069f96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "EUPL-1.2", "dependencies": { - "@conduction/nextcloud-vue": "^2.37.0", + "@conduction/nextcloud-vue": "^2.46.0", "@mdi/js": "^7.4.47", "@nextcloud/axios": "^2.6.0", "@nextcloud/dialogs": "^7.4.1", @@ -18,10 +18,10 @@ "@nextcloud/router": "^3.1.0", "@nextcloud/vue": "^9.11.0", "dexie": "^4.4.5", - "dompurify": "^3.4.14", + "dompurify": "^3.4.15", "gridstack": "^12.2.1", - "marked": "^18.0.3", - "pinia": "~2.1.7", + "marked": "^18.0.12", + "pinia": "^4.0.3", "vue": "^3.5.42", "vue-loader": "^17.4.2", "vue-material-design-icons": "^5.2.0", @@ -37,8 +37,8 @@ "@nextcloud/prettier-config": "^1.2.0", "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/webpack-vue-config": "^7.0.2", - "@playwright/test": "^1.59.1", - "@typescript-eslint/parser": "^8.69.0", + "@playwright/test": "^1.63.0", + "@typescript-eslint/parser": "^8.70.0", "@vitejs/plugin-vue": "^6.0.8", "@vitest/coverage-v8": "^4.1.11", "@vitest/ui": "^4.1.11", @@ -46,11 +46,11 @@ "@vue/server-renderer": "^3.5.42", "@vue/test-utils": "^2.5.0", "@vue/tsconfig": "^0.9.1", - "eslint": "^10.8.1", + "eslint": "^10.10.0", "eslint-config-prettier": "^10.1.8", "jsdom": "^30.0.1", "prettier": "^3.9.6", - "stylelint": "^17.9.1", + "stylelint": "^17.15.0", "terser-webpack-plugin": "^5.3.11", "typescript": "^5.4.0", "vitest": "^4.1.11", @@ -1786,7 +1786,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cacheable/utils": "^2.5.0", @@ -1795,54 +1795,17 @@ "keyv": "^5.6.0" } }, - "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", - "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "keyv": "^5.6.0" - } - }, - "node_modules/@cacheable/memory/node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, "node_modules/@cacheable/utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, - "node_modules/@cacheable/utils/node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, "node_modules/@ckpack/vue-color": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@ckpack/vue-color/-/vue-color-1.6.0.tgz", @@ -2010,9 +1973,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.37.0", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.37.0.tgz", - "integrity": "sha512-3+c+vPHlcswZS0+yEO+HjaFTkgI0ET0Lk33LEmMnZc/E7rDV6lPILq9LNlmxC04WuyQNILx4+zsckkSMYEg7hQ==", + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.46.0.tgz", + "integrity": "sha512-dN8ICjedNn9H5bTpfge6qMpxKlz/wciO0rjc8C6Km5WjFXdkJqEQ4mXtusNA0qqm3zv1cOE6KzFsGMgL3niWpQ==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", @@ -2043,12 +2006,10 @@ "ajv-formats": "^3.0.1", "apexcharts": "^4.7.0", "codemirror": "^6.0.0", - "dompurify": "^3.0.0", "leaflet": "^1.9.0", "leaflet.markercluster": "^1.5.3", "linkifyjs": "^4.3.3", "lodash": "^4.17.21", - "marked": "^12.0.0", "style-mod": "^4.0.0", "vue-codemirror6": "^1.4.3", "vue3-apexcharts": "~1.8.0", @@ -2075,7 +2036,7 @@ "eslint": "^8.56.0 || ^9.0.0 || ^10.0.0", "eslint-plugin-vue": "^9.21.0 || ^10.0.0", "gridstack": "^12.0.0 || ^13.0.0", - "marked": "^12.0.0", + "marked": ">=12 <19", "pinia": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.5.0", "vue-eslint-parser": "^9.4.0 || ^10.0.0", @@ -2085,38 +2046,17 @@ "axe-core": { "optional": true }, - "dexie": { - "optional": true - }, - "dompurify": { - "optional": true - }, "eslint": { "optional": true }, "eslint-plugin-vue": { "optional": true }, - "marked": { - "optional": true - }, "vue-eslint-parser": { "optional": true } } }, - "node_modules/@conduction/nextcloud-vue/node_modules/marked": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", - "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@csstools/color-helpers": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", @@ -3026,9 +2966,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -3717,11 +3657,28 @@ "tslib": "2" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, "node_modules/@keyv/serialize": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@leichtgewicht/ip-codec": { @@ -4816,15 +4773,6 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } }, - "node_modules/@nextcloud/vue/node_modules/@vue/devtools-api": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", - "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", - "license": "MIT", - "dependencies": { - "@vue/devtools-kit": "^8.2.1" - } - }, "node_modules/@nextcloud/vue/node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -4868,33 +4816,18 @@ "@esbuild/win32-x64": "0.28.2" } }, - "node_modules/@nextcloud/vue/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/@nextcloud/vue/node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "license": "MIT", "optional": true, "peer": true, "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -4911,7 +4844,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5241,9 +5174,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.148.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", - "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "license": "MIT", "optional": true, "peer": true, @@ -5775,13 +5708,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.1" + "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" @@ -5798,9 +5731,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", - "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", "cpu": [ "arm" ], @@ -5815,9 +5748,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", - "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ "arm64" ], @@ -5832,9 +5765,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", - "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -5849,9 +5782,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", - "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ "x64" ], @@ -5866,9 +5799,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", - "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -5883,9 +5816,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", - "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ "arm" ], @@ -5900,9 +5833,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", - "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ "arm64" ], @@ -5917,9 +5850,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", - "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ "arm64" ], @@ -5934,9 +5867,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", - "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ "ppc64" ], @@ -5951,9 +5884,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", - "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ "s390x" ], @@ -5968,9 +5901,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", - "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ "x64" ], @@ -5985,9 +5918,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", - "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ "x64" ], @@ -6002,9 +5935,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", - "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ "arm64" ], @@ -6019,9 +5952,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", - "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ "arm64" ], @@ -6036,9 +5969,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", - "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -6879,16 +6812,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", - "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3" }, "engines": { @@ -6904,14 +6837,14 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", - "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.69.0", - "@typescript-eslint/types": "^8.69.0", + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", "debug": "^4.4.3" }, "engines": { @@ -6926,14 +6859,14 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", - "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0" + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6944,9 +6877,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", - "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", "dev": true, "license": "MIT", "engines": { @@ -6961,9 +6894,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", - "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", "dev": true, "license": "MIT", "engines": { @@ -6975,16 +6908,16 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", - "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.69.0", - "@typescript-eslint/tsconfig-utils": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -7003,13 +6936,13 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", - "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/types": "8.70.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -7600,10 +7533,13 @@ } }, "node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", - "license": "MIT" + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } }, "node_modules/@vue/devtools-kit": { "version": "8.2.1", @@ -9147,7 +9083,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cacheable/memory": "^2.2.0", @@ -9157,16 +9093,6 @@ "qified": "^0.10.1" } }, - "node_modules/cacheable/node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -10525,9 +10451,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -10887,9 +10813,9 @@ } }, "node_modules/eslint": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", - "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", "devOptional": true, "license": "MIT", "workspaces": [ @@ -10901,7 +10827,7 @@ "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -10916,7 +10842,7 @@ "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", @@ -11652,16 +11578,13 @@ "license": "MIT" }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "devOptional": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/file-uri-to-path": { @@ -11737,17 +11660,15 @@ } }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "devOptional": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { @@ -11922,10 +11843,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -12298,7 +12218,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hookified": "^1.15.0" @@ -12420,7 +12340,7 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/hosted-git-info": { @@ -13538,13 +13458,6 @@ "node": ">=6" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "devOptional": true, - "license": "MIT" - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -13580,13 +13493,13 @@ } }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "devOptional": true, "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/kind-of": { @@ -14138,9 +14051,9 @@ } }, "node_modules/marked": { - "version": "18.0.11", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", - "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "version": "18.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.12.tgz", + "integrity": "sha512-LEm4ga2YeI2T3GVHj9b0BaDPPk93LLTHMFeMyQbNIzPxc8vCI0y/scy0ZA6z6lXKyT9j9Nhl/OC6ZYKYGuFScA==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -16361,25 +16274,24 @@ } }, "node_modules/pinia": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz", - "integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-4.0.3.tgz", + "integrity": "sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==", "license": "MIT", "dependencies": { - "@vue/devtools-api": "^6.5.0", - "vue-demi": ">=0.14.5" + "nostics": "^1.1.4" }, "funding": { "url": "https://github.com/sponsors/posva" }, "peerDependencies": { - "@vue/composition-api": "^1.4.0", - "typescript": ">=4.4.4", - "vue": "^2.6.14 || ^3.3.0" + "@vue/devtools-api": "^8.1.5", + "typescript": ">=5.6.0", + "vue": "^3.5.11" }, "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true + "@vue/devtools-api": { + "optional": false }, "typescript": { "optional": true @@ -16491,28 +16403,25 @@ } }, "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.1" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -16534,9 +16443,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -16553,7 +16462,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -17110,7 +17019,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hookified": "^2.1.1" @@ -17123,7 +17032,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/qs": { @@ -17739,14 +17648,14 @@ "peer": true }, "node_modules/rolldown": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", - "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "license": "MIT", "optional": true, "peer": true, "dependencies": { - "@oxc-project/types": "=0.148.0", + "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -17756,21 +17665,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.7", - "@rolldown/binding-android-arm64": "1.2.7", - "@rolldown/binding-darwin-arm64": "1.2.7", - "@rolldown/binding-darwin-x64": "1.2.7", - "@rolldown/binding-freebsd-x64": "1.2.7", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", - "@rolldown/binding-linux-arm64-gnu": "1.2.7", - "@rolldown/binding-linux-arm64-musl": "1.2.7", - "@rolldown/binding-linux-ppc64-gnu": "1.2.7", - "@rolldown/binding-linux-s390x-gnu": "1.2.7", - "@rolldown/binding-linux-x64-gnu": "1.2.7", - "@rolldown/binding-linux-x64-musl": "1.2.7", - "@rolldown/binding-openharmony-arm64": "1.2.7", - "@rolldown/binding-win32-arm64-msvc": "1.2.7", - "@rolldown/binding-win32-x64-msvc": "1.2.7" + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, "node_modules/rollup": { @@ -19103,9 +19012,9 @@ } }, "node_modules/stylelint": { - "version": "17.14.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz", - "integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==", + "version": "17.15.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.15.0.tgz", + "integrity": "sha512-mWIkesYQvQjf4Kvdeu9ns0IL8/K/wtjaGxPqsPd6DlLZvaQxGysJsocxUDrBcyHQ5VleAk4uSMat4yMrVED7PA==", "dev": true, "funding": [ { @@ -19119,14 +19028,14 @@ ], "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.2.1", + "@csstools/css-calc": "^3.3.0", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.1.6", + "@csstools/css-syntax-patches-for-csstree": "^1.1.9", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", - "@csstools/selector-resolve-nested": "^4.0.0", + "@csstools/selector-resolve-nested": "^4.0.1", "@csstools/selector-specificity": "^6.0.0", - "colord": "^2.9.3", + "colord": "^2.10.0", "cosmiconfig": "^9.0.2", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", @@ -19135,21 +19044,21 @@ "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.5", "global-modules": "^2.0.0", - "globby": "^16.2.1", + "globby": "^16.2.4", "globjoin": "^0.1.4", "html-tags": "^5.1.0", - "ignore": "^7.0.5", + "ignore": "^7.0.6", "import-meta-resolve": "^4.2.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", - "postcss": "^8.5.16", + "postcss": "^8.5.26", "postcss-safe-parser": "^7.0.1", - "postcss-selector-parser": "^7.1.4", + "postcss-selector-parser": "^7.1.5", "postcss-value-parser": "^4.2.0", - "string-width": "^8.2.1", + "string-width": "^8.2.2", "supports-hyperlinks": "^4.5.0", "svg-tags": "^1.0.0", "table": "^6.9.0", @@ -19330,28 +19239,6 @@ "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", - "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^6.1.23" - } - }, - "node_modules/stylelint/node_modules/flat-cache": { - "version": "6.1.23", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", - "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cacheable": "^2.5.0", - "flatted": "^3.4.2", - "hookified": "^1.15.0" - } - }, "node_modules/stylelint/node_modules/ignore": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", @@ -20775,21 +20662,6 @@ } } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vitest": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", @@ -21052,6 +20924,12 @@ "vue": "^3.5.0" } }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, "node_modules/vue3-apexcharts": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.8.0.tgz", diff --git a/package.json b/package.json index 17539eb4d..c826bcf9e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "watch": "NODE_ENV=development webpack --config webpack.config.js --progress --watch", "check:schema-l10n": "node scripts/check-schema-l10n.js", "check:manifest": "node scripts/check-manifest.js", + "check:l10n-parity": "node tests/l10n/check-l10n-parity.js", "lint": "eslint src tests scripts && npm run check:manifest && npm run lint:initial-state && npm run lint:translation-domain", "lint:fix": "eslint src --fix", "lint:initial-state": "node ./scripts/lint-initial-state.js", @@ -34,7 +35,7 @@ "prepare": "git config core.hooksPath .githooks || true" }, "dependencies": { - "@conduction/nextcloud-vue": "^2.37.0", + "@conduction/nextcloud-vue": "^2.46.0", "@mdi/js": "^7.4.47", "@nextcloud/axios": "^2.6.0", "@nextcloud/dialogs": "^7.4.1", @@ -43,10 +44,10 @@ "@nextcloud/router": "^3.1.0", "@nextcloud/vue": "^9.11.0", "dexie": "^4.4.5", - "dompurify": "^3.4.14", + "dompurify": "^3.4.15", "gridstack": "^12.2.1", - "marked": "^18.0.3", - "pinia": "~2.1.7", + "marked": "^18.0.12", + "pinia": "^4.0.3", "vue": "^3.5.42", "vue-loader": "^17.4.2", "vue-material-design-icons": "^5.2.0", @@ -62,8 +63,8 @@ "@nextcloud/prettier-config": "^1.2.0", "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/webpack-vue-config": "^7.0.2", - "@playwright/test": "^1.59.1", - "@typescript-eslint/parser": "^8.69.0", + "@playwright/test": "^1.63.0", + "@typescript-eslint/parser": "^8.70.0", "@vitejs/plugin-vue": "^6.0.8", "@vitest/coverage-v8": "^4.1.11", "@vitest/ui": "^4.1.11", @@ -71,11 +72,11 @@ "@vue/server-renderer": "^3.5.42", "@vue/test-utils": "^2.5.0", "@vue/tsconfig": "^0.9.1", - "eslint": "^10.8.1", + "eslint": "^10.10.0", "eslint-config-prettier": "^10.1.8", "jsdom": "^30.0.1", "prettier": "^3.9.6", - "stylelint": "^17.9.1", + "stylelint": "^17.15.0", "terser-webpack-plugin": "^5.3.11", "typescript": "^5.4.0", "vitest": "^4.1.11", diff --git a/phpunit-stubs.xml b/phpunit-stubs.xml index 002b0b538..2bf421505 100644 --- a/phpunit-stubs.xml +++ b/phpunit-stubs.xml @@ -25,6 +25,16 @@ + + diff --git a/phpunit.xml b/phpunit.xml index 26f9fe057..78a04de3b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -25,6 +25,32 @@ + + + + diff --git a/src/App.vue b/src/App.vue index faaa7d11e..2edcce6bc 100644 --- a/src/App.vue +++ b/src/App.vue @@ -17,6 +17,7 @@ :manifest="liveManifest" :registry="registry" :pageTypes="pageTypes" + :permissions="permissions" appId="launchpad" /> @@ -114,6 +115,31 @@ export default { type: Object, required: true, }, + + /** + * The permission strings this account holds, built by + * `utils/permissions.js` from the server's `isAdmin` answer and + * forwarded to `CnAppNav`'s filter. + * + * 🔴 THIS PROP USED TO BE ABSENT, and its absence was the bug. + * `manifest.menu` declares `permission: "admin"` on `admin-templates` + * and `admin-settings`, and `CnAppNav.passesPermission` reads a missing + * or empty list as "the app did not say" and renders the entry anyway: + * + * if (!this.permissions || this.permissions.length === 0) return true + * + * So both admin entries rendered for every authenticated account and + * the declaration did nothing. `currentPermissions()` never answers an + * empty array, which is what keeps that escape from firing. + * + * `required: false` with a DENYING default rather than `required: true`: + * a mount that forgets this prop should hide the gated entries, not + * throw a dev-only warning in production and show them. + */ + permissions: { + type: Array, + default: () => ['user'], + }, }, computed: { diff --git a/src/components/admin/DashboardExportImport.vue b/src/components/admin/DashboardExportImport.vue index 51a33e983..fe7af234f 100644 --- a/src/components/admin/DashboardExportImport.vue +++ b/src/components/admin/DashboardExportImport.vue @@ -181,6 +181,18 @@ export default { const data = err?.response?.data if (data && Array.isArray(data.errors)) { this.importResult = data + } else if (typeof data?.error === 'string' && data.error !== '') { + // Show the server's reason. It used to be swallowed in + // favour of "please try again", so an admin whose archive + // was refused for a nameable reason (no manifest.json, not + // a ZIP, a schema version this LaunchPad cannot read) was + // told only that something went wrong, and REQ-EXIM-009's + // "instruct the user to upgrade" could never reach them. + this.importError = this.t( + 'launchpad', + 'Import failed: {reason}', + { reason: data.error }, + ) } else { this.importError = this.t( 'launchpad', diff --git a/src/components/admin/DashboardRegistrySettings.vue b/src/components/admin/DashboardRegistrySettings.vue new file mode 100644 index 000000000..867ee0b56 --- /dev/null +++ b/src/components/admin/DashboardRegistrySettings.vue @@ -0,0 +1,263 @@ + + + + + + + diff --git a/src/components/admin/__tests__/DashboardExportImport.spec.js b/src/components/admin/__tests__/DashboardExportImport.spec.js new file mode 100644 index 000000000..d3e0df90d --- /dev/null +++ b/src/components/admin/__tests__/DashboardExportImport.spec.js @@ -0,0 +1,118 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Vitest tests for `DashboardExportImport.vue`: what the admin is told when an + * import is refused (REQ-EXIM-008, REQ-EXIM-009). + * + * The server names the reason an archive was rejected: `manifest.json not found + * in archive`, `Uploaded file is not a valid ZIP archive`, or the schema-version + * message that tells the admin to upgrade LaunchPad. The component used to + * discard all of it and render "Import failed. Please try again.", so the one + * instruction REQ-EXIM-009 asks for could never reach the person who needed it. + */ + +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@nextcloud/axios', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, +})) + +const importDashboards = vi.fn() +vi.mock('../../../services/api.js', () => ({ + api: { + exportDashboards: vi.fn(), + importDashboards: (...args) => importDashboards(...args), + }, +})) + +import DashboardExportImport from '../DashboardExportImport.vue' + +/** Mount the component with a file already chosen. */ +function mountWithFile() { + const wrapper = mount(DashboardExportImport, { + global: { + // The suite-wide `t` stub returns the key and drops the variables, + // which would hide whether the reason is interpolated at all. + mocks: { + t: (_app, source, vars) => + source.replace( + /\{(\w+)\}/g, + (_m, key) => vars?.[key] ?? `{${key}}`, + ), + }, + stubs: { + NcButton: { template: '' }, + NcCheckboxRadioSwitch: { template: '' }, + Download: true, + Upload: true, + }, + }, + }) + wrapper.vm.selectedFile = new File(['zip'], 'archive.zip') + return wrapper +} + +/** An axios-shaped rejection carrying a response body. */ +function rejectionWith(data) { + return Object.assign(new Error('Request failed'), { response: { data } }) +} + +describe('DashboardExportImport — a refused import', () => { + beforeEach(() => { + importDashboards.mockReset() + }) + + it("shows the server's reason, so the admin learns what to fix", async () => { + importDashboards.mockRejectedValue( + rejectionWith({ error: 'manifest.json not found in archive' }), + ) + + const wrapper = mountWithFile() + await wrapper.vm.runImport() + + expect(wrapper.vm.importError).toBe( + 'Import failed: manifest.json not found in archive', + ) + expect(wrapper.text()).toContain('manifest.json not found in archive') + }) + + it('passes the upgrade instruction through for a newer archive', async () => { + const reason = + 'Unsupported manifest schema version: 2. Only version 1 is supported. ' + + 'Upgrade LaunchPad to import archives of version 2.' + importDashboards.mockRejectedValue(rejectionWith({ error: reason })) + + const wrapper = mountWithFile() + await wrapper.vm.runImport() + + expect(wrapper.text()).toContain('Upgrade LaunchPad') + }) + + it('falls back to the generic message when the server names no reason', async () => { + importDashboards.mockRejectedValue(rejectionWith(undefined)) + + const wrapper = mountWithFile() + await wrapper.vm.runImport() + + expect(wrapper.vm.importError).toBe('Import failed. Please try again.') + }) + + it('still renders a per-dashboard error list rather than the banner', async () => { + // A 409 collision carries `errors`, which belongs in the result list. + importDashboards.mockRejectedValue( + rejectionWith({ + importedDashboardCount: 0, + skippedDashboardCount: 0, + errors: [{ type: 'uuidCollision', message: 'Dashboard exists' }], + }), + ) + + const wrapper = mountWithFile() + await wrapper.vm.runImport() + + expect(wrapper.vm.importError).toBe('') + expect(wrapper.vm.importResult.errors).toHaveLength(1) + }) +}) diff --git a/src/components/admin/__tests__/DashboardRegistrySettings.spec.js b/src/components/admin/__tests__/DashboardRegistrySettings.spec.js new file mode 100644 index 000000000..94443c46a --- /dev/null +++ b/src/components/admin/__tests__/DashboardRegistrySettings.spec.js @@ -0,0 +1,164 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Vitest unit tests for `DashboardRegistrySettings.vue` (dashboard-store + * spec, REQ-STORE-009). These pin the token handling, which is the part a + * browser test cannot see go wrong: an e2e run can check that the field is + * empty, but not that a save without a typed token leaves the stored one + * alone. The server keeps an omitted key and clears an empty string, so + * sending `registryToken: ''` on every save would wipe the token silently. + * + * The `api` module is mocked at the import boundary, and the field stubs + * below bind `modelValue` so v-model round-trips through them. + */ + +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DashboardRegistrySettings from '../DashboardRegistrySettings.vue' +import { api } from '../../../services/api.js' + +vi.mock('../../../services/api.js', () => ({ + api: { + getStoreConfig: vi.fn(), + updateStoreConfig: vi.fn(), + }, +})) + +vi.mock('@nextcloud/dialogs', () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), +})) + +/** + * A field stub that round-trips `v-model` through a real ``. + * + * @param {string} name The component name it stands in for. + * @return {object} The stub component. + */ +function fieldStub(name) { + return { + name, + props: ['modelValue', 'label'], + emits: ['update:modelValue'], + template: + '', + } +} + +const stubs = { + NcTextField: fieldStub('NcTextField'), + NcPasswordField: fieldStub('NcPasswordField'), + NcNoteCard: { name: 'NcNoteCard', template: '
' }, + NcButton: { + name: 'NcButton', + props: ['type', 'variant', 'disabled'], + emits: ['click'], + template: + '', + }, +} + +const STORED = { + registryUrl: 'https://registry.example.org/', + registryRegister: 'launchpad', + tokenConfigured: true, + available: true, +} + +/** + * Mount the component against a given server answer and let `load()` settle. + * + * @param {object} config The redacted connection `getStoreConfig` returns. + * @return {Promise} + */ +async function mountWith(config) { + api.getStoreConfig.mockResolvedValue({ data: config }) + const wrapper = mount(DashboardRegistrySettings, { global: { stubs } }) + await flushPromises() + return wrapper +} + +beforeEach(() => { + api.getStoreConfig.mockReset() + api.updateStoreConfig.mockReset().mockImplementation((payload) => + Promise.resolve({ + data: { + ...STORED, + ...('registryUrl' in payload + ? { registryUrl: payload.registryUrl } + : {}), + tokenConfigured: + 'registryToken' in payload ? payload.registryToken !== '' : true, + }, + }), + ) +}) + +describe('DashboardRegistrySettings', () => { + it('fills the URL and register but never the token', async () => { + const wrapper = await mountWith(STORED) + + expect(wrapper.find('[data-test="registry-url"]').element.value).toBe( + STORED.registryUrl, + ) + expect(wrapper.find('[data-test="registry-register"]').element.value).toBe( + 'launchpad', + ) + expect(wrapper.find('[data-test="registry-token"]').element.value).toBe('') + expect(wrapper.find('[data-test="registry-token-status"]').text()).toBe( + 'A token is set. Enter a new one to replace it.', + ) + }) + + it('leaves the stored token alone when the field is empty on save', async () => { + const wrapper = await mountWith(STORED) + + await wrapper + .find('[data-test="registry-url"]') + .setValue('https://other.example.org/') + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(api.updateStoreConfig).toHaveBeenCalledTimes(1) + const payload = api.updateStoreConfig.mock.calls[0][0] + expect(payload).not.toHaveProperty('registryToken') + expect(payload.registryUrl).toBe('https://other.example.org/') + }) + + it('sends a typed token once and then empties the field', async () => { + const wrapper = await mountWith({ ...STORED, tokenConfigured: false }) + + await wrapper.find('[data-test="registry-token"]').setValue('new-secret') + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(api.updateStoreConfig.mock.calls[0][0].registryToken).toBe( + 'new-secret', + ) + expect(wrapper.find('[data-test="registry-token"]').element.value).toBe('') + expect(wrapper.find('[data-test="registry-token-status"]').text()).toBe( + 'A token is set. Enter a new one to replace it.', + ) + }) + + it('clears only the token when the administrator removes it', async () => { + const wrapper = await mountWith(STORED) + + await wrapper.find('[data-test="registry-remove-token"]').trigger('click') + await flushPromises() + + expect(api.updateStoreConfig).toHaveBeenCalledWith({ registryToken: '' }) + expect(wrapper.find('[data-test="registry-remove-token"]').exists()).toBe( + false, + ) + }) + + it('says so when OpenRegister is not available', async () => { + const wrapper = await mountWith({ ...STORED, available: false }) + + expect(wrapper.find('[data-test="registry-engine-missing"]').exists()).toBe( + true, + ) + }) +}) diff --git a/src/components/admin/tabs/SharingTab.vue b/src/components/admin/tabs/SharingTab.vue index f9c1131f1..30472493b 100644 --- a/src/components/admin/tabs/SharingTab.vue +++ b/src/components/admin/tabs/SharingTab.vue @@ -8,22 +8,28 @@