diff --git a/app/Actions/Teams/AcceptTeamInvite.php b/app/Actions/Teams/AcceptTeamInvite.php new file mode 100644 index 0000000..b5b280e --- /dev/null +++ b/app/Actions/Teams/AcceptTeamInvite.php @@ -0,0 +1,27 @@ +user()->email === $invite->invited_email, 403); + + $team = $invite->team; + $newUser = User::whereEmail($invite->invited_email)->firstOrFail(); + + try { + DB::transaction(function () use ($team, $newUser, $invite) { + $team->members()->attach($newUser); + $invite->delete(); + }); + } catch (\Exception $exception) { + DB::rollBack(); + } + } +} diff --git a/app/Http/Controllers/TeamController.php b/app/Http/Controllers/TeamController.php index 778326d..17c2e6c 100644 --- a/app/Http/Controllers/TeamController.php +++ b/app/Http/Controllers/TeamController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Enums\TeamMemberRole; +use App\Http\Requests\AddTeamMemberRequest; use App\Http\Requests\StoreTeamRequest; use App\Http\Requests\UpdateTeamRequest; use App\Http\Resources\TeamResource; @@ -74,6 +75,18 @@ class TeamController extends Controller ]); } + /** + * Show the form for editing the specified resource. + */ + public function addMember(AddTeamMemberRequest $request, Team $team) + { + $validated = $request->validated(); + + $email = Arr::get($validated, 'email'); + + return response()->json(['message' => __('')]); + } + /** * Update the specified resource in storage. */ diff --git a/app/Http/Controllers/TeamInviteAcceptController.php b/app/Http/Controllers/TeamInviteAcceptController.php new file mode 100644 index 0000000..2764adb --- /dev/null +++ b/app/Http/Controllers/TeamInviteAcceptController.php @@ -0,0 +1,39 @@ +input('inviteToken'); + if ($inviteToken) { + $invite = TeamInvite::findByPlainToken($inviteToken)->first(); + + if ($invite) { + $accept($invite); + + return to_route('teams.index'); + } + } + + return Inertia::render('team/invite/Create', [ + 'inviteToken' => $inviteToken, + ]); + } + + public function store(AcceptTeamInviteRequest $request, AcceptTeamInvite $accept) + { + $request->validated(); + + $accept($request->teamInvite()); + + return to_route('teams.index'); + } +} diff --git a/app/Http/Requests/AcceptTeamInviteRequest.php b/app/Http/Requests/AcceptTeamInviteRequest.php new file mode 100644 index 0000000..6ca6e61 --- /dev/null +++ b/app/Http/Requests/AcceptTeamInviteRequest.php @@ -0,0 +1,39 @@ +|string> + */ + public function rules(): array + { + return [ + 'inviteToken' => 'required|string', + ]; + } + + public function after(): array + { + return [ + function (Validator $validator) { + if (($this->teamInvite() == null) || ($this->teamInvite()->invited_email !== auth()->user()->email)) { + $validator->errors()->add('inviteToken', 'InviteToken is invalid or expired.'); + } + }, + ]; + } + + public function teamInvite(): ?TeamInvite + { + return TeamInvite::findByPlainToken($this->inviteToken)->first(); + } +} diff --git a/app/Http/Requests/AddTeamMemberRequest.php b/app/Http/Requests/AddTeamMemberRequest.php new file mode 100644 index 0000000..a57f977 --- /dev/null +++ b/app/Http/Requests/AddTeamMemberRequest.php @@ -0,0 +1,21 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => 'required|string|email|max:255', + ]; + } +} diff --git a/app/Models/TeamInvite.php b/app/Models/TeamInvite.php new file mode 100644 index 0000000..5b36eed --- /dev/null +++ b/app/Models/TeamInvite.php @@ -0,0 +1,64 @@ +plainInviteToken = Str::random(128); + $hashedToken = hash('sha256', $model->plainInviteToken); + $model->invite_token = $hashedToken; + }); + self::created(function (self $model) { + $model->notify(new InviteToTeam); + }); + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by_user_id'); + } + + public function plainInviteToken(): ?string + { + return $this->plainInviteToken; + } + + /** + * Route notifications for the mail channel. + * + * @return array|string + */ + public function routeNotificationForMail(Notification $notification): array|string + { + return $this->invited_email; + } + + #[Scope] + protected function findByPlainToken(Builder $query, string $token): Builder + { + return $query->where('invite_token', hash('sha256', $token)); + } +} diff --git a/app/Notifications/InviteToTeam.php b/app/Notifications/InviteToTeam.php new file mode 100644 index 0000000..9375dad --- /dev/null +++ b/app/Notifications/InviteToTeam.php @@ -0,0 +1,77 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(TeamInvite $invite): MailMessage + { + $user = User::whereEmail($invite->invited_email)->first(); + + return (new MailMessage) + ->subject('Team Invitation') + ->greeting( + $user !== null + ? sprintf('Hello %s,', $user->name) + : 'Hello', + ) + ->line(sprintf( + '%s invited you to the %s team.', + $invite->invitedBy->name, + $invite->team->name, + )) + ->linesIf($user === null, [ + 'It seems like you don\'t have an account yet.', + 'Create one now for free to join the team.', + ]) + ->action($user === null + ? 'Register and join this team' + : 'Join this team', + route($user === null + ? 'register' + : 'teams.invite.create', + ['inviteToken' => $invite->plainInviteToken()] + )); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + // + ]; + } +} diff --git a/database/migrations/2026_09_19_002717_create_team_invites_table.php b/database/migrations/2026_09_19_002717_create_team_invites_table.php new file mode 100644 index 0000000..bdc425b --- /dev/null +++ b/database/migrations/2026_09_19_002717_create_team_invites_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignIdFor(Team::class)->constrained(); + $table->foreignIdFor(User::class, 'invited_by_user_id')->constrained(); + $table->string('invited_email'); + $table->string('invite_token'); + $table->timestamps(); + + $table->unique(['team_id', 'invited_email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('team_invites'); + } +}; diff --git a/resources/js/pages/team/Create.vue b/resources/js/pages/team/Create.vue index 591fbeb..b156d4c 100644 --- a/resources/js/pages/team/Create.vue +++ b/resources/js/pages/team/Create.vue @@ -9,6 +9,7 @@ import InputError from '@/components/InputError.vue'; import {Checkbox} from '@/components/ui/checkbox'; import {ref, watch} from "vue"; import TeamSlug from "@/components/TeamSlug.vue"; +import {Card, CardContent} from "@/components/ui/card"; defineOptions({ layout: { @@ -42,33 +43,37 @@ watch(() => (form.errors as any).slug, (newError) => slugError.value = newError) diff --git a/resources/js/pages/team/Edit.vue b/resources/js/pages/team/Edit.vue index 3a9cb70..5cb98f3 100644 --- a/resources/js/pages/team/Edit.vue +++ b/resources/js/pages/team/Edit.vue @@ -10,6 +10,7 @@ import {Checkbox} from '@/components/ui/checkbox'; import {Team} from '@/types/models'; import {ref, watch} from "vue"; import TeamSlug from "@/components/TeamSlug.vue"; +import {Card, CardContent} from "@/components/ui/card"; const props = defineProps<{ team: Team; @@ -48,30 +49,35 @@ watch(() => (form.errors as any).slug, (newError) => slugError.value = newError)
-
-
-
- - - -
- -
- - - -
-
-
- -
-
+ + +
+
+
+ + + +
+ +
+ + + +
+
+
+ +
+
+
+
diff --git a/resources/js/pages/team/invite/Create.vue b/resources/js/pages/team/invite/Create.vue new file mode 100644 index 0000000..261e889 --- /dev/null +++ b/resources/js/pages/team/invite/Create.vue @@ -0,0 +1,56 @@ + + + diff --git a/routes/web.php b/routes/web.php index 606cd85..cd9685b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('home'); @@ -9,15 +10,22 @@ Route::middleware(['auth'])->group(function () { Route::inertia('dashboard', 'Dashboard')->name('dashboard'); Route::controller(TeamController::class)->prefix('teams')->name('teams.')->group(function () { + Route::controller(TeamInviteAcceptController::class)->prefix('invite')->name('invite.')->group(function () { + Route::get('/accept', 'create')->name('create'); + Route::post('/accept', 'store')->name('store'); + }); + Route::get('/', 'index')->name('index'); Route::get('/create', 'create')->name('create'); Route::post('/', 'store')->name('store'); - Route::get('/{team:slug}', 'show')->name('show'); Route::get('/edit/{team:slug}', 'edit')->name('edit'); Route::patch('/{team:slug}', 'update')->name('update'); Route::post('/name-to-slug', 'nameToSlug')->name('nameToSlug'); + Route::get('/{team:slug}', 'show')->name('show'); }); + }); + require __DIR__.'/settings.php';