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'));
+?>
+= $this->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'));
+?>
+= $this->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
+ */
+?>
+
+ = h($message) ?>
+
+
+
+
+
+ = __('The request failed. Reload this page to check your access and try again.') ?>
+
+= __('Saving or loading…') ?>
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 @@
+
+
+
= $this->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;
+
+?>
+
+
+
= __('Featured plugins') ?>
= __('The homepage randomly shows up to three enabled, available plugins.') ?>
+
+
+ = $this->element('Admin.feedback', compact('message', 'errors')) ?>
+
+
+
+
+
+
+
+ | = $this->Paginator->sort('package', __('Plugin')) ?> | = $this->Paginator->sort('title', __('Featured title')) ?> |
+ = $this->Paginator->sort('stars', __('GitHub stars')) ?> |
+ = $this->Paginator->sort('downloads', __('Downloads')) ?> |
+ = $this->Paginator->sort('enabled', __('Status')) ?> | = __('Actions') ?> |
+
+
+
+
+ | = h($entry->package) ?> |
+ = h($entry->title ?? '—') ?> |
+ = $entry->catalogue_package !== null ? $this->Number->format($entry->catalogue_package->stars) : '—' ?> |
+ = $entry->catalogue_package !== null ? $this->Number->format($entry->catalogue_package->downloads) : '—' ?> |
+
+
+ = h(FeaturedPluginStatus::fromEnabled($entry->enabled)->label()) ?>
+
+ catalogue_package === null) :
+ ?>= h(FeaturedPluginStatus::Unavailable->label()) ?> |
+
+
+
+ |
+
+
+ | = __('No featured plugins found.') ?> |
+
+
+
+ = $this->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;
+
+?>
+
+
= __('Users') ?>
+
= __('Manage roles and access for GitHub accounts.') ?>
+ = $this->element('Admin.feedback', compact('message', 'errors')) ?>
+
+
+
+
+
+
+
+
+ | = __('Account') ?> | = __('Email') ?> | = __('Role') ?> |
+ = __('Status') ?> | = __('Joined') ?> | = __('Actions') ?> |
+
+
+
+
+ | @= h($user->username) ?> = h(trim($user->first_name . ' ' . $user->last_name)) ?> |
+ = h($user->email) ?> |
+ = h($user->role->label()) ?> |
+ = h(UserStatus::fromActive($user->is_active)->label()) ?> |
+ = h($user->created->format('Y-m-d')) ?> |
+
+ id) : ?>
+ = __('Your account') ?>
+
+
+
+ |
+
+
+ | = __('No users found.') ?> |
+
+
+
+ = $this->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');
+?>
+
+
+
+ = $this->Html->charset() ?>
+
+
+ = h($this->fetch('title')) ?> · CakePHP Plugins Admin
+ = $this->Html->meta('icon') ?>
+ = $this->Vite->css(['files' => ['resources/css/style.css'], 'block' => false, 'attributes' => ['x-interact:asset' => 'reload', 'timestamp' => false]]) ?>
+ = $this->Vite->script(['files' => ['resources/js/app.js'], 'block' => false, 'attributes' => ['x-interact:asset' => 'reload', 'timestamp' => false]]) ?>
+ = $this->fetch('script') ?>
+
+
+ = __('Skip to content') ?>
+
+
+
+
+
+ = __('Administration') ?>
+
+ = $this->Interact->render('ThemeToggle', key: 'admin-theme') ?>
+ @= h($this->User->username()) ?>
+ = $this->Html->link(__('Sign out'), ['plugin' => false, 'prefix' => false, 'controller' => 'Users', 'action' => 'logout'], ['class' => 'btn btn-ghost btn-sm']) ?>
+
+
+
+ = $this->Flash->render() ?>
+ = $this->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 @@
= h($this->User->displayName()) ?>
@= h($this->User->username()) ?>
+ Identity->get('role') === UserRole::Admin && $this->Identity->get('is_active')) : ?>
+ = $this->Html->link(__('Admin'), ['_name' => 'admin:home'], ['class' => 'nav-link']) ?>
+
= $this->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')) : ?>
+ = $this->Html->link(__('Admin'), ['_name' => 'admin:home'], ['class' => 'nav-link']) ?>
+
= $this->Html->link(__('Sign out'), ['controller' => 'Users', 'action' => 'logout'], ['class' => 'nav-link']) ?>
= $this->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 @@
= h($vendor) ?>
-
= h($name ?: $vendor) ?>
+ = h($featuredMetadata[$package->package]['title'] ?? ($name ?: $vendor)) ?>
= $this->element('Packages/awesome_badge', compact('package')) ?>
→
-
= h($package->description ?: __('No description available.')) ?>
+
= h($featuredMetadata[$package->package]['description'] ?? ($package->description ?: __('No description available.'))) ?>