diff --git a/app/Events/ApiRegistered.php b/app/Events/ApiRegistered.php new file mode 100644 index 00000000..f237ce73 --- /dev/null +++ b/app/Events/ApiRegistered.php @@ -0,0 +1,20 @@ +with(['roles', 'permissions']) ->where('email', strtolower($request->input('email'))) @@ -27,7 +28,7 @@ public function login(LoginRequest $request): JsonResponse ]; if (empty($user) || !Auth::attempt($sanitized)) { - throw ValidationException::withMessages([ + throw ValidationException::withMessages([ 'email' => 'Les informations d\'identification fournies sont incorrectes.', ]); } diff --git a/app/Http/Controllers/Api/Auth/RegisterController.php b/app/Http/Controllers/Api/Auth/RegisterController.php new file mode 100644 index 00000000..b91ca668 --- /dev/null +++ b/app/Http/Controllers/Api/Auth/RegisterController.php @@ -0,0 +1,97 @@ +create([ + 'name' => $request->input('name'), + 'username' => $request->input('name'), + 'email' => strtolower($request->input('email')), + 'password' => Hash::make($request->input('password')), + ]); + + $user->assignRole('company'); + + //TODO: Send new company registration notification on Slack + event(new ApiRegistered($user)); + + return response()->json(array_merge( + ['message' => 'Votre compte a été créé avec succès. Un e-mail de vérification vous a été envoyé.'], + $this->userMetaData($user) + ) + ); + } + + public function googleAuthenticator(Request $request): JsonResponse + { + $socialUser = $request->input('socialUser'); + + $user = User::query()->where('email', $socialUser['email'])->first(); + + if (! $user) { + /** @var User $user */ + $user = User::query()->create([ + 'name' => $socialUser['name'], + 'email' => $socialUser['email'], + 'username' => $socialUser['id'], + 'email_verified_at' => now(), + 'avatar_type' => strtolower($socialUser['provider']), + ]); + + $user->assignRole('company'); + } + + if ($user->hasRole('user')) { + return response()->json(['error' => 'Vous n\'êtes pas autorisé à accéder à cette section avec cette adresse e-mail.'], 401); + } + + if (! $user->hasProvider($socialUser['provider'])) { + $user->providers()->save(new SocialAccount([ + 'provider' => $socialUser['provider'], + 'provider_id' => $socialUser['id'], + 'token' => $socialUser['idToken'], + 'avatar' => $socialUser['photoUrl'], + ])); + } + + $user->providers()->update([ + 'token' => $socialUser['idToken'], + 'avatar' => $socialUser['photoUrl'], + ]); + + //TODO: Send welcome email to user 1 hour after registration + + //TODO: Send new company registration notification on Slack + + $user->last_login_at = Carbon::now(); + $user->last_login_ip = $request->ip(); + $user->save(); + + return response()->json($this->userMetaData($user)); + } + + private function userMetaData(User $user): array + { + return [ + 'user' => new AuthenticateUserResource($user), + 'token' => $user->createToken($user->email)->plainTextToken, + 'roles' => $user->roles()->pluck('name'), + 'permissions' => $user->permissions()->pluck('name'), + ]; + } +} diff --git a/app/Http/Controllers/Api/Auth/VerifyEmailController.php b/app/Http/Controllers/Api/Auth/VerifyEmailController.php new file mode 100644 index 00000000..f1e6e46e --- /dev/null +++ b/app/Http/Controllers/Api/Auth/VerifyEmailController.php @@ -0,0 +1,36 @@ +route('id')); + + if ($user->hasVerifiedEmail()) { + return redirect(env('FRONTEND_APP_URL') . '/email/verify/already-success'); + } + + if ($user->markEmailAsVerified()) { + event(new Verified($user)); + } + + return redirect(env('FRONTEND_APP_URL') . '/email/verify/success'); + } + + public function resend(Request $request): JsonResponse + { + $request->user()->sendEmailVerificationNotification(); + + return response()->json(['message', 'Un nouveau lien de Verification a été envoyé!']); + } +} diff --git a/app/Http/Requests/Api/RegisterRequest.php b/app/Http/Requests/Api/RegisterRequest.php new file mode 100644 index 00000000..cd4cb603 --- /dev/null +++ b/app/Http/Requests/Api/RegisterRequest.php @@ -0,0 +1,32 @@ + + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'email' => 'required|email|max:255|unique:users', + 'password' => 'required|min:6', + ]; + } +} diff --git a/app/Listeners/SendCompanyEmailVerificationNotification.php b/app/Listeners/SendCompanyEmailVerificationNotification.php new file mode 100644 index 00000000..115d9936 --- /dev/null +++ b/app/Listeners/SendCompanyEmailVerificationNotification.php @@ -0,0 +1,24 @@ +user; + } +} diff --git a/app/Listeners/SendWelcomeCompanyNotification.php b/app/Listeners/SendWelcomeCompanyNotification.php new file mode 100644 index 00000000..02990b81 --- /dev/null +++ b/app/Listeners/SendWelcomeCompanyNotification.php @@ -0,0 +1,23 @@ +user; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 45621b8f..cb4d232f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\HasProfilePhoto; +use App\Traits\HasUsername; use App\Traits\Reacts; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Builder; @@ -34,6 +35,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasMedia use InteractsWithMedia; use Notifiable; use Reacts; + use HasUsername; /** * The attributes that are mass assignable. @@ -149,17 +151,12 @@ public function registerMediaCollections(): void ->acceptsMimeTypes(['image/jpg', 'image/jpeg', 'image/png', 'image/gif']); } - public static function findByUsername(string $username): self - { - return static::where('username', $username)->firstOrFail(); - } - public static function findByEmailAddress(string $emailAddress): self { return static::where('email', $emailAddress)->firstOrFail(); } - public static function findOrCreateSocialUserProvider($socialUser, string $provider): self + public static function findOrCreateSocialUserProvider($socialUser, string $provider, string $role = 'user'): self { $socialEmail = $socialUser->email ?? "{$socialUser->id}@{$provider}.com"; @@ -176,7 +173,7 @@ public static function findOrCreateSocialUserProvider($socialUser, string $provi 'avatar_type' => $provider, ]); - $user->assignRole('user'); + $user->assignRole($role); } return $user; diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 83a98c34..a34c2f0b 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,16 +2,19 @@ namespace App\Providers; +use App\Events\ApiRegistered; use App\Events\ArticleWasSubmittedForApproval; use App\Events\CommentWasAdded; use App\Events\ReplyWasCreated; use App\Events\ThreadWasCreated; use App\Listeners\NotifyMentionedUsers; use App\Listeners\PostNewThreadNotification; +use App\Listeners\SendCompanyEmailVerificationNotification; use App\Listeners\SendNewArticleNotification; use App\Listeners\SendNewCommentNotification; use App\Listeners\SendNewReplyNotification; use App\Listeners\SendNewThreadNotification; +use App\Listeners\SendWelcomeCompanyNotification; use App\Listeners\SendWelcomeMailNotification; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; @@ -46,5 +49,10 @@ class EventServiceProvider extends ServiceProvider \SocialiteProviders\Manager\SocialiteWasCalled::class => [ \SocialiteProviders\Twitter\TwitterExtendSocialite::class.'@handle', ], + + ApiRegistered::class => [ + SendCompanyEmailVerificationNotification::class, + SendWelcomeCompanyNotification::class, + ], ]; } diff --git a/app/Traits/HasUsername.php b/app/Traits/HasUsername.php new file mode 100644 index 00000000..36bdb726 --- /dev/null +++ b/app/Traits/HasUsername.php @@ -0,0 +1,47 @@ +username; + } + + public function setUsernameAttribute(string $username): void + { + $this->attributes['username'] = $this->generateUniqueUsername($username); + } + + public static function findByUsername(string $username): self + { + return static::where('username', $username)->firstOrFail(); + } + + private function generateUniqueUsername(string $value): string + { + $username = $originalUsername = $value ?: Str::random(6); + $counter = 0; + + while ($this->usernameExists($username, $this->exists ? $this->id : null)) { + $counter++; + $username = $originalUsername.$counter; + } + + return $username; + } + + private function usernameExists(string $username, int $ignoreId = null): bool + { + $query = $this->where('username', $username); + + if ($ignoreId) { + $query->where('id', '!=', $ignoreId); + } + + return $query->exists(); + } +} diff --git a/database/seeders/AddEnterpriseRoleSeeder.php b/database/seeders/AddEnterpriseRoleSeeder.php new file mode 100644 index 00000000..e3626329 --- /dev/null +++ b/database/seeders/AddEnterpriseRoleSeeder.php @@ -0,0 +1,20 @@ + 'company']); + } +} diff --git a/database/seeders/ChannelSeeder.php b/database/seeders/ChannelSeeder.php index 0aa6a62a..f4f204d4 100644 --- a/database/seeders/ChannelSeeder.php +++ b/database/seeders/ChannelSeeder.php @@ -12,7 +12,7 @@ class ChannelSeeder extends Seeder * * @return void */ - public function run() + public function run(): void { $authentification = Channel::create(['name' => 'Authentification', 'slug' => 'authentification', 'color' => '#31c48d']); $authentification->items()->createMany([ diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 18ddcecd..5cce7933 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -12,7 +12,7 @@ class DatabaseSeeder extends Seeder * * @return void */ - public function run() + public function run(): void { $this->call(TagSeeder::class); $this->call(RoleSeeder::class); diff --git a/database/seeders/DeveloperPremiumPlanSeeder.php b/database/seeders/DeveloperPremiumPlanSeeder.php index 62b7a2c7..e0e1d6a1 100644 --- a/database/seeders/DeveloperPremiumPlanSeeder.php +++ b/database/seeders/DeveloperPremiumPlanSeeder.php @@ -13,7 +13,7 @@ class DeveloperPremiumPlanSeeder extends Seeder * * @return void */ - public function run() + public function run(): void { $rookiePlan = Plan::create([ 'name' => 'Le Rookie', @@ -36,7 +36,7 @@ public function run() new Feature(['name' => 'Accès au code source des tutoriels', 'value' => 1, 'sort_order' => 5]), new Feature(['name' => 'Invitation sur le Github du projet', 'value' => 1, 'sort_order' => 6]), ]); - + $proPlan = Plan::create([ 'name' => 'Le Pro', 'description' => 'Le Pro plan', diff --git a/database/seeders/ReactionSeeder.php b/database/seeders/ReactionSeeder.php index 6d1e4616..e360d547 100644 --- a/database/seeders/ReactionSeeder.php +++ b/database/seeders/ReactionSeeder.php @@ -12,7 +12,7 @@ class ReactionSeeder extends Seeder * * @return void */ - public function run() + public function run(): void { Reaction::createFromName('clap'); Reaction::createFromName('fire'); diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index df75f518..3b06e065 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -12,7 +12,7 @@ class RoleSeeder extends Seeder * * @return void */ - public function run() + public function run(): void { Role::create(['name' => 'admin']); Role::create(['name' => 'moderator']); diff --git a/database/seeders/TagSeeder.php b/database/seeders/TagSeeder.php index 73a70f39..56a669db 100644 --- a/database/seeders/TagSeeder.php +++ b/database/seeders/TagSeeder.php @@ -12,7 +12,7 @@ class TagSeeder extends Seeder * * @return void */ - public function run() + public function run(): void { $this->createTag('AlpineJS', 'alpinejs', ['post', 'tutorial']); $this->createTag('Laravel', 'laravel', ['post', 'tutorial']); diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index f8f3bb5d..79466ec7 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -12,7 +12,7 @@ class UserSeeder extends Seeder * * @return void */ - public function run() + public function run(): void { $user = User::factory()->create([ 'name' => 'Arthur Doe', diff --git a/routes/api.php b/routes/api.php index 4871cc1b..0cb7770f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,8 @@ middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); +Route::prefix('register')->group(function () { + Route::post('/', [RegisterController::class, 'register']); + Route::post('google', [RegisterController::class, 'googleAuthenticator']); +}); /* Authenticated Routes */ Route::middleware('auth:sanctum')->group(function () { Route::post('logout', [LoginController::class, 'logout']); + Route::get('email/verify/resend', [VerifyEmailController::class, 'resend']) + ->middleware('throttle:6,1') + ->name('verification.send'); /** User Profile Api */ Route::prefix('user')->group(function () {