diff --git a/README.md b/README.md
index 58c6cef7..d77ccbdf 100644
--- a/README.md
+++ b/README.md
@@ -99,21 +99,16 @@ 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`:
+Sign in with GitHub, then grant your account admin access:
-```php
-'featured' => [
- 'cakedc/users' => ['logo' => '/img/plugins/users.svg'],
- 'vendor/plugin' => ['logo' => 'https://example.com/plugin-logo.png'],
- 'friendsofcake/crud' => ['logo' => null],
-],
+```bash
+ddev exec bin/cake migrations migrate
+ddev exec bin/cake users your-github-username --role admin --active yes
```
-A null or broken logo falls back to the name-based emblem. Each entry can hold
-additional metadata alongside `logo` as needed.
+Manage users at `/admin/users` and featured plugins at `/admin/featured-plugins`.
## 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 287a5f6d..16238958 100644
Binary files a/config/Migrations/schema-dump-default.lock and b/config/Migrations/schema-dump-default.lock differ
diff --git a/config/app.php b/config/app.php
index 9c8a161c..29a1d6f6 100644
--- a/config/app.php
+++ b/config/app.php
@@ -113,21 +113,6 @@
'Packages' => [
// 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/config/form-templates.php b/plugins/Admin/config/form-templates.php
new file mode 100644
index 00000000..5b98292e
--- /dev/null
+++ b/plugins/Admin/config/form-templates.php
@@ -0,0 +1,19 @@
+ '',
+ 'checkbox' => '',
+ 'checkboxWrapper' => '
{{label}}
',
+ 'error' => '{{content}}
',
+ 'input' => '',
+ 'inputContainer' => '{{content}}
',
+ 'inputContainerError' => '{{content}}{{error}}
',
+ 'label' => '',
+ 'nestingLabel' => '{{hidden}}',
+ 'select' => '',
+ 'textarea' => '',
+];
diff --git a/plugins/Admin/src/AdminPlugin.php b/plugins/Admin/src/AdminPlugin.php
new file mode 100644
index 00000000..3902a5b1
--- /dev/null
+++ b/plugins/Admin/src/AdminPlugin.php
@@ -0,0 +1,34 @@
+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..bbe50dcd
--- /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/FeaturedPluginEditorIsland.php b/plugins/Admin/src/Island/FeaturedPluginEditorIsland.php
new file mode 100644
index 00000000..ca7d5729
--- /dev/null
+++ b/plugins/Admin/src/Island/FeaturedPluginEditorIsland.php
@@ -0,0 +1,129 @@
+
+ */
+ #[State(writable: true)]
+ public array $draft = [
+ 'enabled' => true,
+ 'logo' => '',
+ 'title' => '',
+ 'description' => '',
+ ];
+
+ protected ?FeaturedPlugin $entry = null;
+
+ /**
+ * Initialize the draft after authorization; later requests preserve edits.
+ */
+ public function mount(): void
+ {
+ if ($this->entryId === null) {
+ return;
+ }
+ $entry = $this->fetchTable(FeaturedPluginsTable::class)->get($this->entryId);
+ $this->package = $entry->package;
+ $this->draft = [
+ 'enabled' => $entry->enabled,
+ 'logo' => $entry->logo ?? '',
+ 'title' => $entry->title ?? '',
+ 'description' => $entry->description ?? '',
+ ];
+ }
+
+ /**
+ * Only new entries can select a different canonical catalogue package.
+ */
+ #[Action]
+ public function selectPackage(int $id): void
+ {
+ if ($this->entryId !== null) {
+ throw new BadRequestException('Existing featured entries cannot change package.');
+ }
+ $this->package = $this->fetchTable(PackagesTable::class)->get($id)->package;
+ $this->packageSearch = '';
+ }
+
+ /**
+ * Delegate normalization, validation and creation rules to the model.
+ */
+ #[Action]
+ public function save(): void
+ {
+ $table = $this->fetchTable(FeaturedPluginsTable::class);
+ $entry = $this->entryId !== null ? $table->get($this->entryId) : $table->newEmptyEntity();
+ $this->entry = $table->patchEntity($entry, [
+ ...$this->draft,
+ 'package' => $this->package,
+ ], [
+ 'fields' => ['package', 'enabled', 'logo', 'title', 'description'],
+ ]);
+ if (!$table->save($this->entry)) {
+ $this->setErrors($this->entry->getErrors() ?: ['_form' => ['Unable to save this featured plugin.']]);
+
+ return;
+ }
+ $this->dispatch('featured-plugin-saved');
+ }
+
+ /**
+ * Close the parent-owned modal without persisting the draft.
+ */
+ #[Action(render: false)]
+ public function cancel(): void
+ {
+ $this->dispatch('featured-plugin-editor-closed');
+ }
+
+ /**
+ * Preview updates never execute the featured listing query.
+ *
+ * @return array
+ */
+ public function viewData(): array
+ {
+ if (mb_strlen($this->packageSearch) > 100) {
+ throw new BadRequestException('Package search is too long.');
+ }
+ $packages = $this->fetchTable(PackagesTable::class);
+ $table = $this->fetchTable(FeaturedPluginsTable::class);
+
+ return [
+ 'entry' => $this->entry ?? $table->newEntity([
+ ...$this->draft,
+ 'package' => $this->package,
+ ], [
+ 'fields' => ['package', 'enabled', 'logo', 'title', 'description'],
+ ]),
+ 'matches' => $this->entryId === null && $this->package === ''
+ ? $packages->find('featuredCandidates', search: $this->packageSearch)->limit(10)->all()
+ : [],
+ 'previewPackage' => $this->package !== '' ? $packages->find()->where(['package' => $this->package])->first() : null,
+ ];
+ }
+}
diff --git a/plugins/Admin/src/Island/FeaturedPluginsIsland.php b/plugins/Admin/src/Island/FeaturedPluginsIsland.php
new file mode 100644
index 00000000..60c6e76b
--- /dev/null
+++ b/plugins/Admin/src/Island/FeaturedPluginsIsland.php
@@ -0,0 +1,159 @@
+ 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]
+ 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;
+ }
+ }
+
+ /**
+ * Mount a fresh child editor for a new feature.
+ */
+ #[Action]
+ public function create(): void
+ {
+ $this->editing = true;
+ $this->editingId = $this->removingId = null;
+ $this->message = '';
+ }
+
+ /**
+ * Key the editor by record so each entry receives a fresh draft.
+ */
+ #[Action]
+ public function edit(int $id): void
+ {
+ $this->editing = true;
+ $this->editingId = $id;
+ $this->removingId = null;
+ $this->message = '';
+ }
+
+ /**
+ * Refresh the list only after a successful editor save.
+ */
+ #[Action(on: 'featured-plugin-saved')]
+ public function saved(): void
+ {
+ $this->cancel();
+ $this->message = 'Featured plugin saved.';
+ }
+
+ /**
+ * Discard the draft and any removal confirmation.
+ */
+ #[Action(on: 'featured-plugin-editor-closed')]
+ 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 || ($this->status !== '' && $status === null)) {
+ throw new BadRequestException('Invalid featured plugin filters.');
+ }
+ $table = $this->fetchTable(FeaturedPluginsTable::class);
+ $query = $table->find('adminListing', search: $this->search, status: $status);
+
+ return [
+ 'entries' => $this->paginate($query),
+ '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..f06ed060
--- /dev/null
+++ b/plugins/Admin/src/Island/UsersIsland.php
@@ -0,0 +1,172 @@
+ 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_plugin_editor.php b/plugins/Admin/templates/island/featured_plugin_editor.php
new file mode 100644
index 00000000..e42100ed
--- /dev/null
+++ b/plugins/Admin/templates/island/featured_plugin_editor.php
@@ -0,0 +1,106 @@
+ $matches
+ * @var \App\Model\Entity\Package|null $previewPackage
+ * @var int|null $entryId
+ * @var string $package
+ * @var string $packageSearch
+ * @var array> $errors
+ */
+$this->Form->templater()->load('Admin.form-templates');
+?>
+
+
+
diff --git a/plugins/Admin/templates/island/featured_plugins.php b/plugins/Admin/templates/island/featured_plugins.php
new file mode 100644
index 00000000..b8887f61
--- /dev/null
+++ b/plugins/Admin/templates/island/featured_plugins.php
@@ -0,0 +1,92 @@
+> $errors
+ */
+use App\Model\Enum\FeaturedPluginStatus;
+
+$this->Form->templater()->load('Admin.form-templates');
+?>
+
+
+
= __('Featured plugins') ?>
= __('The homepage randomly shows up to three enabled, available plugins.') ?>
+
+
+ = $this->element('Admin.feedback', compact('message', 'errors')) ?>
+
+
+ = $this->Form->select('status', $this->Form->enumOptions(FeaturedPluginStatus::class), [
+ 'class' => 'select w-auto',
+ 'aria-label' => __('Filter by status'),
+ 'empty' => __('All statuses'),
+ 'value' => $status,
+ 'x-interact:model.live' => 'status',
+ ]) ?>
+
+
+
+
+ | = $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') ?>
+
+ = $this->Interact->render('Admin.FeaturedPluginEditor', ['entryId' => $editingId], key: 'featured-editor-' . ($editingId ?? 'new')) ?>
+
+
+
+
+
diff --git a/plugins/Admin/templates/island/users.php b/plugins/Admin/templates/island/users.php
new file mode 100644
index 00000000..a6359336
--- /dev/null
+++ b/plugins/Admin/templates/island/users.php
@@ -0,0 +1,134 @@
+> $errors
+ */
+use App\Model\Enum\UserRole;
+use App\Model\Enum\UserStatus;
+
+$this->Form->templater()->load('Admin.form-templates');
+?>
+
+
= __('Users') ?>
+
= __('Manage roles and access for GitHub accounts.') ?>
+ = $this->element('Admin.feedback', compact('message', 'errors')) ?>
+
+
+ = $this->Form->select('roleFilter', $this->Form->enumOptions(UserRole::class), [
+ 'class' => 'select w-auto',
+ 'aria-label' => __('Filter by role'),
+ 'empty' => __('All roles'),
+ 'value' => $roleFilter,
+ 'x-interact:model.live' => 'roleFilter',
+ ]) ?>
+ = $this->Form->select('status', $this->Form->enumOptions(UserStatus::class), [
+ 'class' => 'select w-auto',
+ 'aria-label' => __('Filter by status'),
+ 'empty' => __('All statuses'),
+ 'value' => $status,
+ 'x-interact:model.live' => 'status',
+ ]) ?>
+
+
+
+
+ | = __('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..6a1f75fa
--- /dev/null
+++ b/plugins/Admin/templates/layout/admin.php
@@ -0,0 +1,90 @@
+getRequest()->getCookie('theme') === 'cakephp-dark' ? 'cakephp-dark' : 'cakephp';
+$controller = $this->getRequest()->getParam('controller');
+$navigation = [
+ 'Users' => ['admin:users', __('Users')],
+ 'FeaturedPlugins' => ['admin:featured', __('Featured plugins')],
+];
+?>
+
+
+
+ = $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..b42c8be6
--- /dev/null
+++ b/plugins/Admin/tests/TestCase/Controller/AdminControllerTest.php
@@ -0,0 +1,119 @@
+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(['AuthId' => 2]);
+ $this->get('/admin/users');
+ $this->assertResponseCode(403);
+ }
+
+ /**
+ * Plugin templates mount islands against Interact's standard endpoint.
+ */
+ public function testAdminPages(): void
+ {
+ $this->session(['AuthId' => 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');
+ }
+
+ /**
+ * ID-only authentication reloads role and active status on each request.
+ */
+ public function testStaleRoleAndSuspension(): void
+ {
+ $this->session(['AuthId' => 1]);
+ $this->get('/admin/users');
+ $this->assertResponseOk();
+ $table = $this->fetchTable(UsersTable::class);
+ $table->updateAll(['role' => UserRole::User], ['id' => 1]);
+ $this->get('/admin/users');
+ $this->assertResponseCode(403);
+ $table->updateAll([
+ 'role' => UserRole::Admin,
+ 'is_active' => false,
+ ], ['id' => 1]);
+ $this->get('/admin/users');
+ $this->assertRedirect('/?redirect=%2Fadmin%2Fusers');
+ }
+
+ /**
+ * Full-identity sessions from the old authenticator are never interpreted as IDs.
+ */
+ public function testLegacySessionRequiresSignIn(): void
+ {
+ $this->session([
+ 'Auth' => [
+ 'id' => 1,
+ 'role' => 'admin',
+ 'is_active' => true,
+ ],
+ ]);
+ $this->get('/admin/users');
+ $this->assertRedirect('/?redirect=%2Fadmin%2Fusers');
+ }
+
+ /**
+ * Deleted accounts cannot continue using an existing authenticated session.
+ */
+ public function testDeletedUserCannotAccessAdmin(): void
+ {
+ $this->session(['AuthId' => 1]);
+ $this->fetchTable(UsersTable::class)->deleteAll(['id' => 1]);
+ $this->get('/admin/users');
+ $this->assertRedirect('/?redirect=%2Fadmin%2Fusers');
+ }
+
+ /**
+ * The existing logout action clears the native ID-only session.
+ */
+ public function testLogoutClearsIdentity(): void
+ {
+ $this->session(['AuthId' => 1]);
+ $this->get('/users/logout');
+ $this->assertRedirect('/');
+ $this->assertSession(null, 'AuthId');
+ }
+
+ /**
+ * Even authenticated admin updates require CSRF.
+ */
+ public function testCsrf(): void
+ {
+ $this->session(['AuthId' => 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..d65857a5
--- /dev/null
+++ b/plugins/Admin/tests/TestCase/Island/AdminIslandsTest.php
@@ -0,0 +1,197 @@
+ '/admin/users']);
+ $authorization = $request->getAttribute('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
+ {
+ $request = $this->adminRequest();
+ $island = $this->testIsland('Admin.FeaturedPlugins', request: $request);
+ $this->fetchTable(UsersTable::class)->updateAll(['role' => 'user'], ['id' => 1]);
+ $this->expectException(ForbiddenException::class);
+ $island->withRequest($this->adminRequest(request: $request))->call('create');
+ }
+
+ /**
+ * The independently mounted editor checks access on its own updates too.
+ */
+ public function testRevokedEditorSnapshotDenied(): void
+ {
+ $request = $this->adminRequest();
+ $island = $this->testIsland('Admin.FeaturedPluginEditor', ['entryId' => 1], request: $request);
+ $this->fetchTable(UsersTable::class)->updateAll(['role' => 'user'], ['id' => 1]);
+ $this->expectException(ForbiddenException::class);
+ $island->withRequest($this->adminRequest(request: $request))->set('draft.title', 'Unauthorized change')->call('save');
+ }
+
+ /**
+ * Guessing a plugin island name never grants access to guests.
+ */
+ public function testGuestCannotMount(): void
+ {
+ $this->expectException(MissingIdentityException::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);
+ $request = $this->adminRequest();
+ $editor = $this->testIsland('Admin.FeaturedPluginEditor', request: $request);
+ $editor->set('packageSearch', 'package-01')->assertSee('vendor/package-01')->call('selectPackage', 2)
+ ->fill([
+ 'draft.title' => 'Our pick',
+ 'draft.description' => 'Editorial description',
+ 'draft.logo' => 'javascript:alert(1)',
+ ])->call('save')->assertHasErrors(['logo']);
+ $editor->set('draft.logo', '/img/pick.svg')->call('save')->assertDispatched('featured-plugin-saved');
+ $entry = $table->find()->where(['package' => 'vendor/package-01'])->firstOrFail();
+ $this->assertSame('Our pick', $entry->title);
+ $this->testIsland('Admin.FeaturedPluginEditor', ['entryId' => $entry->id], request: $request)
+ ->set('draft.enabled', false)->call('save')->assertDispatched('featured-plugin-saved');
+ $this->assertFalse($table->get($entry->id)->enabled);
+ $island = $this->testIsland('Admin.FeaturedPlugins', request: $request);
+ $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.FeaturedPluginEditor', request: $this->adminRequest())
+ ->call('selectPackage', 1)->call('save')->assertHasErrors(['package']);
+ }
+
+ /**
+ * Whole-form drafts still use ORM validation and cannot change the selection.
+ */
+ public function testFeaturedDraftValidationAndFieldRestrictions(): void
+ {
+ $editor = $this->testIsland('Admin.FeaturedPluginEditor', ['entryId' => 1], request: $this->adminRequest());
+ $editor->fill([
+ 'draft.logo' => ['invalid'],
+ 'draft.title' => ['invalid'],
+ ])->call('save')->assertHasErrors(['logo', 'title']);
+ $editor->fill([
+ 'draft.logo' => '',
+ 'draft.title' => 'Editorial title',
+ 'draft.package' => 'cakedc/users',
+ 'draft.id' => 2,
+ ])->call('save')->assertDispatched('featured-plugin-saved');
+ $table = $this->fetchTable(FeaturedPluginsTable::class);
+ $this->assertSame('markstory/asset_compress', $table->get(1)->package);
+ $this->assertSame('Editorial title', $table->get(1)->title);
+ $this->assertNull($table->get(2)->title);
+ }
+
+ /**
+ * 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);
+ $request = $this->adminRequest();
+ $this->fetchTable(PackagesTable::class)->deleteAll(['package' => 'markstory/asset_compress']);
+ $this->testIsland('Admin.FeaturedPlugins', request: $request)->set('status', 'unavailable')
+ ->assertSee('markstory/asset_compress')->assertDontSee('cakedc/users');
+ $this->testIsland('Admin.FeaturedPluginEditor', ['entryId' => 1], request: $request)
+ ->set('draft.enabled', false)->call('save')->assertDispatched('featured-plugin-saved');
+ $this->assertFalse($table->get(1)->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..103a7f8a 100644
--- a/src/Application.php
+++ b/src/Application.php
@@ -22,6 +22,8 @@
use App\Http\RateLimit\AtomicFixedWindowRateLimiter;
use App\Middleware\ThemeCookieMiddleware;
use App\Model\Table\PackagesTable;
+use App\Model\Table\UsersTable;
+use App\Policy\RequestPolicy;
use App\Service\AwesomeListImporter;
use App\Service\AwesomeListParser;
use App\Service\PackagistImporter;
@@ -29,6 +31,13 @@
use Authentication\AuthenticationServiceInterface;
use Authentication\AuthenticationServiceProviderInterface;
use Authentication\Middleware\AuthenticationMiddleware;
+use Authorization\AuthorizationService;
+use Authorization\AuthorizationServiceInterface;
+use Authorization\AuthorizationServiceProviderInterface;
+use Authorization\Exception\MissingIdentityException;
+use Authorization\Middleware\AuthorizationMiddleware;
+use Authorization\Middleware\RequestAuthorizationMiddleware;
+use Authorization\Policy\MapResolver;
use Cake\Core\Configure;
use Cake\Core\ContainerInterface;
use Cake\Datasource\FactoryLocator;
@@ -42,6 +51,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 +65,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.
@@ -117,6 +127,10 @@ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
->add(new ThemeCookieMiddleware())
->add(new SocialAuthMiddleware([
+ 'finder' => 'active',
+ 'loginUrl' => '/',
+ 'userId' => true,
+ 'sessionKey' => 'AuthId',
// SocialConnect Auth service's providers config. https://github.com/SocialConnect/auth/blob/master/README.md
'serviceConfig' => [
'provider' => [
@@ -138,7 +152,16 @@ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
],
]))
- ->add(new AuthenticationMiddleware($this));
+ ->add(new AuthenticationMiddleware($this))
+ ->add(new AuthorizationMiddleware($this))
+ ->add(new RequestAuthorizationMiddleware([
+ 'unauthorizedHandler' => [
+ 'className' => 'Authorization.Redirect',
+ 'exceptions' => [MissingIdentityException::class],
+ 'url' => '/',
+ 'queryParam' => 'redirect',
+ ],
+ ]));
$requestProtection = (array)Configure::read('PublicRequestProtection', []);
$trustedProxyIps = (array)($requestProtection['trustedProxyIps'] ?? []);
@@ -235,10 +258,32 @@ public function getAuthenticationService(ServerRequestInterface $request): Authe
'queryParam' => 'redirect',
]);
- // Load the authenticators. Session should be first.
- // Session just uses session data directly as identity, no identifier needed.
- $service->loadAuthenticator('Authentication.Session');
+ // Resolve current access on each request; legacy full-identity sessions are ignored.
+ $service->loadAuthenticator('Authentication.PrimaryKeySession', [
+ 'sessionKey' => 'AuthId',
+ 'identifier' => [
+ 'className' => 'Authentication.Token',
+ 'tokenField' => 'id',
+ 'dataField' => 'key',
+ 'resolver' => [
+ 'className' => 'Authentication.Orm',
+ 'userModel' => UsersTable::class,
+ 'finder' => 'active',
+ ],
+ ],
+ ]);
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/Model/Entity/FeaturedPlugin.php b/src/Model/Entity/FeaturedPlugin.php
new file mode 100644
index 00000000..5343126f
--- /dev/null
+++ b/src/Model/Entity/FeaturedPlugin.php
@@ -0,0 +1,62 @@
+ true,
+ 'enabled' => true,
+ 'logo' => true,
+ 'title' => true,
+ 'description' => true,
+ ];
+
+ /**
+ * Persist an omitted logo as null, consistently for every ORM caller.
+ */
+ protected function _setLogo(?string $value): ?string
+ {
+ return $this->normalizeOverride($value);
+ }
+
+ /**
+ * Blank editorial titles fall back to the catalogue name.
+ */
+ protected function _setTitle(?string $value): ?string
+ {
+ return $this->normalizeOverride($value);
+ }
+
+ /**
+ * Blank editorial descriptions fall back to imported metadata.
+ */
+ protected function _setDescription(?string $value): ?string
+ {
+ return $this->normalizeOverride($value);
+ }
+
+ /**
+ * Preserve nonempty values, including the string "0".
+ */
+ private function normalizeOverride(?string $value): ?string
+ {
+ $value = $value !== null ? trim($value) : '';
+
+ return $value === '' ? null : $value;
+ }
+}
diff --git a/src/Model/Entity/Package.php b/src/Model/Entity/Package.php
index ed16aaaa..e2a4a8d8 100644
--- a/src/Model/Entity/Package.php
+++ b/src/Model/Entity/Package.php
@@ -125,32 +125,4 @@ protected function groupVersionTags(array $tags, string $prefix): array
return $groups;
}
-
- public const FIELD_ID = 'id';
-
- public const FIELD_PACKAGE = 'package';
-
- public const FIELD_DESCRIPTION = 'description';
-
- public const FIELD_REPO_URL = 'repo_url';
-
- public const FIELD_DOWNLOADS = 'downloads';
-
- public const FIELD_STARS = 'stars';
-
- public const FIELD_LATEST_STABLE_VERSION = 'latest_stable_version';
-
- public const FIELD_LATEST_STABLE_RELEASE_DATE = 'latest_stable_release_date';
-
- public const FIELD_CAKE_PHP_TAGS = 'cake_php_tags';
-
- public const FIELD_CAKE_PHP_TAG_GROUPS = 'cake_php_tag_groups';
-
- public const FIELD_PHP_TAGS = 'php_tags';
-
- public const FIELD_PHP_TAG_GROUPS = 'php_tag_groups';
-
- public const FIELD_TAGGED = 'tagged';
-
- public const FIELD_TAGS = 'tags';
}
diff --git a/src/Model/Entity/User.php b/src/Model/Entity/User.php
index 902ac4ee..d5249025 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,23 +37,7 @@ class User extends Entity
protected array $_accessible = [
'*' => true,
'id' => false,
+ 'role' => false,
+ 'is_active' => false,
];
-
- public const FIELD_ID = 'id';
-
- public const FIELD_FIRST_NAME = 'first_name';
-
- public const FIELD_LAST_NAME = 'last_name';
-
- public const FIELD_EMAIL = 'email';
-
- public const FIELD_USERNAME = 'username';
-
- public const FIELD_CREATED = 'created';
-
- public const FIELD_MODIFIED = 'modified';
-
- public const FIELD_SOCIAL_PROFILE = 'social_profile';
-
- public const FIELD_IS_CAKEPHP_DEV = 'is_cakephp_dev';
}
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' => PackagesTable::class,
+ '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(mixed $value): bool => is_string($value) && (
+ (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.',
+ ]);
+ }
+
+ /**
+ * Validate the same trimmed override values that entity setters persist.
+ *
+ * @param \Cake\Event\EventInterface $event Marshal event.
+ * @param \ArrayObject $data Input fields.
+ * @param \ArrayObject $options Marshal options.
+ */
+ public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options): void
+ {
+ foreach (['logo', 'title', 'description'] as $field) {
+ if (isset($data[$field]) && is_string($data[$field])) {
+ $data[$field] = trim($data[$field]);
+ }
+ }
+ }
+
+ /**
+ * The unique index also protects concurrent additions.
+ */
+ public function buildRules(RulesChecker $rules): RulesChecker
+ {
+ $rules->add($rules->isUnique(['package']), ['errorField' => 'package']);
+ $rules->addCreate($rules->existsIn(['package'], 'CataloguePackages'), 'cataloguePackage', [
+ 'errorField' => 'package',
+ 'message' => 'Choose an existing plugin.',
+ ]);
+
+ return $rules;
+ }
+
+ /**
+ * Build the management listing with catalogue counts and availability.
+ *
+ * @param \Cake\ORM\Query\SelectQuery<\App\Model\Entity\FeaturedPlugin> $query Featured entry query.
+ * @return \Cake\ORM\Query\SelectQuery<\App\Model\Entity\FeaturedPlugin>
+ */
+ public function findAdminListing(SelectQuery $query, string $search = '', ?FeaturedPluginStatus $status = null): SelectQuery
+ {
+ $query->contain(['CataloguePackages']);
+ if (trim($search) !== '') {
+ $query->where(['FeaturedPlugins.package LIKE' => '%' . trim($search) . '%']);
+ }
+ if ($status === FeaturedPluginStatus::Unavailable) {
+ $query->where(['CataloguePackages.id IS' => null]);
+ } elseif ($status instanceof FeaturedPluginStatus) {
+ $query->where(['FeaturedPlugins.enabled' => $status === FeaturedPluginStatus::Enabled]);
+ }
+
+ return $query;
+ }
+}
diff --git a/src/Model/Table/PackagesTable.php b/src/Model/Table/PackagesTable.php
index 54b3475d..77924cdc 100644
--- a/src/Model/Table/PackagesTable.php
+++ b/src/Model/Table/PackagesTable.php
@@ -144,6 +144,19 @@ public function findFeatured(SelectQuery $query, array $names): SelectQuery
->contain(['Categories', 'Tags' => static fn(SelectQuery $tags) => $tags->orderByDesc('Tags.label')]);
}
+ /**
+ * Search canonical package names for the featured editor's picker.
+ *
+ * @param \Cake\ORM\Query\SelectQuery<\App\Model\Entity\Package> $query Package query.
+ * @return \Cake\ORM\Query\SelectQuery<\App\Model\Entity\Package>
+ */
+ public function findFeaturedCandidates(SelectQuery $query, string $search = ''): SelectQuery
+ {
+ return $query->select(['id', 'package'])
+ ->where(['package LIKE' => '%' . trim($search) . '%'])
+ ->orderByAsc('package');
+ }
+
/**
* @return array Imported categories and their directory-wide package counts.
*/
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..a4eda7cf
--- /dev/null
+++ b/src/Policy/RequestPolicy.php
@@ -0,0 +1,31 @@
+getParam('plugin') !== 'Admin') {
+ return true;
+ }
+ if (!$identity instanceof IdentityInterface) {
+ throw new MissingIdentityException(['identity']);
+ }
+
+ return $identity['role'] === UserRole::Admin && $identity['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.'))) ?>