Merge pull request #16 from trypost-it/feature/capture-signup-utms
feat: capture signup UTMs/IP and add GitHub OAuth login
This commit is contained in:
commit
c543bec6af
27 changed files with 729 additions and 50 deletions
|
|
@ -124,11 +124,19 @@ THREADS_CLIENT_REDIRECT="${APP_URL}/accounts/threads/callback"
|
|||
|
||||
# Google (https://console.cloud.google.com)
|
||||
# Used for YouTube social account connection AND Google login/signup
|
||||
GOOGLE_AUTH_ENABLED=false
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback"
|
||||
GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback"
|
||||
|
||||
# GitHub (https://github.com/settings/developers)
|
||||
# Used for GitHub login/signup
|
||||
GITHUB_AUTH_ENABLED=false
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GITHUB_AUTH_CALLBACK="${APP_URL}/auth/github/callback"
|
||||
|
||||
# Pinterest (https://developers.pinterest.com)
|
||||
PINTEREST_CLIENT_ID=
|
||||
PINTEREST_CLIENT_SECRET=
|
||||
|
|
|
|||
|
|
@ -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<string, string> $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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait PreservesUtmParameters
|
||||
{
|
||||
private const array UTM_KEYS = [
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
private function retrieveUtmParameters(): array
|
||||
{
|
||||
return session()->pull('utm_parameters', []);
|
||||
}
|
||||
}
|
||||
92
app/Http/Controllers/Auth/GitHubController.php
Normal file
92
app/Http/Controllers/Auth/GitHubController.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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 GitHubController extends Controller
|
||||
{
|
||||
use PreservesUtmParameters;
|
||||
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@
|
|||
|
||||
'google_auth_enabled' => env('GOOGLE_AUTH_ENABLED', false),
|
||||
|
||||
'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Social Platforms
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->string('github_id')->nullable()->unique()->after('google_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropColumn('github_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->ipAddress('registration_ip')->nullable()->after('utm_content');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropColumn('registration_ip');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
10
public/images/social/github.svg
Normal file
10
public/images/social/github.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 851 B |
|
|
@ -1,29 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { redirect as googleRedirect } from '@/routes/auth/google';
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
}>();
|
||||
|
||||
const page = usePage();
|
||||
const isEnabled = computed(() => page.props.googleAuthEnabled);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="isEnabled">
|
||||
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
|
||||
<img src="/images/social/google.svg" alt="Google" class="size-4" />
|
||||
{{ label }}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
|
||||
>
|
||||
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
39
resources/js/components/auth/SocialLogin.vue
Normal file
39
resources/js/components/auth/SocialLogin.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { redirect as githubRedirect } from '@/routes/auth/github';
|
||||
import { redirect as googleRedirect } from '@/routes/auth/google';
|
||||
|
||||
defineProps<{
|
||||
mode: 'login' | 'signup';
|
||||
}>();
|
||||
|
||||
const page = usePage();
|
||||
const googleEnabled = computed(() => Boolean(page.props.googleAuthEnabled));
|
||||
const githubEnabled = computed(() => Boolean(page.props.githubAuthEnabled));
|
||||
const hasSocial = computed(() => googleEnabled.value || githubEnabled.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="hasSocial">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button v-if="googleEnabled" variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
|
||||
<img src="/images/social/google.svg" alt="Google" class="size-4" />
|
||||
{{ mode === 'login' ? $t('auth.google_login') : $t('auth.google_signup') }}
|
||||
</Button>
|
||||
|
||||
<Button v-if="githubEnabled" variant="outline" class="w-full" as="a" :href="githubRedirect.url()">
|
||||
<img src="/images/social/github.svg" alt="GitHub" class="size-4 dark:invert" />
|
||||
{{ mode === 'login' ? $t('auth.github_login') : $t('auth.github_signup') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
|
||||
>
|
||||
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
|
||||
import GoogleAuthButton from '@/components/auth/GoogleAuthButton.vue';
|
||||
import SocialLogin from '@/components/auth/SocialLogin.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -31,7 +31,7 @@ defineProps<{
|
|||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<GoogleAuthButton :label="$t('auth.google_login')" />
|
||||
<SocialLogin mode="login" />
|
||||
|
||||
<Form v-bind="store.form()" :reset-on-success="['password']" v-slot="{ errors, processing }"
|
||||
class="flex flex-col gap-6">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Form, Head } from '@inertiajs/vue3';
|
|||
import { IconEye, IconEyeOff } from '@tabler/icons-vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import GoogleAuthButton from '@/components/auth/GoogleAuthButton.vue';
|
||||
import SocialLogin from '@/components/auth/SocialLogin.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -39,7 +39,7 @@ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|||
<Head :title="$t('auth.register.page_title')" />
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<GoogleAuthButton :label="$t('auth.google_signup')" />
|
||||
<SocialLogin mode="signup" />
|
||||
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { settings as settingsHub } from '@/routes/app';
|
|||
import { edit as editAuthentication } from '@/routes/app/authentication';
|
||||
import { preferences as notificationPreferences } from '@/routes/app/notifications';
|
||||
import { edit as editProfile } from '@/routes/app/profile';
|
||||
import { redirect as githubRedirect } from '@/routes/auth/github';
|
||||
import { redirect as googleRedirect } from '@/routes/auth/google';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ type Session = {
|
|||
};
|
||||
|
||||
type ConnectedAccount = {
|
||||
provider: 'google';
|
||||
provider: 'google' | 'github';
|
||||
label: string;
|
||||
connected: boolean;
|
||||
can_disconnect: boolean;
|
||||
|
|
@ -67,6 +68,7 @@ const tabs = computed(() => [
|
|||
|
||||
const providerRedirects: Record<ConnectedAccount['provider'], () => { url: string }> = {
|
||||
google: googleRedirect,
|
||||
github: githubRedirect,
|
||||
};
|
||||
|
||||
const passwordHeading = computed(() =>
|
||||
|
|
@ -298,7 +300,7 @@ const logoutDialogOpen = ref(false);
|
|||
<img
|
||||
:src="`/images/social/${account.provider}.svg`"
|
||||
:alt="account.label"
|
||||
class="size-5"
|
||||
:class="['size-5', account.provider === 'github' ? 'dark:invert' : '']"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 space-y-0.5">
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@
|
|||
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationPromptController;
|
||||
use App\Http\Controllers\Auth\GitHubController;
|
||||
use App\Http\Controllers\Auth\GoogleController;
|
||||
use App\Http\Controllers\Auth\NewPasswordController;
|
||||
use App\Http\Controllers\Auth\PasswordResetLinkController;
|
||||
use App\Http\Controllers\Auth\RegisteredUserController;
|
||||
use App\Http\Controllers\Auth\SignupSuccessController;
|
||||
use App\Http\Controllers\Auth\SocialLoginController;
|
||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
|
@ -29,8 +30,11 @@
|
|||
Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])->name('password.reset');
|
||||
Route::post('/reset-password', [NewPasswordController::class, 'store'])->name('password.store');
|
||||
|
||||
Route::get('/auth/google/redirect', [SocialLoginController::class, 'redirect'])->name('auth.google.redirect');
|
||||
Route::get('/auth/google/callback', [SocialLoginController::class, 'callback'])->name('auth.google.callback');
|
||||
Route::get('/auth/google/redirect', [GoogleController::class, 'redirect'])->name('auth.google.redirect');
|
||||
Route::get('/auth/google/callback', [GoogleController::class, 'callback'])->name('auth.google.callback');
|
||||
|
||||
Route::get('/auth/github/redirect', [GitHubController::class, 'redirect'])->name('auth.github.redirect');
|
||||
Route::get('/auth/github/callback', [GitHubController::class, 'callback'])->name('auth.github.callback');
|
||||
});
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
|
|
|
|||
319
tests/Feature/Auth/SignupUtmTrackingTest.php
Normal file
319
tests/Feature/Auth/SignupUtmTrackingTest.php
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
|
||||
test('email registration saves utm parameters from the register page query string', function () {
|
||||
$utms = [
|
||||
'utm_source' => 'peerlist',
|
||||
'utm_medium' => 'social',
|
||||
'utm_campaign' => 'spring-launch',
|
||||
'utm_term' => 'social-platform',
|
||||
'utm_content' => 'cta-button',
|
||||
];
|
||||
|
||||
$this->get(route('register', $utms));
|
||||
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'UTM User',
|
||||
'email' => 'utm@example.com',
|
||||
'password' => 'Password123!',
|
||||
])
|
||||
->assertRedirect(route('register.success', $utms, absolute: false));
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'utm@example.com',
|
||||
...$utms,
|
||||
]);
|
||||
});
|
||||
|
||||
test('email registration without utm parameters saves null utm columns and redirects without query string', function () {
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'No UTM User',
|
||||
'email' => 'no-utm@example.com',
|
||||
'password' => 'Password123!',
|
||||
])
|
||||
->assertRedirect(route('register.success', absolute: false));
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'no-utm@example.com',
|
||||
'utm_source' => null,
|
||||
'utm_medium' => null,
|
||||
'utm_campaign' => null,
|
||||
'utm_term' => null,
|
||||
'utm_content' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('email registration strips non-utm query params from the success redirect', function () {
|
||||
$this->get(route('register', [
|
||||
'utm_source' => 'peerlist',
|
||||
'foo' => 'bar',
|
||||
'ref' => '123',
|
||||
]));
|
||||
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Strip Test',
|
||||
'email' => 'strip@example.com',
|
||||
'password' => 'Password123!',
|
||||
])
|
||||
->assertRedirect(route('register.success', ['utm_source' => 'peerlist'], absolute: false));
|
||||
});
|
||||
|
||||
test('google registration saves utm parameters captured before the oauth round-trip', function () {
|
||||
$utms = [
|
||||
'utm_source' => 'twitter',
|
||||
'utm_medium' => 'paid',
|
||||
'utm_campaign' => 'q1-growth',
|
||||
];
|
||||
|
||||
$this->get(route('auth.google.redirect', $utms));
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'g-utm';
|
||||
$socialiteUser->name = 'Google UTM';
|
||||
$socialiteUser->email = 'google-utm@example.com';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('google-auth')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.google.callback'))
|
||||
->assertRedirect(route('register.success', $utms, absolute: false));
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'google-utm@example.com',
|
||||
...$utms,
|
||||
'utm_term' => null,
|
||||
'utm_content' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('utm parameters captured on the register page survive a google oauth round-trip', function () {
|
||||
$utms = ['utm_source' => 'newsletter', 'utm_medium' => 'email'];
|
||||
|
||||
$this->get(route('register', $utms));
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'g-cross';
|
||||
$socialiteUser->name = 'Cross Flow';
|
||||
$socialiteUser->email = 'cross-flow@example.com';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('google-auth')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.google.callback'))
|
||||
->assertRedirect(route('register.success', $utms, absolute: false));
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'cross-flow@example.com',
|
||||
...$utms,
|
||||
]);
|
||||
});
|
||||
|
||||
test('existing google user login consumes the utm session so utms do not leak to a later signup', function () {
|
||||
User::factory()->create([
|
||||
'email' => 'existing@example.com',
|
||||
'google_id' => 'g-existing',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.google.redirect', ['utm_source' => 'twitter']));
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'g-existing';
|
||||
$socialiteUser->name = 'Existing User';
|
||||
$socialiteUser->email = 'existing@example.com';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('google-auth')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.google.callback'))
|
||||
->assertRedirect(route('app.home'));
|
||||
|
||||
expect(session()->get('utm_parameters'))->toBeNull();
|
||||
});
|
||||
|
||||
test('invitation registration does not include utm parameters in its redirect', function () {
|
||||
$this->get(route('register', ['utm_source' => 'email']));
|
||||
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Invited User',
|
||||
'email' => 'invited@example.com',
|
||||
'password' => 'Password123!',
|
||||
'redirect' => '/invites/some-token',
|
||||
])
|
||||
->assertRedirect('/invites/some-token');
|
||||
});
|
||||
|
||||
test('utm values longer than 255 characters are truncated before being stored', function () {
|
||||
$longValue = str_repeat('a', 300);
|
||||
|
||||
$this->get(route('register', ['utm_source' => $longValue]));
|
||||
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Long UTM User',
|
||||
'email' => 'long-utm@example.com',
|
||||
'password' => 'Password123!',
|
||||
]);
|
||||
|
||||
$user = User::where('email', 'long-utm@example.com')->first();
|
||||
|
||||
expect(mb_strlen($user->utm_source))->toBe(255);
|
||||
});
|
||||
|
||||
test('email registration captures the requesting ip address', function () {
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'IP User',
|
||||
'email' => 'ip@example.com',
|
||||
'password' => 'Password123!',
|
||||
]);
|
||||
|
||||
$user = User::where('email', 'ip@example.com')->first();
|
||||
|
||||
expect($user->registration_ip)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('google registration captures the requesting ip address', function () {
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'g-ip';
|
||||
$socialiteUser->name = 'Google IP';
|
||||
$socialiteUser->email = 'google-ip@example.com';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('google-auth')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.google.callback'));
|
||||
|
||||
$user = User::where('email', 'google-ip@example.com')->first();
|
||||
|
||||
expect($user->registration_ip)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('github registration saves utm parameters captured before the oauth round-trip', function () {
|
||||
$utms = [
|
||||
'utm_source' => 'hackernews',
|
||||
'utm_medium' => 'organic',
|
||||
'utm_campaign' => 'launch-week',
|
||||
];
|
||||
|
||||
$this->get(route('auth.github.redirect', $utms));
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'gh-utm';
|
||||
$socialiteUser->name = 'GitHub UTM';
|
||||
$socialiteUser->email = 'github-utm@example.com';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('github')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('scopes')
|
||||
->andReturnSelf();
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.github.callback'))
|
||||
->assertRedirect(route('register.success', $utms, absolute: false));
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'github-utm@example.com',
|
||||
...$utms,
|
||||
'utm_term' => null,
|
||||
'utm_content' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('github registration captures the requesting ip address', function () {
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'gh-ip';
|
||||
$socialiteUser->name = 'GitHub IP';
|
||||
$socialiteUser->email = 'github-ip@example.com';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('github')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('scopes')
|
||||
->andReturnSelf();
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.github.callback'));
|
||||
|
||||
$user = User::where('email', 'github-ip@example.com')->first();
|
||||
|
||||
expect($user->registration_ip)->not->toBeNull();
|
||||
expect($user->github_id)->toBe('gh-ip');
|
||||
});
|
||||
|
||||
test('github registration without email redirects to login with error', function () {
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'gh-no-email';
|
||||
$socialiteUser->name = 'No Email';
|
||||
$socialiteUser->email = null;
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('github')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('scopes')
|
||||
->andReturnSelf();
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.github.callback'))
|
||||
->assertRedirect(route('login'))
|
||||
->assertSessionHasErrors('email');
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('existing github user login consumes the utm session and skips signup success', function () {
|
||||
User::factory()->create([
|
||||
'email' => 'existing-gh@example.com',
|
||||
'github_id' => 'gh-existing',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.github.redirect', ['utm_source' => 'hackernews']));
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'gh-existing';
|
||||
$socialiteUser->name = 'Existing GitHub';
|
||||
$socialiteUser->email = 'existing-gh@example.com';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('github')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('scopes')
|
||||
->andReturnSelf();
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.github.callback'))
|
||||
->assertRedirect(route('app.home'));
|
||||
|
||||
expect(session()->get('utm_parameters'))->toBeNull();
|
||||
});
|
||||
54
tests/Feature/GitHubAuthToggleTest.php
Normal file
54
tests/Feature/GitHubAuthToggleTest.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
test('login page shares github auth enabled prop as false when disabled', function () {
|
||||
config(['trypost.github_auth_enabled' => false]);
|
||||
|
||||
$response = $this->get(route('login'));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$page = $response->original->getData()['page'];
|
||||
expect($page['props']['githubAuthEnabled'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('login page shares github auth enabled prop as true when enabled', function () {
|
||||
config(['trypost.github_auth_enabled' => true]);
|
||||
|
||||
$response = $this->get(route('login'));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$page = $response->original->getData()['page'];
|
||||
expect($page['props']['githubAuthEnabled'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('register page shares github auth enabled prop', function () {
|
||||
config(['trypost.github_auth_enabled' => true]);
|
||||
|
||||
$response = $this->get(route('register'));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$page = $response->original->getData()['page'];
|
||||
expect($page['props']['githubAuthEnabled'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('github auth redirect route exists', function () {
|
||||
config(['services.github.client_id' => 'test-id']);
|
||||
config(['services.github.client_secret' => 'test-secret']);
|
||||
config(['services.github.redirect' => 'https://app.trypost.test/auth/github/callback']);
|
||||
|
||||
$response = $this->get(route('auth.github.redirect'));
|
||||
|
||||
// Should redirect to GitHub OAuth, not 404
|
||||
$response->assertRedirect();
|
||||
});
|
||||
|
||||
test('github auth callback route exists', function () {
|
||||
$response = $this->get(route('auth.github.callback'));
|
||||
|
||||
// Should redirect to login on failure (no OAuth code), not 404
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
Loading…
Reference in a new issue