feat: assignRole context + expiry, roleAssignment(), fillable morph keys
- assignRole() takes optional $context and $expiresAt, stored on the pivot row. Called without them it behaves exactly as before. - roleAssignment($role) returns the member's active pivot row (context, expires_at, created_at = when it was given), or null. - RoleMember $fillable gains member_type and member_id. RoleMember::create() used to drop them silently, so a direct row for a model without HasRoles could not be written. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5f25ad2bb0
commit
7078bc86ea
|
|
@ -13,6 +13,9 @@ class RoleMember extends MorphPivot
|
|||
protected $fillable = [
|
||||
'role_id',
|
||||
'member',
|
||||
// The morph columns: without them RoleMember::create() silently dropped the member.
|
||||
'member_type',
|
||||
'member_id',
|
||||
'context',
|
||||
'expires_at',
|
||||
'created_at',
|
||||
|
|
|
|||
|
|
@ -69,10 +69,12 @@ trait HasRoles
|
|||
* Assigns the role to the memberable
|
||||
*
|
||||
* @param int|string|Role $role
|
||||
* @param array $context Stored on the pivot row (e.g. who assigned it and why). Read back with roleAssignment().
|
||||
* @param \DateTimeInterface|null $expiresAt The assignment stops counting after this moment; null never expires.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function assignRole(string|Role $role, int $max_times = 1)
|
||||
public function assignRole(string|Role $role, int $max_times = 1, array $context = [], ?\DateTimeInterface $expiresAt = null)
|
||||
{
|
||||
if (self::isRoleIdString($role)) {
|
||||
$role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role);
|
||||
|
|
@ -91,7 +93,11 @@ trait HasRoles
|
|||
}
|
||||
|
||||
if ($role instanceof Role) {
|
||||
$this->roles()->attach($role);
|
||||
$pivot = array_filter([
|
||||
'context' => $context ?: null,
|
||||
'expires_at' => $expiresAt,
|
||||
], fn ($value) => $value !== null);
|
||||
$this->roles()->attach($role, $pivot);
|
||||
} else {
|
||||
throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.');
|
||||
}
|
||||
|
|
@ -99,6 +105,37 @@ trait HasRoles
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The member's active assignment of a role: the pivot row with its context,
|
||||
* expires_at and created_at (when it was given). Null when the member doesn't
|
||||
* hold the role or the assignment has expired. With several active rows
|
||||
* (assignRole with $max_times > 1) it returns the newest.
|
||||
*
|
||||
* @param int|string|Role $role A role id, a slug, or a Role
|
||||
*/
|
||||
public function roleAssignment(string|Role $role): ?\Blax\Roles\Models\RoleMember
|
||||
{
|
||||
$roleModel = config('roles.models.role', \Blax\Roles\Models\Role::class);
|
||||
if (self::isRoleIdString($role)) {
|
||||
$role = $roleModel::find($role);
|
||||
} elseif (is_string($role)) {
|
||||
$role = $roleModel::where('slug', $role)->first();
|
||||
}
|
||||
|
||||
if (! $role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roleMemberModel = config('roles.models.role_member', \Blax\Roles\Models\RoleMember::class);
|
||||
|
||||
return $roleMemberModel::query()
|
||||
->where('role_id', $role->id)
|
||||
->where('member_type', $this->getMorphClass())
|
||||
->where('member_id', (string) $this->getKey())
|
||||
->latest()
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the role from the memberable
|
||||
*
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace Blax\Roles\Tests\Unit;
|
|||
|
||||
use Blax\Roles\Models\Permission;
|
||||
use Blax\Roles\Models\Role;
|
||||
use Blax\Roles\Models\RoleMember;
|
||||
use Blax\Roles\RolesServiceProvider;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -642,4 +643,85 @@ class HasRolesTest extends TestCase
|
|||
|
||||
$this->assertNotNull($membership->expires_at);
|
||||
}
|
||||
|
||||
// ─── assignRole context + expiry, roleAssignment ─────────────
|
||||
|
||||
public function test_assign_role_stores_context_and_expiry(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$role = Role::create(['name' => 'Muted', 'slug' => 'muted']);
|
||||
$until = now()->addDays(3)->startOfSecond();
|
||||
|
||||
$user->assignRole($role, context: ['by' => 'admin:1', 'reason' => 'spam'], expiresAt: $until);
|
||||
|
||||
$assignment = $user->roleAssignment('muted');
|
||||
$this->assertSame(['by' => 'admin:1', 'reason' => 'spam'], $assignment->context);
|
||||
$this->assertTrue($assignment->expires_at->equalTo($until));
|
||||
$this->assertTrue($user->hasRole('muted'));
|
||||
}
|
||||
|
||||
public function test_assign_role_without_context_keeps_the_pivot_fields_empty(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$role = Role::create(['name' => 'Plain', 'slug' => 'plain']);
|
||||
|
||||
$user->assignRole($role);
|
||||
|
||||
$row = DB::table(config('roles.table_names.role_member'))->where('role_id', $role->id)->first();
|
||||
$this->assertNull($row->context);
|
||||
$this->assertNull($row->expires_at);
|
||||
}
|
||||
|
||||
public function test_assign_role_with_context_still_respects_max_times(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$role = Role::create(['name' => 'Once', 'slug' => 'once']);
|
||||
|
||||
$user->assignRole($role, context: ['n' => 1]);
|
||||
$user->assignRole($role, context: ['n' => 2]);
|
||||
|
||||
$this->assertSame(1, DB::table(config('roles.table_names.role_member'))->where('role_id', $role->id)->count());
|
||||
$this->assertSame(['n' => 1], $user->roleAssignment($role)->context);
|
||||
}
|
||||
|
||||
public function test_role_assignment_tells_when_the_role_was_given(): void
|
||||
{
|
||||
$this->travelTo(now()->subHour());
|
||||
$user = User::factory()->create();
|
||||
$role = Role::create(['name' => 'Since', 'slug' => 'since']);
|
||||
$user->assignRole($role);
|
||||
$given = now()->startOfSecond();
|
||||
$this->travelBack();
|
||||
|
||||
$this->assertTrue($user->roleAssignment($role->id)->created_at->equalTo($given));
|
||||
}
|
||||
|
||||
public function test_role_assignment_is_null_when_not_held_or_expired(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$role = Role::create(['name' => 'Gone', 'slug' => 'gone']);
|
||||
|
||||
$this->assertNull($user->roleAssignment('gone'));
|
||||
$this->assertNull($user->roleAssignment('no-such-role'));
|
||||
|
||||
$user->assignRole($role, expiresAt: now()->subMinute());
|
||||
$this->assertNull($user->roleAssignment('gone'));
|
||||
$this->assertFalse($user->hasRole('gone'));
|
||||
}
|
||||
|
||||
public function test_role_member_can_be_created_for_any_model(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$role = Role::create(['name' => 'Direct', 'slug' => 'direct']);
|
||||
|
||||
RoleMember::create([
|
||||
'role_id' => $role->id,
|
||||
'member_type' => $user->getMorphClass(),
|
||||
'member_id' => (string) $user->id,
|
||||
'context' => ['source' => 'direct'],
|
||||
]);
|
||||
|
||||
$this->assertTrue($user->hasRole('direct'));
|
||||
$this->assertSame(['source' => 'direct'], $user->roleAssignment('direct')->context);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue