feat(spaces): invitations, members and owner-scoped feature gates

Add the collaboration layer for Business spaces: owners invite people by email
(seat-capped per subscription), invitees accept via a tokened link to join as
members, and owners can remove members while members can leave. Paid-feature
gates (subscription middleware, open-banking) now resolve against the active
space's owner, so a member of a Business space gets its features without a plan
of their own. Members management and invitations live on the spaces settings
page.
This commit is contained in:
Víctor Falcón 2026-07-06 17:41:33 +02:00
parent 5d2e292088
commit 7df967c487
15 changed files with 787 additions and 10 deletions

View File

@ -19,7 +19,7 @@ trait HandlesSubscriptionGate
return false;
}
return ! $user->canUseFeature(PlanFeature::ConnectedAccounts);
return ! $user->canUseFeatureInSpace(PlanFeature::ConnectedAccounts, $user->activeSpace());
}
private function subscribeJsonResponse(): JsonResponse

View File

@ -7,6 +7,7 @@ use App\Features\Spaces;
use App\Http\Requests\StoreSpaceRequest;
use App\Http\Requests\UpdateSpaceRequest;
use App\Models\Space;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@ -17,12 +18,39 @@ use Laravel\Pennant\Feature;
class SpaceController extends Controller
{
/**
* The spaces management settings page. The list itself is shared globally
* (see HandleInertiaRequests), so this only needs to render the page.
* The spaces management settings page. The space list is shared globally
* (see HandleInertiaRequests); here we add the members and pending
* invitations of the spaces the current user owns, plus the seat usage.
*/
public function index(): Response
public function index(Request $request): Response
{
return Inertia::render('settings/spaces');
$user = $request->user();
$managed = $user->ownedSpaces()
->where('personal', false)
->with(['members:id,name,email', 'invitations' => fn ($query) => $query->whereNull('accepted_at')])
->get()
->map(fn (Space $space): array => [
'id' => $space->id,
'name' => $space->name,
'members' => $space->members->map(fn (User $member): array => [
'id' => $member->id,
'name' => $member->name,
'email' => $member->email,
])->all(),
'invitations' => $space->invitations
->reject(fn ($invitation): bool => $invitation->isExpired())
->map(fn ($invitation): array => [
'id' => $invitation->id,
'email' => $invitation->email,
])->values()->all(),
]);
return Inertia::render('settings/spaces', [
'managedSpaces' => $managed,
'seatsInUse' => $user->seatsInUse(),
'maxSeats' => (int) config('spaces.max_seats'),
]);
}
/**
@ -101,4 +129,37 @@ class SpaceController extends Controller
return back();
}
/**
* Remove a member from a space (owner only). Their pointer self-heals back to
* their personal space on their next request.
*/
public function removeMember(Request $request, Space $space, User $member): RedirectResponse
{
abort_unless($space->owner_id === $request->user()->id, 403);
$space->members()->detach($member->id);
return back()->with('success', __('Member removed.'));
}
/**
* Leave a space you were invited to (members only; an owner cannot leave
* their own space).
*/
public function leave(Request $request, Space $space): RedirectResponse
{
$user = $request->user();
abort_if($space->owner_id === $user->id, 403);
abort_unless($space->members()->whereKey($user->id)->exists(), 403);
$space->members()->detach($user->id);
if ($user->current_space_id === $space->id) {
$user->forceFill(['current_space_id' => $user->personalSpace->id])->save();
}
return back()->with('success', __('You\'ve left :space.', ['space' => $space->name]));
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreSpaceInvitationRequest;
use App\Mail\SpaceInvitationMail;
use App\Models\Space;
use App\Models\SpaceInvitation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
class SpaceInvitationController extends Controller
{
/**
* Invite someone to a space by email. Enforces the subscription seat cap and
* refuses duplicates (already a member or already invited).
*/
public function store(StoreSpaceInvitationRequest $request, Space $space): RedirectResponse
{
$user = $request->user();
$email = strtolower($request->validated('email'));
if ($space->members()->where('email', $email)->exists() || strcasecmp($email, $user->email) === 0) {
return back()->with('error', __('That person is already a member of this space.'));
}
if ($user->seatsInUse() >= config('spaces.max_seats')) {
return back()->with('error', __('You\'ve reached the :count-user limit for your plan.', [
'count' => (int) config('spaces.max_seats'),
]));
}
$invitation = $space->invitations()->updateOrCreate(
['email' => $email, 'accepted_at' => null],
[
'invited_by_id' => $user->id,
'role' => 'member',
'token' => Str::random(48),
'expires_at' => now()->addDays((int) config('spaces.invitation_expiry_days')),
],
);
Mail::to($email)->send(new SpaceInvitationMail($invitation));
return back()->with('success', __('Invitation sent to :email.', ['email' => $email]));
}
/**
* Accept an invitation. The route sits behind auth, so a logged-out invitee
* is sent to log in (or register with the same email) and returns here.
*/
public function accept(Request $request, string $token): RedirectResponse
{
$invitation = SpaceInvitation::query()->where('token', $token)->first();
if ($invitation === null || $invitation->isAccepted() || $invitation->isExpired()) {
return redirect()->route('dashboard')->with('error', __('This invitation is no longer valid.'));
}
$user = $request->user();
if (strcasecmp($user->email, $invitation->email) !== 0) {
return redirect()->route('dashboard')->with('error', __('This invitation was sent to a different email address.'));
}
$invitation->space->members()->syncWithoutDetaching([
$user->id => ['role' => $invitation->role],
]);
$invitation->forceFill(['accepted_at' => now()])->save();
$user->forceFill(['current_space_id' => $invitation->space_id])->save();
return redirect()->route('dashboard')->with('success', __('You\'ve joined :space.', [
'space' => $invitation->space->name,
]));
}
/**
* Revoke a pending invitation (owner only).
*/
public function destroy(Request $request, Space $space, SpaceInvitation $invitation): RedirectResponse
{
abort_unless($space->owner_id === $request->user()->id && $invitation->space_id === $space->id, 403);
$invitation->delete();
return back()->with('success', __('Invitation revoked.'));
}
}

View File

@ -18,12 +18,15 @@ class EnsureUserIsSubscribed
}
$user = $request->user();
$space = $user?->activeSpace();
if ($user?->hasProPlan()) {
// The active space's owner plan governs access: a member of a Business
// space gets in even without a plan of their own.
if ($space?->owner?->hasProPlan()) {
return $next($request);
}
if ($user && ! $user->bankingConnections()->exists() && ! $user->hasActiveAiConsent()) {
if ($space && ! $space->bankingConnections()->exists() && ! $user->hasActiveAiConsent()) {
if (! $user->hasSeenPaywall()) {
return redirect()->route('subscribe');
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Features\Spaces;
use App\Models\Space;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class StoreSpaceInvitationRequest extends FormRequest
{
public function authorize(): bool
{
$space = $this->route('space');
return $space instanceof Space
&& ! $space->personal
&& $space->owner_id === $this->user()?->id
&& Feature::for($this->user())->active(Spaces::class);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
];
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Mail;
use App\Models\SpaceInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class SpaceInvitationMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/** @var int */
public $tries = 5;
/** @var array<int, int> */
public $backoff = [2, 5, 10, 30];
public function __construct(public SpaceInvitation $invitation)
{
$this->onQueue('emails');
}
public function envelope(): Envelope
{
return new Envelope(
subject: __(':inviter invited you to :space on Whisper Money', [
'inviter' => $this->invitation->invitedBy?->name ?? __('Someone'),
'space' => $this->invitation->space->name,
]),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.space-invitation',
with: [
'invitation' => $this->invitation,
'spaceName' => $this->invitation->space->name,
'inviterName' => $this->invitation->invitedBy?->name ?? __('A Whisper Money user'),
'acceptUrl' => route('spaces.invitations.accept', $this->invitation->token),
],
);
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -247,6 +247,47 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
return $this->resolvedActiveSpace = $space;
}
/**
* Whether a paid feature is available while acting in the given space. The
* space owner's plan governs the space, so a member of a Business space gets
* its paid features without a plan of their own.
*/
public function canUseFeatureInSpace(PlanFeature $feature, ?Space $space = null): bool
{
if (! $feature->requiresProPlan()) {
return true;
}
$space ??= $this->activeSpace();
$owner = $space->owner_id === $this->id ? $this : $space->owner;
return $owner->hasProPlan();
}
/**
* Unique users counted against the owner's subscription: the owner, every
* member across their spaces, and still-pending invitations. This is the
* seat count the Business plan caps.
*/
public function seatsInUse(): int
{
$ownedSpaceIds = $this->ownedSpaces()->pluck('id');
$members = static::query()
->whereHas('memberSpaces', fn (Builder $query) => $query->whereIn('spaces.id', $ownedSpaceIds))
->get(['id', 'email']);
$pendingEmails = SpaceInvitation::query()
->whereIn('space_id', $ownedSpaceIds)
->whereNull('accepted_at')
->where(fn (Builder $query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->pluck('email')
->unique()
->reject(fn (string $email): bool => $members->pluck('email')->contains($email));
return 1 + $members->count() + $pendingEmails->count();
}
/** @return HasMany<AutomationRule, $this> */
public function automationRules(): HasMany
{

27
config/spaces.php Normal file
View File

@ -0,0 +1,27 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Seats per subscription
|--------------------------------------------------------------------------
|
| The Business plan includes this many unique users (the owner plus invited
| members and still-pending invitations, counted across all of the owner's
| spaces). At launch this is a hard cap; per-seat billing beyond it is a
| later addition.
|
*/
'max_seats' => (int) env('SPACES_MAX_SEATS', 5),
/*
|--------------------------------------------------------------------------
| Invitation expiry
|--------------------------------------------------------------------------
*/
'invitation_expiry_days' => (int) env('SPACES_INVITATION_EXPIRY_DAYS', 14),
];

View File

@ -9,13 +9,12 @@ return new class extends Migration
public function up(): void
{
Schema::create('space_user', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('space_id')->constrained('spaces')->cascadeOnDelete();
$table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
$table->string('role')->default('member');
$table->timestamps();
$table->unique(['space_id', 'user_id']);
$table->primary(['space_id', 'user_id']);
});
}

View File

@ -2229,5 +2229,36 @@
"Switch": "Cambiar",
"Rename": "Renombrar",
"Rename space": "Renombrar espacio",
"Remove or move this space's accounts before deleting it.": "Elimina o mueve las cuentas de este espacio antes de borrarlo."
"Remove or move this space's accounts before deleting it.": "Elimina o mueve las cuentas de este espacio antes de borrarlo.",
"Members": "Miembros",
"Invite people to your spaces. They can see and work with everything in the spaces they join.": "Invita a personas a tus espacios. Podrán ver y trabajar con todo lo que haya en los espacios a los que se unan.",
":used of :max seats used": ":used de :max asientos usados",
"Pending": "Pendiente",
"Revoke": "Revocar",
"No members yet.": "Aún no hay miembros.",
"name@example.com": "nombre@ejemplo.com",
"Invite": "Invitar",
"You've reached your plan's seat limit.": "Has alcanzado el límite de asientos de tu plan.",
"Leave": "Abandonar",
"That person is already a member of this space.": "Esa persona ya es miembro de este espacio.",
"You've reached the :count-user limit for your plan.": "Has alcanzado el límite de :count usuarios de tu plan.",
"Invitation sent to :email.": "Invitación enviada a :email.",
"This invitation is no longer valid.": "Esta invitación ya no es válida.",
"This invitation was sent to a different email address.": "Esta invitación se envió a una dirección de correo distinta.",
"You've joined :space.": "Te has unido a :space.",
"Invitation revoked.": "Invitación revocada.",
"Member removed.": "Miembro eliminado.",
"You've left :space.": "Has abandonado :space.",
":inviter invited you to :space on Whisper Money": ":inviter te ha invitado a :space en Whisper Money",
"Someone": "Alguien",
"A Whisper Money user": "Un usuario de Whisper Money",
"You've been invited to :space": "Te han invitado a :space",
":inviter has invited you to collaborate in the \":space\" space on Whisper Money.": ":inviter te ha invitado a colaborar en el espacio \":space\" en Whisper Money.",
"Joining gives you access to that space's accounts, transactions, budgets and reports.": "Unirte te da acceso a las cuentas, transacciones, presupuestos e informes de ese espacio.",
"Accept invitation": "Aceptar invitación",
"If you don't have a Whisper Money account yet, you'll be able to create one first — just use this same email address.": "Si aún no tienes una cuenta de Whisper Money, podrás crear una primero: usa esta misma dirección de correo.",
"If you weren't expecting this invitation, you can safely ignore this email.": "Si no esperabas esta invitación, puedes ignorar este correo con tranquilidad.",
"The Whisper Money team": "El equipo de Whisper Money",
"If you're having trouble clicking the \"Accept invitation\" button, copy and paste the URL below into your web browser:": "Si tienes problemas para hacer clic en el botón \"Aceptar invitación\", copia y pega la URL de abajo en tu navegador:",
"Remove": "Eliminar"
}

View File

@ -0,0 +1,197 @@
import { removeMember } from '@/actions/App/Http/Controllers/SpaceController';
import {
store as inviteMember,
destroy as revokeInvitation,
} from '@/actions/App/Http/Controllers/SpaceInvitationController';
import HeadingSmall from '@/components/heading-small';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { Mail, UserMinus } from 'lucide-react';
import { FormEvent, useState } from 'react';
interface Member {
id: string;
name: string;
email: string;
}
interface PendingInvitation {
id: string;
email: string;
}
export interface ManagedSpace {
id: string;
name: string;
members: Member[];
invitations: PendingInvitation[];
}
export function ManageMembers({
managedSpaces,
seatsInUse,
maxSeats,
}: {
managedSpaces: ManagedSpace[];
seatsInUse: number;
maxSeats: number;
}) {
if (managedSpaces.length === 0) {
return null;
}
const atCapacity = seatsInUse >= maxSeats;
return (
<div className="space-y-6">
<HeadingSmall
title={__('Members')}
description={__(
'Invite people to your spaces. They can see and work with everything in the spaces they join.',
)}
/>
<p className="text-sm text-muted-foreground">
{__(':used of :max seats used', {
used: String(seatsInUse),
max: String(maxSeats),
})}
</p>
{managedSpaces.map((space) => (
<SpaceMemberCard
key={space.id}
space={space}
atCapacity={atCapacity}
/>
))}
</div>
);
}
function SpaceMemberCard({
space,
atCapacity,
}: {
space: ManagedSpace;
atCapacity: boolean;
}) {
const [email, setEmail] = useState('');
const [processing, setProcessing] = useState(false);
const invite = (event: FormEvent) => {
event.preventDefault();
setProcessing(true);
router.post(
inviteMember(space.id).url,
{ email },
{
preserveScroll: true,
onFinish: () => setProcessing(false),
onSuccess: () => setEmail(''),
},
);
};
return (
<div className="rounded-lg border border-border p-4">
<div className="font-medium">{space.name}</div>
<ul className="mt-3 space-y-2">
{space.members.map((member) => (
<li
key={member.id}
className="flex items-center justify-between gap-3 text-sm"
data-test="space-member"
>
<span>
{member.name}{' '}
<span className="text-muted-foreground">
{member.email}
</span>
</span>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() =>
router.delete(
removeMember({
space: space.id,
member: member.id,
}).url,
{ preserveScroll: true },
)
}
>
<UserMinus className="size-4" />
{__('Remove')}
</Button>
</li>
))}
{space.invitations.map((invitation) => (
<li
key={invitation.id}
className="flex items-center justify-between gap-3 text-sm text-muted-foreground"
data-test="space-pending-invitation"
>
<span className="inline-flex items-center gap-2">
<Mail className="size-4" />
{invitation.email} · {__('Pending')}
</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
router.delete(
revokeInvitation({
space: space.id,
invitation: invitation.id,
}).url,
{ preserveScroll: true },
)
}
>
{__('Revoke')}
</Button>
</li>
))}
{space.members.length === 0 &&
space.invitations.length === 0 && (
<li className="text-sm text-muted-foreground">
{__('No members yet.')}
</li>
)}
</ul>
<form
onSubmit={invite}
className="mt-4 flex flex-col gap-2 sm:flex-row"
>
<Input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder={__('name@example.com')}
disabled={atCapacity}
required
/>
<Button
type="submit"
disabled={processing || atCapacity || email.trim() === ''}
>
{__('Invite')}
</Button>
</form>
{atCapacity && (
<p className="mt-2 text-xs text-muted-foreground">
{__("You've reached your plan's seat limit.")}
</p>
)}
</div>
);
}

View File

@ -1,11 +1,16 @@
import {
destroy as destroySpace,
leave as leaveSpace,
select as selectSpace,
index as spacesIndex,
store as storeSpace,
update as updateSpace,
} from '@/actions/App/Http/Controllers/SpaceController';
import HeadingSmall from '@/components/heading-small';
import {
ManageMembers,
type ManagedSpace,
} from '@/components/spaces/manage-members';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -34,6 +39,11 @@ const breadcrumbs: BreadcrumbItem[] = [
export default function Spaces() {
const { spaces, currentSpace } = usePage<SharedData>().props;
const { managedSpaces, seatsInUse, maxSeats } = usePage<{
managedSpaces: ManagedSpace[];
seatsInUse: number;
maxSeats: number;
}>().props;
const [createOpen, setCreateOpen] = useState(false);
const [renaming, setRenaming] = useState<Space | null>(null);
const [name, setName] = useState('');
@ -176,11 +186,36 @@ export default function Spaces() {
</Button>
</>
)}
{!space.personal && !space.is_owner && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() =>
router.post(
leaveSpace(space.id)
.url,
{},
{
preserveScroll: true,
},
)
}
>
{__('Leave')}
</Button>
)}
</div>
</li>
);
})}
</ul>
<ManageMembers
managedSpaces={managedSpaces}
seatsInUse={seatsInUse}
maxSeats={maxSeats}
/>
</div>
</SettingsLayout>

View File

@ -0,0 +1,22 @@
<x-mail::message>
# {{ __('You\'ve been invited to :space', ['space' => $spaceName]) }}
{{ __(':inviter has invited you to collaborate in the ":space" space on Whisper Money.', ['inviter' => $inviterName, 'space' => $spaceName]) }}
{{ __('Joining gives you access to that space\'s accounts, transactions, budgets and reports.') }}
<x-mail::button :url="$acceptUrl">
{{ __('Accept invitation') }}
</x-mail::button>
{{ __('If you don\'t have a Whisper Money account yet, you\'ll be able to create one first — just use this same email address.') }}
{{ __('If you weren\'t expecting this invitation, you can safely ignore this email.') }}
{{ __('Best,') }}<br>
{{ __('The Whisper Money team') }}
<x-mail::subcopy>
{{ __('If you\'re having trouble clicking the "Accept invitation" button, copy and paste the URL below into your web browser:') }} <span class="break-all">[{{ $acceptUrl }}]({{ $acceptUrl }})</span>
</x-mail::subcopy>
</x-mail::message>

View File

@ -16,6 +16,7 @@ use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\TimezoneController;
use App\Http\Controllers\Settings\TwoFactorAuthenticationController;
use App\Http\Controllers\SpaceController;
use App\Http\Controllers\SpaceInvitationController;
use App\Http\Controllers\SubscriptionController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
@ -66,6 +67,12 @@ Route::middleware('auth')->group(function () {
Route::patch('settings/spaces/{space}', [SpaceController::class, 'update'])->name('spaces.update');
Route::delete('settings/spaces/{space}', [SpaceController::class, 'destroy'])->name('spaces.destroy');
Route::post('spaces/{space}/switch', [SpaceController::class, 'select'])->name('spaces.switch');
Route::post('spaces/{space}/leave', [SpaceController::class, 'leave'])->name('spaces.leave');
Route::delete('spaces/{space}/members/{member}', [SpaceController::class, 'removeMember'])->name('spaces.members.destroy');
Route::post('spaces/{space}/invitations', [SpaceInvitationController::class, 'store'])->name('spaces.invitations.store');
Route::delete('spaces/{space}/invitations/{invitation}', [SpaceInvitationController::class, 'destroy'])->name('spaces.invitations.destroy');
Route::get('spaces/invitations/{token}/accept', [SpaceInvitationController::class, 'accept'])->name('spaces.invitations.accept');
Route::get('settings/automation-rules', [AutomationRuleController::class, 'index'])->name('automation-rules.index');
Route::post('settings/automation-rules', [AutomationRuleController::class, 'store'])->name('automation-rules.store');

View File

@ -0,0 +1,173 @@
<?php
use App\Features\Spaces;
use App\Mail\SpaceInvitationMail;
use App\Models\Account;
use App\Models\SpaceInvitation;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Inertia\Testing\AssertableInertia;
use Laravel\Pennant\Feature;
function ownerWithBusinessSpace(): array
{
$owner = User::factory()->onboarded()->create();
Feature::for($owner)->activate(Spaces::class);
$space = $owner->ownedSpaces()->create(['name' => 'Acme', 'personal' => false]);
return [$owner, $space];
}
it('sends an invitation and records it as pending', function () {
Mail::fake();
[$owner, $space] = ownerWithBusinessSpace();
$this->actingAs($owner)
->post("/spaces/{$space->id}/invitations", ['email' => 'partner@example.com'])
->assertRedirect();
Mail::assertQueued(SpaceInvitationMail::class);
expect($space->invitations()->where('email', 'partner@example.com')->whereNull('accepted_at')->exists())->toBeTrue();
});
it('enforces the seat cap', function () {
Mail::fake();
[$owner, $space] = ownerWithBusinessSpace();
config(['spaces.max_seats' => 2]); // owner + 1
$this->actingAs($owner)
->post("/spaces/{$space->id}/invitations", ['email' => 'first@example.com'])
->assertRedirect();
// Second invitation would be the 3rd seat (owner + 2) — over the cap of 2.
$this->actingAs($owner)
->post("/spaces/{$space->id}/invitations", ['email' => 'second@example.com'])
->assertSessionHas('error');
expect($space->invitations()->count())->toBe(1);
});
it('rejects inviting someone who cannot be invited', function () {
Mail::fake();
[$owner, $space] = ownerWithBusinessSpace();
$this->actingAs($owner)
->post("/spaces/{$space->id}/invitations", ['email' => $owner->email])
->assertSessionHas('error');
expect($space->invitations()->count())->toBe(0);
});
it('only lets the space owner invite', function () {
[$owner, $space] = ownerWithBusinessSpace();
$stranger = User::factory()->create();
$this->actingAs($stranger)
->post("/spaces/{$space->id}/invitations", ['email' => 'x@example.com'])
->assertForbidden();
});
it('lets an invitee with a matching email accept and join', function () {
[$owner, $space] = ownerWithBusinessSpace();
$invitee = User::factory()->create(['email' => 'partner@example.com']);
$invitation = SpaceInvitation::create([
'space_id' => $space->id,
'invited_by_id' => $owner->id,
'email' => 'partner@example.com',
'role' => 'member',
'token' => 'tok_valid',
'expires_at' => now()->addDays(7),
]);
$this->actingAs($invitee)->get('/spaces/invitations/tok_valid/accept')
->assertRedirect(route('dashboard'));
expect($space->fresh()->hasMember($invitee))->toBeTrue()
->and($invitation->fresh()->accepted_at)->not->toBeNull()
->and($invitee->fresh()->current_space_id)->toBe($space->id);
});
it('refuses to accept an invitation for a different email', function () {
[$owner, $space] = ownerWithBusinessSpace();
$other = User::factory()->create(['email' => 'someone-else@example.com']);
SpaceInvitation::create([
'space_id' => $space->id,
'email' => 'partner@example.com',
'role' => 'member',
'token' => 'tok_mismatch',
'expires_at' => now()->addDays(7),
]);
$this->actingAs($other)->get('/spaces/invitations/tok_mismatch/accept')
->assertRedirect(route('dashboard'));
expect($space->fresh()->hasMember($other))->toBeFalse();
});
it('rejects an expired invitation token', function () {
[$owner, $space] = ownerWithBusinessSpace();
$invitee = User::factory()->create(['email' => 'late@example.com']);
SpaceInvitation::create([
'space_id' => $space->id,
'email' => 'late@example.com',
'role' => 'member',
'token' => 'tok_expired',
'expires_at' => now()->subDay(),
]);
$this->actingAs($invitee)->get('/spaces/invitations/tok_expired/accept')
->assertRedirect(route('dashboard'));
expect($space->fresh()->hasMember($invitee))->toBeFalse();
});
it('lets a member see the owner\'s space data once they join', function () {
[$owner, $space] = ownerWithBusinessSpace();
Account::factory()->for($owner)->create(['space_id' => $space->id, 'name' => 'Acme Bank']);
$member = User::factory()->onboarded()->create();
$space->members()->attach($member->id, ['role' => 'member']);
$member->forceFill(['current_space_id' => $space->id])->save();
$this->actingAs($member)->get('/accounts')
->assertInertia(fn (AssertableInertia $page) => $page
->has('accounts', 1)
->where('accounts.0.name', 'Acme Bank'));
});
it('removes a member and lets a member leave', function () {
[$owner, $space] = ownerWithBusinessSpace();
$member = User::factory()->create();
$space->members()->attach($member->id, ['role' => 'member']);
// Owner removes the member.
$this->actingAs($owner)->delete("/spaces/{$space->id}/members/{$member->id}")
->assertRedirect();
expect($space->fresh()->hasMember($member))->toBeFalse();
// A member can leave on their own.
$space->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member)->post("/spaces/{$space->id}/leave")
->assertRedirect();
expect($space->fresh()->hasMember($member))->toBeFalse();
});
it('counts owner, members and pending invitations as seats', function () {
[$owner, $space] = ownerWithBusinessSpace();
$member = User::factory()->create();
$space->members()->attach($member->id, ['role' => 'member']);
SpaceInvitation::create([
'space_id' => $space->id,
'email' => 'pending@example.com',
'role' => 'member',
'token' => 'tok_pending',
'expires_at' => now()->addDays(7),
]);
// owner + 1 member + 1 pending invitation = 3
expect($owner->seatsInUse())->toBe(3);
});