feat: invitation resend, WorkspaceMemberRole enum, configurable expiry

- Add resend() method to WorkspaceInvitation that regenerates the token,
  resets expiry to configured days, and re-sends the notification (#23)
- Create WorkspaceMemberRole backed enum (PHP 8.1+) with label() and
  colour() helpers; deprecate ROLE_* string constants on WorkspaceMember
  and update internal references to use the enum (#24)
- Replace hardcoded 7-day invitation expiry with
  config('tenant.invitation_expiry_days', 7) in both Workspace::invite()
  and WorkspaceInvitation::resend() (#25)

Fixes #23
Fixes #24
Fixes #25

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-24 13:40:20 +00:00
parent c51e4310b1
commit c04549d362
No known key found for this signature in database
GPG key ID: AF404715446AEB41
4 changed files with 85 additions and 17 deletions

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Core\Tenant\Enums;
/**
* Workspace member roles.
*
* Backed enum replacing the legacy string constants on WorkspaceMember.
*/
enum WorkspaceMemberRole: string
{
case OWNER = 'owner';
case ADMIN = 'admin';
case MEMBER = 'member';
/**
* Get a human-readable label for the role.
*/
public function label(): string
{
return match ($this) {
self::OWNER => 'Owner',
self::ADMIN => 'Admin',
self::MEMBER => 'Member',
};
}
/**
* Get the colour for this role's badge.
*/
public function colour(): string
{
return match ($this) {
self::OWNER => 'violet',
self::ADMIN => 'blue',
self::MEMBER => 'zinc',
};
}
}

View file

@ -689,10 +689,12 @@ class Workspace extends Model
* @param string $email The email address to invite
* @param string $role The role to assign (owner, admin, member)
* @param User|null $invitedBy The user sending the invitation
* @param int $expiresInDays Number of days until invitation expires
* @param int|null $expiresInDays Number of days until invitation expires (defaults to config)
*/
public function invite(string $email, string $role = 'member', ?User $invitedBy = null, int $expiresInDays = 7): WorkspaceInvitation
public function invite(string $email, string $role = 'member', ?User $invitedBy = null, ?int $expiresInDays = null): WorkspaceInvitation
{
$expiresInDays ??= (int) config('tenant.invitation_expiry_days', 7);
// Check if there's already a pending invitation for this email
$existing = $this->invitations()
->where('email', $email)

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Core\Tenant\Models;
use Core\Tenant\Database\Factories\WorkspaceInvitationFactory;
use Core\Tenant\Notifications\WorkspaceInvitationNotification;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -211,6 +212,29 @@ class WorkspaceInvitation extends Model
return true;
}
/**
* Resend the invitation by regenerating the token and resetting expiry.
*
* Generates a new plaintext token, hashes it, resets the expiry
* to the configured number of days, and re-sends the notification.
*
* @return string The new plaintext token (for testing/logging; not stored in plaintext)
*/
public function resend(): string
{
$plaintextToken = static::generateToken();
$this->token = Hash::make($plaintextToken);
$this->expires_at = now()->addDays(
(int) config('tenant.invitation_expiry_days', 7)
);
$this->save();
$this->notify(new WorkspaceInvitationNotification($this, $plaintextToken));
return $plaintextToken;
}
/**
* Get the notification routing for mail.
*/

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Core\Tenant\Models;
use Carbon\Carbon;
use Core\Tenant\Enums\WorkspaceMemberRole;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -49,12 +50,16 @@ class WorkspaceMember extends Model
// ─────────────────────────────────────────────────────────────────────────
// Role Constants (legacy, for backwards compatibility)
// Prefer WorkspaceMemberRole enum for new code.
// ─────────────────────────────────────────────────────────────────────────
/** @deprecated Use WorkspaceMemberRole::OWNER instead */
public const ROLE_OWNER = 'owner';
/** @deprecated Use WorkspaceMemberRole::ADMIN instead */
public const ROLE_ADMIN = 'admin';
/** @deprecated Use WorkspaceMemberRole::MEMBER instead */
public const ROLE_MEMBER = 'member';
// ─────────────────────────────────────────────────────────────────────────
@ -140,7 +145,7 @@ class WorkspaceMember extends Model
*/
public function scopeOwners($query)
{
return $query->where('role', self::ROLE_OWNER);
return $query->where('role', WorkspaceMemberRole::OWNER->value);
}
// ─────────────────────────────────────────────────────────────────────────
@ -186,8 +191,8 @@ class WorkspaceMember extends Model
// Legacy fallback: if no team, derive from role
if (! $this->team_id) {
$rolePermissions = match ($this->role) {
self::ROLE_OWNER => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_OWNER),
self::ROLE_ADMIN => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_ADMIN),
WorkspaceMemberRole::OWNER->value => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_OWNER),
WorkspaceMemberRole::ADMIN->value => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_ADMIN),
default => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_MEMBER),
};
$permissions = array_unique(array_merge($permissions, $rolePermissions));
@ -308,7 +313,7 @@ class WorkspaceMember extends Model
*/
public function isOwner(): bool
{
return $this->role === self::ROLE_OWNER
return $this->role === WorkspaceMemberRole::OWNER->value
|| $this->team?->slug === WorkspaceTeam::TEAM_OWNER;
}
@ -318,7 +323,7 @@ class WorkspaceMember extends Model
public function isAdmin(): bool
{
return $this->isOwner()
|| $this->role === self::ROLE_ADMIN
|| $this->role === WorkspaceMemberRole::ADMIN->value
|| $this->team?->slug === WorkspaceTeam::TEAM_ADMIN;
}
@ -353,11 +358,9 @@ class WorkspaceMember extends Model
return $this->team->name;
}
return match ($this->role) {
self::ROLE_OWNER => 'Owner',
self::ROLE_ADMIN => 'Admin',
default => 'Member',
};
$roleEnum = WorkspaceMemberRole::tryFrom($this->role);
return $roleEnum?->label() ?? 'Member';
}
/**
@ -369,10 +372,8 @@ class WorkspaceMember extends Model
return $this->team->colour;
}
return match ($this->role) {
self::ROLE_OWNER => 'violet',
self::ROLE_ADMIN => 'blue',
default => 'zinc',
};
$roleEnum = WorkspaceMemberRole::tryFrom($this->role);
return $roleEnum?->colour() ?? 'zinc';
}
}