invite team member
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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: {
|
||||
@@ -45,6 +46,8 @@ watch(() => (form.errors as any).slug, (newError) => slugError.value = newError)
|
||||
<Head title="Teams"/>
|
||||
|
||||
<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">
|
||||
<div class="grid grid-cols-2 gap-5 items-start">
|
||||
<div class="grid items-start gap-3">
|
||||
@@ -70,5 +73,7 @@ watch(() => (form.errors as any).slug, (newError) => slugError.value = newError)
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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,6 +49,8 @@ watch(() => (form.errors as any).slug, (newError) => slugError.value = newError)
|
||||
<Head title="Teams"/>
|
||||
|
||||
<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">
|
||||
<div class="grid grid-cols-2 gap-5 items-start">
|
||||
<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"/>
|
||||
<InputError :message="form.errors.name"/>
|
||||
</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">
|
||||
<Label for="require_2fa">Require 2FA</Label>
|
||||
<Checkbox
|
||||
@@ -73,5 +77,7 @@ watch(() => (form.errors as any).slug, (newError) => slugError.value = newError)
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\TeamController;
|
||||
use App\Http\Controllers\TeamInviteAcceptController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::inertia('/', 'Welcome')->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';
|
||||
|
||||
Reference in New Issue
Block a user