From f48706980f30e65b637b45aab58257f901f787a8 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 9 Jul 2026 00:15:25 +0200 Subject: [PATCH 1/3] Implement DDS-004 admin access foundation --- .ddev/config.yaml | 6 + app/Console/Commands/MakeAdminUserCommand.php | 95 ++++++++ app/Enums/Permission.php | 14 ++ app/Enums/Role.php | 9 + app/Models/User.php | 3 +- composer.json | 3 +- composer.lock | 151 +++++++++++- config/permission.php | 219 ++++++++++++++++++ ..._07_08_213607_create_permission_tables.php | 137 +++++++++++ database/seeders/DatabaseSeeder.php | 8 +- .../seeders/RolesAndPermissionsSeeder.php | 36 +++ docs/backlog/initial-build-backlog.md | 29 ++- docs/decisions/README.md | 5 +- docs/product/information-architecture.md | 28 +-- docs/roadmap/roadmap.md | 4 +- docs/technical/architecture.md | 2 +- docs/technical/local-development.md | 26 +++ package-lock.json | 2 +- package.json | 7 +- routes/web.php | 8 +- tests/Feature/Auth/AuthenticationTest.php | 2 +- tests/Feature/Auth/EmailVerificationTest.php | 2 +- .../Feature/Auth/PasswordConfirmationTest.php | 2 +- tests/Feature/Auth/PasswordResetTest.php | 2 +- tests/Feature/Auth/TwoFactorChallengeTest.php | 2 +- .../Auth/VerificationNotificationTest.php | 2 +- tests/Feature/DashboardTest.php | 50 +++- tests/Feature/ExampleTest.php | 2 +- tests/Feature/MakeAdminUserCommandTest.php | 61 +++++ tests/Feature/Settings/ProfileUpdateTest.php | 2 +- tests/Feature/Settings/SecurityTest.php | 2 +- tests/Unit/ExampleTest.php | 2 +- vite.config.ts | 19 ++ 33 files changed, 884 insertions(+), 58 deletions(-) create mode 100644 app/Console/Commands/MakeAdminUserCommand.php create mode 100644 app/Enums/Permission.php create mode 100644 app/Enums/Role.php create mode 100644 config/permission.php create mode 100644 database/migrations/2026_07_08_213607_create_permission_tables.php create mode 100644 database/seeders/RolesAndPermissionsSeeder.php create mode 100644 tests/Feature/MakeAdminUserCommandTest.php diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 33f4be7..4889439 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -236,6 +236,12 @@ corepack_enable: false # The default time that DDEV waits for all containers to become ready can be increased from # the default 120. This helps in importing huge databases, for example. +web_extra_exposed_ports: + - name: vite + container_port: 5173 + http_port: 5172 + https_port: 5173 + #web_extra_exposed_ports: #- name: nodejs # container_port: 3000 diff --git a/app/Console/Commands/MakeAdminUserCommand.php b/app/Console/Commands/MakeAdminUserCommand.php new file mode 100644 index 0000000..4967ff0 --- /dev/null +++ b/app/Console/Commands/MakeAdminUserCommand.php @@ -0,0 +1,95 @@ +run(); + + $email = $this->argument('email') ?: $this->ask('Email address'); + $existingUser = is_string($email) ? User::query()->where('email', $email)->first() : null; + $name = $this->option('name') ?: $existingUser?->name ?: $this->ask('Name'); + $password = $this->option('password'); + $generatedPassword = null; + + if (! $existingUser && ! is_string($password)) { + $password = $generatedPassword = Str::password(24); + } + + $validator = Validator::make([ + 'email' => $email, + 'name' => $name, + 'password' => $password, + ], [ + 'email' => ['required', 'string', 'email:rfc', 'max:255'], + 'name' => ['required', 'string', 'max:255'], + 'password' => [$existingUser ? 'nullable' : 'required', 'string', Password::default()], + ]); + + if ($validator->fails()) { + foreach ($validator->errors()->all() as $error) { + $this->error($error); + } + + return self::FAILURE; + } + + $validated = $validator->validated(); + $user = $existingUser ?: new User(['email' => $validated['email']]); + + $user->forceFill([ + 'name' => $validated['name'], + 'email_verified_at' => $user->email_verified_at ?? now(), + ]); + + if (is_string($validated['password'] ?? null) && $validated['password'] !== '') { + $user->password = $validated['password']; + } + + $user->save(); + $user->assignRole(RoleEnum::Admin->value); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + $this->info(sprintf('Admin access granted to %s.', $user->email)); + + if (is_string($generatedPassword)) { + $this->newLine(); + $this->warn('Generated password: '.$generatedPassword); + $this->warn('Store this password now. It will not be shown again.'); + } + + return self::SUCCESS; + } +} diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php new file mode 100644 index 0000000..b987e72 --- /dev/null +++ b/app/Enums/Permission.php @@ -0,0 +1,14 @@ + */ - use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; + use HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; /** * Get the attributes that should be cast. diff --git a/composer.json b/composer.json index dfb9514..0188aa7 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "laravel/fortify": "^1.37.2", "laravel/framework": "^13.17", "laravel/tinker": "^3.0", - "laravel/wayfinder": "^0.1.14" + "laravel/wayfinder": "^0.1.14", + "spatie/laravel-permission": "^8.3" }, "require-dev": { "fakerphp/faker": "^1.24", diff --git a/composer.lock b/composer.lock index 7a5dbbe..fe909ff 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": "f9d248d2efa62322ed2dcc7cf6e256de", + "content-hash": "be3e8e0a08decd8dc124fa3204d31789", "packages": [ { "name": "bacon/bacon-qr-code", @@ -4119,6 +4119,155 @@ }, "time": "2026-06-18T03:57:49+00:00" }, + { + "name": "spatie/laravel-package-tools", + "version": "1.93.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "d5552849801f2642aea710557463234b59ef65eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", + "reference": "d5552849801f2642aea710557463234b59ef65eb", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-19T14:06:37+00:00" + }, + { + "name": "spatie/laravel-permission", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "60e8ed5b2fbf043c2264433fc2680c76b8b66aa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/60e8ed5b2fbf043c2264433fc2680c76b8b66aa6", + "reference": "60e8ed5b2fbf043c2264433fc2680c76b8b66aa6", + "shasum": "" + }, + "require": { + "illuminate/auth": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/database": "^12.0|^13.0", + "php": "^8.3", + "spatie/laravel-package-tools": "^1.0" + }, + "require-dev": { + "larastan/larastan": "^3.9", + "laravel/octane": "^2.0", + "laravel/passport": "^13.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.1", + "phpstan/phpstan": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "8.x-dev", + "dev-master": "8.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 12 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-07-03T15:36:01+00:00" + }, { "name": "spomky-labs/cbor-php", "version": "3.2.3", diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..8f1f452 --- /dev/null +++ b/config/permission.php @@ -0,0 +1,219 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Role::class, + + /* + * When using the "Teams" feature from this package, we need to know which + * Eloquent model should be used to retrieve your teams. Of course, it + * is often just the "Team" model but you may use whatever you like. + */ + 'team' => null, + + /* + * When using the "HasModels" trait and passing raw IDs to syncModels, + * attachModels, or detachModels, this model class will be used to + * resolve those IDs. If null, defaults to the guard's model. + */ + 'default_model' => null, + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttachedEvent + * \Spatie\Permission\Events\RoleDetachedEvent + * \Spatie\Permission\Events\PermissionAttachedEvent + * \Spatie\Permission\Events\PermissionDetachedEvent + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/database/migrations/2026_07_08_213607_create_permission_tables.php b/database/migrations/2026_07_08_213607_create_permission_tables.php new file mode 100644 index 0000000..8986275 --- /dev/null +++ b/database/migrations/2026_07_08_213607_create_permission_tables.php @@ -0,0 +1,137 @@ +id(); // permission id + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + /** + * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. + */ + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { + $table->id(); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..75cd187 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,7 +2,6 @@ namespace Database\Seeders; -use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -15,11 +14,8 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + RolesAndPermissionsSeeder::class, ]); } } diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php new file mode 100644 index 0000000..8d773a3 --- /dev/null +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -0,0 +1,36 @@ +forgetCachedPermissions(); + + $permissions = collect(PermissionEnum::cases()) + ->map(fn (PermissionEnum $permission) => Permission::findOrCreate($permission->value, 'web')); + + Role::findOrCreate(RoleEnum::Admin->value, 'web') + ->syncPermissions($permissions); + + Role::findOrCreate(RoleEnum::Editor->value, 'web') + ->syncPermissions([ + PermissionEnum::ViewEvents->value, + PermissionEnum::CreateEvents->value, + PermissionEnum::UpdateEvents->value, + ]); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +} diff --git a/docs/backlog/initial-build-backlog.md b/docs/backlog/initial-build-backlog.md index f2b1a0b..cb702e2 100644 --- a/docs/backlog/initial-build-backlog.md +++ b/docs/backlog/initial-build-backlog.md @@ -114,26 +114,39 @@ Acceptance criteria: ### DDS-004: Authentication And Admin Gate -Goal: enable login and protect `/admin`. +Status: implemented locally. `spatie/laravel-permission` is installed and configured, the package migration/config are published, initial `admin` and `editor` roles are seeded, `/dashboard` is protected by auth, email verification, and role middleware, CRUD permissions are available for concrete admin actions, and a repeatable first-admin command exists through `php artisan dds:make-admin`. Backend tests and TypeScript checks pass locally. Final DDEV migration, seeding, build, and browser verification are still pending. + +Goal: enable login and protect the starter `/dashboard` route as the first management entrypoint. Tasks: - use starter kit authentication; - install and configure `spatie/laravel-permission`; - create initial `admin` and `editor` roles; -- create `/admin` route; -- protect `/admin` with auth middleware; -- protect admin access through roles/permissions; -- add a first admin user seeding path. +- use the existing `/dashboard` route from the starter kit; +- protect `/dashboard` with auth middleware; +- protect admin shell access through roles; +- protect concrete admin actions through permissions; +- add a first admin user creation command. Acceptance criteria: -- unauthenticated users cannot access `/admin`; -- authenticated admin user can access `/admin`; +- unauthenticated users cannot access `/dashboard`; +- authenticated admin user can access `/dashboard`; - non-admin behavior is defined; -- role checks use Spatie Permission instead of a custom boolean-only approach; +- role and permission checks use Spatie Permission instead of a custom boolean-only approach; - first admin account can be created repeatably for local/staging setup. +Local verification commands: + +- `ddev artisan migrate`; +- `ddev artisan db:seed`; +- `ddev artisan dds:make-admin`; +- `ddev npm run types:check`; +- `ddev npm run lint:check`; +- `ddev npm run build`; +- `ddev artisan test --compact`. + ### DDS-004A: Initial Commit Checkpoint Goal: define when the first GitHub commit should happen. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 0a95a38..2788f87 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -188,8 +188,9 @@ Implementation direction: - install `spatie/laravel-permission` during `DDS-004`; - create `admin` and `editor` roles; -- protect `/admin` through role/permission middleware or policies; -- seed or document a repeatable path for creating the first admin user. +- use CRUD- and workflow-oriented permissions per domain for concrete actions; +- protect the starter `/dashboard` route through `admin` and `editor` roles, then protect concrete actions through permissions; +- provide a repeatable Artisan command for creating or promoting the first admin user. ## 2026-07-08: Initial Commit After Validated Scaffold diff --git a/docs/product/information-architecture.md b/docs/product/information-architecture.md index 7660a1b..b682803 100644 --- a/docs/product/information-architecture.md +++ b/docs/product/information-architecture.md @@ -89,20 +89,20 @@ Legacy URLs that need redirects: ## Admin Routes ```txt -/admin -/admin/events -/admin/events/create -/admin/events/{event}/edit -/admin/articles -/admin/articles/create -/admin/articles/{article}/edit -/admin/projects -/admin/projects/create -/admin/projects/{project}/edit -/admin/locations -/admin/partners -/admin/media -/admin/contact-submissions +/dashboard +/dashboard/events +/dashboard/events/create +/dashboard/events/{event}/edit +/dashboard/articles +/dashboard/articles/create +/dashboard/articles/{article}/edit +/dashboard/projects +/dashboard/projects/create +/dashboard/projects/{project}/edit +/dashboard/locations +/dashboard/partners +/dashboard/media +/dashboard/contact-submissions ``` ## Homepage Structure diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index 8e51d25..0f4e5ee 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -38,7 +38,7 @@ Acceptance criteria: - app runs locally through DDEV; - login works; - `/` renders through Inertia; -- `/admin` is protected; +- `/dashboard` is protected; - locale configuration is in place; - tests and linting run locally and in CI. @@ -86,7 +86,7 @@ Acceptance criteria: - admin feels like a coherent part of the app; - management patterns are reusable; -- non-admin users cannot access `/admin`; +- non-admin users cannot access `/dashboard`; - CRUD patterns can be applied to the first domain. ## Phase 4: Events As First Domain diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index 62abb16..2cd37f0 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -118,7 +118,7 @@ resources/js/ Start simple: - `User` model with Laravel starter kit authentication; -- admin middleware for `/admin`; +- role middleware for the starter `/dashboard` route; - policies per model. Later: diff --git a/docs/technical/local-development.md b/docs/technical/local-development.md index 4a6e5ab..0e55954 100644 --- a/docs/technical/local-development.md +++ b/docs/technical/local-development.md @@ -54,6 +54,32 @@ Do not run this during the documentation phase. This is the recommended directio - Render public pages through Inertia, not a loose Blade/React mix unless deliberately chosen. - Admin components can be more abstract than public components because tables and forms repeat. +## Vite With DDEV + +The project exposes Vite through DDEV on `https://dds-platform.ddev.site:5173`. + +For active frontend development: + +```bash +ddev npm run dev +``` + +Keep that command running and open the application through the normal DDEV URL: + +```txt +https://dds-platform.ddev.site +``` + +Do not open the raw Vite URL as the application URL. Laravel should serve the app, while Vite serves hot assets. + +For a production-style local check: + +```bash +ddev npm run build +``` + +If `public/hot` exists, Laravel will keep using the dev server even after a build. Stop the dev server cleanly, or remove `public/hot` before checking built assets. + ## Open Spike Questions Before the application is built: diff --git a/package-lock.json b/package-lock.json index 50eaac4..f9a14e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "temp", + "name": "html", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 49bc7a6..243e162 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,10 @@ "dev": "vite", "format": "prettier --write resources/", "format:check": "prettier --check resources/", - "lint": "eslint . --fix", - "lint:check": "eslint .", - "types:check": "tsc --noEmit" + "lint": "npm run wayfinder:generate && eslint . --fix", + "lint:check": "npm run wayfinder:generate && eslint .", + "types:check": "npm run wayfinder:generate && tsc --noEmit", + "wayfinder:generate": "php artisan wayfinder:generate --with-form --no-interaction" }, "devDependencies": { "@eslint/js": "^9.19.0", diff --git a/routes/web.php b/routes/web.php index 2085b21..f7fbc8a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,10 +1,16 @@ name('home'); -Route::middleware(['auth', 'verified'])->group(function () { +Route::middleware([ + 'auth', + 'verified', + RoleMiddleware::using([Role::Admin, Role::Editor]), +])->group(function () { Route::inertia('dashboard', 'dashboard')->name('dashboard'); }); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index c404ceb..d1b9ed2 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -74,4 +74,4 @@ ]); $response->assertTooManyRequests(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 6428476..81eacd8 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -98,4 +98,4 @@ Event::assertNotDispatched(Verified::class); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index 819aac9..5d58992 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -19,4 +19,4 @@ $response = $this->get(route('password.confirm')); $response->assertRedirect(route('login')); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index 5ce8fd7..a2b8010 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -75,4 +75,4 @@ ]); $response->assertSessionHasErrors('email'); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php index c369db2..62e33d7 100644 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -32,4 +32,4 @@ ->assertInertia(fn (Assert $page) => $page ->component('auth/two-factor-challenge'), ); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/VerificationNotificationTest.php b/tests/Feature/Auth/VerificationNotificationTest.php index c6648ee..2e7c33f 100644 --- a/tests/Feature/Auth/VerificationNotificationTest.php +++ b/tests/Feature/Auth/VerificationNotificationTest.php @@ -31,4 +31,4 @@ ->assertRedirect(route('dashboard', absolute: false)); Notification::assertNothingSent(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index d59d2d7..17aa3f1 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -1,16 +1,52 @@ withoutVite(); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + Role::findOrCreate(RoleEnum::Admin->value, 'web'); + Role::findOrCreate(RoleEnum::Editor->value, 'web'); +}); test('guests are redirected to the login page', function () { - $response = $this->get(route('dashboard')); - $response->assertRedirect(route('login')); + $this->get(route('dashboard'))->assertRedirect(route('login')); }); -test('authenticated users can visit the dashboard', function () { +test('authenticated users without an admin role are forbidden from the dashboard', function () { $user = User::factory()->create(); - $this->actingAs($user); - $response = $this->get(route('dashboard')); - $response->assertOk(); -}); \ No newline at end of file + $this->actingAs($user) + ->get(route('dashboard')) + ->assertForbidden(); +}); + +test('admins can visit the dashboard', function () { + $user = User::factory()->create(); + $user->assignRole(RoleEnum::Admin->value); + + $this->actingAs($user) + ->get(route('dashboard')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('dashboard'), + ); +}); + +test('editors can visit the dashboard', function () { + $user = User::factory()->create(); + $user->assignRole(RoleEnum::Editor->value); + + $this->actingAs($user) + ->get(route('dashboard')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('dashboard'), + ); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index a576279..ef7b533 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -4,4 +4,4 @@ $response = $this->get(route('home')); $response->assertOk(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/MakeAdminUserCommandTest.php b/tests/Feature/MakeAdminUserCommandTest.php new file mode 100644 index 0000000..92a9cd1 --- /dev/null +++ b/tests/Feature/MakeAdminUserCommandTest.php @@ -0,0 +1,61 @@ +artisan('dds:make-admin', [ + 'email' => 'admin@example.com', + '--name' => 'DDS Admin', + '--password' => 'password', + ])->assertSuccessful(); + + $user = User::query()->where('email', 'admin@example.com')->firstOrFail(); + + expect($user) + ->name->toBe('DDS Admin') + ->email_verified_at->not->toBeNull() + ->and(Hash::check('password', $user->password))->toBeTrue() + ->and($user->hasRole(Role::Admin->value))->toBeTrue() + ->and($user->can(Permission::DeleteEvents->value))->toBeTrue() + ->and($user->can(Permission::ViewUsers->value))->toBeTrue(); +}); + +test('it generates a password when creating a new admin without a password option', function () { + $this->artisan('dds:make-admin', [ + 'email' => 'generated@example.com', + '--name' => 'Generated Admin', + ]) + ->expectsOutputToContain('Generated password:') + ->expectsOutput('Store this password now. It will not be shown again.') + ->assertSuccessful(); + + $user = User::query()->where('email', 'generated@example.com')->firstOrFail(); + + expect($user) + ->email_verified_at->not->toBeNull() + ->and($user->password)->not->toBeNull() + ->and($user->hasRole(Role::Admin->value))->toBeTrue(); +}); + +test('it promotes an existing user to admin without changing the password', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'editor@example.com', + 'name' => 'Existing User', + 'password' => 'current-password', + ]); + + $this->artisan('dds:make-admin', [ + 'email' => 'editor@example.com', + ])->assertSuccessful(); + + $user->refresh(); + + expect($user) + ->name->toBe('Existing User') + ->email_verified_at->not->toBeNull() + ->and(Hash::check('current-password', $user->password))->toBeTrue() + ->and($user->hasRole(Role::Admin->value))->toBeTrue(); +}); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index b4c4c37..9f49e25 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -82,4 +82,4 @@ ->assertRedirect(route('profile.edit')); expect($user->fresh())->not->toBeNull(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php index c570e31..c190619 100644 --- a/tests/Feature/Settings/SecurityTest.php +++ b/tests/Feature/Settings/SecurityTest.php @@ -101,4 +101,4 @@ $response ->assertSessionHasErrors('current_password') ->assertRedirect(route('security.edit')); -}); \ No newline at end of file +}); diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php index 27f3f87..44a4f33 100644 --- a/tests/Unit/ExampleTest.php +++ b/tests/Unit/ExampleTest.php @@ -2,4 +2,4 @@ test('that true is true', function () { expect(true)->toBeTrue(); -}); \ No newline at end of file +}); diff --git a/vite.config.ts b/vite.config.ts index b855732..eb206f9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,10 @@ import laravel from 'laravel-vite-plugin'; import { bunny } from 'laravel-vite-plugin/fonts'; import { defineConfig } from 'vite'; +const ddevPrimaryUrl = process.env.DDEV_PRIMARY_URL; +const ddevHostname = ddevPrimaryUrl ? new URL(ddevPrimaryUrl).hostname : undefined; +const vitePort = Number(process.env.VITE_PORT ?? 5173); + export default defineConfig({ plugins: [ laravel({ @@ -28,4 +32,19 @@ export default defineConfig({ formVariants: true, }), ], + server: { + host: '0.0.0.0', + port: vitePort, + strictPort: true, + ...(ddevPrimaryUrl + ? { + origin: `${ddevPrimaryUrl}:${vitePort}`, + hmr: { + host: ddevHostname, + protocol: 'wss', + clientPort: vitePort, + }, + } + : {}), + }, }); From f36799c138e74525c1cc3ed7f833e092414c37a1 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 9 Jul 2026 00:17:55 +0200 Subject: [PATCH 2/3] Update backlog for DDS-004 slice --- docs/backlog/initial-build-backlog.md | 93 ++++++++++++++++++++------- 1 file changed, 69 insertions(+), 24 deletions(-) diff --git a/docs/backlog/initial-build-backlog.md b/docs/backlog/initial-build-backlog.md index cb702e2..c700d7c 100644 --- a/docs/backlog/initial-build-backlog.md +++ b/docs/backlog/initial-build-backlog.md @@ -72,6 +72,27 @@ Acceptance criteria: - local URL works over HTTPS or DDEV default routing; - setup steps are documented. +### DDS-002A: Configure DDEV Vite Dev Server + +Status: complete. Vite is exposed through DDEV on `https://dds-platform.ddev.site:5173`, and `vite.config.ts` is configured to use the DDEV host for dev server origin and HMR. + +Goal: make Vite dev mode work through the DDEV URL without distorted or missing assets. + +Tasks: + +- expose Vite port `5173` through DDEV; +- configure Vite to listen on `0.0.0.0`; +- configure DDEV-aware Vite origin and HMR settings; +- document when to use `ddev npm run dev` versus `ddev npm run build`; +- document the role of `public/hot`. + +Acceptance criteria: + +- app is opened through `https://dds-platform.ddev.site`; +- `ddev npm run dev` serves hot assets through `https://dds-platform.ddev.site:5173`; +- `public/hot` points to the DDEV Vite URL while dev mode is running; +- production-style local checks can use built assets after removing `public/hot`. + ### DDS-003: Baseline Quality Tooling Status: baseline complete. `ddev artisan test` and `ddev npm run build` pass. CI can still be added as a follow-up once the initial codebase is committed. @@ -167,23 +188,44 @@ Acceptance criteria: - initial commit contains docs, scaffold, DDEV config, and baseline tooling; - no local secrets are committed. +### DDS-004B: DDS-004 Pull Request Verification + +Goal: finish the first admin-access slice through GitHub review. + +Tasks: + +- push the `codex/dds-004-admin-gate` branch; +- open a ready pull request against `main`; +- verify GitHub Actions for backend tests, frontend typecheck/build, and formatting; +- record any CI-specific follow-ups separately. + +Acceptance criteria: + +- PR is open and not marked as draft; +- CI passes or failures are triaged into concrete follow-up tasks; +- branch contains the DDS-004 admin access foundation only; +- local verification commands are noted in the PR description. + ### DDS-005: Public And Admin Layout Shells -Goal: create the first layout boundaries. +Goal: harden the existing starter layouts into intentional DDS public and management shells. Tasks: -- create `PublicLayout`; -- create `AdminLayout`; -- add basic public header/footer structure; -- add basic admin sidebar/topbar structure; +- turn the existing authenticated app layout into the first management layout for `/dashboard`; +- decide whether the public site needs a separate `PublicLayout` immediately or can start from dedicated public page components; +- replace generic starter navigation labels, logo treatment, and dashboard placeholders with DDS-oriented structure; +- add basic public header/footer structure for the first public pages; +- add basic management sidebar/topbar structure using the existing starter layout patterns; - keep styling minimal but coherent; -- establish initial spacing, typography, navigation, and interaction conventions. +- establish initial spacing, typography, navigation, and interaction conventions; +- preserve the starter authentication/settings UX unless there is a clear DDS reason to change it. Acceptance criteria: -- public pages render in `PublicLayout`; -- admin pages render in `AdminLayout`; +- public pages have a clear layout direction and do not rely on generic starter visuals; +- management pages render through the existing authenticated app layout; +- `/dashboard` feels like a DDS management entrypoint rather than an untouched starter dashboard; - navigation placeholders exist; - mobile and desktop layout shells are usable; - focus, hover, and active states are visible; @@ -297,11 +339,11 @@ Acceptance criteria: ### DDS-011: Admin Event CRUD -Goal: manage events from the custom admin. +Goal: manage events from the `/dashboard` management area. Tasks: -- create admin event index; +- create `/dashboard/events` event index; - create event create/edit forms; - add server-side validation through Form Requests; - add event policies; @@ -309,7 +351,8 @@ Tasks: Acceptance criteria: -- admin can create, edit, and archive events; +- admins can create, edit, and archive events; +- editors can create and update events according to their seeded permissions; - validation errors are shown clearly; - event type and registration status are editable; - public visibility follows status; @@ -409,18 +452,20 @@ Acceptance criteria: 1. DDS-001 2. DDS-002 -3. DDS-003 -4. DDS-004 -5. DDS-005 -6. DDS-006 -7. DDS-007 -8. DDS-009 -9. DDS-010 -10. DDS-011 -11. DDS-012 -12. DDS-013 -13. DDS-014 -14. DDS-015 -15. DDS-016 +3. DDS-002A +4. DDS-003 +5. DDS-004 +6. DDS-004B +7. DDS-005 +8. DDS-006 +9. DDS-007 +10. DDS-009 +11. DDS-010 +12. DDS-011 +13. DDS-012 +14. DDS-013 +15. DDS-014 +16. DDS-015 +17. DDS-016 The public website rebuild can begin once DDS-007 and DDS-010 exist, while admin and import work continue behind it. From 86b0ad5a6b82fdac725d917d0f4f05a97a619628 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 9 Jul 2026 00:29:42 +0200 Subject: [PATCH 3/3] Fix CI PHP version matrix --- .github/workflows/tests.yml | 2 +- composer.json | 2 +- composer.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b4ac38f..e309da9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-version: ['8.3', '8.4', '8.5'] + php-version: ['8.4', '8.5'] steps: - name: Checkout code diff --git a/composer.json b/composer.json index 0188aa7..ae01cff 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ ], "license": "MIT", "require": { - "php": "^8.3", + "php": "^8.4.1", "inertiajs/inertia-laravel": "^3.0", "laravel/chisel": "^0.1.0", "laravel/fortify": "^1.37.2", diff --git a/composer.lock b/composer.lock index fe909ff..bd05629 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": "be3e8e0a08decd8dc124fa3204d31789", + "content-hash": "fb48f6695e1a339b2a2e57c7042846b0", "packages": [ { "name": "bacon/bacon-qr-code", @@ -11419,7 +11419,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.3" + "php": "^8.4.1" }, "platform-dev": {}, "plugin-api-version": "2.9.0"