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 @@ + '{{text}}', + 'checkbox' => '', + 'checkboxWrapper' => '
{{label}}
', + 'error' => '
{{content}}
', + 'input' => '', + 'inputContainer' => '
{{content}}
', + 'inputContainerError' => '
{{content}}{{error}}
', + 'label' => '', + 'nestingLabel' => '{{hidden}}{{input}}{{text}}', + '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')); +?> +Interact->render('Admin.FeaturedPlugins'); diff --git a/plugins/Admin/templates/Users/index.php b/plugins/Admin/templates/Users/index.php new file mode 100644 index 00000000..a343132b --- /dev/null +++ b/plugins/Admin/templates/Users/index.php @@ -0,0 +1,5 @@ +assign('title', __('Users')); +?> +Interact->render('Admin.Users'); diff --git a/plugins/Admin/templates/element/feedback.php b/plugins/Admin/templates/element/feedback.php new file mode 100644 index 00000000..ce0c61a1 --- /dev/null +++ b/plugins/Admin/templates/element/feedback.php @@ -0,0 +1,22 @@ +> $errors + * @var string $message + */ +?> + +
+ + + + + + diff --git a/plugins/Admin/templates/element/paging.php b/plugins/Admin/templates/element/paging.php new file mode 100644 index 00000000..d527b59b --- /dev/null +++ b/plugins/Admin/templates/element/paging.php @@ -0,0 +1,10 @@ + +
+

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

+ +
diff --git a/plugins/Admin/templates/island/featured_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'); +?> +
+
+

+ +
+ element('Admin.feedback', compact('message', 'errors')) ?> +
+ + 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', + ]) ?> +
+
+ + + + + + + + + + + + + + + + + + + + +
Paginator->sort('package', __('Plugin')) ?>Paginator->sort('title', __('Featured title')) ?>Paginator->sort('stars', __('GitHub stars')) ?>Paginator->sort('downloads', __('Downloads')) ?>Paginator->sort('enabled', __('Status')) ?>
package) ?>title ?? '—') ?>catalogue_package !== null ? $this->Number->format($entry->catalogue_package->stars) : '—' ?>catalogue_package !== null ? $this->Number->format($entry->catalogue_package->downloads) : '—' ?> + + enabled)->label()) ?> + + catalogue_package === null) : + ?>label()) ?> + + +
+
+ element('Admin.paging') ?> + + 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'); +?> +
+

+

+ element('Admin.feedback', compact('message', 'errors')) ?> +
+ + 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', + ]) ?> + 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', + ]) ?> +
+
+ + + + + + + + + + + + + + + + + + +
@username) ?>
first_name . ' ' . $user->last_name)) ?>
email) ?>role->label()) ?>is_active)->label()) ?>created->format('Y-m-d')) ?> + id) : ?> + + + + +
+
+ element('Admin.paging') ?> + + + + + +
diff --git a/plugins/Admin/templates/layout/admin.php b/plugins/Admin/templates/layout/admin.php new file mode 100644 index 00000000..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')], +]; +?> + + + + Html->charset() ?> + + + <?= h($this->fetch('title')) ?> · CakePHP Plugins Admin + Html->meta('icon') ?> + Vite->css([ + 'files' => ['resources/css/style.css'], + 'block' => false, + 'attributes' => [ + 'x-interact:asset' => 'reload', + 'timestamp' => false, + ], + ]) ?> + Vite->script([ + 'files' => ['resources/js/app.js'], + 'block' => false, + 'attributes' => [ + 'x-interact:asset' => 'reload', + 'timestamp' => false, + ], + ]) ?> + fetch('script') ?> + + + +
+ +
+ +
+ Flash->render() ?> + fetch('content') ?> +
+
+
+ + +
+
+ + diff --git a/plugins/Admin/tests/TestCase/Controller/AdminControllerTest.php b/plugins/Admin/tests/TestCase/Controller/AdminControllerTest.php new file mode 100644 index 00000000..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 @@ User->displayName()) ?> @User->username()) ?>

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