Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [1.2.0] - 2026-09-20

### Fixed
- Role lookups now compare the polymorphic `context_type` against the model's morph class instead of the configured class name, so a host whose subclass keeps the name already stored, or that registers a morph map, no longer sees empty workspace and member lists
- Member lookups match the user side on the name their role assignment was stored under, which a host mapping its user model had written as an alias

## [1.1.1] - 2026-06-14

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"description": "Workspace management package for Laravel applications",
"type": "library",
"license": "MIT",
"version": "1.1.1",
"version": "1.2.0",
"authors": [
{
"name": "WhileSmart",
Expand Down
10 changes: 5 additions & 5 deletions src/Http/Controllers/WorkspaceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ public function members(Workspace $workspace): JsonResponse

$members = $workspace->members()
->with(['roleAssignments' => function ($query) use ($workspace) {
$query->where('context_type', config('workspaces.workspace_model', Workspace::class))
$query->where('context_type', $workspace->getMorphClass())
->where('context_id', $workspace->id)
->with('role');
}])
Expand Down Expand Up @@ -446,7 +446,7 @@ protected function userCanAccess(Workspace $workspace): bool
}

foreach (Role::cases() as $role) {
if ($role->canAccess() && $user->hasRole($role->value, config('workspaces.workspace_model', Workspace::class), $workspace->id)) {
if ($role->canAccess() && $user->hasRole($role->value, $workspace->getMorphClass(), $workspace->id)) {
return true;
}
}
Expand All @@ -463,7 +463,7 @@ protected function userCanManage(Workspace $workspace): bool
}

foreach (Role::cases() as $role) {
if ($role->canManage() && $user->hasRole($role->value, config('workspaces.workspace_model', Workspace::class), $workspace->id)) {
if ($role->canManage() && $user->hasRole($role->value, $workspace->getMorphClass(), $workspace->id)) {
return true;
}
}
Expand All @@ -479,7 +479,7 @@ protected function userIsOwner(Workspace $workspace): bool
return false;
}

return $user->hasRole(Role::OWNER->value, config('workspaces.workspace_model', Workspace::class), $workspace->id);
return $user->hasRole(Role::OWNER->value, $workspace->getMorphClass(), $workspace->id);
}

protected function getUserRole(Workspace $workspace): ?string
Expand All @@ -491,7 +491,7 @@ protected function getUserRole(Workspace $workspace): ?string
}

foreach (Role::byPrecedence() as $role) {
if ($user->hasRole($role->value, config('workspaces.workspace_model', Workspace::class), $workspace->id)) {
if ($user->hasRole($role->value, $workspace->getMorphClass(), $workspace->id)) {
return $role->value;
}
}
Expand Down
23 changes: 18 additions & 5 deletions src/Models/Workspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ public function members()
'id',
'id',
'assignable_id'
)->where('role_assignments.context_type', static::class)
->where('role_assignments.assignable_type', config('workspaces.user_model', 'App\\Models\\User'));
)->where('role_assignments.context_type', $this->getMorphClass())
->where('role_assignments.assignable_type', $this->userMorphClass());
}

public function invitations(): HasMany
Expand All @@ -93,7 +93,7 @@ public function pendingInvitations(): HasMany
public function roleAssignments()
{
return $this->hasMany('Whilesmart\\Roles\\Models\\RoleAssignment', 'context_id')
->where('context_type', static::class);
->where('context_type', $this->getMorphClass());
}

public function getOwnersAttribute()
Expand All @@ -103,7 +103,7 @@ public function getOwnersAttribute()
$query->whereHas('role', function ($q) {
$q->where('slug', 'workspace-owner');
})
->where('context_type', static::class)
->where('context_type', $this->getMorphClass())
->where('context_id', $this->id);
})
->get();
Expand All @@ -116,7 +116,7 @@ public function getAdminsAttribute()
$query->whereHas('role', function ($q) {
$q->whereIn('slug', ['workspace-owner', 'workspace-admin']);
})
->where('context_type', static::class)
->where('context_type', $this->getMorphClass())
->where('context_id', $this->id);
})
->get();
Expand Down Expand Up @@ -148,4 +148,17 @@ public function setSetting(string $key, mixed $value): self

return $this;
}

/**
* The name a user is stored under in a polymorphic column.
*
* A role assignment is written through the user's own morph relation, so
* this is what was stored, which is not always the configured class.
*/
private function userMorphClass(): string
{
$model = config('workspaces.user_model', 'App\\Models\\User');

return (new $model)->getMorphClass();
}
}
22 changes: 18 additions & 4 deletions src/Traits/HasWorkspaces.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ public static function workspaceModel(): string
return config('workspaces.workspace_model', Workspace::class);
}

/**
* The name a workspace is stored under in a polymorphic column.
*
* Not the class name. A model may answer with something else, which is how
* a morph map works and how a subclass keeps the name its table already
* holds, and a comparison against the class name misses both.
*/
public static function workspaceMorphClass(): string
{
$model = static::workspaceModel();

return (new $model)->getMorphClass();
}

public function ownedWorkspaces(): MorphMany
{
return $this->morphMany(static::workspaceModel(), 'owner');
Expand All @@ -31,8 +45,8 @@ public function workspaces()
'id',
'id',
'context_id'
)->where('role_assignments.assignable_type', static::class)
->where('role_assignments.context_type', static::workspaceModel());
)->where('role_assignments.assignable_type', $this->getMorphClass())
->where('role_assignments.context_type', static::workspaceMorphClass());
}

public function pendingWorkspaceInvitations()
Expand Down Expand Up @@ -90,15 +104,15 @@ public function joinWorkspace(Workspace $workspace, ?string $role = null): void
$role = $role ?? Role::default()->value;

if (method_exists($this, 'assignRole')) {
$this->assignRole($role, static::workspaceModel(), $workspace->id);
$this->assignRole($role, static::workspaceMorphClass(), $workspace->id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

joinWorkspace() receives the concrete $workspace instance, but writes context_type using the morph class of the configured model (static::workspaceMorphClass()). Every read path (Workspace::members(), Workspace::roleAssignments(), WorkspaceController) resolves context_type from $workspace->getMorphClass(). When the configured class and the passed instance disagree (e.g. config still points at the base Workspace while a subclass instance overrides getMorphClass()), the assignment is stored under one name and looked up under another, reproducing exactly the empty-list bug this PR fixes. Resolve the morph class from the instance being joined.

Suggested change
$this->assignRole($role, static::workspaceMorphClass(), $workspace->id);
$this->assignRole($role, $workspace->getMorphClass(), $workspace->id);

}
}

public function leaveWorkspace(Workspace $workspace): void
{
if (method_exists($this, 'removeRole')) {
foreach (Role::values() as $role) {
$this->removeRole($role, static::workspaceModel(), $workspace->id);
$this->removeRole($role, static::workspaceMorphClass(), $workspace->id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same inconsistency as joinWorkspace(): leaveWorkspace() deletes role assignments using the configured model's morph class instead of the morph class of the $workspace instance it was handed. Removal must target the same context_type value that lookups use ($workspace->getMorphClass()), otherwise a mismatched morph class leaves the assignment in place and the user keeps appearing as a member.

Suggested change
$this->removeRole($role, static::workspaceMorphClass(), $workspace->id);
$this->removeRole($role, $workspace->getMorphClass(), $workspace->id);

}
}
}
Expand Down
78 changes: 78 additions & 0 deletions tests/Feature/SubclassedWorkspaceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Tests\Feature;

use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Hash;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
Expand All @@ -15,8 +16,30 @@ class SubWorkspace extends Workspace
protected $table = 'workspaces';
}

class RenamedWorkspace extends Workspace
{
protected $table = 'workspaces';

public function getMorphClass(): string
{
return Workspace::class;
}
}

class SubclassedWorkspaceTest extends TestCase
{
/**
* A morph map is global and outlives the test that set one, so anything
* after it would resolve against a map it never asked for.
*/
protected function tearDown(): void
{
Relation::morphMap([], false);
Relation::requireMorphMap(false);

parent::tearDown();
}

#[Test]
public function members_resolve_when_the_workspace_model_is_subclassed(): void
{
Expand Down Expand Up @@ -47,4 +70,59 @@ public function members_resolve_when_the_workspace_model_is_subclassed(): void
// base Workspace class while the assignment stores the subclass.
$this->assertCount(1, $workspace->members()->get());
}

#[Test]
public function members_resolve_when_a_subclass_keeps_the_name_already_stored(): void
{
config(['workspaces.workspace_model' => RenamedWorkspace::class]);

$user = User::create([
'name' => 'Renamed Owner',
'email' => 'renamed-'.uniqid().'@example.com',
'password' => Hash::make('password'),
]);

$workspace = RenamedWorkspace::create([
'name' => 'Renamed Workspace',
'type' => 'team',
'owner_type' => User::class,
'owner_id' => $user->id,
]);

RoleAssignment::create([
'assignable_type' => User::class,
'assignable_id' => $user->id,
'role_id' => Role::where('slug', 'owner')->first()->id,
'context_type' => Workspace::class,
'context_id' => $workspace->id,
]);

$this->assertCount(1, $workspace->members()->get());
$this->assertContains($workspace->id, $user->workspaces()->pluck('workspaces.id')->all());
}

#[Test]
public function members_resolve_when_the_user_model_is_behind_a_morph_map(): void
{
Relation::morphMap(['user' => User::class]);

$user = User::create([
'name' => 'Mapped Owner',
'email' => 'mapped-'.uniqid().'@example.com',
'password' => Hash::make('password'),
]);

$workspace = Workspace::create([
'name' => 'Mapped Workspace',
'type' => 'team',
'owner_type' => 'user',
'owner_id' => $user->id,
]);

// Written the way a role assignment is written, through the user's own
// morph relation, which stores the alias rather than the class.
$user->assignRole('owner', $workspace->getMorphClass(), $workspace->id);

$this->assertCount(1, $workspace->members()->get());
}
}
Loading