From 987639fefa41834c7899650cb101bf98ea2c94d4 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Fri, 13 Nov 2026 11:26:27 +0100 Subject: [PATCH 1/6] Add admin backend for users and featured plugins --- README.md | 60 ++++- composer.json | 7 +- composer.lock | 94 +++++++- .../20261113090000_AddUserAccess.php | 18 ++ .../20261113090100_AddFeaturedPlugins.php | 52 ++++ config/Migrations/schema-dump-default.lock | Bin 14829 -> 19786 bytes config/app.php | 15 -- config/plugins.php | 2 + phpcs.xml | 1 + phpstan.neon | 1 + phpunit.xml.dist | 1 + playwright.config.js | 2 +- plugins/Admin/src/AdminPlugin.php | 25 ++ .../Admin/src/Controller/AppController.php | 24 ++ .../Controller/FeaturedPluginsController.php | 14 ++ .../Admin/src/Controller/UsersController.php | 24 ++ plugins/Admin/src/Island/AdminIsland.php | 24 ++ .../src/Island/FeaturedPluginsIsland.php | 226 ++++++++++++++++++ plugins/Admin/src/Island/UsersIsland.php | 155 ++++++++++++ .../Admin/templates/FeaturedPlugins/index.php | 5 + plugins/Admin/templates/Users/index.php | 5 + plugins/Admin/templates/element/feedback.php | 22 ++ plugins/Admin/templates/element/paging.php | 10 + .../templates/island/featured_plugins.php | 134 +++++++++++ plugins/Admin/templates/island/users.php | 104 ++++++++ plugins/Admin/templates/layout/admin.php | 62 +++++ .../Controller/AdminControllerTest.php | 72 ++++++ .../TestCase/Island/AdminIslandsTest.php | 140 +++++++++++ resources/css/style.css | 1 + resources/js/app.js | 9 + src/Application.php | 29 ++- src/Command/CleanCommand.php | 3 +- src/Command/UsersCommand.php | 62 +++++ src/Island/FeaturedPluginsIsland.php | 11 +- src/Middleware/AdminAccessMiddleware.php | 37 +++ src/Middleware/RefreshIdentityMiddleware.php | 39 +++ src/Model/Entity/FeaturedPlugin.php | 26 ++ src/Model/Entity/User.php | 4 + src/Model/Enum/FeaturedPluginStatus.php | 24 ++ src/Model/Enum/UserRole.php | 15 ++ src/Model/Enum/UserStatus.php | 23 ++ src/Model/Table/FeaturedPluginsTable.php | 62 +++++ src/Model/Table/UsersTable.php | 20 ++ src/Policy/RequestPolicy.php | 35 +++ src/Service/UserAccessService.php | 60 +++++ templates/element/navbar.php | 8 + templates/island/featured_plugins.php | 6 +- tests/Fixture/FeaturedPluginsFixture.php | 22 ++ tests/Fixture/UsersFixture.php | 22 ++ tests/TestCase/Command/UsersCommandTest.php | 57 +++++ .../Controller/PackagesControllerTest.php | 4 +- tests/TestCase/Island/CatalogueIslandTest.php | 10 +- .../Island/FeaturedPluginsIslandTest.php | 56 ++--- tests/TestCase/Model/Table/UsersTableTest.php | 62 +++++ .../Service/UserAccessServiceTest.php | 56 +++++ tests/browser/admin-state.php | 44 ++++ tests/browser/admin.spec.js | 155 ++++++++++++ tests/browser/setup.php | 12 + 58 files changed, 2185 insertions(+), 88 deletions(-) create mode 100644 config/Migrations/20261113090000_AddUserAccess.php create mode 100644 config/Migrations/20261113090100_AddFeaturedPlugins.php create mode 100644 plugins/Admin/src/AdminPlugin.php create mode 100644 plugins/Admin/src/Controller/AppController.php create mode 100644 plugins/Admin/src/Controller/FeaturedPluginsController.php create mode 100644 plugins/Admin/src/Controller/UsersController.php create mode 100644 plugins/Admin/src/Island/AdminIsland.php create mode 100644 plugins/Admin/src/Island/FeaturedPluginsIsland.php create mode 100644 plugins/Admin/src/Island/UsersIsland.php create mode 100644 plugins/Admin/templates/FeaturedPlugins/index.php create mode 100644 plugins/Admin/templates/Users/index.php create mode 100644 plugins/Admin/templates/element/feedback.php create mode 100644 plugins/Admin/templates/element/paging.php create mode 100644 plugins/Admin/templates/island/featured_plugins.php create mode 100644 plugins/Admin/templates/island/users.php create mode 100644 plugins/Admin/templates/layout/admin.php create mode 100644 plugins/Admin/tests/TestCase/Controller/AdminControllerTest.php create mode 100644 plugins/Admin/tests/TestCase/Island/AdminIslandsTest.php create mode 100644 src/Command/UsersCommand.php create mode 100644 src/Middleware/AdminAccessMiddleware.php create mode 100644 src/Middleware/RefreshIdentityMiddleware.php create mode 100644 src/Model/Entity/FeaturedPlugin.php create mode 100644 src/Model/Enum/FeaturedPluginStatus.php create mode 100644 src/Model/Enum/UserRole.php create mode 100644 src/Model/Enum/UserStatus.php create mode 100644 src/Model/Table/FeaturedPluginsTable.php create mode 100644 src/Policy/RequestPolicy.php create mode 100644 src/Service/UserAccessService.php create mode 100644 tests/Fixture/FeaturedPluginsFixture.php create mode 100644 tests/Fixture/UsersFixture.php create mode 100644 tests/TestCase/Command/UsersCommandTest.php create mode 100644 tests/TestCase/Model/Table/UsersTableTest.php create mode 100644 tests/TestCase/Service/UserAccessServiceTest.php create mode 100644 tests/browser/admin-state.php create mode 100644 tests/browser/admin.spec.js diff --git a/README.md b/README.md index 58c6cef7..01ab04d6 100644 --- a/README.md +++ b/README.md @@ -99,21 +99,59 @@ in `Packages.awesomeAliases` in `config/app.php`: Both importers and the Markdown parser are registered in `Application::services()`. The console command receives its importers through CakePHP's container. -## Featured plugin logos +## Administration -Set an optional logo URL or image path alongside the package name in -`Packages.featured` in `config/app.php`: +The local `Admin` plugin provides `/admin/users` and `/admin/featured-plugins`. +It reuses GitHub sign-in, the public theme preference, and Interact's standard +update endpoint. Every admin island checks current authorization on every request. +The plugin owns the admin interface; shared models, migrations, and access services +remain in the application. -```php -'featured' => [ - 'cakedc/users' => ['logo' => '/img/plugins/users.svg'], - 'vendor/plugin' => ['logo' => 'https://example.com/plugin-logo.png'], - 'friendsofcake/crud' => ['logo' => null], -], +Apply the application migrations, sign in with GitHub once, then promote that +existing account from the console: + +```bash +ddev exec bin/cake migrations migrate +ddev exec bin/cake users your-github-username --role admin --active yes +``` + +New and existing accounts default to active users. The `users` command without +flags prompts for role and active status. Explicit flags also support suspension +and reactivation: + +```bash +ddev exec bin/cake users some-user --active no +ddev exec bin/cake users some-user --active yes +ddev exec bin/cake users some-user --role user ``` -A null or broken logo falls back to the name-based emblem. Each entry can hold -additional metadata alongside `logo` as needed. +Admins can search/filter users and change their role or suspend/reactivate access. +The interface prevents self-demotion and self-suspension, and both the console and +interface preserve the last active admin. Role/status changes take effect on the +next request in existing sessions. Suspended accounts may still browse anonymously. +Roles use CakePHP's ORM-backed `UserRole` enum. Status labels and filter options +come from shared enums; active/enabled flags remain booleans. + +### Featured plugins + +Manage featured selections in the admin interface. Entries have an enabled flag, +optional logo, and optional plain-text title/description overrides. The homepage +randomly displays up to three enabled entries that exist in the catalogue. Blank +overrides use imported package values. Logos accept HTTPS URLs or local `/img/` +paths; missing or broken logos use a name-based emblem. +All featured table data columns support sorting, including catalogue stars and +downloads. Sorting and filters update through Interact without a page reload. + +The migration preserves the previous configured selections once. Configuration +then lives exclusively in the database. Package synchronization and cleanup leave +editorial settings intact; missing packages are marked unavailable in admin. + +### Admin browser tests + +The Playwright suite includes admin access, user management, featured-card editing, +theme, and mobile navigation checks. Test identities are created by a CLI-only +fixture helper against `tmp/browser.sqlite`, with sessions in `tmp/browser-sessions`. +No application login bypass is installed. ## Get social auth working diff --git a/composer.json b/composer.json index 85f4bc63..878c34fc 100644 --- a/composer.json +++ b/composer.json @@ -8,12 +8,13 @@ "php": "^8.5", "admad/cakephp-social-auth": "^2.2", "cakephp/authentication": "^4.0", + "cakephp/authorization": "^3.0", "cakephp/cakephp": "5.4.*", "cakephp/migrations": "^5.0.0", "cakephp/plugin-installer": "^2.0", "dereuromark/cakephp-tags": "^3.0", "friendsofcake/search": "^7.5", - "josbeir/cakephp-interact": "^0.2", + "josbeir/cakephp-interact": "^0.2.1", "josbeir/cakephp-vite": "^0.1.4", "knplabs/packagist-api": "^3.0", "league/commonmark": "^2.7", @@ -36,12 +37,14 @@ }, "autoload": { "psr-4": { - "App\\": "src/" + "App\\": "src/", + "Admin\\": "plugins/Admin/src/" } }, "autoload-dev": { "psr-4": { "App\\Test\\": "tests/", + "Admin\\Test\\": "plugins/Admin/tests/", "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" } }, diff --git a/composer.lock b/composer.lock index 3b2457bd..702f8a3e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9df7a35774f30bc92672c7b898254e91", + "content-hash": "09bbbfd9826c6cc598249ab1cd42b51e", "packages": [ { "name": "admad/cakephp-social-auth", @@ -121,6 +121,72 @@ }, "time": "2026-07-08T03:44:13+00:00" }, + { + "name": "cakephp/authorization", + "version": "3.5.3", + "source": { + "type": "git", + "url": "https://github.com/cakephp/authorization.git", + "reference": "6727b15a3006da323c23306d241e15efdca77a9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/authorization/zipball/6727b15a3006da323c23306d241e15efdca77a9c", + "reference": "6727b15a3006da323c23306d241e15efdca77a9c", + "shasum": "" + }, + "require": { + "cakephp/http": "^5.1", + "php": ">=8.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "cakephp/authentication": "^3.0 || ^4.0", + "cakephp/bake": "^3.2", + "cakephp/cakephp": "^5.1", + "cakephp/cakephp-codesniffer": "^5.1", + "phpunit/phpunit": "^10.5.58 || ^11.5.3 || ^12.4 || ^13.0" + }, + "suggest": { + "cakephp/http": "To use \"RequestPolicyInterface\" (Not needed separately if using full CakePHP framework).", + "cakephp/orm": "To use \"OrmResolver\" (Not needed separately if using full CakePHP framework)." + }, + "type": "cakephp-plugin", + "autoload": { + "psr-4": { + "Authorization\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/authorization/graphs/contributors" + } + ], + "description": "Authorization abstraction layer plugin for CakePHP", + "keywords": [ + "access", + "auth", + "authorization", + "cakephp" + ], + "support": { + "docs": "https://cakephp.org/authorization/2/en/", + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/authorization/issues", + "source": "https://github.com/cakephp/authorization" + }, + "time": "2026-07-07T14:41:50+00:00", + "published-time": "2026-07-07T14:47:21+00:00" + }, { "name": "cakephp/cakephp", "version": "5.4.4", @@ -1004,16 +1070,16 @@ }, { "name": "josbeir/cakephp-interact", - "version": "0.2.0", + "version": "0.2.1", "source": { "type": "git", "url": "https://github.com/josbeir/cakephp-interact.git", - "reference": "5fb351e15f4d327d8ffc205d395c7f2504a83907" + "reference": "681e4dbf5cd8468c0a3a487b5c239cc7e93b2b07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/josbeir/cakephp-interact/zipball/5fb351e15f4d327d8ffc205d395c7f2504a83907", - "reference": "5fb351e15f4d327d8ffc205d395c7f2504a83907", + "url": "https://api.github.com/repos/josbeir/cakephp-interact/zipball/681e4dbf5cd8468c0a3a487b5c239cc7e93b2b07", + "reference": "681e4dbf5cd8468c0a3a487b5c239cc7e93b2b07", "shasum": "" }, "require": { @@ -1021,14 +1087,20 @@ "php": "^8.5" }, "require-dev": { + "cakephp/authentication": "^3.0", + "cakephp/authorization": "^3.0", + "cakephp/bake": "^3.8", "cakephp/mago-rules": "dev-main", - "carthage-software/mago": "^1.47", + "carthage-software/mago": "^1.59", "josbeir/cakephp-mercure": "^0.3.1", - "phpunit/phpunit": "^12.5", - "rector/rector": "^2.7", + "phpunit/phpunit": "^12.5 || ^13.0", + "rector/rector": "^2.8.1", "symfony/process": "^8.0" }, "suggest": { + "cakephp/authentication": "Use AuthenticationTrait for direct access to the application's authentication service. Install ^3.0 for CakePHP 5.", + "cakephp/authorization": "Use AuthorizationTrait for optional policy checks and scopes in islands. Install ^3.0 for CakePHP 5.", + "cakephp/bake": "Generate island classes and matching templates with bin/cake bake island. Install as a development dependency.", "josbeir/cakephp-mercure": "Mercure 1.x publishing and subscription authorization with the CakePHP 5 compatible package." }, "type": "cakephp-plugin", @@ -1049,10 +1121,10 @@ "description": "Reactive server-rendered PHP islands for CakePHP.", "support": { "issues": "https://github.com/josbeir/cakephp-interact/issues", - "source": "https://github.com/josbeir/cakephp-interact/tree/0.2.0" + "source": "https://github.com/josbeir/cakephp-interact/tree/0.2.1" }, - "time": "2026-11-07T09:29:14+00:00", - "published-time": "2026-11-07T09:30:27+00:00" + "time": "2026-11-12T10:18:43+00:00", + "published-time": "2026-11-12T10:22:33+00:00" }, { "name": "josbeir/cakephp-vite", diff --git a/config/Migrations/20261113090000_AddUserAccess.php b/config/Migrations/20261113090000_AddUserAccess.php new file mode 100644 index 00000000..aa34ae9d --- /dev/null +++ b/config/Migrations/20261113090000_AddUserAccess.php @@ -0,0 +1,18 @@ +table('users') + ->addColumn('role', 'string', ['limit' => 16, 'default' => 'user']) + ->addColumn('is_active', 'boolean', ['default' => true]) + ->update(); + } +} diff --git a/config/Migrations/20261113090100_AddFeaturedPlugins.php b/config/Migrations/20261113090100_AddFeaturedPlugins.php new file mode 100644 index 00000000..fbb532dd --- /dev/null +++ b/config/Migrations/20261113090100_AddFeaturedPlugins.php @@ -0,0 +1,52 @@ +table('featured_plugins') + ->addColumn('package', 'string', ['limit' => 191]) + ->addColumn('enabled', 'boolean', ['default' => true]) + ->addColumn('logo', 'string', ['limit' => 2048, 'null' => true]) + ->addColumn('title', 'string', ['null' => true]) + ->addColumn('description', 'text', ['null' => true]) + ->addTimestamps('created', 'modified') + ->addIndex(['package'], ['unique' => true]) + ->create(); + $featured = [ + // Optional logo URL or image path; null uses a name-based emblem. + 'cakedc/users' => ['logo' => null], + 'dereuromark/cakephp-ide-helper' => ['logo' => 'https://dereuromark.github.io/cakephp-ide-helper/logo.svg'], + 'dereuromark/cakephp-queue' => ['logo' => 'https://dereuromark.github.io/cakephp-queue/logo.svg'], + 'dereuromark/cakephp-tools' => ['logo' => 'https://dereuromark.github.io/cakephp-tools/logo.svg'], + 'friendsofcake/cakepdf' => ['logo' => null], + 'friendsofcake/crud' => ['logo' => null], + 'friendsofcake/search' => ['logo' => null], + 'josbeir/cakephp-interact' => ['logo' => 'https://www.cakeinteract.dev/docs/branding/interact-mark.svg'], + 'josbeir/cakephp-vite' => ['logo' => null], + 'josegonzalez/cakephp-upload' => ['logo' => null], + 'lordsimal/cakephp-sentry' => ['logo' => null], + 'markstory/asset_compress' => ['logo' => null], + ]; + + $rows = []; + foreach ($featured as $package => $metadata) { + $rows[] = ['package' => $package, 'enabled' => true, 'logo' => $metadata['logo'], 'created' => date('Y-m-d H:i:s')]; + } + $this->table('featured_plugins')->insert($rows)->saveData(); + } + + /** + * Remove only the editorial configuration on rollback. + */ + public function down(): void + { + $this->table('featured_plugins')->drop()->save(); + } +} diff --git a/config/Migrations/schema-dump-default.lock b/config/Migrations/schema-dump-default.lock index 287a5f6d1b69c6e066176692b05d8961577e1e3a..16238958d3cd695c712df84efbc71ddc12d472d0 100644 GIT binary patch delta 1144 zcmaJ=OKTHR7-jO9rV_E_U86RRZA3%O%$VjeL6QnCMBPZRxEgPhX&9VLLMG8}+JNH9 zMM*zbt*ErFT(yj@gxW3y5fO1Ou3Reh2MFFfd32JgZif54&OP6`XMSoQ*C;y@X148H zUN4svm<0(nnsuop)|6&hR^i>qtjC&eqmVw~i&Ib=ZNXRnhF)=uQSim@q$L&2D-z^g z!e~lhI#GK!0CU{5T^2Zf%Qi_llxn#Ov#!iYLST&ZUhI~gO{C#>cojqXVgI}jmi-0S zjKDPH2F4@J-2irD1=tURxe+#l58ziUI+UN&U-MT9$v&dqN}Qn}Z(o5MNgm!X;cTB} ziOFgLj^?XXMM5fxy7b5qYoImivRWoVkI4?hf-j}O80};(vKH|)v4)CwQCSjYwInS9 zie^vzuMM8Lr{H}w2jA#Vy6#=?$@^kxt%AN8%nqyti{l%XrfcD}fuN6^WIIrAPwiN` zUPW%3p>B4C`Qzd7LS6ovs5MYsgPi-)VQE4;IQ2NB2|QM;KU1pSSCuL%8FPVul*nvk zas>)Uk7F6>aoz-AQP0O8QZ}mw%V!sC9IHos7syf%Vg208;fexcWvozKNpW#mY@)5&MO--sZfC63{mmGGxJ0mc% zCZb|PzGqAiB-(p^(@!)(!ICq8SA{hu46WqngaJ?PwYb#`ONs{`Uv-C|m|{ZYsR=Vp tBwrO(OpY}ke6VOJp2*~bn!sVOPEo2LSvfHJv7w4F^C+Ez@AL<1=`XUUmy`ei delta 996 zcmX>#i}7tSbE1{WM&=E|o0qA~W@Ib2GO$uo+MFn=z{qT5YC1VleG*h`av`V4sd=eIi8=AfnM#udU4l0kXjsEcAxcNsWNoeateIA1S$0d? z0?D$v%@cJEkol7x^!Jf#G@oH4l2IU^r%kS96`TA@(R{ML@gp8fE2WhD^1Ph<#FXO6 z4yNXl<#hxmU-glmyvekmtJuoaN~yRcu?Q^10q0!wk)J%>qC%PU)RA#p+K-ID3H?pcOwlcF)O3h2p%u7|W26H^~^737r zKpdcZlJj$N5=%1k^FVwYab!JN&sTc#ck_(RLaO>m9&DN{WVM9Mu$p|#nry9AwsXj{ zHEgn`J-O*-@(qXiz*KE$rDQNkekp4D&e%NL$p [ // Resolve moved or ambiguous Awesome-list repositories to an exact Composer package name. 'awesomeAliases' => [], - 'featured' => [ - // Optional logo URL or image path; null uses a name-based emblem. - 'cakedc/users' => ['logo' => null], - 'dereuromark/cakephp-ide-helper' => ['logo' => 'https://dereuromark.github.io/cakephp-ide-helper/logo.svg'], - 'dereuromark/cakephp-queue' => ['logo' => 'https://dereuromark.github.io/cakephp-queue/logo.svg'], - 'dereuromark/cakephp-tools' => ['logo' => 'https://dereuromark.github.io/cakephp-tools/logo.svg'], - 'friendsofcake/cakepdf' => ['logo' => null], - 'friendsofcake/crud' => ['logo' => null], - 'friendsofcake/search' => ['logo' => null], - 'josbeir/cakephp-interact' => ['logo' => 'https://www.cakeinteract.dev/docs/branding/interact-mark.svg'], - 'josbeir/cakephp-vite' => ['logo' => null], - 'josegonzalez/cakephp-upload' => ['logo' => null], - 'lordsimal/cakephp-sentry' => ['logo' => null], - 'markstory/asset_compress' => ['logo' => null], - ], ], 'CakeVite' => [ diff --git a/config/plugins.php b/config/plugins.php index 0d6d9c5d..8b68b80d 100644 --- a/config/plugins.php +++ b/config/plugins.php @@ -42,5 +42,7 @@ 'Tags', 'ADmad/SocialAuth', 'Authentication', + 'Authorization', + 'Admin', 'IdeHelper' => ['onlyDebug' => true], ]; diff --git a/phpcs.xml b/phpcs.xml index 7b47eea8..b2e04da5 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -16,4 +16,5 @@ tests/ config/ templates/ + plugins/Admin/ diff --git a/phpstan.neon b/phpstan.neon index 577b36ae..98a30018 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,6 +7,7 @@ parameters: bootstrapFiles: - config/bootstrap.php paths: + - plugins/Admin/ - src/ - config/app.php - templates/ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 38c7d781..21300dba 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -14,6 +14,7 @@ tests/TestCase/ + plugins/Admin/tests/TestCase/ diff --git a/playwright.config.js b/playwright.config.js index 3c962aef..ab7fdb2e 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -8,7 +8,7 @@ export default defineConfig({ use: { baseURL: 'http://127.0.0.1:8765', trace: 'retain-on-failure' }, projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], webServer: { - command: 'php tests/browser/setup.php && php -S 127.0.0.1:8765 -t webroot webroot/index.php 2>tmp/browser-server.log', + command: 'php tests/browser/setup.php && php -d session.save_path=tmp/browser-sessions -S 127.0.0.1:8765 -t webroot webroot/index.php 2>tmp/browser-server.log', url: 'http://127.0.0.1:8765', reuseExistingServer: false, env: { diff --git a/plugins/Admin/src/AdminPlugin.php b/plugins/Admin/src/AdminPlugin.php new file mode 100644 index 00000000..78f86bab --- /dev/null +++ b/plugins/Admin/src/AdminPlugin.php @@ -0,0 +1,25 @@ +plugin('Admin', ['path' => '/admin'], function (RouteBuilder $builder): void { + $builder->get('/', ['controller' => 'Users', 'action' => 'home'], 'admin:home'); + $builder->get('/users', ['controller' => 'Users', 'action' => 'index'], 'admin:users'); + $builder->get('/featured-plugins', ['controller' => 'FeaturedPlugins', 'action' => 'index'], 'admin:featured'); + }); + } +} diff --git a/plugins/Admin/src/Controller/AppController.php b/plugins/Admin/src/Controller/AppController.php new file mode 100644 index 00000000..dec3669f --- /dev/null +++ b/plugins/Admin/src/Controller/AppController.php @@ -0,0 +1,24 @@ +loadComponent('Flash'); + $this->loadComponent('Authentication.Authentication'); + $this->viewBuilder()->setClassName(AppView::class)->setLayout('Admin.admin'); + } +} diff --git a/plugins/Admin/src/Controller/FeaturedPluginsController.php b/plugins/Admin/src/Controller/FeaturedPluginsController.php new file mode 100644 index 00000000..45299018 --- /dev/null +++ b/plugins/Admin/src/Controller/FeaturedPluginsController.php @@ -0,0 +1,14 @@ +redirect(['_name' => 'admin:users']); + } + + /** + * The island owns user management interactions. + */ + public function index(): void + { + } +} diff --git a/plugins/Admin/src/Island/AdminIsland.php b/plugins/Admin/src/Island/AdminIsland.php new file mode 100644 index 00000000..2f3fa9d7 --- /dev/null +++ b/plugins/Admin/src/Island/AdminIsland.php @@ -0,0 +1,24 @@ +Authorization->authorize($context->request->withParam('plugin', 'Admin'), 'access'); + } +} diff --git a/plugins/Admin/src/Island/FeaturedPluginsIsland.php b/plugins/Admin/src/Island/FeaturedPluginsIsland.php new file mode 100644 index 00000000..248af3be --- /dev/null +++ b/plugins/Admin/src/Island/FeaturedPluginsIsland.php @@ -0,0 +1,226 @@ + 20, + 'order' => ['FeaturedPlugins.package' => 'asc', 'FeaturedPlugins.id' => 'asc'], + 'sortableFields' => [ + 'package' => 'FeaturedPlugins.package', + 'title' => 'FeaturedPlugins.title', + 'stars' => 'CataloguePackages.stars', + 'downloads' => 'CataloguePackages.downloads', + 'enabled' => 'FeaturedPlugins.enabled', + ], + ], + url: ['page' => 'page', 'sort' => 'sort', 'direction' => 'direction'], + defaults: ['sort' => 'package', 'direction' => 'asc'], + )] + public PaginationState $paging; + + #[State] + public bool $editing = false; + + #[State] + public ?int $editingId = null; + + #[State(writable: true)] + public string $packageSearch = ''; + + #[State] + public string $package = ''; + + #[State(writable: true)] + public bool $enabled = true; + + #[State(writable: true)] + public string $logo = ''; + + #[State(writable: true)] + public string $title = ''; + + #[State(writable: true)] + public string $description = ''; + + #[State] + public ?int $removingId = null; + + #[State] + public string $message = ''; + + /** + * Restart paging when editing list filters. + */ + public function updated(UpdatedPaths $paths): void + { + if ($paths->has('search') || $paths->has('status')) { + $this->paging->page = 1; + } + } + + /** + * Start an empty editorial draft. + */ + #[Action] + public function create(): void + { + $this->editing = true; + $this->editingId = null; + $this->packageSearch = $this->package = $this->logo = $this->title = $this->description = $this->message = ''; + $this->enabled = true; + } + + /** + * Only existing catalogue entries may be selected for a new feature. + */ + #[Action] + public function selectPackage(int $id): void + { + if ($this->editingId !== null || !$this->editing) { + throw new BadRequestException('Start a new featured entry first.'); + } + $this->package = $this->fetchTable(PackagesTable::class)->get($id)->package; + $this->packageSearch = ''; + } + + /** + * Load an existing entry, including entries missing from the catalogue. + */ + #[Action] + public function edit(int $id): void + { + $entry = $this->fetchTable(FeaturedPluginsTable::class)->get($id); + $this->editing = true; + $this->editingId = $id; + $this->package = $entry->package; + $this->enabled = $entry->enabled; + $this->logo = $entry->logo ?? ''; + $this->title = $entry->title ?? ''; + $this->description = $entry->description ?? ''; + $this->packageSearch = $this->message = ''; + } + + /** + * Validate and save plain-text overrides without changing imported metadata. + */ + #[Action] + public function save(): void + { + if (!$this->editing) { + throw new BadRequestException('Open the featured editor first.'); + } + $table = $this->fetchTable(FeaturedPluginsTable::class); + if ($this->editingId === null && !$this->fetchTable(PackagesTable::class)->exists(['package' => $this->package])) { + $this->setErrors(['package' => ['Choose an existing plugin.']]); + + return; + } + $entry = $this->editingId !== null ? $table->get($this->editingId) : $table->newEmptyEntity(); + $entry = $table->patchEntity($entry, [ + 'package' => $this->package, + 'enabled' => $this->enabled, + 'logo' => trim($this->logo) === '' ? null : trim($this->logo), + 'title' => trim($this->title) === '' ? null : trim($this->title), + 'description' => trim($this->description) === '' ? null : trim($this->description), + ]); + if (!$table->save($entry)) { + $this->setErrors($entry->getErrors() ?: ['_form' => ['Unable to save this featured plugin.']]); + + return; + } + $this->cancel(); + $this->message = 'Featured plugin saved.'; + } + + /** + * Discard the draft and any removal confirmation. + */ + #[Action] + public function cancel(): void + { + $this->editing = false; + $this->editingId = $this->removingId = null; + } + + /** + * Load the entry before offering a removal confirmation. + */ + #[Action] + public function confirmRemoval(int $id): void + { + $this->fetchTable(FeaturedPluginsTable::class)->get($id); + $this->removingId = $id; + $this->editing = false; + $this->message = ''; + } + + /** + * Remove only the confirmed editorial entry, never its package. + */ + #[Action] + public function remove(): void + { + if ($this->removingId === null) { + throw new BadRequestException('Confirm removal first.'); + } + $table = $this->fetchTable(FeaturedPluginsTable::class); + $table->deleteOrFail($table->get($this->removingId)); + $this->cancel(); + $this->message = 'Featured plugin removed.'; + } + + /** + * @return array + */ + public function viewData(): array + { + $status = FeaturedPluginStatus::tryFrom($this->status); + if (mb_strlen($this->search) > 100 || mb_strlen($this->packageSearch) > 100 || ($this->status !== '' && $status === null)) { + throw new BadRequestException('Invalid featured plugin filters.'); + } + $table = $this->fetchTable(FeaturedPluginsTable::class); + $query = $table->find()->contain(['CataloguePackages']); + if (trim($this->search) !== '') { + $query->where(['FeaturedPlugins.package LIKE' => '%' . trim($this->search) . '%']); + } + if ($status === FeaturedPluginStatus::Unavailable) { + $query->where(['CataloguePackages.id IS' => null]); + } elseif ($status !== null) { + $query->where(['FeaturedPlugins.enabled' => $status === FeaturedPluginStatus::Enabled]); + } + $packages = $this->fetchTable(PackagesTable::class); + $matches = []; + if ($this->editing && $this->editingId === null && $this->package === '') { + $matches = $packages->find()->select(['id', 'package'])->where(['package LIKE' => '%' . trim($this->packageSearch) . '%']) + ->orderByAsc('package')->limit(10)->all(); + } + + return [ + 'entries' => $this->paginate($query), + 'matches' => $matches, + 'previewLogo' => $this->logo !== '' && $table->getValidator()->validate(['logo' => $this->logo], false) === [] ? $this->logo : null, + 'previewPackage' => $this->editing && $this->package !== '' ? $packages->find()->where(['package' => $this->package])->first() : null, + 'removingEntry' => $this->removingId !== null ? $table->get($this->removingId) : null, + ]; + } +} diff --git a/plugins/Admin/src/Island/UsersIsland.php b/plugins/Admin/src/Island/UsersIsland.php new file mode 100644 index 00000000..d0e9ea90 --- /dev/null +++ b/plugins/Admin/src/Island/UsersIsland.php @@ -0,0 +1,155 @@ + 20, 'order' => ['Users.username' => 'asc']], url: ['page' => 'page'])] + public PaginationState $paging; + + #[State] + public ?int $editingId = null; + + #[State(writable: true)] + public string $role = UserRole::User->value; + + #[State(writable: true)] + public bool $active = true; + + #[State] + public bool $confirming = false; + + #[State] + public string $message = ''; + + /** + * Filters start at page one; all list queries remain server-side. + */ + public function updated(UpdatedPaths $paths): void + { + if ($paths->has('search') || $paths->has('roleFilter') || $paths->has('status')) { + $this->paging->page = 1; + } + if ($paths->has('role') || $paths->has('active')) { + $this->confirming = false; + } + } + + /** + * Load a fresh account into a small editable access draft. + */ + #[Action] + public function edit(int $id): void + { + $user = $this->fetchTable(UsersTable::class)->get($id); + $this->editingId = $id; + $this->role = $user->role->value; + $this->active = $user->is_active; + $this->confirming = false; + $this->message = ''; + } + + /** + * Require a separate confirmation before committing access changes. + */ + #[Action] + public function confirm(): void + { + if ($this->editingId !== null) { + $this->confirming = true; + } + } + + /** + * Revalidate target and actor inside the shared transactional service. + */ + #[Action] + public function save(): void + { + if ($this->editingId === null || !$this->confirming) { + throw new BadRequestException('Confirm the access change first.'); + } + $role = UserRole::tryFrom($this->role); + if ($role === null) { + $this->setErrors(['role' => ['Choose a valid user role.']]); + + return; + } + try { + new UserAccessService($this->fetchTable(UsersTable::class))->update( + $this->editingId, + $role, + $this->active, + (int)$this->getRequest()->getAttribute('identity')->getIdentifier(), + ); + } catch (DomainException $exception) { + $this->setErrors(['_form' => [$exception->getMessage()]]); + + return; + } + $this->cancel(); + $this->message = 'User access updated.'; + } + + /** + * Discard the open access editor. + */ + #[Action] + public function cancel(): void + { + $this->editingId = null; + $this->confirming = false; + } + + /** + * @return array + */ + public function viewData(): array + { + $roleFilter = UserRole::tryFrom($this->roleFilter); + $status = UserStatus::tryFrom($this->status); + if (mb_strlen($this->search) > 100 || ($this->roleFilter !== '' && $roleFilter === null) || ($this->status !== '' && $status === null)) { + throw new BadRequestException('Invalid user filters.'); + } + $table = $this->fetchTable(UsersTable::class); + $query = $table->find(); + if (trim($this->search) !== '') { + $search = '%' . trim($this->search) . '%'; + $query->where(['OR' => ['Users.username LIKE' => $search, 'Users.email LIKE' => $search, 'Users.first_name LIKE' => $search, 'Users.last_name LIKE' => $search]]); + } + if ($roleFilter !== null) { + $query->where(['Users.role' => $roleFilter]); + } + if ($status !== null) { + $query->where(['Users.is_active' => $status === UserStatus::Active]); + } + + return [ + 'users' => $this->paginate($query), + 'editingUser' => $this->editingId !== null ? $table->get($this->editingId) : null, + 'actorId' => (int)$this->getRequest()->getAttribute('identity')->getIdentifier(), + ]; + } +} diff --git a/plugins/Admin/templates/FeaturedPlugins/index.php b/plugins/Admin/templates/FeaturedPlugins/index.php new file mode 100644 index 00000000..d9a1bd90 --- /dev/null +++ b/plugins/Admin/templates/FeaturedPlugins/index.php @@ -0,0 +1,5 @@ +assign('title', __('Featured plugins')); +?> +Interact->render('Admin.FeaturedPlugins'); diff --git a/plugins/Admin/templates/Users/index.php b/plugins/Admin/templates/Users/index.php new file mode 100644 index 00000000..a343132b --- /dev/null +++ b/plugins/Admin/templates/Users/index.php @@ -0,0 +1,5 @@ +assign('title', __('Users')); +?> +Interact->render('Admin.Users'); diff --git a/plugins/Admin/templates/element/feedback.php b/plugins/Admin/templates/element/feedback.php new file mode 100644 index 00000000..ce0c61a1 --- /dev/null +++ b/plugins/Admin/templates/element/feedback.php @@ -0,0 +1,22 @@ +> $errors + * @var string $message + */ +?> + +
+ + + + + + diff --git a/plugins/Admin/templates/element/paging.php b/plugins/Admin/templates/element/paging.php new file mode 100644 index 00000000..d527b59b --- /dev/null +++ b/plugins/Admin/templates/element/paging.php @@ -0,0 +1,10 @@ + +
+

Paginator->counter(__('Page {{page}} of {{pages}} · {{count}} records')) ?>

+ +
diff --git a/plugins/Admin/templates/island/featured_plugins.php b/plugins/Admin/templates/island/featured_plugins.php new file mode 100644 index 00000000..19f14f9c --- /dev/null +++ b/plugins/Admin/templates/island/featured_plugins.php @@ -0,0 +1,134 @@ + $matches + * @var \App\Model\Entity\Package|null $previewPackage + * @var \App\Model\Entity\FeaturedPlugin|null $removingEntry + * @var string $search + * @var string $status + * @var bool $editing + * @var int|null $editingId + * @var string $package + * @var string $packageSearch + * @var bool $enabled + * @var string $logo + * @var string|null $previewLogo + * @var string $title + * @var string $description + * @var string $message + * @var array> $errors + */ +use App\Model\Enum\FeaturedPluginStatus; + +?> +
+
+

+ +
+ element('Admin.feedback', compact('message', 'errors')) ?> +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + +
Paginator->sort('package', __('Plugin')) ?>Paginator->sort('title', __('Featured title')) ?>Paginator->sort('stars', __('GitHub stars')) ?>Paginator->sort('downloads', __('Downloads')) ?>Paginator->sort('enabled', __('Status')) ?>
package) ?>title ?? '—') ?>catalogue_package !== null ? $this->Number->format($entry->catalogue_package->stars) : '—' ?>catalogue_package !== null ? $this->Number->format($entry->catalogue_package->downloads) : '—' ?> + + enabled)->label()) ?> + + catalogue_package === null) : + ?>label()) ?> + + +
+
+ element('Admin.paging') ?> + + + + + + + + + + +
diff --git a/plugins/Admin/templates/island/users.php b/plugins/Admin/templates/island/users.php new file mode 100644 index 00000000..cc285833 --- /dev/null +++ b/plugins/Admin/templates/island/users.php @@ -0,0 +1,104 @@ +> $errors + */ +use App\Model\Enum\UserRole; +use App\Model\Enum\UserStatus; + +?> +
+

+

+ element('Admin.feedback', compact('message', 'errors')) ?> +
+ + + +
+
+ + + + + + + + + + + + + + + + + + +
@username) ?>
first_name . ' ' . $user->last_name)) ?>
email) ?>role->label()) ?>is_active)->label()) ?>created->format('Y-m-d')) ?> + id) : ?> + + + + +
+
+ element('Admin.paging') ?> + + + + + +
diff --git a/plugins/Admin/templates/layout/admin.php b/plugins/Admin/templates/layout/admin.php new file mode 100644 index 00000000..783abff6 --- /dev/null +++ b/plugins/Admin/templates/layout/admin.php @@ -0,0 +1,62 @@ +getRequest()->getCookie('theme') === 'cakephp-dark' ? 'cakephp-dark' : 'cakephp'; +$controller = $this->getRequest()->getParam('controller'); +?> + + + + Html->charset() ?> + + + <?= h($this->fetch('title')) ?> · CakePHP Plugins Admin + Html->meta('icon') ?> + Vite->css(['files' => ['resources/css/style.css'], 'block' => false, 'attributes' => ['x-interact:asset' => 'reload', 'timestamp' => false]]) ?> + Vite->script(['files' => ['resources/js/app.js'], 'block' => false, 'attributes' => ['x-interact:asset' => 'reload', 'timestamp' => false]]) ?> + fetch('script') ?> + + + +
+ +
+ +
+ Flash->render() ?> + fetch('content') ?> +
+
+
+ + +
+
+ + diff --git a/plugins/Admin/tests/TestCase/Controller/AdminControllerTest.php b/plugins/Admin/tests/TestCase/Controller/AdminControllerTest.php new file mode 100644 index 00000000..31b592bd --- /dev/null +++ b/plugins/Admin/tests/TestCase/Controller/AdminControllerTest.php @@ -0,0 +1,72 @@ +get('/admin/users'); + $this->assertRedirect('/?redirect=%2Fadmin%2Fusers'); + } + + /** + * Signed-in ordinary users receive 403, rather than a login loop. + */ + public function testUserForbidden(): void + { + $this->session(['Auth' => ['id' => 2]]); + $this->get('/admin/users'); + $this->assertResponseCode(403); + } + + /** + * Plugin templates mount islands against Interact's standard endpoint. + */ + public function testAdminPages(): void + { + $this->session(['Auth' => ['id' => 1]]); + $this->get('/admin/users'); + $this->assertResponseOk(); + $this->assertResponseContains('Admin navigation'); + $this->assertResponseContains('/_interact/update'); + $this->assertResponseContains('member@example.com'); + $this->get('/admin/featured-plugins'); + $this->assertResponseOk(); + $this->assertResponseContains('markstory/asset_compress'); + } + + /** + * A previously privileged session is refreshed before policy evaluation. + */ + public function testStaleRoleAndSuspension(): void + { + $this->session(['Auth' => ['id' => 2, 'role' => 'admin', 'is_active' => true]]); + $this->get('/admin/users'); + $this->assertResponseCode(403); + $this->session(['Auth' => ['id' => 3, 'role' => 'admin', 'is_active' => true]]); + $this->get('/admin/users'); + $this->assertRedirect('/?redirect=%2Fadmin%2Fusers'); + } + + /** + * Even authenticated admin updates require CSRF. + */ + public function testCsrf(): void + { + $this->session(['Auth' => ['id' => 1]]); + $this->post('/_interact/update', []); + $this->assertResponseCode(403); + } +} diff --git a/plugins/Admin/tests/TestCase/Island/AdminIslandsTest.php b/plugins/Admin/tests/TestCase/Island/AdminIslandsTest.php new file mode 100644 index 00000000..1b787b86 --- /dev/null +++ b/plugins/Admin/tests/TestCase/Island/AdminIslandsTest.php @@ -0,0 +1,140 @@ + '/admin/users']); + $authorization = new Application(CONFIG)->getAuthorizationService($request); + $identity = $id !== null ? new AuthorizedIdentity($authorization, new Identity($this->fetchTable(UsersTable::class)->get($id))) : null; + + return $request->withAttribute('authorization', $authorization)->withAttribute('identity', $identity); + } + + /** + * Search, confirm and persist both access fields through real island updates. + */ + public function testUserManagement(): void + { + $island = $this->testIsland('Admin.Users', request: $this->adminRequest()); + $island->set('search', 'member')->assertSee('member@example.com')->assertDontSee('suspended@example.com') + ->call('edit', 2)->fill(['role' => 'admin', 'active' => false])->call('confirm')->assertSet('confirming', true) + ->call('save')->assertSee('User access updated.'); + $user = $this->fetchTable(UsersTable::class)->get(2); + $this->assertSame(UserRole::Admin, $user->role); + $this->assertFalse($user->is_active); + } + + /** + * Hiding self-edit controls is backed by server-side enforcement. + */ + public function testSelfEditRejected(): void + { + $this->testIsland('Admin.Users', request: $this->adminRequest())->call('edit', 1) + ->fill(['role' => 'user'])->call('confirm')->call('save')->assertHasErrors(['_form']); + $this->assertSame(UserRole::Admin, $this->fetchTable(UsersTable::class)->get(1)->role); + } + + /** + * Browser state remains scalar and invalid role values cannot reach the service. + */ + public function testInvalidRoleRejected(): void + { + $this->testIsland('Admin.Users', request: $this->adminRequest())->call('edit', 2) + ->set('role', 'owner')->call('confirm')->call('save')->assertHasErrors(['role']); + $this->assertSame(UserRole::User, $this->fetchTable(UsersTable::class)->get(2)->role); + } + + /** + * The standard update endpoint still rechecks current admin access. + */ + public function testRevokedSnapshotDenied(): void + { + $island = $this->testIsland('Admin.FeaturedPlugins', request: $this->adminRequest()); + $this->fetchTable(UsersTable::class)->updateAll(['role' => 'user'], ['id' => 1]); + $this->expectException(ForbiddenException::class); + $island->call('create'); + } + + /** + * Guessing a plugin island name never grants access to guests. + */ + public function testGuestCannotMount(): void + { + $this->expectException(ForbiddenException::class); + $this->testIsland('Admin.Users', request: $this->adminRequest(null)); + } + + /** + * Add, validate, disable and remove entries without package mutation. + */ + public function testFeaturedManagement(): void + { + $table = $this->fetchTable(FeaturedPluginsTable::class); + $island = $this->testIsland('Admin.FeaturedPlugins', request: $this->adminRequest()); + $island->call('create')->set('packageSearch', 'package-01')->assertSee('vendor/package-01')->call('selectPackage', 2) + ->fill(['title' => 'Our pick', 'description' => 'Editorial description', 'logo' => 'javascript:alert(1)'])->call('save')->assertHasErrors(['logo']); + $island->set('logo', '/img/pick.svg')->call('save')->assertSee('Featured plugin saved.'); + $entry = $table->find()->where(['package' => 'vendor/package-01'])->firstOrFail(); + $this->assertSame('Our pick', $entry->title); + $island->call('edit', $entry->id)->set('enabled', false)->call('save'); + $this->assertFalse($table->get($entry->id)->enabled); + $island->call('confirmRemoval', $entry->id)->assertSee('Confirm removal')->call('remove'); + $this->assertFalse($table->exists(['id' => $entry->id])); + } + + /** + * Existing featured packages cannot be added twice. + */ + public function testDuplicateFeaturedEntry(): void + { + $this->testIsland('Admin.FeaturedPlugins', request: $this->adminRequest())->call('create')->call('selectPackage', 1)->call('save')->assertHasErrors(['package']); + } + + /** + * Associated catalogue counts sort numerically through the native paginator. + */ + public function testFeaturedCountSorting(): void + { + $island = $this->testIsland('Admin.FeaturedPlugins', request: $this->adminRequest()); + $island->fill(['paging.sort' => 'stars', 'paging.direction' => 'asc']); + preg_match_all('/data-featured-id="(\d+)"/', $island->result()->html, $matches); + $this->assertSame(['3', '1', '2'], $matches[1]); + $island->fill(['paging.sort' => 'downloads', 'paging.direction' => 'desc']); + preg_match_all('/data-featured-id="(\d+)"/', $island->result()->html, $matches); + $this->assertSame(['3', '2', '1'], $matches[1]); + } + + /** + * Curation remains editable after catalogue deletion. + */ + public function testUnavailableEntry(): void + { + $table = $this->fetchTable(FeaturedPluginsTable::class); + $entry = $table->saveOrFail($table->newEntity(['package' => 'missing/package', 'enabled' => true])); + $this->testIsland('Admin.FeaturedPlugins', request: $this->adminRequest())->set('status', 'unavailable') + ->assertSee('missing/package')->assertDontSee('markstory/asset_compress')->call('edit', $entry->id)->set('enabled', false)->call('save'); + $this->assertFalse($table->get($entry->id)->enabled); + } +} diff --git a/resources/css/style.css b/resources/css/style.css index 8a70f338..582d73e6 100644 --- a/resources/css/style.css +++ b/resources/css/style.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@source "../../plugins/Admin/templates"; @source not "../../wireframes-plugins.html"; @plugin "daisyui/index.js" { themes: cakephp --default, cakephp-dark; diff --git a/resources/js/app.js b/resources/js/app.js index b6b2043b..a13096fa 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,6 +1,15 @@ import 'vite/modulepreload-polyfill' const register = (Alpine) => { + Alpine.data('adminNavigation', () => ({ open: false })) + + Alpine.data('adminDialog', () => ({ + init() { + // Keep the server-rendered open attribute across morphs and enter the native modal layer. + this.$el.removeAttribute('open') + this.$el.showModal() + }, + })) Alpine.store('theme', { dark: document.documentElement.dataset.theme === 'cakephp-dark', apply(event) { diff --git a/src/Application.php b/src/Application.php index 2a1bd3ca..630ee6e2 100644 --- a/src/Application.php +++ b/src/Application.php @@ -20,8 +20,11 @@ use App\Command\SyncPackagesCommand; use App\Event\AfterGithubIdentify; use App\Http\RateLimit\AtomicFixedWindowRateLimiter; +use App\Middleware\AdminAccessMiddleware; +use App\Middleware\RefreshIdentityMiddleware; use App\Middleware\ThemeCookieMiddleware; use App\Model\Table\PackagesTable; +use App\Policy\RequestPolicy; use App\Service\AwesomeListImporter; use App\Service\AwesomeListParser; use App\Service\PackagistImporter; @@ -29,6 +32,11 @@ use Authentication\AuthenticationServiceInterface; use Authentication\AuthenticationServiceProviderInterface; use Authentication\Middleware\AuthenticationMiddleware; +use Authorization\AuthorizationService; +use Authorization\AuthorizationServiceInterface; +use Authorization\AuthorizationServiceProviderInterface; +use Authorization\Middleware\AuthorizationMiddleware; +use Authorization\Policy\MapResolver; use Cake\Core\Configure; use Cake\Core\ContainerInterface; use Cake\Datasource\FactoryLocator; @@ -42,6 +50,7 @@ use Cake\Http\Middleware\CsrfProtectionMiddleware; use Cake\Http\Middleware\RateLimitMiddleware; use Cake\Http\MiddlewareQueue; +use Cake\Http\ServerRequest; use Cake\ORM\Locator\TableLocator; use Cake\Routing\Middleware\AssetMiddleware; use Cake\Routing\Middleware\RoutingMiddleware; @@ -55,7 +64,7 @@ * This defines the bootstrapping logic and middleware layers you * want to use in your application. */ -class Application extends BaseApplication implements AuthenticationServiceProviderInterface +class Application extends BaseApplication implements AuthenticationServiceProviderInterface, AuthorizationServiceProviderInterface { /** * Load all the application configuration and bootstrap logic. @@ -115,8 +124,11 @@ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue ->add($csrf) ->add(new ThemeCookieMiddleware()) + ->add(new RefreshIdentityMiddleware()) ->add(new SocialAuthMiddleware([ + 'finder' => 'active', + 'loginUrl' => '/', // SocialConnect Auth service's providers config. https://github.com/SocialConnect/auth/blob/master/README.md 'serviceConfig' => [ 'provider' => [ @@ -138,7 +150,9 @@ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue ], ])) - ->add(new AuthenticationMiddleware($this)); + ->add(new AuthenticationMiddleware($this)) + ->add(new AuthorizationMiddleware($this)) + ->add(new AdminAccessMiddleware()); $requestProtection = (array)Configure::read('PublicRequestProtection', []); $trustedProxyIps = (array)($requestProtection['trustedProxyIps'] ?? []); @@ -241,4 +255,15 @@ public function getAuthenticationService(ServerRequestInterface $request): Authe return $service; } + + /** + * Share request policies between admin routes and island updates. + */ + public function getAuthorizationService(ServerRequestInterface $request): AuthorizationServiceInterface + { + $resolver = new MapResolver(); + $resolver->map(ServerRequest::class, RequestPolicy::class); + + return new AuthorizationService($resolver); + } } diff --git a/src/Command/CleanCommand.php b/src/Command/CleanCommand.php index bee50c2e..aca5d610 100644 --- a/src/Command/CleanCommand.php +++ b/src/Command/CleanCommand.php @@ -3,6 +3,7 @@ namespace App\Command; +use App\Model\Table\PackagesTable; use Cake\Cache\Cache; use Cake\Command\Command; use Cake\Console\Arguments; @@ -58,7 +59,7 @@ public function execute(Arguments $args, ConsoleIo $io): void return; } - $packagesTable = $this->fetchTable('Packages'); + $packagesTable = $this->fetchTable(PackagesTable::class); $allPackages = $packagesTable->find()->all(); foreach ($allPackages as $package) { diff --git a/src/Command/UsersCommand.php b/src/Command/UsersCommand.php new file mode 100644 index 00000000..ea190c06 --- /dev/null +++ b/src/Command/UsersCommand.php @@ -0,0 +1,62 @@ +setDescription('View or change a GitHub user role and suspension status.') + ->addArgument('username', ['required' => true, 'help' => 'Existing GitHub username.']) + ->addOption('role', ['choices' => array_column(UserRole::cases(), 'value'), 'help' => 'Set the account role.']) + ->addOption('active', ['choices' => ['yes', 'no'], 'help' => 'Reactivate (yes) or suspend (no).']); + } + + /** + * Explicit options are idempotent; without options, prompt with current values. + */ + public function execute(Arguments $args, ConsoleIo $io): int + { + $users = $this->fetchTable(UsersTable::class); + $user = $users->find()->where(['username' => $args->getArgument('username')])->first(); + if ($user === null) { + $io->err('User not found. Sign in with GitHub first.'); + + return self::CODE_ERROR; + } + $io->out(sprintf('%s: %s, %s', $user->username, $user->role->value, UserStatus::fromActive($user->is_active)->value)); + $role = $args->getOption('role'); + $active = $args->getOption('active'); + if ($role === null && $active === null) { + $role = $io->askChoice('Role', array_column(UserRole::cases(), 'value'), $user->role->value); + $active = $io->askChoice('Active', ['yes', 'no'], $user->is_active ? 'yes' : 'no'); + } + try { + $user = new UserAccessService($users)->update((int)$user->id, $role !== null ? UserRole::from($role) : $user->role, $active === null ? $user->is_active : $active === 'yes'); + } catch (DomainException $exception) { + $io->err($exception->getMessage()); + + return self::CODE_ERROR; + } + $io->out(sprintf('Saved %s: %s, %s', $user->username, $user->role->value, UserStatus::fromActive($user->is_active)->value)); + + return self::CODE_SUCCESS; + } +} diff --git a/src/Island/FeaturedPluginsIsland.php b/src/Island/FeaturedPluginsIsland.php index d44fd21f..7a78e6ec 100644 --- a/src/Island/FeaturedPluginsIsland.php +++ b/src/Island/FeaturedPluginsIsland.php @@ -3,8 +3,8 @@ namespace App\Island; +use App\Model\Table\FeaturedPluginsTable; use App\Model\Table\PackagesTable; -use Cake\Core\Configure; use Interact\Attribute\State; use Interact\Island; @@ -59,10 +59,15 @@ public function viewData(): array /** * Read featured metadata keyed by package name. * - * @return array + * @return array */ protected function featured(): array { - return (array)Configure::read('Packages.featured', []); + $metadata = []; + foreach ($this->fetchTable(FeaturedPluginsTable::class)->find()->where(['enabled' => true])->all() as $entry) { + $metadata[$entry->package] = ['logo' => $entry->logo, 'title' => $entry->title, 'description' => $entry->description]; + } + + return $metadata; } } diff --git a/src/Middleware/AdminAccessMiddleware.php b/src/Middleware/AdminAccessMiddleware.php new file mode 100644 index 00000000..5de8a7ca --- /dev/null +++ b/src/Middleware/AdminAccessMiddleware.php @@ -0,0 +1,37 @@ +getAttribute('identity'); + if (!$request->getAttribute('authorization')->can($identity, 'access', $request)) { + if ($identity === null) { + if (in_array($request->getMethod(), ['GET', 'HEAD'], true)) { + return new Response()->withLocation('/?redirect=' . rawurlencode($request->getRequestTarget())); + } + throw new UnauthorizedException('Sign in to continue.'); + } + throw new ForbiddenException('Administrator access is required.'); + } + + return $handler->handle($request); + } +} diff --git a/src/Middleware/RefreshIdentityMiddleware.php b/src/Middleware/RefreshIdentityMiddleware.php new file mode 100644 index 00000000..58a16ef3 --- /dev/null +++ b/src/Middleware/RefreshIdentityMiddleware.php @@ -0,0 +1,39 @@ +getAttribute('session'); + $id = $session->read('Auth.id'); + if ($id !== null) { + $user = $this->fetchTable(UsersTable::class)->find()->where(['id' => $id, 'is_active' => true])->first(); + if ($user === null) { + $session->delete('Auth'); + $session->renew(); + } else { + $session->write('Auth', $user); + } + } + + return $handler->handle($request); + } +} diff --git a/src/Model/Entity/FeaturedPlugin.php b/src/Model/Entity/FeaturedPlugin.php new file mode 100644 index 00000000..749f77f3 --- /dev/null +++ b/src/Model/Entity/FeaturedPlugin.php @@ -0,0 +1,26 @@ + true, + 'enabled' => true, + 'logo' => true, + 'title' => true, + 'description' => true, + ]; +} diff --git a/src/Model/Entity/User.php b/src/Model/Entity/User.php index 902ac4ee..51dc620a 100644 --- a/src/Model/Entity/User.php +++ b/src/Model/Entity/User.php @@ -15,6 +15,8 @@ * @property string $email * @property string $username * @property bool $is_cakephp_dev + * @property \App\Model\Enum\UserRole $role + * @property bool $is_active * @property \Cake\I18n\DateTime $created * @property \Cake\I18n\DateTime|null $modified * @@ -35,6 +37,8 @@ class User extends Entity protected array $_accessible = [ '*' => true, 'id' => false, + 'role' => false, + 'is_active' => false, ]; public const FIELD_ID = 'id'; diff --git a/src/Model/Enum/FeaturedPluginStatus.php b/src/Model/Enum/FeaturedPluginStatus.php new file mode 100644 index 00000000..e2587627 --- /dev/null +++ b/src/Model/Enum/FeaturedPluginStatus.php @@ -0,0 +1,24 @@ + + * @method \App\Model\Entity\FeaturedPlugin get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args) + * @method \App\Model\Entity\FeaturedPlugin newEmptyEntity() + * @method \App\Model\Entity\FeaturedPlugin newEntity(array $data, array $options = []) + * @method \App\Model\Entity\FeaturedPlugin patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = []) + * @method \Cake\ORM\Query\SelectQuery<\App\Model\Entity\FeaturedPlugin> find(string $type = 'all', mixed ...$args) + */ +class FeaturedPluginsTable extends Table +{ + /** + * Match by canonical name even if catalogue IDs change after a clean sync. + * + * @param array $config Table configuration. + */ + public function initialize(array $config): void + { + parent::initialize($config); + $this->setDisplayField('package'); + $this->addBehavior('Timestamp'); + $this->belongsTo('CataloguePackages', ['className' => 'Packages', 'foreignKey' => 'package', 'bindingKey' => 'package', 'propertyName' => 'catalogue_package']); + } + + /** + * Logos are browser-loaded images; never fetch or interpret editorial HTML. + */ + public function validationDefault(Validator $validator): Validator + { + return $validator + ->scalar('package')->maxLength('package', 191)->requirePresence('package', 'create')->notEmptyString('package') + ->boolean('enabled')->notEmptyString('enabled') + ->scalar('title')->maxLength('title', 255)->allowEmptyString('title') + ->scalar('description')->maxLength('description', 2000)->allowEmptyString('description') + ->scalar('logo')->maxLength('logo', 2048)->allowEmptyString('logo') + ->add('logo', 'imageLocation', [ + 'rule' => static fn(string $value): bool => (str_starts_with($value, 'https://') && filter_var($value, FILTER_VALIDATE_URL) !== false) + || (bool)preg_match('#^/img/[a-zA-Z0-9_./-]+$#D', $value) && !str_contains($value, '..'), + 'message' => 'Use an HTTPS URL or a local /img/ path.', + ]); + } + + /** + * The unique index also protects concurrent additions. + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add($rules->isUnique(['package']), ['errorField' => 'package']); + + return $rules; + } +} diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index db0ff308..35361917 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -4,8 +4,12 @@ namespace App\Model\Table; use ADmad\SocialAuth\Model\Entity\SocialProfile; +use App\Model\Enum\UserRole; +use Cake\Database\Type\EnumType; use Cake\Datasource\EntityInterface; +use Cake\Http\Exception\ForbiddenException; use Cake\Http\Session; +use Cake\ORM\Query\SelectQuery; use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\Validation\Validator; @@ -46,6 +50,7 @@ public function initialize(array $config): void $this->setTable('users'); $this->setDisplayField('first_name'); $this->setPrimaryKey('id'); + $this->getSchema()->setColumnType('role', EnumType::from(UserRole::class)); $this->addBehavior('Timestamp'); } @@ -58,6 +63,8 @@ public function initialize(array $config): void */ public function validationDefault(Validator $validator): Validator { + $validator->enum('role', UserRole::class)->notEmptyString('role'); + $validator->boolean('is_active')->notEmptyString('is_active'); $validator ->scalar('first_name') ->maxLength('first_name', 255) @@ -97,6 +104,17 @@ public function buildRules(RulesChecker $rules): RulesChecker return $rules; } + /** + * Exclude suspended users when resolving a linked GitHub profile. + * + * @param \Cake\ORM\Query\SelectQuery $query User query. + * @return \Cake\ORM\Query\SelectQuery + */ + public function findActive(SelectQuery $query): SelectQuery + { + return $query->where(['Users.is_active' => true]); + } + /** * @param \Cake\Datasource\EntityInterface $profile The data from github * @param \Cake\Http\Session $session The current session @@ -121,6 +139,8 @@ public function getUser(EntityInterface $profile, Session $session): mixed if (!$user) { // Create new user account $user = $this->newEntity([]); + } elseif (!$user->is_active) { + throw new ForbiddenException('This account is suspended.'); } $user = $this->patchEntity($user, [ diff --git a/src/Policy/RequestPolicy.php b/src/Policy/RequestPolicy.php new file mode 100644 index 00000000..34b8a548 --- /dev/null +++ b/src/Policy/RequestPolicy.php @@ -0,0 +1,35 @@ +getParam('plugin') !== 'Admin') { + return true; + } + + return $identity !== null && $this->fetchTable(UsersTable::class)->exists([ + 'id' => $identity['id'], + 'role' => UserRole::Admin, + 'is_active' => true, + ]); + } +} diff --git a/src/Service/UserAccessService.php b/src/Service/UserAccessService.php new file mode 100644 index 00000000..e9f46dfb --- /dev/null +++ b/src/Service/UserAccessService.php @@ -0,0 +1,60 @@ +users->getConnection()->transactional(function () use ($id, $role, $active, $actorId): User { + // SQLite serializes writers; on MariaDB lock the same rows in ID order. + $query = $this->users->find()->orderByAsc('Users.id'); + if ($this->users->getConnection()->getDriver() instanceof Sqlite) { + $this->users->updateAll(['id' => $id], ['id' => $id]); + } else { + $query->epilog('FOR UPDATE'); + } + $accounts = $query->all()->indexBy('id')->toArray(); + $user = $accounts[$id] ?? null; + if (!$user instanceof User) { + throw new DomainException('User not found.'); + } + if ($actorId !== null) { + $actor = $accounts[$actorId] ?? null; + if (!$actor instanceof User || $actor->role !== UserRole::Admin || !$actor->is_active) { + throw new DomainException('Administrator access is required.'); + } + if ($actorId === $id && ($role !== UserRole::Admin || !$active)) { + throw new DomainException('You cannot demote or suspend your own account.'); + } + } + $otherAdmins = array_filter($accounts, static fn(User $account): bool => $account->id !== $id && $account->role === UserRole::Admin && $account->is_active); + if ($user->role === UserRole::Admin && $user->is_active && ($role !== UserRole::Admin || !$active) && $otherAdmins === []) { + throw new DomainException('At least one active administrator must remain.'); + } + $user->set('role', $role)->set('is_active', $active); + + return $this->users->saveOrFail($user); + }); + } +} diff --git a/templates/element/navbar.php b/templates/element/navbar.php index a1133ece..164e632a 100644 --- a/templates/element/navbar.php +++ b/templates/element/navbar.php @@ -3,6 +3,8 @@ * @var \App\View\AppView $this * @var bool|null $minimal */ +use App\Model\Enum\UserRole; + $minimal = $minimal ?? false; $requirements = $this->getRequest()->getPath() === '/requirements'; $loggedIn = !$minimal && $this->Identity->isLoggedIn(); @@ -40,6 +42,9 @@ User->displayName()) ?> @User->username()) ?>

+ Identity->get('role') === UserRole::Admin && $this->Identity->get('is_active')) : ?> + Html->link(__('Admin'), ['_name' => 'admin:home'], ['class' => 'nav-link']) ?> + Html->link(__('Sign out'), ['controller' => 'Users', 'action' => 'logout'], ['class' => 'nav-link']) ?> @@ -68,6 +73,9 @@ + Identity->get('role') === UserRole::Admin && $this->Identity->get('is_active')) : ?> + Html->link(__('Admin'), ['_name' => 'admin:home'], ['class' => 'nav-link']) ?> + Html->link(__('Sign out'), ['controller' => 'Users', 'action' => 'logout'], ['class' => 'nav-link']) ?> Form->postLink(__('Sign in with GitHub'), $loginUrl, ['class' => 'action-link btn btn-outline']) ?> diff --git a/templates/island/featured_plugins.php b/templates/island/featured_plugins.php index f2b15533..cb11bbf4 100644 --- a/templates/island/featured_plugins.php +++ b/templates/island/featured_plugins.php @@ -2,7 +2,7 @@ /** * @var \App\View\IslandTemplateView $this * @var list<\App\Model\Entity\Package> $featuredPackages - * @var array $featuredMetadata + * @var array $featuredMetadata */ ?>
@@ -32,12 +32,12 @@
- +