feat(auth): consolidate OAuth settings into standard authentication page

Display all OAuth providers on one settings page, prioritize OpenID Connect, and add its provider icon.
This commit is contained in:
Andras Bacsai
2026-08-18 14:22:49 +02:00
parent 253e59e731
commit 8400e26a3f
4 changed files with 144 additions and 166 deletions
+6 -4
View File
@@ -66,11 +66,13 @@ class SettingsOauth extends Component
$this->settings = instanceSettings();
$this->selectedProvider = $provider;
$this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled;
$this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) {
$carry[$setting->provider] = $this->oauthSettingToArray($setting);
$this->oauth_settings_map = OauthSetting::all()
->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider)
->reduce(function ($carry, $setting) {
$carry[$setting->provider] = $this->oauthSettingToArray($setting);
return $carry;
}, []);
return $carry;
}, []);
if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) {
abort(404);
+5
View File
@@ -0,0 +1,5 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>OpenID Connect</title>
<path fill-rule="evenodd" d="M10 5.5a7.5 7.5 0 1 0 7.5 7.5c0-.9-.16-1.77-.45-2.57l-2.78 1.04c.15.48.23.99.23 1.53a4.5 4.5 0 1 1-4.5-4.5c.54 0 1.05.09 1.53.26l1.05-2.8A7.48 7.48 0 0 0 10 5.5Z" clip-rule="evenodd"/>
<path d="M16.5 1 12 5.5h3V10h3V5.5h3L16.5 1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 383 B

+112 -143
View File
@@ -1,153 +1,122 @@
<div>
<x-slot:title>
Settings | Coolify
Authentication | Coolify
</x-slot>
<x-settings.navbar />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="sub-menu-wrapper">
<a class="sub-menu-item {{ $selectedProvider === null ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('settings.oauth') }}"><span class="menu-item-label">General</span></a>
<x-settings.layout>
<x-slot:submenu>
<div
x-data="{ activeProvider: location.hash.slice(1).replace('-oauth-section', '') || @js($selectedProvider ?? array_key_first($oauth_settings_map)) }"
@hashchange.window="activeProvider = location.hash.slice(1).replace('-oauth-section', '')">
<nav aria-label="OAuth providers" class="grid gap-0.5 py-1">
@foreach ($oauth_settings_map as $provider => $oauth_setting)
<a href="#{{ $provider }}-oauth-section" class="menu-item min-h-8! py-1! text-[12px]!"
:class="{ 'menu-item-active': activeProvider === '{{ $provider }}' }"
@click.prevent="activeProvider = '{{ $provider }}'; history.replaceState(null, '', '#{{ $provider }}-oauth-section'); window.scrollToSettingsSection?.('{{ $provider }}-oauth-section')">
<span class="menu-item-icon bg-current"
style="mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat; -webkit-mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat;"></span>
<span class="menu-item-label">{{ $oauth_setting['label'] }}</span>
</a>
@endforeach
</nav>
</div>
</x-slot:submenu>
<form wire:submit="submit" class="application-settings-form flex w-full min-w-0 flex-col gap-6">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="Registration"
description="Control password registration when an OAuth provider is available.">
<x-forms.checkbox canGate="update" :canResource="$settings"
id="disable_registration_when_oauth_enabled"
label="Disable password registration when OAuth is enabled"
helper="OAuth providers can still create users when registration is enabled for that provider."
instantSave="saveRegistrationPolicy" />
</x-application.settings-section>
@foreach ($oauth_settings_map as $provider => $oauth_setting)
<a class="sub-menu-item {{ $selectedProvider === $provider ? 'menu-item-active' : '' }}"
{{ wireNavigate() }} href="{{ route('settings.oauth.provider', $provider) }}"><span
class="menu-item-label">{{ $oauth_setting['label'] }}</span></a>
@endforeach
</div>
<form wire:submit='submit' class="flex flex-col w-full">
@if ($selectedProvider === null)
<div class="flex flex-col">
<div class="flex items-center gap-2 pb-2">
<h2>Authentication</h2>
</div>
<div class="pb-4">General authentication settings for your Coolify instance.</div>
</div>
<div class="flex flex-col gap-2 pt-4">
<div>
<div class="flex items-center gap-2">
<h3>Registration</h3>
<x-helper
helper="When enabled, the normal registration page is hidden if at least one OAuth provider is enabled. OAuth providers can still create users if their provider-specific registration option allows it." />
<x-application.settings-section id="{{ $provider }}-oauth-section" class="scroll-mt-28"
title="{{ $oauth_setting['label'] }}">
<x-slot:actions>
<div x-data="{ enabled: @js((bool) $oauth_setting['enabled']), provider: @js($provider) }">
<x-forms.button canGate="update" :canResource="$settings" type="button"
:isHighlighted="!$oauth_setting['enabled']"
x-on:click="
if (!enabled) {
const invalidField = [...$el.closest('section').querySelectorAll('[required]')]
.find(field => !field.checkValidity());
if (invalidField) { invalidField.reportValidity(); return; }
}
$wire.toggleProvider(provider);
">
{{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }}
</x-forms.button>
</div>
<div class="w-full max-w-2xl">
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.redirect_uri"
placeholder="{{ route('auth.callback', $provider) }}" label="Redirect URI" />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.client_id" label="Client ID" required />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.client_secret" type="password"
label="Client secret" autocomplete="new-password" required />
@if ($provider === 'azure')
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.tenant" label="Tenant" required />
@endif
@if ($provider === 'google')
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.tenant"
helper="Optional hosted domain supplied to Google as a login hint."
label="Hosted domain" />
@endif
@if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true))
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.base_url" label="Base URL"
:required="in_array($provider, ['authentik', 'clerk'], true)" />
@endif
@if ($provider === 'oidc')
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.base_url" label="Issuer URL" required
helper="OpenID Provider issuer URL, for example https://example.okta.com. Coolify uses it to discover the authorization, token, userinfo, and JWKS endpoints." />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.custom_label" label="Login button label"
placeholder="Login with SSO" />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.scopes" label="Scopes"
helper="Must include openid. Common scopes are openid email profile groups." />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.clock_skew_seconds" type="number"
label="Clock skew (seconds)" />
@endif
</div>
<div class="mt-4 grid gap-3 lg:grid-cols-2">
@if ($provider === 'oidc')
<x-forms.checkbox canGate="update" :canResource="$settings"
id="disable_registration_when_oauth_enabled"
label="Disable password registration when OAuth is enabled"
instantSave="saveRegistrationPolicy" fullWidth />
</div>
id="oauth_settings_map.{{ $provider }}.allow_registration"
label="Allow OIDC user creation"
helper="Allow a successful OIDC login to create a user when password registration is disabled." />
<x-forms.checkbox canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.require_email_verified"
label="Require verified email" />
<x-forms.checkbox canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.use_pkce" label="Use PKCE" />
@endif
<x-forms.checkbox canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.auto_join_root_team"
label="Auto-join new users to Root team"
helper="Add newly-created OAuth users to the Root team as members without creating a personal team." />
</div>
</div>
@else
@php
$oauth_setting = $oauth_settings_map[$selectedProvider] ?? null;
@endphp
@if ($oauth_setting)
<div class="flex flex-col">
<div class="flex items-center gap-2 pb-2">
<h2>{{ $oauth_setting['label'] }}</h2>
@if ($oauth_setting['enabled'])
<x-forms.button canGate="update" :canResource="$settings" type="submit">
Save
</x-forms.button>
<x-forms.button canGate="update" :canResource="$settings"
wire:click="toggleProvider('{{ $oauth_setting['provider'] }}')">
Disable {{ $oauth_setting['label'] }}
</x-forms.button>
@else
<x-forms.button canGate="update" :canResource="$settings" isHighlighted
wire:click="toggleProvider('{{ $oauth_setting['provider'] }}')">
Enable {{ $oauth_setting['label'] }}
</x-forms.button>
@endif
</div>
<div class="pb-4">OAuth configuration for {{ $oauth_setting['label'] }}.</div>
</div>
<div class="flex flex-col gap-2 pt-4">
<div>
<div class="flex flex-col w-full gap-2 xl:flex-row">
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.client_id"
label="Client ID" />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.client_secret"
type="password" label="Client Secret" autocomplete="new-password" />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.redirect_uri"
placeholder="{{ route('auth.callback', $oauth_setting['provider']) }}"
label="Redirect URI" />
@if ($oauth_setting['provider'] == 'azure')
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.tenant"
label="Tenant" />
@endif
@if ($oauth_setting['provider'] == 'google')
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.tenant"
helper="Optional parameter that supplies a hosted domain (HD) to Google, which<br>triggers a login hint to be displayed on the OAuth screen with this domain.<br><br><a class='underline dark:text-warning text-coollabs' href='https://developers.google.com/identity/openid-connect/openid-connect#hd-param' target='_blank'>Google Documentation</a>"
label="Tenant" />
@endif
@if (
$oauth_setting['provider'] == 'authentik' ||
$oauth_setting['provider'] == 'clerk' ||
$oauth_setting['provider'] == 'zitadel' ||
$oauth_setting['provider'] == 'gitlab')
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.base_url"
label="Base URL" />
@endif
@if ($oauth_setting['provider'] == 'oidc')
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.base_url"
label="Issuer URL"
helper="OpenID Provider issuer URL, for example https://example.okta.com. Coolify uses this URL to discover authorization, token, userinfo, and JWKS endpoints." />
@endif
</div>
@if ($oauth_setting['provider'] == 'oidc')
<div class="flex flex-col w-full gap-2 pt-2 xl:flex-row">
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.custom_label"
label="Login Button Label" placeholder="Login with SSO" />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.scopes"
label="Scopes"
helper="Must include openid. Common Okta scopes: openid email profile groups." />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.clock_skew_seconds"
type="number" label="Clock Skew (seconds)" />
</div>
<div class="flex flex-col gap-2 pt-2">
<div class="md:w-96">
<x-forms.checkbox canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.allow_registration"
label="Allow OIDC user creation"
helper="When enabled, a successful OIDC login can create a Coolify user even if normal password registration is disabled." />
</div>
<div class="md:w-96">
<x-forms.checkbox canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.require_email_verified"
label="Require verified email" />
</div>
<div class="md:w-96">
<x-forms.checkbox canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.use_pkce"
label="Use PKCE" />
</div>
</div>
@endif
<div class="flex flex-col gap-2 pt-2">
<div class="md:w-96">
<x-forms.checkbox canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.auto_join_root_team"
label="Auto-join new users to Root team"
helper="When enabled, newly-created OAuth users are added to the Root team as members and no personal team is created for them." />
</div>
</div>
</div>
</div>
@endif
@endif
</x-application.settings-section>
@endforeach
</form>
</div>
</x-settings.layout>
</div>
+21 -19
View File
@@ -36,31 +36,33 @@ beforeEach(function () {
OauthSetting::create(['provider' => 'bitbucket']);
});
it('shows oauth general settings with provider subnavigation', function () {
it('uses the standard settings design and keeps every oauth provider on one page', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('General')
->assertSee('Authentication')
->assertSee('Registration')
->assertSee('Authentik')
->assertSee('Bitbucket')
->assertSee(route('settings.oauth.provider', 'authentik'), false)
->assertSee(route('settings.oauth.provider', 'bitbucket'), false)
->assertSee('OpenID Connect')
->assertSee('Disable password registration when OAuth is enabled')
->assertDontSee('Client Secret');
->assertSee('Client secret')
->assertSee('application-settings-form', false)
->assertDontSee(route('settings.oauth.provider', 'authentik'), false);
});
it('shows the registration helper next to the section title in a wider row', function () {
it('lists openid connect before the other oauth providers', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('flex items-center gap-2', false)
->assertSee('max-w-2xl', false)
->assertSee('Disable password registration when OAuth is enabled')
->assertDontSee('md:w-96', false);
$providers = array_keys(Livewire::test(SettingsOauth::class)->get('oauth_settings_map'));
expect($providers[0])->toBe('oidc');
});
it('has an icon for openid connect', function () {
expect(public_path('svgs/oidc.svg'))->toBeFile();
});
it('auto saves registration policy without a general save button', function () {
@@ -81,24 +83,24 @@ it('auto saves registration policy without a general save button', function () {
expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue();
});
it('shows a provider settings page with a naked okta issuer url example', function () {
it('shows oidc fields with a naked okta issuer url example', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth.provider', 'oidc'))
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('OpenID Connect')
->assertSee('https://example.okta.com', false)
->assertDontSee('/oauth2/default', false);
});
it('shows provider enable controls as actions without boxed sections', function () {
it('shows provider enable controls as settings section actions', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth.provider', 'authentik'))
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('Enable Authentik')
->assertSee('Enable')
->assertDontSee('label="Enabled"', false)
->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false);
});
@@ -107,7 +109,7 @@ it('stacks oidc option checkboxes vertically', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth.provider', 'oidc'))
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('Allow OIDC user creation')
->assertSee('Require verified email')