From cb4c8c02f81d97eaba66ba6f3e9528b937be4d57 Mon Sep 17 00:00:00 2001 From: June Chi Date: Wed, 29 Jul 2026 16:27:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(oidc):=20=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20OIDC=20=E7=99=BB=E5=BD=95=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=90=8D=E7=A7=B0=20(#6012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add design spec for OIDC custom display name Mirrors the existing Custom OAuth Provider name pattern so admins can show a meaningful label instead of the hardcoded "OIDC" on the login page and in related copy. * feat(oidc): add configurable display name with OIDC fallback * feat(oidc): use configured display name in provider name and status API * feat(oidc): add display name field to default-theme OIDC settings Claude-Session: https://claude.ai/code/session_01FDkWJqigJi9yE3HG5pjZP5 * feat(oidc): show configured display name on default-theme login button * feat(oidc): add display name field to classic-theme OIDC settings * feat(oidc): show configured display name on classic-theme login button * fix(oidc): trim whitespace before applying display name fallback * chore: remove internal design doc from PR Design/planning docs are working artifacts for this session and shouldn't be submitted to the upstream project. * fix(oidc): lead with example in classic-theme display name placeholder Reorders the combined placeholder to show the example first, then the fallback note, matching the Custom OAuth Provider Name field's placeholder convention (example-only) that this feature mirrors. * fix(i18n): improve Russian grammar in OIDC display-name placeholder translation Leads each clause with its condition/subject and adds the missing verb, per PR review feedback. * test(web): remove redundant OIDC harness tests --- controller/misc.go | 1 + controller/misc_oidc_test.go | 60 +++++++++++++++++++ oauth/oidc.go | 2 +- oauth/oidc_test.go | 22 +++++++ setting/system_setting/oidc.go | 18 +++++- setting/system_setting/oidc_test.go | 47 +++++++++++++++ .../auth/components/oauth-providers.tsx | 5 +- web/src/features/auth/types.ts | 2 + .../features/system-settings/auth/index.tsx | 1 + .../system-settings/auth/oauth-section.tsx | 31 ++++++++++ .../system-settings/auth/section-registry.tsx | 1 + .../hooks/use-update-option.ts | 7 ++- web/src/features/system-settings/types.ts | 1 + web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/fr.json | 3 + web/src/i18n/locales/ja.json | 3 + web/src/i18n/locales/ru.json | 3 + web/src/i18n/locales/vi.json | 3 + web/src/i18n/locales/zh-TW.json | 3 + web/src/i18n/locales/zh.json | 3 + 20 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 controller/misc_oidc_test.go create mode 100644 oauth/oidc_test.go create mode 100644 setting/system_setting/oidc_test.go diff --git a/controller/misc.go b/controller/misc.go index 1a48399b..7343b12f 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -111,6 +111,7 @@ func GetStatus(c *gin.Context) { "oidc_enabled": system_setting.GetOIDCSettings().Enabled, "oidc_client_id": system_setting.GetOIDCSettings().ClientId, "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, + "oidc_display_name": system_setting.GetOIDCSettings().GetEffectiveDisplayName(), "passkey_login": passkeySetting.Enabled, "passkey_display_name": passkeySetting.RPDisplayName, "passkey_rp_id": passkeySetting.RPID, diff --git a/controller/misc_oidc_test.go b/controller/misc_oidc_test.go new file mode 100644 index 00000000..09d83c9c --- /dev/null +++ b/controller/misc_oidc_test.go @@ -0,0 +1,60 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetStatusReturnsEffectiveOIDCDisplayName(t *testing.T) { + settings := system_setting.GetOIDCSettings() + originalDisplayName := settings.DisplayName + originalOptionMap := common.OptionMap + t.Cleanup(func() { + settings.DisplayName = originalDisplayName + common.OptionMap = originalOptionMap + }) + common.OptionMap = map[string]string{} + + tests := []struct { + name string + displayName string + want string + }{ + { + name: "custom name is trimmed", + displayName: " Acme SSO ", + want: "Acme SSO", + }, + { + name: "whitespace-only name falls back", + displayName: " ", + want: "OIDC", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings.DisplayName = tt.displayName + response := httptest.NewRecorder() + context, _ := gin.CreateTestContext(response) + context.Request = httptest.NewRequest(http.MethodGet, "/api/status", nil) + + GetStatus(context) + + var payload struct { + Success bool `json:"success"` + Data map[string]any `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload)) + require.True(t, payload.Success) + assert.Equal(t, tt.want, payload.Data["oidc_display_name"]) + }) + } +} diff --git a/oauth/oidc.go b/oauth/oidc.go index 9bdc6d01..25874329 100644 --- a/oauth/oidc.go +++ b/oauth/oidc.go @@ -41,7 +41,7 @@ type oidcUser struct { } func (p *OIDCProvider) GetName() string { - return "OIDC" + return system_setting.GetOIDCSettings().GetEffectiveDisplayName() } func (p *OIDCProvider) IsEnabled() bool { diff --git a/oauth/oidc_test.go b/oauth/oidc_test.go new file mode 100644 index 00000000..80f590aa --- /dev/null +++ b/oauth/oidc_test.go @@ -0,0 +1,22 @@ +package oauth + +import ( + "testing" + + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/stretchr/testify/assert" +) + +func TestOIDCProvider_GetName(t *testing.T) { + settings := system_setting.GetOIDCSettings() + originalDisplayName := settings.DisplayName + defer func() { settings.DisplayName = originalDisplayName }() + + p := &OIDCProvider{} + + settings.DisplayName = "" + assert.Equal(t, "OIDC", p.GetName()) + + settings.DisplayName = " Acme SSO " + assert.Equal(t, "Acme SSO", p.GetName()) +} diff --git a/setting/system_setting/oidc.go b/setting/system_setting/oidc.go index 307d3b4a..dbba8579 100644 --- a/setting/system_setting/oidc.go +++ b/setting/system_setting/oidc.go @@ -1,9 +1,14 @@ package system_setting -import "github.com/QuantumNous/new-api/setting/config" +import ( + "strings" + + "github.com/QuantumNous/new-api/setting/config" +) type OIDCSettings struct { Enabled bool `json:"enabled"` + DisplayName string `json:"display_name"` ClientId string `json:"client_id"` ClientSecret string `json:"client_secret"` WellKnown string `json:"well_known"` @@ -23,3 +28,14 @@ func init() { func GetOIDCSettings() *OIDCSettings { return &defaultOIDCSettings } + +// GetEffectiveDisplayName returns the admin-configured display name, or the +// literal "OIDC" when none has been set. Centralizing this fallback keeps the +// default in one place for both the OAuth provider name and the public +// status payload. +func (s *OIDCSettings) GetEffectiveDisplayName() string { + if trimmed := strings.TrimSpace(s.DisplayName); trimmed != "" { + return trimmed + } + return "OIDC" +} diff --git a/setting/system_setting/oidc_test.go b/setting/system_setting/oidc_test.go new file mode 100644 index 00000000..0ed2b597 --- /dev/null +++ b/setting/system_setting/oidc_test.go @@ -0,0 +1,47 @@ +package system_setting + +import ( + "testing" + + "github.com/QuantumNous/new-api/setting/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOIDCSettings_GetEffectiveDisplayName(t *testing.T) { + tests := []struct { + name string + displayName string + want string + }{ + {name: "blank falls back to OIDC", displayName: "", want: "OIDC"}, + {name: "custom name is returned verbatim", displayName: "Acme SSO", want: "Acme SSO"}, + {name: "whitespace-only falls back to OIDC", displayName: " ", want: "OIDC"}, + {name: "surrounding whitespace is trimmed", displayName: " Acme SSO ", want: "Acme SSO"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &OIDCSettings{DisplayName: tt.displayName} + assert.Equal(t, tt.want, s.GetEffectiveDisplayName()) + }) + } +} + +func TestOIDCSettings_DisplayNamePersistenceRoundTrip(t *testing.T) { + settings := &OIDCSettings{DisplayName: " Acme SSO "} + manager := config.NewConfigManager() + manager.Register("oidc", settings) + + saved := make(map[string]string) + require.NoError(t, manager.SaveToDB(func(key, value string) error { + saved[key] = value + return nil + })) + require.Equal(t, " Acme SSO ", saved["oidc.display_name"]) + + settings.DisplayName = "" + require.NoError(t, manager.LoadFromDB(saved)) + assert.Equal(t, " Acme SSO ", settings.DisplayName) + assert.Equal(t, "Acme SSO", settings.GetEffectiveDisplayName()) +} diff --git a/web/src/features/auth/components/oauth-providers.tsx b/web/src/features/auth/components/oauth-providers.tsx index 4d7c5bb6..a686d356 100644 --- a/web/src/features/auth/components/oauth-providers.tsx +++ b/web/src/features/auth/components/oauth-providers.tsx @@ -107,9 +107,12 @@ export function OAuthProviders({ } if (status?.oidc_enabled) { + const oidcDisplayName = status.oidc_display_name?.trim() || 'OIDC' providerButtons.push({ key: 'oidc', - label: t('Continue with OIDC'), + label: t('Continue with {{name}}', { + name: oidcDisplayName, + }), onClick: handleOIDCLogin, }) } diff --git a/web/src/features/auth/types.ts b/web/src/features/auth/types.ts index cee0e4bf..afaa4b71 100644 --- a/web/src/features/auth/types.ts +++ b/web/src/features/auth/types.ts @@ -103,6 +103,7 @@ export interface SystemStatus { oidc_enabled?: boolean oidc_authorization_endpoint?: string oidc_client_id?: string + oidc_display_name?: string linuxdo_oauth?: boolean linuxdo_client_id?: string telegram_oauth?: boolean @@ -147,6 +148,7 @@ export interface SystemStatus { oidc_enabled?: boolean oidc_authorization_endpoint?: string oidc_client_id?: string + oidc_display_name?: string linuxdo_oauth?: boolean linuxdo_client_id?: string telegram_oauth?: boolean diff --git a/web/src/features/system-settings/auth/index.tsx b/web/src/features/system-settings/auth/index.tsx index 97596557..915801fd 100644 --- a/web/src/features/system-settings/auth/index.tsx +++ b/web/src/features/system-settings/auth/index.tsx @@ -40,6 +40,7 @@ const defaultAuthSettings: AuthSettings = { 'discord.client_id': '', 'discord.client_secret': '', 'oidc.enabled': false, + 'oidc.display_name': '', 'oidc.client_id': '', 'oidc.client_secret': '', 'oidc.well_known': '', diff --git a/web/src/features/system-settings/auth/oauth-section.tsx b/web/src/features/system-settings/auth/oauth-section.tsx index 235713db..73bbf264 100644 --- a/web/src/features/system-settings/auth/oauth-section.tsx +++ b/web/src/features/system-settings/auth/oauth-section.tsx @@ -72,6 +72,7 @@ const oauthSchema = z.object({ }), oidc: z.object({ enabled: z.boolean(), + display_name: z.string(), client_id: z.string(), client_secret: z.string(), well_known: z.string(), @@ -102,6 +103,7 @@ type FlatOAuthDefaults = { 'discord.client_id': string 'discord.client_secret': string 'oidc.enabled': boolean + 'oidc.display_name': string 'oidc.client_id': string 'oidc.client_secret': string 'oidc.well_known': string @@ -184,6 +186,7 @@ const buildFormDefaults = (defaults: FlatOAuthDefaults): OAuthFormValues => ({ }, oidc: { enabled: defaults['oidc.enabled'], + display_name: defaults['oidc.display_name'] ?? '', client_id: defaults['oidc.client_id'] ?? '', client_secret: defaults['oidc.client_secret'] ?? '', well_known: defaults['oidc.well_known'] ?? '', @@ -212,6 +215,7 @@ const normalizeFormValues = (values: OAuthFormValues): FlatOAuthDefaults => ({ 'discord.client_id': values.discord.client_id, 'discord.client_secret': values.discord.client_secret, 'oidc.enabled': values.oidc.enabled, + 'oidc.display_name': values.oidc.display_name, 'oidc.client_id': values.oidc.client_id, 'oidc.client_secret': values.oidc.client_secret, 'oidc.well_known': values.oidc.well_known, @@ -617,6 +621,33 @@ export function OAuthSection(props: OAuthSectionProps) { )} /> + ( + + {t('OIDC Display Name')} + + + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} + /> + + + {t('Defaults to "OIDC" if left blank')} + + + + )} + /> +