From 2e9e0f716bf886bf35dd0c486d2ae17cac5a5363 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 4 May 2026 18:42:25 -0300 Subject: [PATCH 1/4] feat: capture signup UTMs/IP and add GitHub OAuth login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persists marketing attribution and registration metadata for new users across the three signup paths (email, Google, GitHub): - 5 utm_* columns + registration_ip on the users table - PreservesUtmParameters trait stores incoming utm_* query params on the register/redirect GET, retrieves them on the POST/callback — surviving the OAuth round-trip via session - request()->ip() captured at the controller layer Adds GitHub as a second OAuth provider: - GitHubController mirroring the Google one (now renamed from SocialLoginController for symmetry) - Settings → Authentication can connect/disconnect GitHub like Google - Single SocialLogin.vue component replaces the per-provider buttons on Login/Register, rendering each enabled provider plus a single "or continue with" divider UserFactory gains defaults for the new nullable columns so model strict-mode access in tests doesn't trip. --- app/Actions/User/CreateUser.php | 13 +- .../App/Settings/AuthenticationController.php | 3 +- .../Auth/Concerns/PreservesUtmParameters.php | 48 +++ .../Controllers/Auth/GitHubController.php | 92 +++++ ...ginController.php => GoogleController.php} | 19 +- .../Auth/RegisteredUserController.php | 12 +- .../Middleware/App/HandleInertiaRequests.php | 1 + app/Models/User.php | 7 + config/services.php | 7 + config/trypost.php | 2 + database/factories/UserFactory.php | 7 + ..._212145_add_utm_columns_to_users_table.php | 28 ++ ...04_212935_add_github_id_to_users_table.php | 24 ++ ...935_add_registration_ip_to_users_table.php | 24 ++ lang/en/auth.php | 3 + lang/es/auth.php | 3 + lang/pt-BR/auth.php | 3 + .../js/components/auth/GoogleAuthButton.vue | 29 -- resources/js/components/auth/SocialLogin.vue | 38 +++ resources/js/pages/auth/Login.vue | 4 +- resources/js/pages/auth/Register.vue | 4 +- .../pages/settings/profile/Authentication.vue | 13 +- routes/auth.php | 10 +- tests/Feature/Auth/SignupUtmTrackingTest.php | 319 ++++++++++++++++++ tests/Feature/GitHubAuthToggleTest.php | 54 +++ 25 files changed, 716 insertions(+), 51 deletions(-) create mode 100644 app/Http/Controllers/Auth/Concerns/PreservesUtmParameters.php create mode 100644 app/Http/Controllers/Auth/GitHubController.php rename app/Http/Controllers/Auth/{SocialLoginController.php => GoogleController.php} (77%) create mode 100644 database/migrations/2026_05_04_212145_add_utm_columns_to_users_table.php create mode 100644 database/migrations/2026_05_04_212935_add_github_id_to_users_table.php create mode 100644 database/migrations/2026_05_04_212935_add_registration_ip_to_users_table.php delete mode 100644 resources/js/components/auth/GoogleAuthButton.vue create mode 100644 resources/js/components/auth/SocialLogin.vue create mode 100644 tests/Feature/Auth/SignupUtmTrackingTest.php create mode 100644 tests/Feature/GitHubAuthToggleTest.php diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index 85099135..f2b4f7e0 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -11,11 +11,12 @@ class CreateUser { /** - * @param array{name: string, email: string, password?: string, google_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool} $data + * @param array{name: string, email: string, password?: string, google_id?: string, github_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool, registration_ip?: string|null} $data + * @param array $utmParameters */ - public static function execute(array $data): User + public static function execute(array $data, array $utmParameters = []): User { - return DB::transaction(function () use ($data): User { + return DB::transaction(function () use ($data, $utmParameters): User { $isInviteRegistration = data_get($data, 'is_invite', false); $account = Account::create([ @@ -23,14 +24,16 @@ public static function execute(array $data): User 'billing_email' => data_get($data, 'email'), ]); - $user = User::create([ + $user = User::create(array_merge([ 'name' => data_get($data, 'name'), 'email' => data_get($data, 'email'), 'password' => data_get($data, 'password'), 'google_id' => data_get($data, 'google_id'), + 'github_id' => data_get($data, 'github_id'), 'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null), 'account_id' => $account->id, - ]); + 'registration_ip' => data_get($data, 'registration_ip'), + ], $utmParameters)); $account->update(['owner_id' => $user->id]); diff --git a/app/Http/Controllers/App/Settings/AuthenticationController.php b/app/Http/Controllers/App/Settings/AuthenticationController.php index b531bf4e..e093d9a4 100644 --- a/app/Http/Controllers/App/Settings/AuthenticationController.php +++ b/app/Http/Controllers/App/Settings/AuthenticationController.php @@ -17,7 +17,7 @@ class AuthenticationController extends Controller { - private const array PROVIDERS = ['google']; + private const array PROVIDERS = ['google', 'github']; public function edit(Request $request): Response { @@ -115,6 +115,7 @@ private function getConnectedAccounts(User $user): array { $labels = [ 'google' => 'Google', + 'github' => 'GitHub', ]; return collect(self::PROVIDERS)->map(fn (string $provider) => [ diff --git a/app/Http/Controllers/Auth/Concerns/PreservesUtmParameters.php b/app/Http/Controllers/Auth/Concerns/PreservesUtmParameters.php new file mode 100644 index 00000000..90b174c4 --- /dev/null +++ b/app/Http/Controllers/Auth/Concerns/PreservesUtmParameters.php @@ -0,0 +1,48 @@ + + */ + private function extractUtmParameters(Request $request): array + { + return array_filter( + array_map( + fn (string $value) => mb_substr($value, 0, 255), + array_filter($request->only(self::UTM_KEYS), 'is_string'), + ), + ); + } + + private function storeUtmParameters(Request $request): void + { + $utms = $this->extractUtmParameters($request); + + if ($utms !== []) { + $request->session()->put('utm_parameters', $utms); + } + } + + /** + * @return array + */ + private function retrieveUtmParameters(): array + { + return session()->pull('utm_parameters', []); + } +} diff --git a/app/Http/Controllers/Auth/GitHubController.php b/app/Http/Controllers/Auth/GitHubController.php new file mode 100644 index 00000000..05be8a6b --- /dev/null +++ b/app/Http/Controllers/Auth/GitHubController.php @@ -0,0 +1,92 @@ +storeUtmParameters($request); + + return Socialite::driver('github') + ->scopes(['read:user', 'user:email']) + ->redirect(); + } + + public function callback(): RedirectResponse + { + try { + $githubUser = Socialite::driver('github')->user(); + } catch (\Exception) { + return redirect()->route('login'); + } + + $user = User::where('github_id', (string) $githubUser->getId()) + ->when($githubUser->getEmail(), fn ($query, $email) => $query->orWhere('email', $email)) + ->first(); + + if ($user) { + return $this->loginExistingUser($user, (string) $githubUser->getId()); + } + + if (! $githubUser->getEmail()) { + return redirect()->route('login')->withErrors([ + 'email' => __('auth.github_email_unavailable'), + ]); + } + + return $this->registerNewUser($githubUser); + } + + private function loginExistingUser(User $user, string $githubId): RedirectResponse + { + if (! $user->github_id) { + $user->update(['github_id' => $githubId]); + } + + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + } + + Auth::login($user, remember: true); + + $this->retrieveUtmParameters(); + + return redirect()->route('app.home'); + } + + private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): RedirectResponse + { + $utmParameters = $this->retrieveUtmParameters(); + + $user = CreateUser::execute([ + 'name' => $githubUser->getName() ?? $githubUser->getNickname() ?? explode('@', $githubUser->getEmail())[0], + 'email' => $githubUser->getEmail(), + 'github_id' => (string) $githubUser->getId(), + 'email_verified_at' => now(), + 'registration_ip' => request()->ip(), + ], $utmParameters); + + event(new Registered($user)); + + Auth::login($user, remember: true); + + session()->flash('auth_provider', 'github'); + + return redirect()->route('register.success', $utmParameters); + } +} diff --git a/app/Http/Controllers/Auth/SocialLoginController.php b/app/Http/Controllers/Auth/GoogleController.php similarity index 77% rename from app/Http/Controllers/Auth/SocialLoginController.php rename to app/Http/Controllers/Auth/GoogleController.php index ae75291e..822b7a8e 100644 --- a/app/Http/Controllers/Auth/SocialLoginController.php +++ b/app/Http/Controllers/Auth/GoogleController.php @@ -5,17 +5,23 @@ namespace App\Http\Controllers\Auth; use App\Actions\User\CreateUser; +use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters; use App\Http\Controllers\Controller; use App\Models\User; use Illuminate\Auth\Events\Registered; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Laravel\Socialite\Facades\Socialite; -class SocialLoginController extends Controller +class GoogleController extends Controller { - public function redirect(): RedirectResponse + use PreservesUtmParameters; + + public function redirect(Request $request): RedirectResponse { + $this->storeUtmParameters($request); + return Socialite::driver('google-auth')->redirect(); } @@ -50,17 +56,22 @@ private function loginExistingUser(User $user, string $googleId): RedirectRespon Auth::login($user, remember: true); + $this->retrieveUtmParameters(); + return redirect()->route('app.home'); } private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): RedirectResponse { + $utmParameters = $this->retrieveUtmParameters(); + $user = CreateUser::execute([ 'name' => $googleUser->getName(), 'email' => $googleUser->getEmail(), 'google_id' => $googleUser->getId(), 'email_verified_at' => now(), - ]); + 'registration_ip' => request()->ip(), + ], $utmParameters); event(new Registered($user)); @@ -68,6 +79,6 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): session()->flash('auth_provider', 'google'); - return redirect()->route('register.success'); + return redirect()->route('register.success', $utmParameters); } } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 54c95c14..b030065a 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth; use App\Actions\User\CreateUser; +use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters; use App\Http\Controllers\Controller; use App\Models\User; use App\Rules\Timezone; @@ -18,8 +19,12 @@ class RegisteredUserController extends Controller { + use PreservesUtmParameters; + public function create(Request $request): Response { + $this->storeUtmParameters($request); + return Inertia::render('auth/Register', [ 'email' => $request->query('email'), 'redirect' => $request->query('redirect'), @@ -37,13 +42,16 @@ public function store(Request $request): RedirectResponse $isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/'); + $utmParameters = $this->retrieveUtmParameters(); + $user = CreateUser::execute([ 'name' => $request->name, 'email' => $request->email, 'password' => $request->password, 'timezone' => $request->input('timezone', 'UTC'), 'is_invite' => $isInviteRegistration, - ]); + 'registration_ip' => $request->ip(), + ], $utmParameters); event(new Registered($user)); @@ -57,6 +65,6 @@ public function store(Request $request): RedirectResponse session()->flash('auth_provider', 'email'); - return redirect()->route('register.success'); + return redirect()->route('register.success', $utmParameters); } } diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index f3b13785..da9300ed 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -59,6 +59,7 @@ public function share(Request $request): array 'aiEnabled' => ! empty(config('services.gemini.api_key')) || ! empty(config('services.openai.api_key')), 'selfHosted' => $isSelfHosted, 'googleAuthEnabled' => config('trypost.google_auth_enabled'), + 'githubAuthEnabled' => config('trypost.github_auth_enabled'), 'trialDays' => config('cashier.trial_days'), ]; } diff --git a/app/Models/User.php b/app/Models/User.php index 5b0eb8b6..dafee99d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -32,9 +32,16 @@ class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable 'email', 'password', 'google_id', + 'github_id', 'account_id', 'current_workspace_id', 'email_verified_at', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'registration_ip', ]; /** diff --git a/config/services.php b/config/services.php index d5ca4f63..af7517ce 100644 --- a/config/services.php +++ b/config/services.php @@ -76,6 +76,13 @@ 'redirect' => env('GOOGLE_AUTH_CALLBACK'), ], + // GitHub OAuth (used for login/signup) + 'github' => [ + 'client_id' => env('GITHUB_CLIENT_ID'), + 'client_secret' => env('GITHUB_CLIENT_SECRET'), + 'redirect' => env('GITHUB_AUTH_CALLBACK'), + ], + // Facebook Pages 'facebook' => [ 'client_id' => env('FACEBOOK_CLIENT_ID'), diff --git a/config/trypost.php b/config/trypost.php index ea202f01..9ab2e2b9 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -46,6 +46,8 @@ 'google_auth_enabled' => env('GOOGLE_AUTH_ENABLED', false), + 'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false), + /* |-------------------------------------------------------------------------- | Social Platforms diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 0a435277..61edb164 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -33,12 +33,19 @@ public function definition(): array 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'google_id' => null, + 'github_id' => null, 'remember_token' => Str::random(10), 'account_id' => Account::factory(), 'current_workspace_id' => null, 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, + 'utm_source' => null, + 'utm_medium' => null, + 'utm_campaign' => null, + 'utm_term' => null, + 'utm_content' => null, + 'registration_ip' => null, ]; } diff --git a/database/migrations/2026_05_04_212145_add_utm_columns_to_users_table.php b/database/migrations/2026_05_04_212145_add_utm_columns_to_users_table.php new file mode 100644 index 00000000..7f80b90b --- /dev/null +++ b/database/migrations/2026_05_04_212145_add_utm_columns_to_users_table.php @@ -0,0 +1,28 @@ +string('utm_source')->nullable()->after('current_workspace_id'); + $table->string('utm_medium')->nullable()->after('utm_source'); + $table->string('utm_campaign')->nullable()->after('utm_medium'); + $table->string('utm_term')->nullable()->after('utm_campaign'); + $table->string('utm_content')->nullable()->after('utm_term'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn(['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']); + }); + } +}; diff --git a/database/migrations/2026_05_04_212935_add_github_id_to_users_table.php b/database/migrations/2026_05_04_212935_add_github_id_to_users_table.php new file mode 100644 index 00000000..49cb5eaa --- /dev/null +++ b/database/migrations/2026_05_04_212935_add_github_id_to_users_table.php @@ -0,0 +1,24 @@ +string('github_id')->nullable()->unique()->after('google_id'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('github_id'); + }); + } +}; diff --git a/database/migrations/2026_05_04_212935_add_registration_ip_to_users_table.php b/database/migrations/2026_05_04_212935_add_registration_ip_to_users_table.php new file mode 100644 index 00000000..f1c0bbfc --- /dev/null +++ b/database/migrations/2026_05_04_212935_add_registration_ip_to_users_table.php @@ -0,0 +1,24 @@ +ipAddress('registration_ip')->nullable()->after('utm_content'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('registration_ip'); + }); + } +}; diff --git a/lang/en/auth.php b/lang/en/auth.php index ec66618b..9662c88d 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -54,6 +54,9 @@ 'or_continue_with' => 'Or continue with', 'google_login' => 'Log in with Google', 'google_signup' => 'Sign up with Google', + 'github_login' => 'Log in with GitHub', + 'github_signup' => 'Sign up with GitHub', + 'github_email_unavailable' => 'Unable to retrieve your email from GitHub. Make your GitHub email public or grant the email scope, then try again.', 'signup_success' => [ 'page_title' => 'Welcome', diff --git a/lang/es/auth.php b/lang/es/auth.php index 7c947049..383fb2ba 100644 --- a/lang/es/auth.php +++ b/lang/es/auth.php @@ -42,6 +42,9 @@ 'or_continue_with' => 'O continuar con', 'google_login' => 'Iniciar sesión con Google', 'google_signup' => 'Registrarse con Google', + 'github_login' => 'Iniciar sesión con GitHub', + 'github_signup' => 'Registrarse con GitHub', + 'github_email_unavailable' => 'No fue posible obtener tu correo de GitHub. Haz tu correo público en GitHub o concede el permiso de correo y vuelve a intentar.', 'signup_success' => [ 'page_title' => 'Bienvenido', diff --git a/lang/pt-BR/auth.php b/lang/pt-BR/auth.php index 1bc72741..192cbe0b 100644 --- a/lang/pt-BR/auth.php +++ b/lang/pt-BR/auth.php @@ -54,6 +54,9 @@ 'or_continue_with' => 'Ou continue com', 'google_login' => 'Entrar com Google', 'google_signup' => 'Cadastrar com Google', + 'github_login' => 'Entrar com GitHub', + 'github_signup' => 'Cadastrar com GitHub', + 'github_email_unavailable' => 'Não foi possível obter seu e-mail do GitHub. Torne seu e-mail público ou conceda a permissão de e-mail e tente novamente.', 'signup_success' => [ 'page_title' => 'Bem-vindo', diff --git a/resources/js/components/auth/GoogleAuthButton.vue b/resources/js/components/auth/GoogleAuthButton.vue deleted file mode 100644 index 6fa14435..00000000 --- a/resources/js/components/auth/GoogleAuthButton.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/resources/js/components/auth/SocialLogin.vue b/resources/js/components/auth/SocialLogin.vue new file mode 100644 index 00000000..bb8a8f6f --- /dev/null +++ b/resources/js/components/auth/SocialLogin.vue @@ -0,0 +1,38 @@ + + + diff --git a/resources/js/pages/auth/Login.vue b/resources/js/pages/auth/Login.vue index cc3f0f9e..6a3376f7 100644 --- a/resources/js/pages/auth/Login.vue +++ b/resources/js/pages/auth/Login.vue @@ -1,7 +1,7 @@