diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 8c33b783b1..34591c4ee5 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -123,6 +123,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { if paymentCfg == nil { paymentCfg = &service.PaymentConfig{} } + passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration() payload := dto.SystemSettings{ RegistrationEnabled: settings.RegistrationEnabled, @@ -134,6 +135,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + PasskeyEnabled: settings.PasskeyEnabled, + PasskeyConfigured: passkeyConfigured, + PasskeyRPID: passkeyRPID, + PasskeyRPOrigins: passkeyRPOrigins, SessionBindingEnabled: settings.SessionBindingEnabled, StepUpEnabled: settings.StepUpEnabled, AuditLogRetentionDays: settings.AuditLogRetentionDays, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index c8b8bf1b24..7e59baf3f4 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -56,6 +56,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.TotpEnabled != after.TotpEnabled { changed = append(changed, "totp_enabled") } + if before.PasskeyEnabled != after.PasskeyEnabled { + changed = append(changed, "passkey_enabled") + } if before.SessionBindingEnabled != after.SessionBindingEnabled { changed = append(changed, "session_binding_enabled") } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index f852bb7e6f..a517ddfc2b 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -28,6 +28,7 @@ type UpdateSettingsRequest struct { FrontendURL string `json:"frontend_url"` InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + PasskeyEnabled *bool `json:"passkey_enabled"` // Passkey 登录(省略=保持现值) SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值) StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值) AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 @@ -403,6 +404,17 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if req.StepUpEnabled != nil { stepUpEnabled = *req.StepUpEnabled } + passkeyEnabled := previousSettings.PasskeyEnabled + if req.PasskeyEnabled != nil { + passkeyEnabled = *req.PasskeyEnabled + } + if passkeyEnabled { + configured, _, _ := h.settingService.PasskeyConfiguration() + if !configured { + response.BadRequest(c, "Passkey sign-in requires a valid WebAuthn RP ID and allowed HTTPS origins in the deployment configuration") + return + } + } forwardedClientIPHeaders := append([]string(nil), previousSettings.ForwardedClientIPHeaders...) if req.ForwardedClientIPHeaders != nil { forwardedClientIPHeaders = append([]string(nil), (*req.ForwardedClientIPHeaders)...) @@ -1253,6 +1265,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { FrontendURL: req.FrontendURL, InvitationCodeEnabled: req.InvitationCodeEnabled, TotpEnabled: req.TotpEnabled, + PasskeyEnabled: passkeyEnabled, SessionBindingEnabled: sessionBindingEnabled, StepUpEnabled: stepUpEnabled, AuditLogRetentionDays: req.AuditLogRetentionDays, @@ -1776,6 +1789,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if updatedPaymentCfg == nil { updatedPaymentCfg = &service.PaymentConfig{} } + passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration() payload := dto.SystemSettings{ RegistrationEnabled: updatedSettings.RegistrationEnabled, @@ -1787,6 +1801,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, TotpEnabled: updatedSettings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + PasskeyEnabled: updatedSettings.PasskeyEnabled, + PasskeyConfigured: passkeyConfigured, + PasskeyRPID: passkeyRPID, + PasskeyRPOrigins: passkeyRPOrigins, SessionBindingEnabled: updatedSettings.SessionBindingEnabled, StepUpEnabled: updatedSettings.StepUpEnabled, AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 2931228dc5..3afe48064b 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -36,9 +36,13 @@ type SystemSettings struct { InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 - SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 - StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA - AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 + PasskeyEnabled bool `json:"passkey_enabled"` + PasskeyConfigured bool `json:"passkey_configured"` + PasskeyRPID string `json:"passkey_rp_id"` + PasskeyRPOrigins []string `json:"passkey_rp_origins"` + SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 + StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA + AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 LoginAgreementEnabled bool `json:"login_agreement_enabled"` LoginAgreementMode string `json:"login_agreement_mode"` LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` diff --git a/backend/internal/handler/passkey_handler.go b/backend/internal/handler/passkey_handler.go index 9ffc920e0d..c5521ebdd1 100644 --- a/backend/internal/handler/passkey_handler.go +++ b/backend/internal/handler/passkey_handler.go @@ -53,6 +53,9 @@ const passkeyFinishBodyMaxBytes = 64 * 1024 // BeginLogin starts a usernameless, discoverable-credential login ceremony. func (h *PasskeyHandler) BeginLogin(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } assertion, token, err := h.passkeys.BeginLogin(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) @@ -66,6 +69,9 @@ func (h *PasskeyHandler) BeginLogin(c *gin.Context) { // already supplies phishing-resistant multi-factor authentication and does not // enter the separate TOTP challenge flow. func (h *PasskeyHandler) FinishLogin(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } req, ok := bindPasskeyFinishRequest(c) if !ok { return @@ -87,6 +93,9 @@ func (h *PasskeyHandler) FinishLogin(c *gin.Context) { } func (h *PasskeyHandler) BeginRegistration(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") @@ -101,6 +110,9 @@ func (h *PasskeyHandler) BeginRegistration(c *gin.Context) { } func (h *PasskeyHandler) FinishRegistration(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") @@ -168,6 +180,22 @@ func (h *PasskeyHandler) Delete(c *gin.Context) { response.Success(c, gin.H{"success": true}) } +func (h *PasskeyHandler) requirePasskeysEnabled(c *gin.Context) bool { + if h.settingSvc == nil { + response.ErrorFrom(c, service.ErrPasskeysDisabled) + return false + } + enabled, err := h.settingSvc.PasskeyEnabled(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return false + } + if !enabled { + response.ErrorFrom(c, service.ErrPasskeysDisabled) + } + return enabled +} + func (h *PasskeyHandler) ensureBackendModeAllowsUser(ctx context.Context, user *service.User) error { if err := ensureLoginUserActive(user); err != nil { return err diff --git a/backend/internal/handler/passkey_handler_test.go b/backend/internal/handler/passkey_handler_test.go index 0197ffa05d..0d3b57fae4 100644 --- a/backend/internal/handler/passkey_handler_test.go +++ b/backend/internal/handler/passkey_handler_test.go @@ -1,15 +1,42 @@ package handler import ( + "context" + "errors" "net/http" "net/http/httptest" "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +type passkeySwitchSettingRepo struct { + value string + err error +} + +func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) { + return nil, service.ErrSettingNotFound +} +func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, error) { + return r.value, r.err +} +func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil } +func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error { + return nil +} +func (r *passkeySwitchSettingRepo) GetAll(context.Context) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *passkeySwitchSettingRepo) Delete(context.Context, string) error { return nil } + func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() @@ -25,3 +52,50 @@ func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) { require.False(t, ok) require.Equal(t, http.StatusBadRequest, recorder.Code) } + +func TestPasskeyBeginLoginRejectsDisabledAdminSwitch(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &passkeySwitchSettingRepo{value: "false"} + settings := service.NewSettingService(repo, &config.Config{ + WebAuthn: config.WebAuthnConfig{Enabled: true}, + }) + handler := NewPasskeyHandler(nil, nil, settings) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil) + + handler.BeginLogin(ginContext) + + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Contains(t, recorder.Body.String(), "PASSKEY_DISABLED") +} + +func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + settings := service.NewSettingService( + &passkeySwitchSettingRepo{err: errors.New("database unavailable")}, + &config.Config{WebAuthn: config.WebAuthnConfig{Enabled: true}}, + ) + handler := NewPasskeyHandler(nil, nil, settings) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil) + + handler.BeginLogin(ginContext) + + require.Equal(t, http.StatusInternalServerError, recorder.Code) + require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED") +} + +func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := NewPasskeyHandler(nil, nil, nil) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/passkeys", nil) + + handler.List(ginContext) + + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED") +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 755790f0ef..a42f535697 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -170,7 +170,8 @@ const ( settingKeyForwardedClientIPModeV2 = "forwarded_client_ip_mode_v2_migrated" // TOTP 双因素认证设置 - SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能 + SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能 + SettingKeyPasskeyEnabled = "passkey_enabled" // 是否启用 Passkey 登录(仍要求有效的 WebAuthn 部署配置) // 会话安全设置 SettingKeySessionBindingEnabled = "session_binding_enabled" // 会话 IP/UA 绑定(变更即失效),默认关闭 diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 52ba3670ff..c2beefe76a 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -173,6 +173,47 @@ func (s *SettingService) IsTotpEnabled(ctx context.Context) bool { return value == "true" } +// PasskeyEnabled reports the effective runtime switch. WebAuthn deployment +// configuration remains the security boundary; the database setting can only +// disable a valid configured relying party, never replace or weaken it. +func (s *SettingService) PasskeyEnabled(ctx context.Context) (bool, error) { + if !s.passkeyConfigured() { + return false, nil + } + value, err := s.settingRepo.GetValue(ctx, SettingKeyPasskeyEnabled) + if errors.Is(err, ErrSettingNotFound) { + return true, nil // preserve enabled deployments when upgrading from config-only releases + } + if err != nil { + return false, fmt.Errorf("read passkey setting: %w", err) + } + return value == "true", nil +} + +// PasskeyConfiguration returns non-secret relying-party configuration for the +// admin status UI. Enabled configurations have already passed Config.Validate. +func (s *SettingService) PasskeyConfiguration() (configured bool, rpID string, origins []string) { + if s == nil || s.cfg == nil { + return false, "", []string{} + } + origins = append([]string{}, s.cfg.WebAuthn.RPOrigins...) + return s.cfg.WebAuthn.Enabled, + strings.TrimSpace(s.cfg.WebAuthn.RPID), + origins +} + +func (s *SettingService) passkeyConfigured() bool { + return s != nil && s.cfg != nil && s.cfg.WebAuthn.Enabled +} + +func (s *SettingService) passkeySettingEnabled(settings map[string]string) bool { + value, ok := settings[SettingKeyPasskeyEnabled] + if !ok { + return s.passkeyConfigured() + } + return value == "true" +} + // IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置 // 只有手动配置了密钥才允许在管理后台启用 TOTP 功能 func (s *SettingService) IsTotpEncryptionKeyConfigured() bool { diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index adc1195c04..40e528291e 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -299,6 +299,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin FrontendURL: settings[SettingKeyFrontendURL], InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + PasskeyEnabled: s.passkeySettingEnabled(settings), SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] == "true", // 默认关闭 StepUpEnabled: settings[SettingKeyStepUpEnabled] == "true", // 默认关闭 AuditLogRetentionDays: parseAuditLogRetentionDays(settings[SettingKeyAuditLogRetentionDays]), diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 4ee4964b6e..0a13f8b18c 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -164,6 +164,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyPasswordResetEnabled, SettingKeyInvitationCodeEnabled, SettingKeyTotpEnabled, + SettingKeyPasskeyEnabled, SettingKeyLoginAgreementEnabled, SettingKeyLoginAgreementMode, SettingKeyLoginAgreementUpdatedAt, @@ -289,7 +290,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings PasswordResetEnabled: passwordResetEnabled, InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - PasskeyEnabled: s.cfg != nil && s.cfg.WebAuthn.Enabled, + PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings), LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), LoginAgreementUpdatedAt: loginAgreementUpdatedAt, diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 3a4050f5b2..e3cbddc09f 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -850,3 +850,32 @@ func TestSettingService_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource( require.Equal(t, "INVALID_PAYMENT_VISIBLE_METHOD_SOURCE", infraerrors.Reason(err)) require.Nil(t, repo.updates) } + +func TestSettingService_PasskeySwitchPersistsAndDefaultsToConfigured(t *testing.T) { + cfg := &config.Config{WebAuthn: config.WebAuthnConfig{ + Enabled: true, + RPID: "sub3.nebula-spaces.com", + RPOrigins: []string{"https://sub3.nebula-spaces.com"}, + }} + runtimeRepo := &forwardedIPMigrationRepoStub{values: map[string]string{}} + runtimeService := NewSettingService(runtimeRepo, cfg) + + enabled, err := runtimeService.PasskeyEnabled(context.Background()) + require.NoError(t, err) + require.True(t, enabled) + + updateRepo := &settingUpdateRepoStub{} + updateService := NewSettingService(updateRepo, cfg) + require.NoError(t, updateService.UpdateSettings(context.Background(), &SystemSettings{ + PasskeyEnabled: false, + })) + require.Equal(t, "false", updateRepo.updates[SettingKeyPasskeyEnabled]) + + runtimeRepo.values[SettingKeyPasskeyEnabled] = "false" + enabled, err = runtimeService.PasskeyEnabled(context.Background()) + require.NoError(t, err) + require.False(t, enabled) + publicSettings, err := runtimeService.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.False(t, publicSettings.PasskeyEnabled) +} diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 7449a3de69..ab26fc494a 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -127,6 +127,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyFrontendURL] = settings.FrontendURL updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) + updates[SettingKeyPasskeyEnabled] = strconv.FormatBool(settings.PasskeyEnabled) updates[SettingKeySessionBindingEnabled] = strconv.FormatBool(settings.SessionBindingEnabled) updates[SettingKeyStepUpEnabled] = strconv.FormatBool(settings.StepUpEnabled) updates[SettingKeyAuditLogRetentionDays] = strconv.Itoa(settings.AuditLogRetentionDays) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index e4d9096607..bc88e49118 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -20,6 +20,7 @@ type SystemSettings struct { FrontendURL string InvitationCodeEnabled bool TotpEnabled bool // TOTP 双因素认证 + PasskeyEnabled bool // Passkey 登录 SessionBindingEnabled bool // 会话 IP/UA 绑定(变更即失效) StepUpEnabled bool // 敏感操作 step-up 2FA 门控 AuditLogRetentionDays int // 审计日志保留天数(<=0 永久保留) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 8861ce56bd..f48b4b7587 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -366,6 +366,10 @@ export interface SystemSettings { invitation_code_enabled: boolean; totp_enabled: boolean; // TOTP 双因素认证 totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置 + passkey_enabled: boolean; + passkey_configured: boolean; + passkey_rp_id: string; + passkey_rp_origins: string[]; session_binding_enabled: boolean; // 会话 IP/UA 绑定 step_up_enabled: boolean; // 敏感操作 step-up 2FA audit_log_retention_days: number; // 审计日志保留天数 @@ -674,6 +678,7 @@ export interface UpdateSettingsRequest { frontend_url?: string; invitation_code_enabled?: boolean; totp_enabled?: boolean; // TOTP 双因素认证 + passkey_enabled?: boolean; session_binding_enabled?: boolean; // 会话 IP/UA 绑定 step_up_enabled?: boolean; // 敏感操作 step-up 2FA audit_log_retention_days?: number; // 审计日志保留天数 diff --git a/frontend/src/components/user/profile/ProfilePasskeyCard.vue b/frontend/src/components/user/profile/ProfilePasskeyCard.vue index cefed6d6e6..8bdcc1c6a9 100644 --- a/frontend/src/components/user/profile/ProfilePasskeyCard.vue +++ b/frontend/src/components/user/profile/ProfilePasskeyCard.vue @@ -21,15 +21,15 @@
+ {{ t("admin.settings.security.passkeyHint") }} +
++ {{ + form.passkey_configured + ? t("admin.settings.security.passkeyConfigured") + : t("admin.settings.security.passkeyNotConfigured") + }} +
++ {{ t("admin.settings.security.passkeyRPID") }}: + {{ + form.passkey_rp_id || + t("admin.settings.security.passkeyValueNotConfigured") + }} +
++ {{ t("admin.settings.security.passkeyOrigins") }}: + {{ + form.passkey_rp_origins.length > 0 + ? form.passkey_rp_origins.join(", ") + : t( + "admin.settings.security.passkeyValueNotConfigured", + ) + }} +
+