diff --git a/app/Http/Controllers/Company/Auth/AuthController.php b/app/Http/Controllers/Company/Auth/AuthController.php index c6ed8238..5a587e30 100644 --- a/app/Http/Controllers/Company/Auth/AuthController.php +++ b/app/Http/Controllers/Company/Auth/AuthController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers\Company\Auth; use App\Http\Controllers\Controller; use App\Http\Requests\LoginRequest; +use App\Models\CompanyInvitation; use App\Models\User; +use App\Services\InvitationService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; @@ -22,6 +24,17 @@ class AuthController extends Controller ]); } + // Auto-accept invitation if token is provided + if ($request->has('invitation_token') && $request->invitation_token) { + $invitation = CompanyInvitation::where('token', $request->invitation_token) + ->pending() + ->first(); + + if ($invitation) { + app(InvitationService::class)->accept($invitation, $user); + } + } + return response()->json([ 'type' => 'Bearer', 'token' => $user->createToken($request->device_name)->plainTextToken, diff --git a/app/Http/Controllers/Company/Auth/InvitationRegistrationController.php b/app/Http/Controllers/Company/Auth/InvitationRegistrationController.php new file mode 100644 index 00000000..1142243c --- /dev/null +++ b/app/Http/Controllers/Company/Auth/InvitationRegistrationController.php @@ -0,0 +1,83 @@ +pending() + ->with(['company', 'role']) + ->first(); + + if (! $invitation) { + return response()->json([ + 'error' => 'Invitation not found or expired.', + ], 404); + } + + return response()->json([ + 'email' => $invitation->email, + 'company_name' => $invitation->company->name, + 'role_name' => $invitation->role->title, + ]); + } + + /** + * Register a new user and auto-accept the invitation. + */ + public function register(Request $request): JsonResponse + { + $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:8|confirmed', + 'invitation_token' => 'required|string', + ]); + + $invitation = CompanyInvitation::where('token', $request->invitation_token) + ->pending() + ->first(); + + if (! $invitation) { + throw ValidationException::withMessages([ + 'invitation_token' => ['Invitation not found or expired.'], + ]); + } + + if ($invitation->email !== $request->email) { + throw ValidationException::withMessages([ + 'email' => ['Email does not match the invitation.'], + ]); + } + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => $request->password, + ]); + + $this->invitationService->accept($invitation, $user); + + return response()->json([ + 'type' => 'Bearer', + 'token' => $user->createToken('web')->plainTextToken, + ]); + } +} diff --git a/app/Mail/CompanyInvitationMail.php b/app/Mail/CompanyInvitationMail.php index 9cfebc39..dd6824e1 100644 --- a/app/Mail/CompanyInvitationMail.php +++ b/app/Mail/CompanyInvitationMail.php @@ -28,6 +28,13 @@ class CompanyInvitationMail extends Mailable public function content(): Content { + $token = $this->invitation->token; + $hasAccount = $this->invitation->user_id !== null; + + $acceptUrl = $hasAccount + ? url("/login?invitation={$token}") + : url("/register?invitation={$token}"); + return new Content( markdown: 'emails.company-invitation', with: [ @@ -35,8 +42,9 @@ class CompanyInvitationMail extends Mailable 'companyName' => $this->invitation->company->name, 'roleName' => $this->invitation->role->title, 'inviterName' => $this->invitation->invitedBy->name, - 'acceptUrl' => url("/invitations/{$this->invitation->token}/accept"), - 'declineUrl' => url("/invitations/{$this->invitation->token}/decline"), + 'acceptUrl' => $acceptUrl, + 'declineUrl' => url("/invitations/{$token}/decline"), + 'hasAccount' => $hasAccount, ], ); } diff --git a/resources/scripts/admin/admin-router.js b/resources/scripts/admin/admin-router.js index 18ab534a..b8be3067 100644 --- a/resources/scripts/admin/admin-router.js +++ b/resources/scripts/admin/admin-router.js @@ -138,8 +138,16 @@ const AdminFileDisk = () => const NoCompanyView = () => import('@/scripts/admin/views/NoCompanyView.vue') +const RegisterWithInvitation = () => + import('@/scripts/admin/views/auth/RegisterWithInvitation.vue') export default [ + { + path: '/register', + name: 'register', + component: RegisterWithInvitation, + meta: { requiresAuth: false }, + }, { path: '/admin/no-company', name: 'no.company', diff --git a/resources/scripts/admin/views/auth/RegisterWithInvitation.vue b/resources/scripts/admin/views/auth/RegisterWithInvitation.vue new file mode 100644 index 00000000..ff91707a --- /dev/null +++ b/resources/scripts/admin/views/auth/RegisterWithInvitation.vue @@ -0,0 +1,195 @@ + + + diff --git a/resources/views/emails/company-invitation.blade.php b/resources/views/emails/company-invitation.blade.php index bcf7671b..429ccd36 100644 --- a/resources/views/emails/company-invitation.blade.php +++ b/resources/views/emails/company-invitation.blade.php @@ -3,8 +3,14 @@ **{{ $inviterName }}** has invited you to join **{{ $companyName }}** as **{{ $roleName }}**. +@if($hasAccount) +Log in to accept the invitation: +@else +Create your account to get started: +@endif + -Accept Invitation +{{ $hasAccount ? 'Log In & Accept' : 'Create Account & Accept' }} If you don't want to join, you can decline this invitation. diff --git a/routes/api.php b/routes/api.php index c645a772..a57f9bcd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Admin\UsersController; use App\Http\Controllers\AppVersionController; use App\Http\Controllers\Company\Auth\AuthController; use App\Http\Controllers\Company\Auth\ForgotPasswordController; +use App\Http\Controllers\Company\Auth\InvitationRegistrationController; use App\Http\Controllers\Company\Auth\ResetPasswordController; use App\Http\Controllers\Company\Customer\CustomersController; use App\Http\Controllers\Company\Customer\CustomerStatsController; @@ -115,6 +116,12 @@ Route::prefix('/v1')->group(function () { Route::post('reset/password', [ResetPasswordController::class, 'reset']); }); + // Invitation Registration (public) + // ---------------------------------- + + Route::get('/invitations/{token}/details', [InvitationRegistrationController::class, 'details']); + Route::post('/auth/register-with-invitation', [InvitationRegistrationController::class, 'register']); + // Countries // ---------------------------------- diff --git a/routes/web.php b/routes/web.php index 323c1773..8ed23b65 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Modules\ScriptController; use App\Http\Controllers\Modules\StyleController; use App\Http\Controllers\Pdf\DocumentPdfController; use App\Models\Company; +use App\Models\CompanyInvitation; use Illuminate\Support\Facades\Route; // Module Asset Includes @@ -76,6 +77,21 @@ Route::middleware('auth:sanctum')->prefix('reports')->group(function () { // PDF Endpoints // ---------------------------------------------- +// Invitation email link handlers +// ------------------------------------------------- + +Route::get('/invitations/{token}/decline', function (string $token) { + $invitation = CompanyInvitation::where('token', $token)->pending()->first(); + + if (! $invitation) { + return view('app')->with(['message' => 'Invitation not found or already expired.']); + } + + $invitation->update(['status' => CompanyInvitation::STATUS_DECLINED]); + + return view('app')->with(['message' => 'Invitation declined.']); +}); + Route::middleware('pdf-auth')->group(function () { // invoice pdf diff --git a/tests/Feature/Admin/InvitationTest.php b/tests/Feature/Admin/InvitationTest.php index cfedfa1e..65f7317e 100644 --- a/tests/Feature/Admin/InvitationTest.php +++ b/tests/Feature/Admin/InvitationTest.php @@ -225,3 +225,89 @@ test('bootstrap includes pending invitations', function () { $response->assertOk(); $response->assertJsonStructure(['pending_invitations']); }); + +test('get invitation details by token', function () { + Mail::fake(); + + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + + // Create invitation for non-existent user + $invitation = CompanyInvitation::create([ + 'company_id' => $company->id, + 'user_id' => null, + 'email' => 'newperson@example.com', + 'role_id' => $role->id, + 'token' => 'test-details-token', + 'status' => 'pending', + 'invited_by' => User::first()->id, + 'expires_at' => now()->addDays(7), + ]); + + $response = getJson("api/v1/invitations/{$invitation->token}/details"); + + $response->assertOk(); + $response->assertJsonPath('email', 'newperson@example.com'); + $response->assertJsonPath('company_name', $company->name); +}); + +test('register with invitation creates account and accepts', function () { + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + + $invitation = CompanyInvitation::create([ + 'company_id' => $company->id, + 'user_id' => null, + 'email' => 'register@example.com', + 'role_id' => $role->id, + 'token' => 'test-register-token', + 'status' => 'pending', + 'invited_by' => User::first()->id, + 'expires_at' => now()->addDays(7), + ]); + + $response = postJson('api/v1/auth/register-with-invitation', [ + 'name' => 'New User', + 'email' => 'register@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + 'invitation_token' => 'test-register-token', + ]); + + $response->assertOk(); + $response->assertJsonStructure(['type', 'token']); + + $newUser = User::where('email', 'register@example.com')->first(); + $this->assertNotNull($newUser); + $this->assertTrue($newUser->hasCompany($company->id)); + $this->assertDatabaseHas('company_invitations', [ + 'token' => 'test-register-token', + 'status' => 'accepted', + ]); +}); + +test('cannot register with mismatched email', function () { + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + + CompanyInvitation::create([ + 'company_id' => $company->id, + 'user_id' => null, + 'email' => 'correct@example.com', + 'role_id' => $role->id, + 'token' => 'test-mismatch-token', + 'status' => 'pending', + 'invited_by' => User::first()->id, + 'expires_at' => now()->addDays(7), + ]); + + $response = postJson('api/v1/auth/register-with-invitation', [ + 'name' => 'Wrong User', + 'email' => 'wrong@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + 'invitation_token' => 'test-mismatch-token', + ]); + + $response->assertStatus(422); +});