diff --git a/web/public/auth0-logo.svg b/web/public/auth0-logo.svg
new file mode 100644
index 000000000..0de1fd303
--- /dev/null
+++ b/web/public/auth0-logo.svg
@@ -0,0 +1 @@
+
diff --git a/web/public/keycloak-logo.svg b/web/public/keycloak-logo.svg
new file mode 100644
index 000000000..c048427e8
--- /dev/null
+++ b/web/public/keycloak-logo.svg
@@ -0,0 +1 @@
+
diff --git a/web/public/okta-logo.svg b/web/public/okta-logo.svg
new file mode 100644
index 000000000..e546c180a
--- /dev/null
+++ b/web/public/okta-logo.svg
@@ -0,0 +1 @@
+
diff --git a/web/src/features/auth/login-button.test.tsx b/web/src/features/auth/login-button.test.tsx
index a78d06730..a6c0c7f29 100644
--- a/web/src/features/auth/login-button.test.tsx
+++ b/web/src/features/auth/login-button.test.tsx
@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
+import { existsSync } from 'node:fs'
+import { resolve } from 'node:path'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AuthMethod } from '@/api/types'
@@ -69,6 +71,62 @@ describe('AuthMethodButtonList', () => {
expect(screen.queryByText('Login with Password')).toBeNull()
})
+ it.each([false, true])('uses the Keycloak icon while preserving existing provider icons (compact=%s)', (compact) => {
+ const keycloakMethod: AuthMethod = {
+ id: 'keycloak',
+ methodType: 'OAUTH_REDIRECT',
+ provider: 'Keycloak',
+ displayName: 'Keycloak',
+ actionUrl: '/oauth2/authorization/keycloak',
+ }
+
+ render()
+
+ expect(screen.getByRole('img', { name: 'Keycloak' }).getAttribute('src')).toBe('/keycloak-logo.svg')
+ expect(screen.getByRole('img', { name: 'github' }).getAttribute('src')).toBe('/github-logo.svg')
+ })
+
+ it.each([
+ ['Auth0', 'auth0'],
+ ['OKTA', 'okta'],
+ ])('uses the %s provider icon', (provider, icon) => {
+ render()
+
+ expect(screen.getByRole('img', { name: provider }).getAttribute('src')).toBe(`/${icon}-logo.svg`)
+ })
+
+ it.each(['Enterprise SSO', 'constructor', '__proto__'])('uses the packaged OIDC icon for unrecognized provider %s', (provider) => {
+ render()
+
+ expect(screen.getByRole('img', { name: provider }).getAttribute('src')).toBe('/oidc-logo.svg')
+ })
+
+ it.each(['github', 'gitlab', 'feishu', 'dingtalk', 'keycloak', 'auth0', 'okta', 'unknown'])('bundles the icon selected for %s', (provider) => {
+ render()
+
+ const iconPath = screen.getByRole('img', { name: provider }).getAttribute('src') ?? ''
+ expect(iconPath).toMatch(/^\/[a-z0-9-]+-logo\.svg$/)
+ expect(existsSync(resolve(process.cwd(), 'public', iconPath.slice(1)))).toBe(true)
+ })
+
it('renders the supplied empty fallback when no OAuth methods are available', () => {
render( method.methodType !== 'OAUTH_REDIRECT')} emptyFallback={No methods
} />)
diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx
index 08d9b6568..5f787c89e 100644
--- a/web/src/features/auth/login-button.tsx
+++ b/web/src/features/auth/login-button.tsx
@@ -12,14 +12,19 @@ interface LoginButtonProps {
emptyFallback?: React.ReactNode
}
+const packagedIconProviders = new Set([
+ 'github', 'gitlab', 'feishu', 'dingtalk', 'keycloak', 'auth0', 'okta',
+])
+
/**
* Returns the appropriate icon for a given OAuth provider.
*/
function OAuthIcon({ provider, compact = false }: { provider: string, compact?: boolean }) {
- const normalizedProvider = provider.toLowerCase()
+ const normalizedProvider = provider.trim().toLowerCase()
+ const iconProvider = packagedIconProviders.has(normalizedProvider) ? normalizedProvider : 'oidc'
return (