Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
}
},
"require": {
"laravel/socialite": "^5.21",
"stechstudio/filament-impersonate": "^5.1"
"laravel/socialite": "^5.30.1",
"stechstudio/filament-impersonate": "^5.6"
},
"minimum-stability": "stable"
}
4,179 changes: 1,707 additions & 2,472 deletions composer.lock

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

use Spatie\LaravelSettings\Migrations\SettingsMigration;

return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->add('auth.enabled_socialite_providers', []);
}
};
79 changes: 46 additions & 33 deletions resources/js/react/components/SocialiteProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,66 @@ import IconGoogle from '~icons/simple-icons/google';

type Provider = {
name: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
};

const providers: Provider[] = [
{ name: 'google', icon: IconGoogle },
{ name: 'github', icon: IconGithub },
];
type AuthProps = {
last_social_provider?: string | null;
socialite_providers?: Provider[];
};

const providerIcons: Record<
string,
React.ComponentType<{ className?: string }>
> = {
google: IconGoogle,
github: IconGithub,
};

export default function SocialiteProviders() {
const t = useT();
const page = usePage();
const lastUsed = (page.props.auth as any)?.last_social_provider as
| string
| undefined;
const auth = (page.props.auth as AuthProps) ?? {};
const providers = auth.socialite_providers ?? [];
const lastUsed = auth.last_social_provider;

if (!route().has('auth.socialite.redirect') || !providers.length) {
return null;
}

return (
<div className="mb-2 space-y-3">
{providers.map(({ name, icon: Icon }) => (
<div key={name} className="relative">
<Button variant="outline" className="w-full" asChild>
<a
href={route('auth.socialite.redirect', {
provider: name,
})}
>
<Icon className="h-5 w-5" />
<span>
{t('Connect with :Provider', {
Provider: name,
<div className="mb-2 space-y-3" data-testid="socialite-providers">
{providers.map(({ name, label }) => {
const Icon = providerIcons[name];

return (
<div key={name} className="relative">
<Button variant="outline" className="w-full" asChild>
<a
href={route('auth.socialite.redirect', {
provider: name,
})}
data-testid={`socialite-provider-${name}`}
>
{Icon && <Icon className="h-5 w-5" />}
<span>
{t('Connect with :Provider', {
Provider: label,
})}
</span>
</a>
</Button>
{lastUsed === name && (
<span
data-testid={`last-used-badge-${name}`}
className="bg-muted/80 text-muted-foreground absolute -top-2 -right-2 rounded-xl border px-2 py-0.5 text-xs drop-shadow-lg"
>
{t('Last used')}
</span>
</a>
</Button>
{lastUsed === name && (
<span
data-testid={`last-used-badge-${name}`}
className="bg-muted/80 text-muted-foreground absolute -top-2 -right-2 rounded-xl border px-2 py-0.5 text-xs drop-shadow-lg"
>
{t('Last used')}
</span>
)}
</div>
))}
)}
</div>
);
})}
<div className="after:border-border 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">
<span className="bg-card text-muted-foreground relative z-10 px-2">
{t('Or continue with email')}
Expand Down
4 changes: 4 additions & 0 deletions resources/js/types/page-props.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ declare module '@inertiajs/core' {
user: User | null;
last_social_provider?: string | null;
magic_link_enabled?: boolean;
socialite_providers?: Array<{
name: string;
label: string;
}>;
};
}
}
Expand Down
33 changes: 24 additions & 9 deletions resources/js/vue/components/SocialiteProviders.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,41 @@ import IconGithub from '~icons/simple-icons/github';
import IconGoogle from '~icons/simple-icons/google';

type Provider = { name: string; icon: any };
type AuthProps = {
last_social_provider?: string | null;
socialite_providers?: Array<{ name: string; label: string }>;
};

const providers: Provider[] = [
{ name: 'google', icon: IconGoogle },
{ name: 'github', icon: IconGithub },
];
const providerIcons: Record<string, Provider['icon']> = {
google: IconGoogle,
github: IconGithub,
};

const lastUsed = computed(() => usePage().props.auth.last_social_provider);
const page = usePage();
const auth = computed(() => (page.props.auth as AuthProps) ?? {});
const providers = computed(() => auth.value.socialite_providers ?? []);
const lastUsed = computed(() => auth.value.last_social_provider);
</script>

<template>
<div
v-if="route().has('auth.socialite.redirect') && providers.length"
class="mb-2 space-y-3"
data-testid="socialite-providers"
>
<div v-for="{ name, icon } in providers" :key="name" class="relative">
<div v-for="{ name, label } in providers" :key="name" class="relative">
<Button as-child variant="outline" class="w-full">
<a :href="route('auth.socialite.redirect', { provider: name })">
<component :is="icon" class="h-5 w-5" />
<a
:href="route('auth.socialite.redirect', { provider: name })"
:data-testid="`socialite-provider-${name}`"
>
<component
:is="providerIcons[name]"
v-if="providerIcons[name]"
class="h-5 w-5"
/>
<span>
{{ $t('Connect with :Provider', { provider: name }) }}
{{ $t('Connect with :Provider', { Provider: label }) }}
</span>
</a>
</Button>
Expand Down
3 changes: 3 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Modules\Auth\Http\Controllers\ResetPasswordController;
use Modules\Auth\Http\Controllers\SocialiteController;
use Modules\Auth\Http\Controllers\VerifyEmailController;
use Modules\Auth\Http\Middleware\EnsureSocialiteProviderEnabled;

Route::middleware('web')->group(function (): void {
Route::prefix('auth')->group(function (): void {
Expand Down Expand Up @@ -81,9 +82,11 @@
* - Authenticated users can use them to connect additional social providers
*/
Route::get('socialite/{provider}', [SocialiteController::class, 'redirect'])
->middleware(EnsureSocialiteProviderEnabled::class)
->name('auth.socialite.redirect');

Route::get('socialite/{provider}/callback', [SocialiteController::class, 'callback'])
->middleware(EnsureSocialiteProviderEnabled::class)
->name('auth.socialite.callback');

/**
Expand Down
22 changes: 22 additions & 0 deletions src/Filament/Pages/AuthenticationSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@

use App\Filament\Pages\SettingsPage;
use BackedEnum;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Validation\Rule;
use Modules\Auth\Services\SocialiteService;
use Modules\Auth\Settings\AuthSettings;

class AuthenticationSettings extends SettingsPage
Expand All @@ -32,6 +35,25 @@ public function getTitle(): string
public function form(Schema $schema): Schema
{
return $schema->columns(1)->components([
Section::make(__('Social Login'))
->description(__('Choose which social providers visitors can use to sign in or create an account.'))
->icon(Heroicon::OutlinedShare)
->schema([
CheckboxList::make('enabled_socialite_providers')
->label(__('Enabled providers'))
->extraAttributes(['data-testid' => 'admin-enabled-socialite-providers'])
->options(fn (): array => collect(SocialiteService::providers())
->mapWithKeys(fn (array $provider): array => [
$provider['name'] => $provider['label'],
])
->all())
->nestedRecursiveRule(fn (CheckboxList $component) => Rule::in(
array_keys($component->getEnabledOptions()),
))
->helperText(__('Provider credentials must also be configured in the deployment environment.'))
->columns(2)
->columnSpanFull(),
]),
Section::make(__('Magic Link Authentication'))
->description(__('Configure passwordless authentication by email.'))
->icon(Heroicon::OutlinedKey)
Expand Down
30 changes: 30 additions & 0 deletions src/Http/Middleware/EnsureSocialiteProviderEnabled.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Modules\Auth\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Modules\Auth\Services\SocialiteService;
use Symfony\Component\HttpFoundation\Response;

class EnsureSocialiteProviderEnabled
{
public function __construct(private readonly SocialiteService $socialiteService) {}

/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$provider = $request->route('provider');

abort_unless(
is_string($provider) && $this->socialiteService->isProviderEnabled($provider),
404,
);

return $next($request);
}
}
5 changes: 5 additions & 0 deletions src/Providers/AuthServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Modules\Auth\Services\SocialiteService;
use Modules\Auth\Settings\AuthSettings;
use Spatie\Permission\Models\Role;
use STS\FilamentImpersonate\ImpersonateManager;
Expand All @@ -20,6 +21,10 @@ protected function shareInertiaData(): void
{
Inertia::share('auth.user', fn () => Auth::user());
Inertia::share('auth.last_social_provider', fn () => request()->cookie('last_social_provider'));
Inertia::share(
'auth.socialite_providers',
fn (): array => $this->app->make(SocialiteService::class)->enabledProviders(),
);
Inertia::share(
'auth.magic_link_enabled',
fn (): bool => $this->app->make(AuthSettings::class)->magic_link_enabled,
Expand Down
40 changes: 36 additions & 4 deletions src/Services/SocialiteService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,53 @@
use Laravel\Socialite\Two\User as SocialiteUser;
use Modules\Auth\Exceptions\SocialiteException;
use Modules\Auth\Models\SocialAccount;
use Modules\Auth\Settings\AuthSettings;

class SocialiteService
{
public function __construct(private readonly AuthSettings $settings) {}

/**
* Get the list of available social providers.
*
* @return list<array{name: string, label: string}>
*/
public static function providers(): array
{
return config('services.socialite_providers', []);
return array_values(array_filter(
(array) config('services.socialite_providers', []),
fn (mixed $provider): bool => is_array($provider)
&& isset($provider['name'], $provider['label'])
&& is_string($provider['name'])
&& is_string($provider['label']),
));
}

protected function validateProvider(string $provider): void
/**
* Get the configured providers that the administrator has enabled.
*
* @return list<array{name: string, label: string}>
*/
public function enabledProviders(): array
{
$enabledProviders = array_flip($this->settings->enabled_socialite_providers);

return array_values(array_filter(
self::providers(),
fn (array $provider): bool => array_key_exists($provider['name'], $enabledProviders),
));
}

public function isProviderEnabled(string $provider): bool
{
$availableProviders = array_map(fn ($p) => $p['name'], self::providers());
return collect($this->enabledProviders())->contains(
fn (array $enabledProvider): bool => $enabledProvider['name'] === $provider,
);
}

if (! in_array($provider, $availableProviders, true)) {
protected function validateProvider(string $provider): void
{
if (! $this->isProviderEnabled($provider)) {
throw SocialiteException::unsupportedProvider($provider);
}
}
Expand Down Expand Up @@ -84,6 +115,7 @@ public function handleCallback(string $provider): User
*/
public function linkAccountToUser(User $user, string $provider, SocialiteUser $socialiteUser): SocialAccount
{
$this->validateProvider($provider);
$this->validateSocialUser($socialiteUser);

$avatarUrl = $this->validateAvatarUrl($socialiteUser->getAvatar());
Expand Down
3 changes: 3 additions & 0 deletions src/Settings/AuthSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

class AuthSettings extends Settings
{
/** @var list<string> */
public array $enabled_socialite_providers;

public bool $magic_link_enabled;

public int $magic_link_expiry;
Expand Down
4 changes: 3 additions & 1 deletion tests/Feature/AuthSettingsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ class AuthSettingsTest extends TestCase
{
use RefreshDatabase;

public function test_fresh_install_has_magic_link_defaults(): void
public function test_fresh_install_has_authentication_defaults(): void
{
$settings = app(AuthSettings::class);

$this->assertSame([], $settings->enabled_socialite_providers);
$this->assertTrue($settings->magic_link_enabled);
$this->assertSame(15, $settings->magic_link_expiry);
$this->assertFalse($settings->login_notification_enabled);
}
}
Loading