diff --git a/app/Http/Controllers/Admin/AuthController.php b/app/Http/Controllers/Admin/AuthController.php deleted file mode 100644 index ce3230ac..00000000 --- a/app/Http/Controllers/Admin/AuthController.php +++ /dev/null @@ -1,105 +0,0 @@ -route('admin.index'); - } - - return Inertia::render('auth/Login'); - } - - public function redirectToGoogle(Request $request) - { - if (Auth::check()) { - return redirect()->route('admin.index'); - } - - if ($request->filled('redirect')) { - $redirectUrl = $request->query('redirect'); - - if (str_starts_with($redirectUrl, '/') && ! str_starts_with($redirectUrl, '//')) { - session(['url.intended' => url($redirectUrl)]); - } else { - $host = parse_url($redirectUrl, PHP_URL_HOST); - $appHost = parse_url(config('app.url'), PHP_URL_HOST); - - if ($host && $appHost && ($host === $appHost || str_ends_with($host, '.'.$appHost) || $host === 'localhost')) { - session(['url.intended' => $redirectUrl]); - } - } - } - - return Socialite::driver('google')->redirect(); - } - - public function handleGoogleCallback(Request $request) - { - try { - $googleUser = Socialite::driver('google')->user(); - } catch (\Throwable $e) { - return redirect()->route('login')->with('error', 'Failed to authenticate with Google. Please try again.'); - } - - $user = User::where('google_id', $googleUser->getId()) - ->orWhere('email', $googleUser->getEmail()) - ->first(); - - $isNewUser = false; - - if ($user) { - $user->update([ - 'google_id' => $googleUser->getId(), - 'email_verified_at' => $user->email_verified_at ?? now(), - ]); - } else { - $user = User::create([ - 'name' => $googleUser->getName() ?? $googleUser->getNickname() ?? 'Google User', - 'email' => $googleUser->getEmail(), - 'google_id' => $googleUser->getId(), - 'email_verified_at' => now(), - ]); - $isNewUser = true; - - Mail::to($user->email)->queue(new WelcomeUserMail($user)); - } - - Auth::login($user, remember: true); - $request->session()->regenerate(); - - $defaultUrl = $user->username - ? route('user.profile', $user->username) - : route('profile.edit'); - - $redirect = redirect()->intended($defaultUrl); - - if ($isNewUser) { - $redirect->with('success', 'Account not found. New account created.'); - } - - return $redirect; - } - - public function logout(Request $request) - { - Auth::logout(); - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return redirect()->route('index'); - } -} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php new file mode 100644 index 00000000..9b42ce1c --- /dev/null +++ b/app/Http/Controllers/AuthController.php @@ -0,0 +1,172 @@ +route('admin.index'); + } + + return Inertia::render('auth/Login'); + } + + public function redirectToGoogle(Request $request) + { + if (Auth::check()) { + return redirect()->route('admin.index'); + } + + if ($request->filled('redirect')) { + $redirectUrl = $request->query('redirect'); + + if (str_starts_with($redirectUrl, '/') && ! str_starts_with($redirectUrl, '//')) { + session(['url.intended' => url($redirectUrl)]); + } else { + $host = parse_url($redirectUrl, PHP_URL_HOST); + $appHost = parse_url(config('app.url'), PHP_URL_HOST); + + if ($host && $appHost && ($host === $appHost || str_ends_with($host, '.'.$appHost) || $host === 'localhost')) { + session(['url.intended' => $redirectUrl]); + } + } + } + + return Socialite::driver('google')->redirect(); + } + + public function handleGoogleCallback(Request $request) + { + try { + $googleUser = Socialite::driver('google')->user(); + } catch (\Throwable $e) { + return redirect()->route('login')->with('error', 'Failed to authenticate with Google. Please try again.'); + } + + $user = User::where('google_id', $googleUser->getId()) + ->orWhere('email', $googleUser->getEmail()) + ->first(); + + if ($user) { + $user->update([ + 'google_id' => $googleUser->getId(), + 'email_verified_at' => $user->email_verified_at ?? now(), + ]); + + Auth::login($user, remember: true); + $request->session()->regenerate(); + + $defaultUrl = $user->username + ? route('user.profile', $user->username) + : route('profile.edit'); + + return redirect()->intended($defaultUrl); + } + + $request->session()->put('onboarding_user', [ + 'google_id' => $googleUser->getId(), + 'email' => $googleUser->getEmail(), + 'name' => $googleUser->getName() ?? $googleUser->getNickname() ?? '', + 'avatar' => $googleUser->getAvatar() ?? null, + ]); + + return redirect()->route('onboarding'); + } + + public function showOnboarding(Request $request) + { + if (Auth::check()) { + return redirect()->route('index'); + } + + if (! $request->session()->has('onboarding_user')) { + return redirect()->route('login')->with('error', 'Please continue with Google to create an account.'); + } + + $onboardingUser = $request->session()->get('onboarding_user'); + + return Inertia::render('auth/Onboarding', [ + 'user' => $onboardingUser, + ]); + } + + public function completeOnboarding(Request $request) + { + if (Auth::check()) { + return redirect()->route('index'); + } + + if (! $request->session()->has('onboarding_user')) { + return redirect()->route('login')->with('error', 'Session expired. Please continue with Google again.'); + } + + $onboardingData = $request->session()->get('onboarding_user'); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'username' => [ + 'required', + 'string', + 'min:3', + 'max:30', + 'regex:/^[a-zA-Z0-9_]+$/', + 'unique:users,username', + ], + 'school' => ['required', 'string', 'max:255'], + ], [ + 'school.required' => 'Please enter your school, college, or institution name.', + 'username.regex' => 'Username can only contain letters, numbers, and underscores.', + 'username.unique' => 'This username is already taken. Please choose another one.', + ]); + + $user = User::where('google_id', $onboardingData['google_id']) + ->orWhere('email', $onboardingData['email']) + ->first(); + + if (! $user) { + $user = User::create([ + 'name' => $validated['name'], + 'username' => $validated['username'], + 'email' => $onboardingData['email'], + 'google_id' => $onboardingData['google_id'], + 'institution' => $validated['school'], + 'email_verified_at' => now(), + ]); + + Mail::to($user->email)->queue(new WelcomeUserMail($user)); + } + + $request->session()->forget('onboarding_user'); + + Auth::login($user, remember: true); + $request->session()->regenerate(); + + $defaultUrl = $user->username + ? route('user.profile', $user->username) + : route('profile.edit'); + + return redirect()->intended($defaultUrl)->with('success', 'Account created successfully! Welcome to HSCStack.'); + } + + public function logout(Request $request) + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('index'); + } +} diff --git a/resources/js/pages/auth/Login.vue b/resources/js/pages/auth/Login.vue index dac62a37..e8a36597 100644 --- a/resources/js/pages/auth/Login.vue +++ b/resources/js/pages/auth/Login.vue @@ -141,10 +141,11 @@ const googleAuthUrl = redirectParam > Instant 1-Click Access:Fast Google Access: - নতুন ইউজার হলে এক ক্লিকেই auto account create - হবে, আর পুরাতন ইউজার হলে সরাসরি Login হয়ে যাবে। + নতুন ইউজার হলে তথ্য সেটআপ করে অ্যাকাউন্ট তৈরি + করতে পারবেন, আর পুরাতন ইউজার হলে সরাসরি Login + হয়ে যাবে। diff --git a/resources/js/pages/auth/Onboarding.vue b/resources/js/pages/auth/Onboarding.vue new file mode 100644 index 00000000..f304ff29 --- /dev/null +++ b/resources/js/pages/auth/Onboarding.vue @@ -0,0 +1,301 @@ + + + + + Complete Your Profile - HSCStack + + + + + + + + + + + + + + + Almost there! + + + অ্যাকাউন্ট তৈরি সম্পন্ন করতে আপনার তথ্যগুলো নিশ্চিত করুন + + + + + + + {{ flashError }} + + + + + + + + + {{ props.user.name?.charAt(0)?.toUpperCase() || 'U' }} + + + + + {{ props.user.email }} + + + + + Verified via Google + + + + + + + + + + Full Name * + + + + + + + + + {{ form.errors.name }} + + + + + + + Username * + + + + + + + + + {{ form.errors.username }} + + + Letters, numbers, and underscores (3–30 chars). + + + + + + + School / College / Institution + * + + + + + + + + + {{ form.errors.school }} + + + + + + + + {{ + form.processing + ? 'Creating Account...' + : 'Create Account & Get Started' + }} + + + + + + + + অ্যাকাউন্ট তৈরির মাধ্যমে আপনি আমাদের + + Terms of Service + + ও + + Privacy Policy -তে সম্মতি দিচ্ছেন। + + + + + + + + + Back to Sign In + + + + + diff --git a/routes/web.php b/routes/web.php index 3d29b952..de5cd748 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,7 @@ name('logout'); Route::get('/auth/google', [AuthController::class, 'redirectToGoogle'])->name('auth.google'); Route::get('/auth/google/callback', [AuthController::class, 'handleGoogleCallback'])->name('auth.google.callback'); + Route::get('/onboarding', [AuthController::class, 'showOnboarding'])->name('onboarding'); + Route::post('/onboarding', [AuthController::class, 'completeOnboarding'])->name('onboarding.complete'); Route::get('/blogs', [BlogController::class, 'index']); Route::get('/blogs/{blog}', [BlogController::class, 'show']); diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 374ccc04..4dfc3efd 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -1,6 +1,8 @@ assertStringContainsString('accounts.google.com', $response->headers->get('Location')); }); -test('google auth creates new user account if not exists and flashes notice', function () { +test('google auth transfers new user to onboarding without creating account immediately', function () { $abstractUser = Mockery::mock(Laravel\Socialite\Two\User::class); $abstractUser->shouldReceive('getId')->andReturn('google-id-12345'); $abstractUser->shouldReceive('getEmail')->andReturn('newuser@example.com'); $abstractUser->shouldReceive('getName')->andReturn('Google User'); $abstractUser->shouldReceive('getNickname')->andReturn('googleuser'); + $abstractUser->shouldReceive('getAvatar')->andReturn('https://example.com/avatar.jpg'); Socialite::shouldReceive('driver->user')->andReturn($abstractUser); $response = $this->get(route('auth.google.callback')); - $user = User::where('email', 'newuser@example.com')->first(); - $response->assertRedirect(route('user.profile', $user->username)); - $response->assertSessionHas('success', 'Account not found. New account created.'); - $this->assertAuthenticated(); - $this->assertDatabaseHas('users', [ + $response->assertRedirect(route('onboarding')); + $this->assertGuest(); + $this->assertDatabaseMissing('users', [ 'email' => 'newuser@example.com', - 'google_id' => 'google-id-12345', - 'name' => 'Google User', ]); + $response->assertSessionHas('onboarding_user', function ($data) { + return $data['email'] === 'newuser@example.com' + && $data['google_id'] === 'google-id-12345' + && $data['name'] === 'Google User' + && $data['avatar'] === 'https://example.com/avatar.jpg'; + }); +}); + +test('onboarding page is accessible with onboarding session', function () { + $response = $this->withSession([ + 'onboarding_user' => [ + 'google_id' => 'google-id-12345', + 'email' => 'newuser@example.com', + 'name' => 'Google User', + 'avatar' => null, + ], + ])->get(route('onboarding')); + + $response->assertStatus(200); +}); + +test('onboarding page redirects to login without onboarding session', function () { + $response = $this->get(route('onboarding')); + + $response->assertRedirect(route('login')); + $response->assertSessionHas('error', 'Please continue with Google to create an account.'); +}); + +test('completing onboarding creates user, queues welcome mail, and logs in', function () { + Mail::fake(); + + $response = $this->withSession([ + 'onboarding_user' => [ + 'google_id' => 'google-id-12345', + 'email' => 'newuser@example.com', + 'name' => 'Google User', + 'avatar' => null, + ], + ])->post(route('onboarding.complete'), [ + 'name' => 'Custom Name', + 'username' => 'custom_handle', + 'school' => 'Notre Dame College', + ]); + + $user = User::where('email', 'newuser@example.com')->first(); + $this->assertNotNull($user); + $this->assertEquals('Custom Name', $user->name); + $this->assertEquals('custom_handle', $user->username); + $this->assertEquals('Notre Dame College', $user->institution); + $this->assertEquals('google-id-12345', $user->google_id); + $this->assertNotNull($user->email_verified_at); + + $this->assertAuthenticatedAs($user); + $response->assertRedirect(route('user.profile', 'custom_handle')); + $response->assertSessionHas('success'); + $response->assertSessionMissing('onboarding_user'); + + Mail::assertQueued(WelcomeUserMail::class, function ($mail) { + return $mail->hasTo('newuser@example.com'); + }); +}); + +test('completing onboarding validates username uniqueness and format', function () { + User::factory()->create([ + 'username' => 'taken_handle', + ]); + + $response = $this->withSession([ + 'onboarding_user' => [ + 'google_id' => 'google-id-12345', + 'email' => 'newuser@example.com', + 'name' => 'Google User', + 'avatar' => null, + ], + ])->post(route('onboarding.complete'), [ + 'name' => 'Custom Name', + 'username' => 'taken_handle', + 'school' => 'Dhaka College', + ]); + + $response->assertSessionHasErrors(['username']); + $this->assertGuest(); +}); + +test('completing onboarding requires school field', function () { + $response = $this->withSession([ + 'onboarding_user' => [ + 'google_id' => 'google-id-12345', + 'email' => 'newuser@example.com', + 'name' => 'Google User', + 'avatar' => null, + ], + ])->post(route('onboarding.complete'), [ + 'name' => 'Custom Name', + 'username' => 'valid_handle', + 'school' => '', + ]); + + $response->assertSessionHasErrors(['school']); + $this->assertGuest(); +}); + +test('redirects to custom redirect url after onboarding for new user', function () { + $this->get('/auth/google?redirect=/ai'); + + $abstractUser = Mockery::mock(Laravel\Socialite\Two\User::class); + $abstractUser->shouldReceive('getId')->andReturn('google-id-new-redirect'); + $abstractUser->shouldReceive('getEmail')->andReturn('new-redirect@example.com'); + $abstractUser->shouldReceive('getName')->andReturn('New Redirect User'); + $abstractUser->shouldReceive('getNickname')->andReturn('newredirect'); + $abstractUser->shouldReceive('getAvatar')->andReturn(null); + + Socialite::shouldReceive('driver->user')->andReturn($abstractUser); + + $callbackResponse = $this->get(route('auth.google.callback')); + $callbackResponse->assertRedirect(route('onboarding')); + + // Complete onboarding with intended URL in session + $onboardResponse = $this->post(route('onboarding.complete'), [ + 'name' => 'New Redirect User', + 'username' => 'new_redirect_user', + 'school' => 'Dhaka College', + ]); + + $onboardResponse->assertRedirect(url('/ai')); }); -test('redirects to custom redirect url after authentication if provided', function () { +test('redirects to custom redirect url after authentication for existing user', function () { + $user = User::factory()->create([ + 'email' => 'redirect-test@example.com', + 'google_id' => 'google-id-custom', + ]); + $this->get('/auth/google?redirect=/ai'); $abstractUser = Mockery::mock(Laravel\Socialite\Two\User::class); @@ -54,9 +183,15 @@ $response->assertRedirect(url('/ai')); }); -test('redirects to trusted subdomain after authentication if provided', function () { +test('redirects to trusted subdomain after authentication for existing user', function () { config(['app.url' => 'https://hscstack.site']); $subdomainUrl = 'https://ssc2026.hscstack.site'; + + $user = User::factory()->create([ + 'email' => 'subdomain@example.com', + 'google_id' => 'google-id-subdomain', + ]); + $this->get('/auth/google?redirect='.urlencode($subdomainUrl)); $abstractUser = Mockery::mock(Laravel\Socialite\Two\User::class); @@ -101,7 +236,12 @@ $response->assertSessionHas('error', 'Failed to authenticate with Google. Please try again.'); }); -test('google auth redirects to intended url if set', function () { +test('google auth redirects to intended url if set for existing user', function () { + $user = User::factory()->create([ + 'email' => 'intended@example.com', + 'google_id' => 'google-id-intended', + ]); + $abstractUser = Mockery::mock(Laravel\Socialite\Two\User::class); $abstractUser->shouldReceive('getId')->andReturn('google-id-intended'); $abstractUser->shouldReceive('getEmail')->andReturn('intended@example.com');
+ অ্যাকাউন্ট তৈরি সম্পন্ন করতে আপনার তথ্যগুলো নিশ্চিত করুন +
+ {{ props.user.email }} +
+ Verified via Google +
+ {{ form.errors.name }} +
+ {{ form.errors.username }} +
+ Letters, numbers, and underscores (3–30 chars). +
+ {{ form.errors.school }} +
+ অ্যাকাউন্ট তৈরির মাধ্যমে আপনি আমাদের + + Terms of Service + + ও + + Privacy Policy -তে সম্মতি দিচ্ছেন। +