diff --git a/app/Http/Controllers/Admin/AuthController.php b/app/Http/Controllers/Admin/AuthController.php index ce3230ac..f1ff1752 100644 --- a/app/Http/Controllers/Admin/AuthController.php +++ b/app/Http/Controllers/Admin/AuthController.php @@ -81,9 +81,10 @@ public function handleGoogleCallback(Request $request) Auth::login($user, remember: true); $request->session()->regenerate(); - $defaultUrl = $user->username - ? route('user.profile', $user->username) - : route('profile.edit'); + $needsOnboarding = empty($user->username) || $user->username === "student_{$user->id}" || empty($user->institution); + $defaultUrl = $needsOnboarding + ? route('onboarding.index') + : route('user.profile', $user->username); $redirect = redirect()->intended($defaultUrl); diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php new file mode 100644 index 00000000..2c3a9db4 --- /dev/null +++ b/app/Http/Controllers/OnboardingController.php @@ -0,0 +1,129 @@ +user(); + + // If user already customized their username and set institution, skip onboarding + if (! empty($user->username) && $user->username !== "student_{$user->id}" && ! empty($user->institution)) { + return redirect()->intended(route('user.profile', ['username' => $user->username])); + } + + // Generate a clean suggested username from name or email + $suggestedUsername = $this->generateSuggestedUsername($user); + + return Inertia::render('Onboarding', [ + 'user' => [ + 'name' => $user->name, + 'email' => $user->email, + 'suggested_username' => $suggestedUsername, + 'institution' => $user->institution ?? '', + ], + ]); + } + + /** + * Complete onboarding and store profile details. + */ + public function store(Request $request): RedirectResponse + { + $user = $request->user(); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'username' => [ + 'required', + 'string', + 'min:3', + 'max:30', + 'regex:/^[a-zA-Z0-9_]+$/', + Rule::unique('users', 'username')->ignore($user->id), + ], + 'institution' => ['required', 'string', 'max:255'], + ], [ + 'username.regex' => 'The username may only contain letters, numbers, and underscores.', + 'username.unique' => 'This username is already taken. Please choose another one.', + ]); + + $user->update([ + 'name' => $validated['name'], + 'username' => Str::lower($validated['username']), + 'institution' => $validated['institution'], + ]); + + return redirect()->intended(route('user.profile', ['username' => $user->username])) + ->with('success', 'Welcome to HSCStack! Your profile is ready.'); + } + + /** + * Real-time username availability checker API. + */ + public function checkUsername(Request $request) + { + $username = trim((string) $request->query('username', '')); + + if (empty($username) || strlen($username) < 3 || strlen($username) > 30 || ! preg_match('/^[a-zA-Z0-9_]+$/', $username)) { + return response()->json([ + 'valid' => false, + 'available' => false, + 'message' => 'Username must be 3-30 characters (letters, numbers, underscores).', + ]); + } + + $userId = $request->user()?->id; + $isTaken = User::where('username', Str::lower($username)) + ->when($userId, fn ($query) => $query->where('id', '!=', $userId)) + ->exists(); + + return response()->json([ + 'valid' => true, + 'available' => ! $isTaken, + 'message' => $isTaken ? 'This username is already taken.' : 'Username is available!', + ]); + } + + /** + * Helper to generate a unique suggested username. + */ + private function generateSuggestedUsername(User $user): string + { + $base = Str::slug($user->name, '_'); + $base = preg_replace('/[^a-zA-Z0-9_]/', '', $base); + + if (empty($base) || strlen($base) < 3) { + $base = Str::before($user->email, '@'); + $base = preg_replace('/[^a-zA-Z0-9_]/', '', $base); + } + + $base = Str::lower(substr($base, 0, 20)); + + if (strlen($base) < 3) { + $base = 'student'; + } + + $suggested = $base; + $counter = 1; + + while (User::where('username', $suggested)->where('id', '!=', $user->id)->exists()) { + $suggested = "{$base}_{$counter}"; + $counter++; + } + + return $suggested; + } +} diff --git a/resources/js/pages/Onboarding.vue b/resources/js/pages/Onboarding.vue new file mode 100644 index 00000000..d6d1ab8f --- /dev/null +++ b/resources/js/pages/Onboarding.vue @@ -0,0 +1,415 @@ + + + diff --git a/routes/web.php b/routes/web.php index 3d29b952..5749ba62 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ use App\Http\Controllers\BlogController; use App\Http\Controllers\ChatController; use App\Http\Controllers\NodeController; +use App\Http\Controllers\OnboardingController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\ResourceController; use App\Http\Controllers\ShortUrlController; @@ -21,6 +22,10 @@ }); Route::middleware(['throttle:60,1', 'auth'])->group(function () { + Route::get('/onboarding', [OnboardingController::class, 'show'])->name('onboarding.index'); + Route::post('/onboarding', [OnboardingController::class, 'store'])->name('onboarding.store'); + Route::get('/api/check-username', [OnboardingController::class, 'checkUsername'])->name('onboarding.check-username'); + Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::put('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::post('/api/short-urls', [ShortUrlController::class, 'store'])->name('short-urls.store'); diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 374ccc04..439a5f7d 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -28,7 +28,7 @@ $response = $this->get(route('auth.google.callback')); $user = User::where('email', 'newuser@example.com')->first(); - $response->assertRedirect(route('user.profile', $user->username)); + $response->assertRedirect(route('onboarding.index')); $response->assertSessionHas('success', 'Account not found. New account created.'); $this->assertAuthenticated(); $this->assertDatabaseHas('users', [ diff --git a/tests/Feature/OnboardingTest.php b/tests/Feature/OnboardingTest.php new file mode 100644 index 00000000..576a19dc --- /dev/null +++ b/tests/Feature/OnboardingTest.php @@ -0,0 +1,142 @@ +get(route('onboarding.index')); + + $response->assertRedirect(route('login')); +}); + +test('new user can view onboarding page with suggested username', function () { + $user = User::factory()->create([ + 'name' => 'Tajim Ahmed', + 'username' => 'student_999', + 'institution' => null, + ]); + + $response = $this->actingAs($user)->get(route('onboarding.index')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('Onboarding') + ->has('user.suggested_username') + ->where('user.name', 'Tajim Ahmed') + ); +}); + +test('already onboarded user is redirected to profile when accessing onboarding', function () { + $user = User::factory()->create([ + 'username' => 'tajim_pro', + 'institution' => 'Notre Dame College', + ]); + + $response = $this->actingAs($user)->get(route('onboarding.index')); + + $response->assertRedirect(route('user.profile', ['username' => 'tajim_pro'])); +}); + +test('user can complete onboarding and is redirected to profile', function () { + $user = User::factory()->create([ + 'name' => 'Original Name', + 'username' => 'student_123', + 'institution' => null, + ]); + + $response = $this->actingAs($user)->post(route('onboarding.store'), [ + 'name' => 'Tajim Ahmed', + 'username' => 'tajim_ahmed', + 'institution' => 'Notre Dame College', + ]); + + $response->assertRedirect(route('user.profile', ['username' => 'tajim_ahmed'])); + + $user->refresh(); + expect($user->name)->toBe('Tajim Ahmed'); + expect($user->username)->toBe('tajim_ahmed'); + expect($user->institution)->toBe('Notre Dame College'); +}); + +test('onboarding validates unique username', function () { + User::factory()->create([ + 'username' => 'existing_handle', + ]); + + $user = User::factory()->create([ + 'username' => 'student_456', + 'institution' => null, + ]); + + $response = $this->actingAs($user)->post(route('onboarding.store'), [ + 'name' => 'Tajim Ahmed', + 'username' => 'existing_handle', + 'institution' => 'Notre Dame College', + ]); + + $response->assertSessionHasErrors(['username']); +}); + +test('onboarding validates required fields', function () { + $user = User::factory()->create([ + 'username' => 'student_789', + 'institution' => null, + ]); + + $response = $this->actingAs($user)->post(route('onboarding.store'), [ + 'name' => '', + 'username' => '', + 'institution' => '', + ]); + + $response->assertSessionHasErrors(['name', 'username', 'institution']); +}); + +test('check username endpoint returns availability status', function () { + User::factory()->create([ + 'username' => 'taken_user', + ]); + + $user = User::factory()->create(); + + // Check taken username + $response = $this->actingAs($user)->getJson('/api/check-username?username=taken_user'); + $response->assertOk() + ->assertJson([ + 'valid' => true, + 'available' => false, + ]); + + // Check available username + $response = $this->actingAs($user)->getJson('/api/check-username?username=fresh_user_123'); + $response->assertOk() + ->assertJson([ + 'valid' => true, + 'available' => true, + ]); + + // Check invalid format + $response = $this->actingAs($user)->getJson('/api/check-username?username=ab'); + $response->assertOk() + ->assertJson([ + 'valid' => false, + 'available' => false, + ]); +}); + +test('onboarding respects intended redirect url', function () { + $user = User::factory()->create([ + 'username' => 'student_555', + 'institution' => null, + ]); + + // Set intended URL in session + session()->put('url.intended', '/chat'); + + $response = $this->actingAs($user)->post(route('onboarding.store'), [ + 'name' => 'Tajim Ahmed', + 'username' => 'tajim_chat', + 'institution' => 'Notre Dame College', + ]); + + $response->assertRedirect('/chat'); +});