Skip to content

Commit ed9a41a

Browse files
committed
fix: resolve merge conflicts with main
Merge origin/main into feat/rest-api-mcp-server, keeping both: - API middleware priority (SetApiTeamContext) from feature branch - Guest redirect logic (team invitation flow) from main
2 parents 4c9bd03 + f4716cf commit ed9a41a

22 files changed

+924
-45
lines changed

app/Actions/Jetstream/InviteTeamMember.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@ public function invite(User $user, Team $team, string $email, ?string $role = nu
3333

3434
event(new InvitingTeamMember($team, $email, $role));
3535

36+
$expiryDays = (int) config('jetstream.invitation_expiry_days', 7);
37+
3638
$invitation = $team->teamInvitations()->create([
3739
'email' => $email,
3840
'role' => $role,
41+
'expires_at' => now()->addDays($expiryDays),
3942
]);
4043

4144
/** @var TeamInvitationModel $invitation */
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Concerns;
6+
7+
use App\Models\TeamInvitation;
8+
use Illuminate\Contracts\Support\Htmlable;
9+
use Illuminate\Support\HtmlString;
10+
11+
trait DetectsTeamInvitation
12+
{
13+
protected function getTeamInvitationFromSession(): ?TeamInvitation
14+
{
15+
$intendedUrl = session('url.intended', '');
16+
17+
if (! str_contains((string) $intendedUrl, '/team-invitations/')) {
18+
return null;
19+
}
20+
21+
$path = parse_url((string) $intendedUrl, PHP_URL_PATH);
22+
23+
if (! $path) {
24+
return null;
25+
}
26+
27+
$segments = explode('/', trim($path, '/'));
28+
$invitationIndex = array_search('team-invitations', $segments, true);
29+
30+
if ($invitationIndex === false || ! isset($segments[$invitationIndex + 1])) {
31+
return null;
32+
}
33+
34+
return TeamInvitation::query()
35+
->whereKey($segments[$invitationIndex + 1])
36+
->first();
37+
}
38+
39+
protected function getTeamInvitationSubheading(): ?Htmlable
40+
{
41+
$invitation = $this->getTeamInvitationFromSession();
42+
43+
if (! $invitation || $invitation->isExpired()) {
44+
return null;
45+
}
46+
47+
return new HtmlString(
48+
__('You\'ve been invited to join <strong>:team</strong>', [
49+
'team' => e($invitation->team->name),
50+
])
51+
);
52+
}
53+
54+
protected function getInvitationContentHtml(): string
55+
{
56+
$subheading = $this->getTeamInvitationSubheading();
57+
58+
if ($subheading === null) {
59+
return '';
60+
}
61+
62+
return '<p class="text-center text-sm text-gray-500 dark:text-gray-400">'.$subheading->toHtml().'</p>';
63+
}
64+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Console\Commands;
6+
7+
use App\Models\TeamInvitation;
8+
use Illuminate\Console\Command;
9+
use Illuminate\Contracts\Database\Query\Builder;
10+
11+
final class CleanupExpiredInvitationsCommand extends Command
12+
{
13+
/**
14+
* @var string
15+
*/
16+
protected $signature = 'invitations:cleanup
17+
{--days=30 : Delete invitations expired more than this many days ago}';
18+
19+
/**
20+
* @var string
21+
*/
22+
protected $description = 'Delete team invitations that have been expired for a specified number of days';
23+
24+
public function handle(): void
25+
{
26+
$days = (int) $this->option('days');
27+
28+
$cutoff = now()->subDays($days);
29+
30+
$deleted = TeamInvitation::query()
31+
->where(function (Builder $query) use ($cutoff): void {
32+
$query->where('expires_at', '<', $cutoff)
33+
->orWhere(function (Builder $query) use ($cutoff): void {
34+
$query->whereNull('expires_at')
35+
->where('created_at', '<', $cutoff);
36+
});
37+
})
38+
->delete();
39+
40+
$this->info("Purged {$deleted} expired invitation(s).");
41+
}
42+
}

app/Filament/Pages/Auth/Login.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,30 @@
44

55
namespace App\Filament\Pages\Auth;
66

7+
use App\Concerns\DetectsTeamInvitation;
78
use Filament\Actions\Action;
9+
use Filament\Schemas\Components\Html;
10+
use Filament\Schemas\Components\RenderHook;
11+
use Filament\Schemas\Schema;
812
use Filament\Support\Enums\Size;
13+
use Filament\View\PanelsRenderHook;
914

1015
final class Login extends \Filament\Auth\Pages\Login
1116
{
17+
use DetectsTeamInvitation;
18+
19+
public function content(Schema $schema): Schema
20+
{
21+
return $schema
22+
->components([
23+
Html::make(fn (): string => $this->getInvitationContentHtml()),
24+
RenderHook::make(PanelsRenderHook::AUTH_LOGIN_FORM_BEFORE),
25+
$this->getFormContentComponent(),
26+
$this->getMultiFactorChallengeFormContentComponent(),
27+
RenderHook::make(PanelsRenderHook::AUTH_LOGIN_FORM_AFTER),
28+
]);
29+
}
30+
1231
protected function getAuthenticateFormAction(): Action
1332
{
1433
return Action::make('authenticate')

app/Filament/Pages/Auth/Register.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,32 @@
44

55
namespace App\Filament\Pages\Auth;
66

7+
use App\Concerns\DetectsTeamInvitation;
78
use Filament\Actions\Action;
89
use Filament\Auth\Pages\Register as BaseRegister;
910
use Filament\Forms\Components\TextInput;
11+
use Filament\Schemas\Components\Html;
12+
use Filament\Schemas\Components\RenderHook;
13+
use Filament\Schemas\Schema;
1014
use Filament\Support\Enums\Size;
15+
use Filament\View\PanelsRenderHook;
16+
use Illuminate\Database\Eloquent\Model;
1117

1218
final class Register extends BaseRegister
1319
{
20+
use DetectsTeamInvitation;
21+
22+
public function content(Schema $schema): Schema
23+
{
24+
return $schema
25+
->components([
26+
Html::make(fn (): string => $this->getInvitationContentHtml()),
27+
RenderHook::make(PanelsRenderHook::AUTH_REGISTER_FORM_BEFORE),
28+
$this->getFormContentComponent(),
29+
RenderHook::make(PanelsRenderHook::AUTH_REGISTER_FORM_AFTER),
30+
]);
31+
}
32+
1433
protected function getEmailFormComponent(): TextInput
1534
{
1635
return TextInput::make('email')
@@ -29,4 +48,20 @@ public function getRegisterFormAction(): Action
2948
->label(__('filament-panels::auth/pages/register.form.actions.register.label'))
3049
->submit('register');
3150
}
51+
52+
/**
53+
* @param array<string, mixed> $data
54+
*/
55+
protected function handleRegistration(array $data): Model
56+
{
57+
$user = $this->getUserModel()::query()->create($data);
58+
59+
$invitation = $this->getTeamInvitationFromSession();
60+
61+
if ($invitation && $invitation->email === $data['email']) {
62+
$user->forceFill(['email_verified_at' => now()])->save();
63+
}
64+
65+
return $user;
66+
}
3267
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers;
6+
7+
use App\Models\TeamInvitation;
8+
use App\Models\User;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Illuminate\Support\Facades\Log;
12+
use Illuminate\View\View;
13+
use Laravel\Jetstream\Contracts\AddsTeamMembers;
14+
15+
final readonly class AcceptTeamInvitationController
16+
{
17+
public function __invoke(Request $request, string $invitationId): RedirectResponse|View
18+
{
19+
$invitation = TeamInvitation::query()->whereKey($invitationId)->firstOrFail();
20+
21+
if ($invitation->isExpired()) {
22+
Log::warning('Expired invitation accessed', [
23+
'invitation_id' => $invitation->id,
24+
'team_id' => $invitation->team_id,
25+
]);
26+
27+
return view('teams.invitation-expired');
28+
}
29+
30+
if ($request->user()->email !== $invitation->email) {
31+
Log::warning('Invitation email mismatch', [
32+
'invitation_id' => $invitation->id,
33+
'user_id' => $request->user()->id,
34+
]);
35+
36+
abort(403, __('This invitation was sent to a different email address.'));
37+
}
38+
39+
/** @var User $owner */
40+
$owner = $invitation->team->owner;
41+
42+
resolve(AddsTeamMembers::class)->add(
43+
$owner,
44+
$invitation->team,
45+
$invitation->email,
46+
$invitation->role,
47+
);
48+
49+
$invitation->delete();
50+
51+
/** @var User $user */
52+
$user = $request->user();
53+
$user->switchTeam($invitation->team);
54+
55+
return redirect(config('fortify.home'))
56+
->banner(__('Great! You have accepted the invitation to join the :team team.', [ // @phpstan-ignore method.notFound
57+
'team' => $invitation->team->name,
58+
]));
59+
}
60+
}

app/Livewire/App/Teams/AddTeamMember.php

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
use Filament\Forms\Components\Radio;
1414
use Filament\Forms\Components\TextInput;
1515
use Filament\Infolists\Components\TextEntry;
16+
use Filament\Notifications\Notification;
1617
use Filament\Schemas\Components\Actions;
1718
use Filament\Schemas\Components\Grid;
1819
use Filament\Schemas\Components\Section;
1920
use Filament\Schemas\Schema;
2021
use Illuminate\Contracts\View\View;
2122
use Illuminate\Support\Facades\Gate;
23+
use Illuminate\Validation\ValidationException;
2224
use Laravel\Jetstream\Jetstream;
2325

2426
final class AddTeamMember extends BaseLivewireComponent
@@ -89,12 +91,21 @@ public function addTeamMember(Team $team): void
8991

9092
$data = $this->form->getState();
9193

92-
resolve(InviteTeamMember::class)->invite(
93-
$this->authUser(),
94-
$team,
95-
$data['email'],
96-
$data['role'] ?? null
97-
);
94+
try {
95+
resolve(InviteTeamMember::class)->invite(
96+
$this->authUser(),
97+
$team,
98+
$data['email'],
99+
$data['role'] ?? null
100+
);
101+
} catch (ValidationException $e) {
102+
Notification::make()
103+
->title($e->validator->errors()->first())
104+
->danger()
105+
->send();
106+
107+
return;
108+
}
98109

99110
$this->sendNotification(__('teams.notifications.team_invitation_sent.success'));
100111

0 commit comments

Comments
 (0)