invite team member
CI/CD / test_and_check_app (push) Failing after 57s
CI/CD / Publish immutable image (push) Skipped

This commit is contained in:
2026-09-19 04:56:36 +02:00
parent f9958fb7cf
commit 5113ea56fc
12 changed files with 442 additions and 52 deletions
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Actions\Teams;
use App\Models\TeamInvite;
use App\Models\User;
use Illuminate\Support\Facades\DB;
readonly class AcceptTeamInvite
{
public function __invoke(TeamInvite $invite): void
{
abort_unless(auth()->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();
}
}
}
+13
View File
@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\TeamMemberRole; use App\Enums\TeamMemberRole;
use App\Http\Requests\AddTeamMemberRequest;
use App\Http\Requests\StoreTeamRequest; use App\Http\Requests\StoreTeamRequest;
use App\Http\Requests\UpdateTeamRequest; use App\Http\Requests\UpdateTeamRequest;
use App\Http\Resources\TeamResource; 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. * Update the specified resource in storage.
*/ */
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Teams\AcceptTeamInvite;
use App\Http\Requests\AcceptTeamInviteRequest;
use App\Models\TeamInvite;
use Illuminate\Http\Request;
use Inertia\Inertia;
class TeamInviteAcceptController extends Controller
{
public function create(Request $request, AcceptTeamInvite $accept)
{
$inviteToken = $request->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');
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests;
use App\Models\TeamInvite;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
class AcceptTeamInviteRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|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();
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class AddTeamMemberRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => 'required|string|email|max:255',
];
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Models;
use App\Notifications\InviteToTeam;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Notifications\Notifiable;
use Illuminate\Notifications\Notification;
use Str;
#[Fillable(['team_id', 'invited_by_user_id', 'invited_email'])]
class TeamInvite extends Model
{
use Notifiable;
protected ?string $plainInviteToken = null;
protected static function booted(): void
{
self::creating(function (self $model) {
$model->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, string>|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));
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Notifications;
use App\Models\TeamInvite;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class InviteToTeam extends Notification
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct()
{
//
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
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<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
//
];
}
}
@@ -0,0 +1,35 @@
<?php
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('team_invites', function (Blueprint $table) {
$table->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');
}
};
+5
View File
@@ -9,6 +9,7 @@ import InputError from '@/components/InputError.vue';
import {Checkbox} from '@/components/ui/checkbox'; import {Checkbox} from '@/components/ui/checkbox';
import {ref, watch} from "vue"; import {ref, watch} from "vue";
import TeamSlug from "@/components/TeamSlug.vue"; import TeamSlug from "@/components/TeamSlug.vue";
import {Card, CardContent} from "@/components/ui/card";
defineOptions({ defineOptions({
layout: { layout: {
@@ -45,6 +46,8 @@ watch(() => (form.errors as any).slug, (newError) => slugError.value = newError)
<Head title="Teams"/> <Head title="Teams"/>
<div class="flex flex-col justify-center p-16"> <div class="flex flex-col justify-center p-16">
<Card class="w-full">
<CardContent>
<form class="grid w-full gap-5 py-4" @submit.prevent="submit"> <form class="grid w-full gap-5 py-4" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-5 items-start"> <div class="grid grid-cols-2 gap-5 items-start">
<div class="grid items-start gap-3"> <div class="grid items-start gap-3">
@@ -70,5 +73,7 @@ watch(() => (form.errors as any).slug, (newError) => slugError.value = newError)
</Button> </Button>
</div> </div>
</form> </form>
</CardContent>
</Card>
</div> </div>
</template> </template>
+7 -1
View File
@@ -10,6 +10,7 @@ import {Checkbox} from '@/components/ui/checkbox';
import {Team} from '@/types/models'; import {Team} from '@/types/models';
import {ref, watch} from "vue"; import {ref, watch} from "vue";
import TeamSlug from "@/components/TeamSlug.vue"; import TeamSlug from "@/components/TeamSlug.vue";
import {Card, CardContent} from "@/components/ui/card";
const props = defineProps<{ const props = defineProps<{
team: Team; team: Team;
@@ -48,6 +49,8 @@ watch(() => (form.errors as any).slug, (newError) => slugError.value = newError)
<Head title="Teams"/> <Head title="Teams"/>
<div class="flex flex-col justify-center p-16"> <div class="flex flex-col justify-center p-16">
<Card class="w-full">
<CardContent>
<form class="grid w-full gap-5 py-4" @submit.prevent="submit"> <form class="grid w-full gap-5 py-4" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-5 items-start"> <div class="grid grid-cols-2 gap-5 items-start">
<div class="grid items-start gap-3"> <div class="grid items-start gap-3">
@@ -55,7 +58,8 @@ watch(() => (form.errors as any).slug, (newError) => slugError.value = newError)
<Input id="name" v-model="form.name" name="name"/> <Input id="name" v-model="form.name" name="name"/>
<InputError :message="form.errors.name"/> <InputError :message="form.errors.name"/>
</div> </div>
<TeamSlug :ignored-slug="team.slug" :name="form.name" :slug="slug" :slug-error="slugError" :slug-taken="slugTaken" /> <TeamSlug :ignored-slug="team.slug" :name="form.name" :slug="slug" :slug-error="slugError"
:slug-taken="slugTaken"/>
<div class="grid items-start gap-3"> <div class="grid items-start gap-3">
<Label for="require_2fa">Require 2FA</Label> <Label for="require_2fa">Require 2FA</Label>
<Checkbox <Checkbox
@@ -73,5 +77,7 @@ watch(() => (form.errors as any).slug, (newError) => slugError.value = newError)
</Button> </Button>
</div> </div>
</form> </form>
</CardContent>
</Card>
</div> </div>
</template> </template>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts" setup>
import teamRoutes from '@/routes/teams';
import {Form, Head, useForm} from '@inertiajs/vue3';
import {Card, CardContent} from "@/components/ui/card";
import {Input} from "@/components/ui/input";
import InputError from "@/components/InputError.vue";
import {Button} from "@/components/ui/button";
defineOptions({
layout: {
breadcrumbs: [
{
title: 'Teams',
href: teamRoutes.index(),
},
{
title: 'Invite',
href: teamRoutes.invite.create(),
},
],
},
});
const props = defineProps<{
inviteToken?: string,
}>()
const form = useForm({
inviteToken: props.inviteToken
})
const submit = () => form.post(teamRoutes.invite.store().url)
</script>
<template>
<Head title="Team Invite"/>
<div class="flex flex-col justify-center p-16">
<Card class="w-full">
<CardContent>
<h1 class="font-bold text-2xl">Team Einladung</h1>
<form class="grid gap-y-3" @submit.prevent="submit">
<div class="grid items-start gap-3">
<Input id="inviteToken" v-model="form.inviteToken" name="inviteToken" placeholder="Token"/>
<InputError :message="form.errors.inviteToken"/>
</div>
<div class="w-full flex justify-end">
<Button :disabled="form.processing" type="submit">Team Beitreten</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</template>
+9 -1
View File
@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\TeamController; use App\Http\Controllers\TeamController;
use App\Http\Controllers\TeamInviteAcceptController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::inertia('/', 'Welcome')->name('home'); Route::inertia('/', 'Welcome')->name('home');
@@ -9,15 +10,22 @@ Route::middleware(['auth'])->group(function () {
Route::inertia('dashboard', 'Dashboard')->name('dashboard'); Route::inertia('dashboard', 'Dashboard')->name('dashboard');
Route::controller(TeamController::class)->prefix('teams')->name('teams.')->group(function () { 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('/', 'index')->name('index');
Route::get('/create', 'create')->name('create'); Route::get('/create', 'create')->name('create');
Route::post('/', 'store')->name('store'); Route::post('/', 'store')->name('store');
Route::get('/{team:slug}', 'show')->name('show');
Route::get('/edit/{team:slug}', 'edit')->name('edit'); Route::get('/edit/{team:slug}', 'edit')->name('edit');
Route::patch('/{team:slug}', 'update')->name('update'); Route::patch('/{team:slug}', 'update')->name('update');
Route::post('/name-to-slug', 'nameToSlug')->name('nameToSlug'); Route::post('/name-to-slug', 'nameToSlug')->name('nameToSlug');
Route::get('/{team:slug}', 'show')->name('show');
}); });
}); });
require __DIR__.'/settings.php'; require __DIR__.'/settings.php';