Initial commit: OAuth IAM Laravel application

Laravel-based OAuth/IAM service with Passport, Vue 3 frontend, and
Docker Compose setup for local and production deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 01:02:41 +02:00
commit c2b5291a74
174 changed files with 29318 additions and 0 deletions

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_auth_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->index();
$table->foreignUuid('client_id');
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_auth_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_access_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id');
$table->string('name')->nullable();
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->timestamps();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_access_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->char('access_token_id', 80)->index();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_refresh_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_clients', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->nullableMorphs('owner');
$table->string('name');
$table->string('secret')->nullable();
$table->string('provider')->nullable();
$table->text('redirect_uris');
$table->text('grant_types');
$table->boolean('revoked');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_clients');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_device_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id')->index();
$table->char('user_code', 8)->unique();
$table->text('scopes');
$table->boolean('revoked');
$table->dateTime('user_approved_at')->nullable();
$table->dateTime('last_polled_at')->nullable();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_device_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,137 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
/**
* 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['permissions'], static function (Blueprint $table) {
$table->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']);
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_active')->default(true)->after('password');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_active');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('app_user_roles', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('client_id')->constrained('oauth_clients')->cascadeOnDelete();
$table->string('role');
$table->timestamps();
$table->unique(['user_id', 'client_id', 'role']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('app_user_roles');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('login_events', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->foreignUuid('client_id')->nullable()->constrained('oauth_clients')->nullOnDelete();
$table->string('type'); // 'session' (panel admin) | 'oauth' (token emitido para una app)
$table->string('ip_address', 45)->nullable();
$table->string('user_agent')->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['created_at']);
$table->index(['client_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('login_events');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->softDeletes()->after('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// null = todavía no lo aprobó un admin (pendiente). Se completa
// solo cuando: (a) lo crea un admin directamente, o (b) un admin
// aprueba un registro propio del usuario. Es un concepto aparte
// de "is_active" (que sirve para suspender a alguien ya aprobado).
$table->timestamp('approved_at')->nullable()->after('is_active');
});
// Los usuarios que ya existían antes de este cambio quedan aprobados
// (si no, todos se bloquean de golpe la primera vez que se corre esto).
DB::table('users')->whereNull('approved_at')->update(['approved_at' => now()]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('approved_at');
});
}
};

View File

@@ -0,0 +1,170 @@
<?php
namespace Database\Seeders;
use App\Models\LoginEvent;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Passport\Client;
use Laravel\Passport\ClientRepository;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
public function run(ClientRepository $clients): void
{
$this->call(RolesAndPermissionsSeeder::class);
$adminUser = User::firstOrCreate(
['email' => 'admin@example.com'],
['name' => 'Admin', 'password' => Hash::make('password'), 'approved_at' => now()]
);
$adminUser->syncRoles(['admin']);
// Usuarios extra solo para que las analíticas (sección "usuarios más activos")
// tengan algo interesante que mostrar de entrada.
$extraUsers = collect([
['name' => 'Carla Gómez', 'email' => 'carla@example.com'],
['name' => 'Martín Ruiz', 'email' => 'martin@example.com'],
['name' => 'Bea Fernández', 'email' => 'bea@example.com'],
])->map(function (array $data) {
$u = User::firstOrCreate(['email' => $data['email']], ['name' => $data['name'], 'password' => Hash::make('password'), 'approved_at' => now()]);
$u->syncRoles(['user']);
return $u;
});
// Usuario de ejemplo que se autorregistró y todavía nadie aprobó, para
// que la pestaña "Pendientes" del panel de usuarios no arranque vacía.
User::firstOrCreate(
['email' => 'nuevo@example.com'],
['name' => 'Nuevo Registro', 'password' => Hash::make('password'), 'approved_at' => null]
)->assignRole('user');
// Cliente requerido para poder emitir Personal Access Tokens (grant "personal_access").
if (! Client::where('personal_access_client', true)->exists()) {
$clients->createPersonalAccessGrantClient('Personal Access Client');
}
// Cliente de demo para el flujo Authorization Code + PKCE (público, sin secret).
$pkceClient = $adminUser->oauthApps()->firstOrCreate(
['name' => 'Demo SPA (Authorization Code + PKCE)'],
[
'secret' => null,
'redirect_uris' => ['http://localhost:8000/callback'],
'grant_types' => ['authorization_code', 'refresh_token'],
'revoked' => false,
]
);
// Cliente de demo para Client Credentials (machine-to-machine).
$ccClient = $adminUser->oauthApps()->firstOrCreate(
['name' => 'Demo Service (Client Credentials)'],
[
'secret' => Str::random(40),
'redirect_uris' => [],
'grant_types' => ['client_credentials'],
'revoked' => false,
]
);
// Cliente de demo para Password Grant.
$pwClient = $adminUser->oauthApps()->firstOrCreate(
['name' => 'Demo First-Party App (Password Grant)'],
[
'secret' => Str::random(40),
'redirect_uris' => [],
'grant_types' => ['password', 'refresh_token'],
'revoked' => false,
]
);
// Cliente confidencial para probar el botón "Authorize" de Swagger UI
// (usa /api/oauth2-callback, que ya registra L5-Swagger).
$swaggerClient = $adminUser->oauthApps()->firstOrCreate(
['name' => 'Swagger UI'],
[
'secret' => Str::random(40),
'redirect_uris' => [rtrim(config('app.url'), '/').'/api/oauth2-callback'],
'grant_types' => ['authorization_code', 'client_credentials', 'refresh_token'],
'revoked' => false,
]
);
// Rol por-app de ejemplo: carla@example.com es "admin" dentro de la app
// "Demo SPA", aunque globalmente sea un usuario normal. Muestra que el
// rol puede variar según qué app pregunte por el usuario.
DB::table('app_user_roles')->insertOrIgnore([
'user_id' => $extraUsers->first()->id,
'client_id' => $pkceClient->id,
'role' => 'admin',
'created_at' => now(),
'updated_at' => now(),
]);
if (! LoginEvent::query()->exists()) {
$this->seedLoginEvents(
$extraUsers->push($adminUser),
collect([$pkceClient, $ccClient, $pwClient, $swaggerClient]),
);
}
$this->command?->info('Admin: admin@example.com / password');
$this->command?->info('Client Credentials -> client_id: '.$ccClient->id.' secret: '.($ccClient->plainSecret ?? '(ya existía, ver DB)'));
$this->command?->info('Password Grant -> client_id: '.$pwClient->id.' secret: '.($pwClient->plainSecret ?? '(ya existía, ver DB)'));
$this->command?->info('Swagger UI -> client_id: '.$swaggerClient->id.' secret: '.($swaggerClient->plainSecret ?? '(ya existía, ver DB)'));
}
/**
* Historial sintético de logins (últimos 30 días) para que la pantalla de
* analíticas tenga algo real que graficar apenas se levanta la app.
*
* @param \Illuminate\Support\Collection<int, User> $users
* @param \Illuminate\Support\Collection<int, Client> $clients
*/
private function seedLoginEvents($users, $clients): void
{
$rows = [];
$now = now();
for ($daysAgo = 29; $daysAgo >= 0; $daysAgo--) {
$dayStart = $now->copy()->subDays($daysAgo)->startOfDay();
$maxMinute = $daysAgo === 0 ? $now->diffInMinutes($dayStart) : 1439;
$isWeekend = $dayStart->isWeekend();
$oauthLogins = random_int($isWeekend ? 1 : 4, $isWeekend ? 6 : 14);
$sessionLogins = random_int($isWeekend ? 0 : 1, $isWeekend ? 2 : 5);
for ($i = 0; $i < $oauthLogins; $i++) {
$rows[] = [
'user_id' => $users->random()->id,
'client_id' => $clients->random()->id,
'type' => 'oauth',
'ip_address' => '10.0.'.random_int(0, 9).'.'.random_int(2, 254),
'user_agent' => 'Mozilla/5.0 (demo seed)',
'created_at' => $dayStart->copy()->addMinutes(random_int(0, max(0, $maxMinute))),
];
}
for ($i = 0; $i < $sessionLogins; $i++) {
$rows[] = [
'user_id' => $users->random()->id,
'client_id' => null,
'type' => 'session',
'ip_address' => '10.0.'.random_int(0, 9).'.'.random_int(2, 254),
'user_agent' => 'Mozilla/5.0 (demo seed)',
'created_at' => $dayStart->copy()->addMinutes(random_int(0, max(0, $maxMinute))),
];
}
}
foreach (array_chunk($rows, 200) as $chunk) {
LoginEvent::insert($chunk);
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
/**
* Roles y permisos base (admin/user + permisos "users.*"). Se ejecuta desde
* DatabaseSeeder y también en runtime cuando se registra el primer usuario
* de una instalación (ver AuthController::register) — así el flujo de
* "primer admin" funciona aunque nunca se haya corrido "db:seed" en producción.
*/
class RolesAndPermissionsSeeder extends Seeder
{
public function run(): void
{
foreach (['users.view', 'users.create', 'users.update', 'users.delete'] as $permission) {
Permission::findOrCreate($permission);
}
$admin = Role::findOrCreate('admin');
$admin->syncPermissions(Permission::all());
Role::findOrCreate('user');
}
}