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:
parent
5d2e292088
commit
7df967c487
|
|
@ -19,7 +19,7 @@ trait HandlesSubscriptionGate
|
|||
return false;
|
||||
}
|
||||
|
||||
return ! $user->canUseFeature(PlanFeature::ConnectedAccounts);
|
||||
return ! $user->canUseFeatureInSpace(PlanFeature::ConnectedAccounts, $user->activeSpace());
|
||||
}
|
||||
|
||||
private function subscribeJsonResponse(): JsonResponse
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
];
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
33
lang/es.json
33
lang/es.json
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue