diff --git a/docker/development/.env.sso-test b/docker/development/.env.sso-test index 2c6f1627d2e1..8beeee358c52 100644 --- a/docker/development/.env.sso-test +++ b/docker/development/.env.sso-test @@ -2,8 +2,8 @@ # Configures OM server to use mock OIDC provider for E2E testing. # # Usage: -# 1. Start mock OIDC provider first: -# docker compose --profile sso-test up -d mock-oidc-provider +# 1. Start SSO test fixtures (mock OIDC + OpenLDAP): +# docker compose --profile sso-test up -d # # 2. Start (or restart) OM server with this env file: # docker compose --env-file .env.sso-test up -d @@ -12,8 +12,21 @@ # cd openmetadata-ui/src/main/resources/ui # npx playwright test --config=playwright.sso.config.ts # -# NOTE: Server-side URLs use Docker hostname (mock-oidc-provider:9090). -# Client-side URLs (authority, callback) use localhost:9090. +# NOTE: Server-side URLs use Docker hostname (mock-oidc-provider:9090, +# openldap-test:1389). Client-side URLs (authority, callback) +# use localhost:9090. +# +# LDAP fixture (consumed by SSOTestLogin-LDAP.spec.ts via the SSO API, +# not by the OM server's auth provider — these vars are documentation): +# LDAP host (server-side): openldap-test +# LDAP host (host network): localhost +# LDAP port: 1389 +# admin DN: cn=admin,dc=test,dc=local +# admin password: admin-pass +# user base DN: ou=people,dc=test,dc=local +# mail attribute: mail +# seed user (with mail): cn=alice,ou=people,dc=test,dc=local / alice-pass / mail=alice@company.com +# seed user (no mail): cn=bob,ou=people,dc=test,dc=local / bob-pass # Authentication provider — custom-oidc uses OidcAuthenticator AUTHENTICATION_PROVIDER=custom-oidc diff --git a/docker/development/docker-compose.yml b/docker/development/docker-compose.yml index 1ae7722f224e..bbc420a2b168 100644 --- a/docker/development/docker-compose.yml +++ b/docker/development/docker-compose.yml @@ -598,6 +598,34 @@ services: profiles: - sso-test + openldap-test: + build: + context: ./openldap-seed + dockerfile: Dockerfile + container_name: openldap_test + environment: + LDAP_ORGANISATION: OpenMetadata Test + LDAP_DOMAIN: test.local + LDAP_BASE_DN: dc=test,dc=local + LDAP_ADMIN_PASSWORD: admin-pass + LDAP_TLS: "false" + expose: + - 389 + ports: + - "1389:389" + networks: + - local_app_net + healthcheck: + test: + - "CMD-SHELL" + - "ldapsearch -x -H ldap://localhost:389 -D 'cn=admin,dc=test,dc=local' -w admin-pass -b 'ou=people,dc=test,dc=local' '(cn=alice)' cn >/dev/null 2>&1" + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + profiles: + - sso-test + networks: local_app_net: name: ometa_network diff --git a/docker/development/mock-oidc-provider/server.js b/docker/development/mock-oidc-provider/server.js index ad4a79a6ec4f..c27243bea21d 100644 --- a/docker/development/mock-oidc-provider/server.js +++ b/docker/development/mock-oidc-provider/server.js @@ -118,6 +118,9 @@ const providerConfig = { profile: ['name', 'preferred_username'], }, scopes: ['openid', 'email', 'profile', 'offline_access'], + // Include all granted scope claims in the id_token so SSO Test Login (which + // reads claims from id_token only, not userinfo) can surface email/profile. + conformIdTokenClaims: false, features: { devInteractions: { enabled: false }, rpInitiatedLogout: { enabled: true }, diff --git a/docker/development/openldap-seed/01-people.ldif b/docker/development/openldap-seed/01-people.ldif new file mode 100644 index 000000000000..239ff2154370 --- /dev/null +++ b/docker/development/openldap-seed/01-people.ldif @@ -0,0 +1,29 @@ +# People organizational unit + seed users for SSO Test Login E2E specs. +# Baked into the openldap-test image at build time (see Dockerfile). +# Mirrors the fixture used by openmetadata-service TestLdapHandlerTest so the +# E2E and unit-test bind shapes stay aligned. + +dn: ou=people,dc=test,dc=local +objectClass: organizationalUnit +ou: people + +dn: cn=alice,ou=people,dc=test,dc=local +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: alice +sn: Smith +givenName: Alice +mail: alice@company.com +userPassword: alice-pass + +dn: cn=bob,ou=people,dc=test,dc=local +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: bob +sn: Jones +givenName: Bob +userPassword: bob-pass diff --git a/docker/development/openldap-seed/Dockerfile b/docker/development/openldap-seed/Dockerfile new file mode 100644 index 000000000000..eeb7b99fe6c3 --- /dev/null +++ b/docker/development/openldap-seed/Dockerfile @@ -0,0 +1,4 @@ +FROM osixia/openldap:1.5.0 + +COPY --chown=openldap:openldap 01-people.ldif \ + /container/service/slapd/assets/config/bootstrap/ldif/custom/01-people.ldif diff --git a/docker/local-sso/keycloak-saml/realms/om-azure-saml-realm.json b/docker/local-sso/keycloak-saml/realms/om-azure-saml-realm.json index cc44c38303bc..919fc2b52163 100644 --- a/docker/local-sso/keycloak-saml/realms/om-azure-saml-realm.json +++ b/docker/local-sso/keycloak-saml/realms/om-azure-saml-realm.json @@ -10,7 +10,7 @@ "editUsernameAllowed": false, "clients": [ { - "clientId": "http://localhost:8585/api/v1/saml/metadata", + "clientId": "http://localhost:8585", "name": "OpenMetadata", "enabled": true, "protocol": "saml", diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java index 5cfc44dd2ceb..77c1dd3d61af 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java @@ -8,6 +8,9 @@ import static org.openmetadata.service.apps.bundles.insights.DataInsightsApp.getDataStreamName; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.VisibleForTesting; import com.unboundid.ldap.sdk.LDAPConnection; import com.unboundid.ldap.sdk.LDAPConnectionOptions; @@ -87,12 +90,12 @@ import org.openmetadata.service.security.JwtFilter; import org.openmetadata.service.security.SecurityUtil; import org.openmetadata.service.security.auth.LoginAttemptCache; +import org.openmetadata.service.security.auth.SecurityConfigurationManager; import org.openmetadata.service.security.auth.validator.Auth0Validator; import org.openmetadata.service.security.auth.validator.AzureAuthValidator; import org.openmetadata.service.security.auth.validator.CognitoAuthValidator; import org.openmetadata.service.security.auth.validator.CustomOidcValidator; import org.openmetadata.service.security.auth.validator.GoogleAuthValidator; -import org.openmetadata.service.security.auth.validator.OidcDiscoveryValidator; import org.openmetadata.service.security.auth.validator.OktaAuthValidator; import org.openmetadata.service.security.auth.validator.SamlValidator; import org.openmetadata.service.util.EntityUtil; @@ -101,6 +104,7 @@ import org.openmetadata.service.util.RestUtil; import org.openmetadata.service.util.ValidationErrorBuilder; import org.openmetadata.service.util.ValidationErrorBuilder.FieldPaths; +import org.openmetadata.service.util.ValidationHttpUtil; @Slf4j @Repository @@ -1052,12 +1056,31 @@ public SecurityValidationResponse validateSecurityConfiguration( SecurityConfiguration securityConfig, OpenMetadataApplicationConfig applicationConfig, String currentUsername) { + return validateSecurityConfiguration(securityConfig, applicationConfig, currentUsername, false); + } + + public SecurityValidationResponse validateSecurityConfiguration( + SecurityConfiguration securityConfig, + OpenMetadataApplicationConfig applicationConfig, + String currentUsername, + boolean skipTestLoginFields) { List errors = new ArrayList<>(); try { if (securityConfig.getAuthenticationConfiguration() != null) { AuthenticationConfiguration authConfig = securityConfig.getAuthenticationConfiguration(); + // Upstream: if discoveryUri is provided for OIDC, verify it's reachable and valid + // BEFORE downstream validators run. This surfaces the root cause ("Could not reach + // Discovery URI") rather than cascading misleading errors about derived fields. + FieldError discoveryError = validateDiscoveryUriReachable(authConfig); + if (discoveryError != null) { + SecurityValidationResponse response = new SecurityValidationResponse(); + response.setStatus(SecurityValidationResponse.Status.FAILED); + response.setErrors(List.of(discoveryError)); + return response; + } + // First validate all required fields from AuthenticationConfiguration schema FieldError baseError = validateAuthenticationConfigurationBaseFields(authConfig); if (baseError != null) { @@ -1104,7 +1127,7 @@ public SecurityValidationResponse validateSecurityConfiguration( if (securityConfig.getAuthorizerConfiguration() != null) { FieldError authzError = validateAuthorizerConfiguration( - securityConfig.getAuthorizerConfiguration(), currentUsername); + securityConfig.getAuthorizerConfiguration(), currentUsername, skipTestLoginFields); if (authzError != null) { errors.add(authzError); } @@ -1321,52 +1344,350 @@ private FieldError validateOidcConfiguration( } } + private static final Pattern AZURE_TENANT_PATTERN = + Pattern.compile( + "login\\.(?:microsoftonline|partner\\.microsoftonline)\\.(?:com|us|cn)/([^/]+)/"); + + private static final String DEFAULT_CALLBACK_PATH = "/callback"; + /** - * Auto-populates publicKeyUrls from OIDC discovery document for confidential clients - * This is called during save operation to ensure publicKeyUrls is populated before persisting + * Upstream validation: verifies the OIDC discoveryUri is reachable and returns a + * valid discovery document before downstream validators run. Short-circuits with + * a clear root-cause error ("Could not reach Discovery URI"), preventing cascading + * misleading errors about fields (authority, tenant, publicKeyUrls) that would + * otherwise have been derived from the document. + * Returns null for non-OIDC providers and when discoveryUri is absent (legacy mode). */ - public void autoPopulatePublicKeyUrlsIfNeeded(AuthenticationConfiguration authConfig) { + public FieldError validateDiscoveryUriReachable(AuthenticationConfiguration authConfig) { + if (authConfig == null || !isOidcAuthProvider(authConfig.getProvider())) { + return null; + } + String uri = authConfig.getDiscoveryUri(); + if (nullOrEmpty(uri)) { + return null; + } + + if (!isValidHttpUrl(uri)) { + return ValidationErrorBuilder.createFieldError( + ValidationErrorBuilder.FieldPaths.AUTH_DISCOVERY_URI, + "Discovery URI is not a valid HTTP(S) URL: " + uri); + } + + try { + ValidationHttpUtil.HttpResponseData response = ValidationHttpUtil.safeGet(uri); + if (response.getStatusCode() != 200) { + return ValidationErrorBuilder.createFieldError( + ValidationErrorBuilder.FieldPaths.AUTH_DISCOVERY_URI, + String.format( + "Could not reach Discovery URI (HTTP %d): %s", response.getStatusCode(), uri)); + } + JsonNode doc = JsonUtils.readTree(response.getBody()); + if (!doc.has("issuer") || !doc.has("jwks_uri")) { + return ValidationErrorBuilder.createFieldError( + ValidationErrorBuilder.FieldPaths.AUTH_DISCOVERY_URI, + "Discovery document is missing required fields (issuer, jwks_uri)"); + } + } catch (Exception e) { + return ValidationErrorBuilder.createFieldError( + ValidationErrorBuilder.FieldPaths.AUTH_DISCOVERY_URI, + "Failed to fetch Discovery URI: " + e.getMessage()); + } + + if (authConfig.getProvider() == AuthProvider.AZURE + && !AZURE_TENANT_PATTERN.matcher(uri).find()) { + return ValidationErrorBuilder.createFieldError( + ValidationErrorBuilder.FieldPaths.AUTH_DISCOVERY_URI, + "Discovery URI does not match Azure AD format. Expected: " + + "https://login.microsoftonline.com//v2.0/.well-known/openid-configuration"); + } + + return null; + } + + private boolean isOidcAuthProvider(AuthProvider provider) { + return provider == AuthProvider.CUSTOM_OIDC + || provider == AuthProvider.GOOGLE + || provider == AuthProvider.AZURE + || provider == AuthProvider.OKTA + || provider == AuthProvider.AUTH_0 + || provider == AuthProvider.AWS_COGNITO; + } + + private boolean isValidHttpUrl(String uri) { + try { + java.net.URI parsed = java.net.URI.create(uri); + String scheme = parsed.getScheme(); + return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && !nullOrEmpty(parsed.getHost()); + } catch (Exception e) { + return false; + } + } + + /** + * Overlays the request's edits on top of the saved SecurityConfiguration and + * returns the merged result. Used by /security/validate and Test Login flows + * in "existing" mode so admins can re-test or re-validate without retyping + * masked secrets. + * + *

Implementation: load saved → serialize both to JsonNode → recursively + * deep-merge (request fields win, missing/empty/masked fields fall through + * to saved) → deserialize back. Robust to schema additions because the + * merge walks the JSON tree rather than enumerating fields. + * + *

Conceptually a JSON Merge Patch (RFC 7396) of request onto saved, with + * three additional "treat as not-specified" rules: empty arrays, empty + * strings, and the {@link PasswordEntityMasker#PASSWORD_MASK} placeholder. + */ + public SecurityConfiguration overlayOnSavedSecurityConfig(JsonNode requestBody) { + ObjectMapper mapper = JsonUtils.getObjectMapper(); + if (requestBody == null || requestBody.isNull()) { + requestBody = mapper.createObjectNode(); + } + SecurityConfiguration saved = + SecurityConfigurationManager.getInstance().getCurrentSecurityConfig(); + if (saved == null) { + // No saved config to merge with — just deserialize the request as-is + return mapper.convertValue(requestBody, SecurityConfiguration.class); + } + JsonNode savedNode = mapper.valueToTree(saved); + JsonNode merged = deepMerge(savedNode, requestBody); + try { + return mapper.treeToValue(merged, SecurityConfiguration.class); + } catch (Exception e) { + LOG.error("Failed to deserialize merged SecurityConfiguration; falling back to saved", e); + return saved; + } + } + + /** + * Recursive JSON tree merge. For matching object nodes, recurse field-by-field. + * Otherwise, the patch value replaces the saved value — unless the patch value + * is "empty-ish" ({@link #shouldUsePatchValue}), in which case saved wins. + */ + private static JsonNode deepMerge(JsonNode saved, JsonNode patch) { + if (!shouldUsePatchValue(patch)) { + return saved; + } + if (!saved.isObject() || !patch.isObject()) { + return patch; + } + ObjectNode result = saved.deepCopy(); + patch + .fields() + .forEachRemaining( + entry -> { + String key = entry.getKey(); + JsonNode patchValue = entry.getValue(); + JsonNode savedValue = result.get(key); + if (!shouldUsePatchValue(patchValue)) { + return; + } + if (patchValue.isObject() && savedValue != null && savedValue.isObject()) { + result.set(key, deepMerge(savedValue, patchValue)); + } else { + result.set(key, patchValue); + } + }); + return result; + } + + /** + * Returns false if the value should be treated as "not specified" — i.e., the + * caller wants the saved value to remain. Catches: null, JSON null, empty + * arrays, empty strings, and the password mask placeholder. + */ + private static boolean shouldUsePatchValue(JsonNode value) { + if (value == null || value.isNull()) { + return false; + } + if (value.isArray() && value.isEmpty()) { + return false; + } + if (value.isTextual()) { + String text = value.asText(); + if (text.isEmpty() || PasswordEntityMasker.PASSWORD_MASK.equals(text)) { + return false; + } + } + return true; + } + + /** + * Normalizes authentication configuration for persistence. + * Accepts any partial payload and produces a complete, runtime-ready config: + * - Mirrors canonical and legacy field locations using explicit priority (firstNonEmpty). + * - Derives authority and publicKeyUrls from discoveryUri (discovery doc is authoritative). + * - Extracts Azure tenant from discoveryUri when applicable. + * - Defaults callbackUrl when missing. + * Safe for all callers: UI, CLI, SDK, Postman. + */ + public void normalizeForPersistence(AuthenticationConfiguration authConfig) { if (authConfig == null) { return; } - // Only auto-populate for OIDC providers with confidential client type - boolean isOidcProvider = - authConfig.getProvider() == AuthProvider.CUSTOM_OIDC - || authConfig.getProvider() == AuthProvider.GOOGLE - || authConfig.getProvider() == AuthProvider.AZURE - || authConfig.getProvider() == AuthProvider.OKTA - || authConfig.getProvider() == AuthProvider.AUTH_0 - || authConfig.getProvider() == AuthProvider.AWS_COGNITO; + OidcClientConfig oidcConfig = authConfig.getOidcConfiguration(); + + // Mirror root→nested only. Never nested→root — that's hydrateForResponse's job + // (read path only). This prevents legacy configs (only nested discoveryUri) from + // accidentally triggering derivation on unrelated PATCHes. + String discoveryUri = authConfig.getDiscoveryUri(); + if (!nullOrEmpty(discoveryUri) && oidcConfig != null) { + oidcConfig.setDiscoveryUri(discoveryUri); + } + + String clientId = + firstNonEmpty(oidcConfig != null ? oidcConfig.getId() : null, authConfig.getClientId()); + if (!nullOrEmpty(clientId)) { + authConfig.setClientId(clientId); + if (oidcConfig != null) { + oidcConfig.setId(clientId); + } + } + + String defaultCallback = buildDefaultCallbackUrl(); + String callbackUrl = + firstNonEmpty( + authConfig.getCallbackUrl(), + oidcConfig != null ? oidcConfig.getCallbackUrl() : null, + defaultCallback); + if (!nullOrEmpty(callbackUrl)) { + authConfig.setCallbackUrl(callbackUrl); + if (oidcConfig != null) { + oidcConfig.setCallbackUrl(callbackUrl); + } + } + + if (oidcConfig != null && nullOrEmpty(oidcConfig.getServerUrl())) { + String serverUrl = + firstNonEmpty( + callbackUrl != null ? callbackUrl.replaceAll("/callback/?$", "") : null, + defaultCallback != null ? defaultCallback.replaceAll("/callback/?$", "") : null); + if (!nullOrEmpty(serverUrl)) { + oidcConfig.setServerUrl(serverUrl); + } + } + + if (!nullOrEmpty(discoveryUri)) { + deriveFromDiscoveryDocument(authConfig, discoveryUri); + } - boolean isConfidentialClient = authConfig.getClientType() == ClientType.CONFIDENTIAL; + deriveProviderSpecificFields(authConfig); + deriveClientTypeFromSecret(authConfig); + } - if (!isOidcProvider || !isConfidentialClient) { - LOG.debug("Skipping publicKeyUrls auto-population - not OIDC confidential client"); + /** + * Hydrates canonical fields from legacy locations for GET responses. + * In-memory transform only (no network I/O, no DB writes) so legacy configs + * display consistently for all API consumers (UI, CLI, SDK). + */ + public void hydrateForResponse(AuthenticationConfiguration authConfig) { + if (authConfig == null) { return; } + OidcClientConfig oidcConfig = authConfig.getOidcConfiguration(); + + if (nullOrEmpty(authConfig.getDiscoveryUri()) + && oidcConfig != null + && !nullOrEmpty(oidcConfig.getDiscoveryUri())) { + authConfig.setDiscoveryUri(oidcConfig.getDiscoveryUri()); + } + + if (oidcConfig != null + && nullOrEmpty(oidcConfig.getId()) + && !nullOrEmpty(authConfig.getClientId())) { + oidcConfig.setId(authConfig.getClientId()); + } + + if (oidcConfig != null + && nullOrEmpty(oidcConfig.getCallbackUrl()) + && !nullOrEmpty(authConfig.getCallbackUrl())) { + oidcConfig.setCallbackUrl(authConfig.getCallbackUrl()); + } + } + + /** + * @deprecated Use {@link #normalizeForPersistence} instead. + * Kept as a thin delegate for backward compatibility during migration. + */ + @Deprecated + public void syncFieldsFromDiscoveryUri(AuthenticationConfiguration authConfig) { + normalizeForPersistence(authConfig); + } + + private void deriveFromDiscoveryDocument( + AuthenticationConfiguration authConfig, String discoveryUri) { + try { + ValidationHttpUtil.HttpResponseData response = ValidationHttpUtil.safeGet(discoveryUri); + if (response.getStatusCode() != 200) { + return; + } + JsonNode discoveryDoc = JsonUtils.readTree(response.getBody()); + if (discoveryDoc.has("issuer")) { + authConfig.setAuthority(discoveryDoc.get("issuer").asText()); + } + if (discoveryDoc.has("jwks_uri")) { + authConfig.setPublicKeyUrls(List.of(discoveryDoc.get("jwks_uri").asText())); + } + } catch (Exception e) { + LOG.warn("Failed to derive fields from discoveryUri: {}", e.getMessage()); + } + } + + private void deriveProviderSpecificFields(AuthenticationConfiguration authConfig) { + if (authConfig.getProvider() == AuthProvider.AZURE) { + deriveAzureTenant(authConfig); + } + } - // Skip if already populated - if (authConfig.getPublicKeyUrls() != null && !authConfig.getPublicKeyUrls().isEmpty()) { - LOG.debug("publicKeyUrls already populated, skipping auto-population"); + private void deriveAzureTenant(AuthenticationConfiguration authConfig) { + OidcClientConfig oidcConfig = authConfig.getOidcConfiguration(); + if (oidcConfig == null) { + return; + } + String discoveryUri = authConfig.getDiscoveryUri(); + if (nullOrEmpty(discoveryUri)) { return; } + Matcher m = AZURE_TENANT_PATTERN.matcher(discoveryUri); + if (m.find()) { + oidcConfig.setTenant(m.group(1)); + } + } + private void deriveClientTypeFromSecret(AuthenticationConfiguration authConfig) { OidcClientConfig oidcConfig = authConfig.getOidcConfiguration(); - if (oidcConfig == null || nullOrEmpty(oidcConfig.getDiscoveryUri())) { - LOG.warn("Cannot auto-populate publicKeyUrls - missing oidcConfiguration or discoveryUri"); + if (authConfig.getClientType() != null || oidcConfig == null) { return; } + boolean hasSecret = !nullOrEmpty(oidcConfig.getSecret()); + authConfig.setClientType(hasSecret ? ClientType.CONFIDENTIAL : ClientType.PUBLIC); + } + private String buildDefaultCallbackUrl() { try { - OidcDiscoveryValidator discoveryValidator = new OidcDiscoveryValidator(); - discoveryValidator.autoPopulatePublicKeyUrls(oidcConfig.getDiscoveryUri(), authConfig); - LOG.info( - "Auto-populated publicKeyUrls from discovery document for provider: {}", - authConfig.getProvider()); + Settings setting = getOMBaseUrlConfigInternal(); + if (setting != null) { + OpenMetadataBaseUrlConfiguration urlConfig = + (OpenMetadataBaseUrlConfiguration) setting.getConfigValue(); + if (urlConfig != null && !nullOrEmpty(urlConfig.getOpenMetadataUrl())) { + return urlConfig.getOpenMetadataUrl().replaceAll("/+$", "") + DEFAULT_CALLBACK_PATH; + } + } } catch (Exception e) { - LOG.error("Failed to auto-populate publicKeyUrls: {}", e.getMessage(), e); + LOG.warn("Failed to build default callback URL: {}", e.getMessage()); } + return null; + } + + private static String firstNonEmpty(String... values) { + for (String v : values) { + if (!nullOrEmpty(v)) { + return v; + } + } + return null; } private FieldError validateLdapConfiguration(LdapConfiguration ldapConfig) { @@ -1759,7 +2080,7 @@ private FieldError validateSamlConfiguration( } private FieldError validateAuthorizerConfiguration( - AuthorizerConfiguration authzConfig, String currentUsername) { + AuthorizerConfiguration authzConfig, String currentUsername, boolean skipTestLoginFields) { try { // Validate required fields if (nullOrEmpty(authzConfig.getClassName())) { @@ -1767,14 +2088,16 @@ private FieldError validateAuthorizerConfiguration( "authorizerConfiguration.className", "Class name is required"); } - // Validate admin principals - if (authzConfig.getAdminPrincipals() == null || authzConfig.getAdminPrincipals().isEmpty()) { + // Validate admin principals (skip in test login context — set via Claim Selector) + if (!skipTestLoginFields + && (authzConfig.getAdminPrincipals() == null + || authzConfig.getAdminPrincipals().isEmpty())) { return ValidationErrorBuilder.createFieldError( FieldPaths.AUTHZ_ADMIN_PRINCIPALS, "At least one admin principal is required"); } - // Validate principal domain (required field) - if (nullOrEmpty(authzConfig.getPrincipalDomain())) { + // Validate principal domain (skip in test login context — derived from email claim) + if (!skipTestLoginFields && nullOrEmpty(authzConfig.getPrincipalDomain())) { return ValidationErrorBuilder.createFieldError( FieldPaths.AUTHZ_PRINCIPAL_DOMAIN, "Principal domain is required"); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/ConfigResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/ConfigResource.java index fffe12eff007..de9670043986 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/ConfigResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/ConfigResource.java @@ -19,10 +19,17 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import java.util.HashMap; import java.util.Map; import org.openmetadata.api.configuration.UiThemePreference; @@ -38,6 +45,9 @@ import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.settings.SettingsCache; import org.openmetadata.service.security.auth.SecurityConfigurationManager; +import org.openmetadata.service.security.auth.TestLdapHandler; +import org.openmetadata.service.security.auth.TestLoginHandler; +import org.openmetadata.service.security.auth.TestSamlHandler; import org.openmetadata.service.security.jwt.JWKSResponse; import org.openmetadata.service.security.jwt.JWTTokenGenerator; @@ -219,4 +229,157 @@ public PipelineServiceAPIClientConfig getPipelineServiceConfig() { public JWKSResponse getJWKSResponse() { return jwtTokenGenerator.getJWKSResponse(); } + + @POST + @Path("/auth/test-login/initiate") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Operation( + operationId = "testLoginInitiate", + summary = "Initiate OIDC Test Login", + description = + "Initiates an OIDC Test Login flow by redirecting to the IdP. " + + "Accepts form-encoded body so secrets stay out of the URL. " + + "Opens in a popup window via hidden form POST with target=_blank.") + public Response testLoginInitiate( + @Context HttpServletRequest request, + @FormParam("mode") String mode, + @FormParam("discoveryUri") String discoveryUri, + @FormParam("clientId") String clientId, + @FormParam("clientSecret") String clientSecret, + @FormParam("scope") String scope, + @FormParam("callbackUrl") String callbackUrl, + @FormParam("prompt") String prompt, + @FormParam("maxAge") String maxAge, + @FormParam("clientAuthenticationMethod") String clientAuthMethod, + @FormParam("disablePkce") String disablePkce, + @FormParam("useNonce") String useNonce, + @FormParam("customParams") String customParams) { + return TestLoginHandler.handleInitiate( + request, + mode, + discoveryUri, + clientId, + clientSecret, + scope, + callbackUrl, + prompt, + maxAge, + clientAuthMethod, + disablePkce, + useNonce, + customParams); + } + + @POST + @Path("/auth/test-login/saml-initiate") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Operation( + operationId = "samlTestLoginInitiate", + summary = "Initiate SAML Test Login", + description = + "Initiates a SAML Test Login flow by redirecting the browser to the IdP SSO URL " + + "with a SAML AuthnRequest. Expects form-encoded body with idpEntityId, " + + "idpSsoLoginUrl, idpX509Certificate, spEntityId, spAcsUrl, nameIdFormat. " + + "Browser is typically navigated to this endpoint via a hidden form POST " + + "with target=_blank so the response 302 loads inside the popup.") + public Response samlTestLoginInitiate( + @Context HttpServletRequest request, + @Context HttpServletResponse response, + @FormParam("mode") String mode, + @FormParam("idpEntityId") String idpEntityId, + @FormParam("idpSsoLoginUrl") String idpSsoLoginUrl, + @FormParam("idpX509Certificate") String idpX509Certificate, + @FormParam("spEntityId") String spEntityId, + @FormParam("spAcsUrl") String spAcsUrl, + @FormParam("nameIdFormat") String nameIdFormat) { + return TestSamlHandler.handleInitiate( + request, + response, + mode, + idpEntityId, + idpSsoLoginUrl, + idpX509Certificate, + spEntityId, + spAcsUrl, + nameIdFormat); + } + + @POST + @Path("/auth/test-login") + @Operation( + operationId = "testLoginLdap", + summary = "Test Login (LDAP) — saved config", + description = + "Tests LDAP authentication using the SAVED LDAP configuration. " + + "For pre-save LDAP Test Login, use /auth/test-login/ldap-initiate instead.") + public Response testLoginLdap(Map credentials) { + String email = credentials.get("email"); + String password = credentials.get("password"); + Map result = TestLoginHandler.handleLdapTestLogin(email, password); + + return Response.ok(result).build(); + } + + @POST + @Path("/auth/test-login/ldap-initiate") + @Consumes(MediaType.APPLICATION_JSON) + @Operation( + operationId = "ldapTestLoginInitiate", + summary = "Initiate LDAP Test Login (pre-save)", + description = + "Tests LDAP authentication using form-provided configuration — does not rely on " + + "saved state. Accepts { ldapConfiguration, email, password } as JSON body. " + + "Binds as admin, searches for the user by mailAttributeName, binds as the user " + + "to verify password, and returns the derived email + domain + admin principal.") + public Response ldapTestLoginInitiate(LdapTestLoginRequest body) { + if (body == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("success", false, "error", "Request body is required")) + .build(); + } + Map result = + TestLdapHandler.handleLdapTestLogin( + body.getMode(), body.getLdapConfiguration(), body.getEmail(), body.getPassword()); + return Response.ok(result).build(); + } + + public static class LdapTestLoginRequest { + private String mode; + private org.openmetadata.schema.auth.LdapConfiguration ldapConfiguration; + private String email; + private String password; + + public String getMode() { + return mode; + } + + public void setMode(String mode) { + this.mode = mode; + } + + public org.openmetadata.schema.auth.LdapConfiguration getLdapConfiguration() { + return ldapConfiguration; + } + + public void setLdapConfiguration( + org.openmetadata.schema.auth.LdapConfiguration ldapConfiguration) { + this.ldapConfiguration = ldapConfiguration; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/SystemResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/SystemResource.java index 9bde39b93cbf..46d17aabf0c5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/SystemResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/SystemResource.java @@ -644,6 +644,9 @@ public SecurityConfiguration getSecurityConfig(@Context SecurityContext security String configJson = JsonUtils.pojoToJson(originalConfig); SecurityConfiguration config = JsonUtils.readValue(configJson, SecurityConfiguration.class); + // Hydrate canonical fields from legacy locations so UI sees a consistent shape + systemRepository.hydrateForResponse(config.getAuthenticationConfiguration()); + // Apply password masking if needed - only to the copy if (authorizer.shouldMaskPasswords(securityContext)) { // Mask OIDC configuration if present @@ -685,14 +688,15 @@ public SecurityConfiguration getSecurityConfig(@Context SecurityContext security public Response updateSecurityConfig( @Context UriInfo uriInfo, @Context SecurityContext securityContext, - @Valid SecurityConfiguration securityConfig) { + SecurityConfiguration securityConfig) { authorizer.authorizeAdmin(securityContext); try { AuthenticationConfiguration authConfig = securityConfig.getAuthenticationConfiguration(); - // Auto-populate publicKeyUrls for OIDC confidential clients before saving - systemRepository.autoPopulatePublicKeyUrlsIfNeeded(authConfig); + // Normalize authentication configuration for persistence: mirror canonical and legacy + // field locations, derive authority/publicKeyUrls from discoveryUri, default callbackUrl. + systemRepository.normalizeForPersistence(authConfig); // Update both configurations in a transaction Settings authSettings = @@ -761,6 +765,12 @@ public Response patchSecurityConfig( SecurityConfiguration updatedConfig = JsonUtils.readValue(jsonString, SecurityConfiguration.class); + // Normalize after patch so derived fields (authority, publicKeyUrls, etc.) + // stay in sync with patched canonical fields like discoveryUri or oidcConfig.id. + if (updatedConfig.getAuthenticationConfiguration() != null) { + systemRepository.normalizeForPersistence(updatedConfig.getAuthenticationConfiguration()); + } + String currentUsername = SecurityUtil.getUserName(securityContext); SecurityValidationResponse validationResponse = systemRepository.validateSecurityConfiguration( @@ -826,11 +836,23 @@ public Response patchSecurityConfig( schema = @Schema(implementation = SecurityValidationResponse.class))) }) public SecurityValidationResponse validateSecurityConfig( - @Context SecurityContext securityContext, @Valid SecurityConfiguration securityConfig) { + @Context SecurityContext securityContext, + @QueryParam("context") String context, + SecurityConfiguration securityConfig) { authorizer.authorizeAdmin(securityContext); + + // Normalize configuration before validation so authority, publicKeyUrls, + // and mirrored fields are populated from discoveryUri. + AuthenticationConfiguration authConfig = securityConfig.getAuthenticationConfiguration(); + if (authConfig != null) { + systemRepository.normalizeForPersistence(authConfig); + } + String currentUsername = SecurityUtil.getUserName(securityContext); + boolean isTestLoginContext = "testLogin".equals(context); + return systemRepository.validateSecurityConfiguration( - securityConfig, applicationConfig, currentUsername); + securityConfig, applicationConfig, currentUsername, isTestLoginContext); } @GET diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthCallbackServlet.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthCallbackServlet.java index deff129d5ca1..4afc31f2c76c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthCallbackServlet.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthCallbackServlet.java @@ -4,12 +4,17 @@ import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; +import org.openmetadata.service.security.auth.TestLoginHandler; +import org.openmetadata.service.security.auth.TestSamlHandler; @WebServlet("/callback") @Slf4j public class AuthCallbackServlet extends HttpServlet { + private static final String OIDC_TEST_LOGIN_PREFIX = "test-login:"; + @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) { // Check if this is an MCP OAuth callback (pac4j state matches a pending MCP auth request). @@ -17,6 +22,13 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) { // /callback (the registered redirect URI). Forward to /mcp/callback so McpCallbackServlet // can handle it with proper state restoration from DB. String state = req.getParameter("state"); + + // Check if this is an OIDC Test Login callback (state prefixed with "test-login:") + if (state != null && state.startsWith(OIDC_TEST_LOGIN_PREFIX)) { + handleOidcTestLogin(req, resp); + return; + } + if (AuthenticationCodeFlowHandler.isMcpState(state)) { try { LOG.debug("Forwarding MCP OAuth callback to /mcp/callback"); @@ -40,8 +52,49 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) { - // SAML uses POST for callback with SAMLResponse + // SAML uses POST for callback with SAMLResponse. Test Login SAML prefixes + // RelayState with "saml-test-login:" so we can route to the test handler. + String relayState = req.getParameter("RelayState"); + if (relayState != null && relayState.startsWith(TestSamlHandler.RELAY_STATE_PREFIX)) { + handleSamlTestLogin(req, resp); + return; + } AuthServeletHandler handler = AuthServeletHandlerRegistry.getHandler(); handler.handleCallback(req, resp); } + + private void handleOidcTestLogin(HttpServletRequest req, HttpServletResponse resp) { + try { + LOG.debug("Handling OIDC Test Login callback"); + writeResponse(resp, TestLoginHandler.handleCallback(req)); + } catch (Exception e) { + LOG.error("Failed to handle OIDC Test Login callback", e); + sendInternalError(resp, "Failed to process Test Login"); + } + } + + private void handleSamlTestLogin(HttpServletRequest req, HttpServletResponse resp) { + try { + LOG.debug("Handling SAML Test Login callback"); + writeResponse(resp, TestSamlHandler.handleCallback(req)); + } catch (Exception e) { + LOG.error("Failed to handle SAML Test Login callback", e); + sendInternalError(resp, "Failed to process SAML Test Login"); + } + } + + private void writeResponse(HttpServletResponse resp, Response response) throws Exception { + resp.setContentType( + response.getMediaType() != null ? response.getMediaType().toString() : "text/html"); + resp.setStatus(response.getStatus()); + resp.getWriter().write(response.getEntity().toString()); + } + + private void sendInternalError(HttpServletResponse resp, String message) { + try { + resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, message); + } catch (Exception writeEx) { + LOG.error("Failed to write error response", writeEx); + } + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java index 8fad2fe71e88..23e2d97862c9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java @@ -892,7 +892,7 @@ private User getOrCreateOidcUser(String userName, String email, Map mapping) { // If emtpy, jwtPrincipalClaims will be used so no need to validate } - private HTTPResponse executeTokenHttpRequest(TokenRequest request) throws IOException { + public HTTPResponse executeTokenHttpRequest(TokenRequest request) throws IOException { HTTPRequest tokenHttpRequest = request.toHTTPRequest(); client.getConfiguration().configureHttpRequest(tokenHttpRequest); @@ -1135,7 +1135,7 @@ private HTTPResponse executeTokenHttpRequest(TokenRequest request) throws IOExce return httpResponse; } - private TokenRequest createTokenRequest(final AuthorizationGrant grant) { + public TokenRequest createTokenRequest(final AuthorizationGrant grant) { if (clientAuthentication != null) { return new TokenRequest( client.getConfiguration().findProviderMetadata().getTokenEndpointURI(), diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java index f003fa71a8f4..45023cccef9a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java @@ -79,6 +79,7 @@ public class JwtFilter implements ContainerRequestFilter { public static final String IMPERSONATED_USER_CLAIM = "impersonatedUser"; @Getter private List jwtPrincipalClaims; @Getter private Map jwtPrincipalClaimsMapping; + @Getter private String emailClaim; @Getter private String jwtTeamClaimMapping; private JwkProvider jwkProvider; private String principalDomain; @@ -104,7 +105,10 @@ public class JwtFilter implements ContainerRequestFilter { "v1/users/password/reset", "v1/users/login", "v1/users/refresh", - "v1/collate/apps/support/login"); + "v1/collate/apps/support/login", + "v1/system/config/auth/test-login/initiate", + "v1/system/config/auth/test-login/saml-initiate", + "v1/system/config/auth/test-login/ldap-initiate"); @SuppressWarnings("unused") private JwtFilter() {} @@ -121,6 +125,10 @@ public JwtFilter( .map(s -> s.split(":")) .collect(Collectors.toMap(s -> s[0], s -> s[1])); validatePrincipalClaimsMapping(jwtPrincipalClaimsMapping); + // SAML reissues OM's own JWT with a hardcoded "email" claim, so honoring + // the configured emailClaim here would always fail to find it. + this.emailClaim = + providerType == AuthProvider.SAML ? null : authenticationConfiguration.getEmailClaim(); this.jwtTeamClaimMapping = authenticationConfiguration.getJwtTeamClaimMapping(); ImmutableList.Builder publicKeyUrlsBuilder = ImmutableList.builder(); @@ -170,7 +178,7 @@ public void filter(ContainerRequestContext requestContext) { findUserNameFromClaims(jwtPrincipalClaimsMapping, jwtPrincipalClaims, claims); String email = findEmailFromClaims( - jwtPrincipalClaimsMapping, jwtPrincipalClaims, claims, principalDomain); + jwtPrincipalClaimsMapping, emailClaim, jwtPrincipalClaims, claims, principalDomain); boolean isBotUser = isBot(claims); String impersonateUser = requestContext.getHeaderString("X-Impersonate-User"); @@ -340,7 +348,8 @@ public CatalogSecurityContext getCatalogSecurityContext(String token) { Map claims = validateJwtAndGetClaims(token); String userName = findUserNameFromClaims(jwtPrincipalClaimsMapping, jwtPrincipalClaims, claims); String email = - findEmailFromClaims(jwtPrincipalClaimsMapping, jwtPrincipalClaims, claims, principalDomain); + findEmailFromClaims( + jwtPrincipalClaimsMapping, emailClaim, jwtPrincipalClaims, claims, principalDomain); CatalogPrincipal catalogPrincipal = new CatalogPrincipal(userName, email); boolean isBotUser = isBot(claims); return new CatalogSecurityContext( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/SecurityUtil.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/SecurityUtil.java index fb4bfb5fbcbd..f683fe1ce6cf 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/SecurityUtil.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/SecurityUtil.java @@ -49,6 +49,24 @@ public final class SecurityUtil { private SecurityUtil() {} + /** + * Returns true if adminPrincipals contains either the userName or the email. + * Admins are often configured by email (e.g. "alice@company.com") while the + * derived userName is typically the email local part ("alice"). Matching + * either form avoids surprising failures where the user set their admin + * entry as an email but the login identifier is a username. + */ + public static boolean isAdminPrincipal( + Set adminPrincipals, String userName, String email) { + if (adminPrincipals == null || adminPrincipals.isEmpty()) { + return false; + } + if (!nullOrEmpty(userName) && adminPrincipals.contains(userName)) { + return true; + } + return !nullOrEmpty(email) && adminPrincipals.contains(email); + } + public static String getUserName(SecurityContext securityContext) { Principal principal = securityContext.getUserPrincipal(); return principal == null ? null : principal.getName().split("[/@]")[0]; @@ -137,27 +155,48 @@ public static String findEmailFromClaims( List jwtPrincipalClaimsOrder, Map claims, String defaulPrincipalClaim) { - String email; + return findEmailFromClaims( + jwtPrincipalClaimsMapping, null, jwtPrincipalClaimsOrder, claims, defaulPrincipalClaim); + } + public static String findEmailFromClaims( + Map jwtPrincipalClaimsMapping, + String emailClaimName, + List jwtPrincipalClaimsOrder, + Map claims, + String defaulPrincipalClaim) { + // Priority 1: jwtPrincipalClaimsMapping (existing, highest priority) + // Explicit config — hard fail if claim missing if (!nullOrEmpty(jwtPrincipalClaimsMapping) && !isBotW(claims)) { - // We have a mapping available so we will use that String emailClaim = jwtPrincipalClaimsMapping.get(EMAIL_CLAIM_KEY); String emailClaimValue = getClaimOrObject(claims.get(emailClaim)); if (!nullOrEmpty(emailClaimValue) && emailClaimValue.contains("@")) { - email = emailClaimValue; - } else { - throw new AuthenticationException( - String.format( - "Invalid JWT token, 'email' claim is not present or invalid : %s", - emailClaimValue)); + return emailClaimValue.toLowerCase(); } - } else { - String jwtClaim = getFirstMatchJwtClaim(jwtPrincipalClaimsOrder, claims); - email = - jwtClaim.contains("@") - ? jwtClaim - : String.format("%s@%s", jwtClaim, defaulPrincipalClaim); + throw new AuthenticationException( + String.format( + "Invalid JWT token, 'email' claim is not present or invalid : %s", emailClaimValue)); } + + // Priority 2: emailClaim (new, set via Test Login) + // Explicit config — hard fail if claim missing + if (!nullOrEmpty(emailClaimName) && !isBotW(claims)) { + String emailClaimValue = getClaimOrObject(claims.get(emailClaimName)); + if (!nullOrEmpty(emailClaimValue) && emailClaimValue.contains("@")) { + return emailClaimValue.toLowerCase(); + } + throw new AuthenticationException( + String.format( + "Email claim '%s' not found or invalid in JWT token. " + + "Run Test Login in SSO settings to reconfigure the email claim.", + emailClaimName)); + } + + // Priority 3: jwtPrincipalClaims (legacy fallback) + // No explicit config — soft matching, existing behavior + String jwtClaim = getFirstMatchJwtClaim(jwtPrincipalClaimsOrder, claims); + String email = + jwtClaim.contains("@") ? jwtClaim : String.format("%s@%s", jwtClaim, defaulPrincipalClaim); return email.toLowerCase(); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java index edad29c4f26d..e7f454d19db4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java @@ -477,11 +477,12 @@ private void getRoleForLdap(String userDn, User user, Boolean reAssign) /** * Check if user should be admin based on adminPrincipals configuration */ - private boolean checkAdminPrincipals(String userName) { + private boolean checkAdminPrincipals(String userName, String email) { try { - return SecurityConfigurationManager.getCurrentAuthzConfig() - .getAdminPrincipals() - .contains(userName); + return SecurityUtil.isAdminPrincipal( + SecurityConfigurationManager.getCurrentAuthzConfig().getAdminPrincipals(), + userName, + email); } catch (Exception e) { LOG.warn("Failed to check adminPrincipals for user {}: {}", userName, e.getMessage()); return false; @@ -493,7 +494,7 @@ private boolean checkAdminPrincipals(String userName) { */ private void checkAndApplyAdminPrincipals(User user) { try { - boolean shouldBeAdminFromPrincipals = checkAdminPrincipals(user.getName()); + boolean shouldBeAdminFromPrincipals = checkAdminPrincipals(user.getName(), user.getEmail()); if (shouldBeAdminFromPrincipals) { user.setIsAdmin(true); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/SamlAuthServletHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/SamlAuthServletHandler.java index ca68404be35a..dd5ecc8a165e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/SamlAuthServletHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/SamlAuthServletHandler.java @@ -39,6 +39,7 @@ import org.openmetadata.service.auth.JwtResponse; import org.openmetadata.service.exception.AuthenticationException; import org.openmetadata.service.security.AuthServeletHandler; +import org.openmetadata.service.security.SecurityUtil; import org.openmetadata.service.security.jwt.JWTTokenGenerator; import org.openmetadata.service.security.saml.SamlSettingsHolder; import org.openmetadata.service.util.TokenUtil; @@ -431,7 +432,7 @@ private User getOrCreateUser( Entity.getEntityByName( Entity.USER, username, "id,roles,teams,isAdmin,email", Include.NON_DELETED); - boolean shouldBeAdmin = getAdminPrincipals().contains(username); + boolean shouldBeAdmin = SecurityUtil.isAdminPrincipal(getAdminPrincipals(), username, email); boolean needsUpdate = false; LOG.info( @@ -475,7 +476,7 @@ private User getOrCreateUser( } catch (Exception e) { LOG.info("User not found, creating new user: {}", username); if (authConfig.getEnableSelfSignup()) { - boolean isAdmin = getAdminPrincipals().contains(username); + boolean isAdmin = SecurityUtil.isAdminPrincipal(getAdminPrincipals(), username, email); LOG.info( "Creating new user - Username: {}, DisplayName: {}, Should be admin: {}", username, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestLdapHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestLdapHandler.java new file mode 100644 index 000000000000..b1a0d90bfdf5 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestLdapHandler.java @@ -0,0 +1,194 @@ +/* + * Copyright 2025 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.security.auth; + +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.unboundid.ldap.sdk.Filter; +import com.unboundid.ldap.sdk.LDAPConnection; +import com.unboundid.ldap.sdk.LDAPConnectionOptions; +import com.unboundid.ldap.sdk.SearchResult; +import com.unboundid.ldap.sdk.SearchResultEntry; +import com.unboundid.ldap.sdk.SearchScope; +import com.unboundid.util.ssl.SSLUtil; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.api.security.AuthenticationConfiguration; +import org.openmetadata.schema.auth.LdapConfiguration; +import org.openmetadata.schema.configuration.SecurityConfiguration; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.util.LdapUtil; + +/** + * Stateless LDAP Test Login handler. Accepts form-provided {@link LdapConfiguration} + * + user credentials directly — does not use {@code SecurityConfigurationManager} or + * any singleton, so it works BEFORE the config is saved. Does not create/update + * users in the OpenMetadata database; purely verifies the LDAP config works. + * + *

Flow: admin bind → search user by mail attribute → bind as user to verify + * password → read mail attribute value → return for UI auto-fill. + */ +@Slf4j +public final class TestLdapHandler { + + private static final int TIMEOUT_MS = 5000; + + private TestLdapHandler() {} + + public static Map handleLdapTestLogin( + String mode, LdapConfiguration ldapConfig, String email, String password) { + if (nullOrEmpty(email) || nullOrEmpty(password)) { + return error("Email and password are required"); + } + // Existing-config mode: merge form values onto saved LDAP config so admins + // can re-test without retyping the masked admin password. + ldapConfig = maybeMergeWithSaved(mode, ldapConfig); + if (ldapConfig == null) { + return error("LDAP configuration is required"); + } + if (nullOrEmpty(ldapConfig.getHost()) + || ldapConfig.getPort() == null + || nullOrEmpty(ldapConfig.getDnAdminPrincipal()) + || nullOrEmpty(ldapConfig.getDnAdminPassword()) + || nullOrEmpty(ldapConfig.getUserBaseDN()) + || nullOrEmpty(ldapConfig.getMailAttributeName())) { + return error("LDAP configuration is missing required fields"); + } + + LDAPConnection adminConn = null; + try { + adminConn = + buildConnection( + ldapConfig, ldapConfig.getDnAdminPrincipal(), ldapConfig.getDnAdminPassword()); + + String userDn = searchUserDn(adminConn, ldapConfig, email); + if (userDn == null) { + return error( + "User '" + email + "' not found in LDAP directory under " + ldapConfig.getUserBaseDN()); + } + + // Verify the user's password via bind + try (LDAPConnection userConn = buildConnection(ldapConfig, userDn, password)) { + // bind success = password valid + } catch (Exception e) { + return error("Invalid username or password"); + } + + String mail = readAttribute(adminConn, userDn, ldapConfig.getMailAttributeName()); + if (nullOrEmpty(mail)) { + mail = email; + } + + Map result = new LinkedHashMap<>(); + result.put("success", true); + result.put("email", mail); + result.put("username", extractCn(userDn)); + result.put("derivedPrincipalDomain", mail.contains("@") ? mail.split("@")[1] : ""); + result.put("suggestedAdminPrincipal", mail); + return result; + } catch (Exception e) { + LOG.error("[LDAP Test Login] failed", e); + return error("LDAP test failed: " + e.getMessage()); + } finally { + if (adminConn != null) { + adminConn.close(); + } + } + } + + private static LDAPConnection buildConnection( + LdapConfiguration ldapConfig, String bindDn, String bindPassword) throws Exception { + LDAPConnectionOptions options = new LDAPConnectionOptions(); + options.setConnectTimeoutMillis(TIMEOUT_MS); + options.setResponseTimeoutMillis(TIMEOUT_MS); + + if (Boolean.TRUE.equals(ldapConfig.getSslEnabled())) { + LdapUtil ldapUtil = new LdapUtil(); + SSLUtil sslUtil = new SSLUtil(ldapUtil.getLdapSSLConnection(ldapConfig, options)); + return new LDAPConnection( + sslUtil.createSSLSocketFactory(), + options, + ldapConfig.getHost(), + ldapConfig.getPort(), + bindDn, + bindPassword); + } + return new LDAPConnection( + options, ldapConfig.getHost(), ldapConfig.getPort(), bindDn, bindPassword); + } + + private static String searchUserDn( + LDAPConnection conn, LdapConfiguration ldapConfig, String email) throws Exception { + Filter filter = Filter.createEqualityFilter(ldapConfig.getMailAttributeName(), email); + SearchResult result = conn.search(ldapConfig.getUserBaseDN(), SearchScope.SUB, filter, "dn"); + if (result.getEntryCount() == 0) { + return null; + } + return result.getSearchEntries().get(0).getDN(); + } + + private static String readAttribute(LDAPConnection conn, String userDn, String attribute) + throws Exception { + SearchResultEntry entry = conn.getEntry(userDn, attribute); + if (entry == null) { + return null; + } + return entry.getAttributeValue(attribute); + } + + private static String extractCn(String dn) { + if (nullOrEmpty(dn)) { + return ""; + } + for (String part : dn.split(",")) { + String trimmed = part.trim(); + if (trimmed.toLowerCase().startsWith("cn=")) { + return trimmed.substring(3); + } + } + return dn; + } + + private static Map error(String message) { + return Map.of("success", false, "error", message); + } + + /** + * When mode == "existing", overlay the incoming partial LDAP config onto + * saved via the shared deep-merge in SystemRepository. Returns the merged + * LdapConfiguration. Empty / null / masked values in the request fall + * through to saved automatically (via {@code shouldUsePatchValue}). + */ + static LdapConfiguration maybeMergeWithSaved(String mode, LdapConfiguration formConfig) { + if (!TestLoginHandler.MODE_EXISTING.equalsIgnoreCase(mode)) { + return formConfig; + } + ObjectMapper mapper = JsonUtils.getObjectMapper(); + ObjectNode root = mapper.createObjectNode(); + ObjectNode authConfig = mapper.createObjectNode(); + if (formConfig != null) { + authConfig.set("ldapConfiguration", mapper.valueToTree(formConfig)); + } + if (!authConfig.isEmpty()) { + root.set("authenticationConfiguration", authConfig); + } + SecurityConfiguration merged = Entity.getSystemRepository().overlayOnSavedSecurityConfig(root); + AuthenticationConfiguration mergedAuth = merged.getAuthenticationConfiguration(); + return mergedAuth != null ? mergedAuth.getLdapConfiguration() : formConfig; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestLoginHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestLoginHandler.java new file mode 100644 index 000000000000..1d0831f15bc1 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestLoginHandler.java @@ -0,0 +1,528 @@ +/* + * Copyright 2025 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.security.auth; + +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.oauth2.sdk.AuthorizationCode; +import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; +import com.nimbusds.oauth2.sdk.TokenRequest; +import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.ClientSecretPost; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.http.HTTPRequest; +import com.nimbusds.oauth2.sdk.http.HTTPResponse; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.openid.connect.sdk.AuthenticationRequest; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponse; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; +import com.nimbusds.openid.connect.sdk.token.OIDCTokens; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.core.Response; +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.api.security.AuthenticationConfiguration; +import org.openmetadata.schema.configuration.SecurityConfiguration; +import org.openmetadata.schema.security.client.OidcClientConfig; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.secrets.masker.PasswordEntityMasker; +import org.pac4j.oidc.config.OidcConfiguration; + +@Slf4j +public class TestLoginHandler { + + private static final String TEST_LOGIN_STATE = "testLoginState"; + private static final String TEST_LOGIN_CLIENT_ID = "testLoginClientId"; + private static final String TEST_LOGIN_CLIENT_SECRET = "testLoginClientSecret"; + private static final String TEST_LOGIN_DISCOVERY_URI = "testLoginDiscoveryUri"; + private static final String TEST_LOGIN_CALLBACK_URL = "testLoginCallbackUrl"; + private static final String TEST_LOGIN_STATE_PREFIX = "test-login:"; + private static final String DEFAULT_CALLBACK_PATH = "/callback"; + + private TestLoginHandler() {} + + private static final String TEST_LOGIN_CODE_VERIFIER = "testLoginCodeVerifier"; + private static final String TEST_LOGIN_CLIENT_AUTH_METHOD = "testLoginClientAuthMethod"; + public static final String MODE_EXISTING = "existing"; + + public static Response handleInitiate( + HttpServletRequest req, + String mode, + String discoveryUri, + String clientId, + String clientSecret, + String scope, + String callbackUrl, + String prompt, + String maxAge, + String clientAuthMethod, + String disablePkce, + String useNonce, + String customParams) { + try { + // Existing-config mode: overlay the admin's edits onto saved config via + // the shared deep-merge in SystemRepository. Form fields that are blank + // or contain the masked placeholder are skipped from the sparse request, + // so saved values fall through automatically. + if (MODE_EXISTING.equalsIgnoreCase(mode)) { + JsonNode sparseRequest = + buildSparseOidcRequest( + discoveryUri, + clientId, + clientSecret, + scope, + callbackUrl, + prompt, + maxAge, + clientAuthMethod, + disablePkce, + useNonce, + customParams); + SecurityConfiguration merged = + Entity.getSystemRepository().overlayOnSavedSecurityConfig(sparseRequest); + AuthenticationConfiguration mergedAuth = merged.getAuthenticationConfiguration(); + if (mergedAuth != null) { + discoveryUri = mergedAuth.getDiscoveryUri(); + callbackUrl = mergedAuth.getCallbackUrl(); + OidcClientConfig mergedOidc = mergedAuth.getOidcConfiguration(); + if (mergedOidc != null) { + clientId = mergedOidc.getId(); + clientSecret = mergedOidc.getSecret(); + scope = mergedOidc.getScope(); + prompt = mergedOidc.getPrompt(); + maxAge = mergedOidc.getMaxAge(); + useNonce = mergedOidc.getUseNonce(); + if (mergedOidc.getClientAuthenticationMethod() != null) { + clientAuthMethod = mergedOidc.getClientAuthenticationMethod().value(); + } + if (mergedOidc.getDisablePkce() != null) { + disablePkce = String.valueOf(mergedOidc.getDisablePkce()); + } + if (mergedOidc.getCustomParams() != null) { + try { + customParams = JsonUtils.pojoToJson(mergedOidc.getCustomParams()); + } catch (Exception e) { + LOG.warn("[Test Login] Failed to serialize merged customParams", e); + } + } + } + } + } + + if (nullOrEmpty(discoveryUri)) { + return buildHtmlErrorResponse("Discovery URI is required for Test Login."); + } + if (nullOrEmpty(clientId)) { + return buildHtmlErrorResponse("Client ID is required for Test Login."); + } + + if (nullOrEmpty(scope)) { + scope = "openid email profile"; + } + + OidcConfiguration oidcConfig = new OidcConfiguration(); + oidcConfig.setClientId(clientId); + if (!nullOrEmpty(clientSecret)) { + oidcConfig.setSecret(clientSecret); + } + oidcConfig.setDiscoveryURI(discoveryUri); + oidcConfig.setScope(scope); + oidcConfig.setResponseType("code"); + + OIDCProviderMetadata providerMetadata = + OIDCProviderMetadata.parse( + new HTTPRequest(HTTPRequest.Method.GET, new URI(discoveryUri).toURL()) + .send() + .getContent()); + if (providerMetadata == null || providerMetadata.getAuthorizationEndpointURI() == null) { + return buildHtmlErrorResponse( + "Could not fetch OIDC discovery document from: " + discoveryUri); + } + + HttpSession session = req.getSession(true); + + if (nullOrEmpty(callbackUrl)) { + String serverUrl = + req.getScheme() + + "://" + + req.getServerName() + + (req.getServerPort() != 80 && req.getServerPort() != 443 + ? ":" + req.getServerPort() + : ""); + callbackUrl = serverUrl + DEFAULT_CALLBACK_PATH; + } + + session.setAttribute(TEST_LOGIN_CLIENT_ID, clientId); + session.setAttribute(TEST_LOGIN_CLIENT_SECRET, clientSecret != null ? clientSecret : ""); + session.setAttribute(TEST_LOGIN_DISCOVERY_URI, discoveryUri); + session.setAttribute(TEST_LOGIN_CALLBACK_URL, callbackUrl); + session.setAttribute( + TEST_LOGIN_CLIENT_AUTH_METHOD, clientAuthMethod != null ? clientAuthMethod : ""); + + Map params = new HashMap<>(); + + // Merge customParams first (lower priority — explicit params override below) + if (!nullOrEmpty(customParams)) { + try { + Map custom = + JsonUtils.readValue( + customParams, new com.fasterxml.jackson.core.type.TypeReference<>() {}); + if (custom != null) { + params.putAll(custom); + } + } catch (Exception e) { + LOG.warn("[Test Login] Failed to parse customParams: {}", e.getMessage()); + } + } + + params.put(OidcConfiguration.SCOPE, scope); + params.put(OidcConfiguration.RESPONSE_TYPE, "code"); + params.put(OidcConfiguration.RESPONSE_MODE, "query"); + params.put(OidcConfiguration.CLIENT_ID, clientId); + params.put(OidcConfiguration.REDIRECT_URI, callbackUrl); + + if (!nullOrEmpty(prompt)) { + params.put(OidcConfiguration.PROMPT, prompt); + } + if (!nullOrEmpty(maxAge)) { + params.put(OidcConfiguration.MAX_AGE, maxAge); + } + + // PKCE: send code_challenge unless explicitly disabled (mirrors real login) + if (!"true".equalsIgnoreCase(disablePkce)) { + com.nimbusds.oauth2.sdk.pkce.CodeVerifier codeVerifier = + new com.nimbusds.oauth2.sdk.pkce.CodeVerifier(); + session.setAttribute(TEST_LOGIN_CODE_VERIFIER, codeVerifier.getValue()); + com.nimbusds.oauth2.sdk.pkce.CodeChallenge challenge = + com.nimbusds.oauth2.sdk.pkce.CodeChallenge.compute( + com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod.S256, codeVerifier); + params.put("code_challenge", challenge.getValue()); + params.put("code_challenge_method", "S256"); + } + + // Prefix state with "test-login:" so AuthCallbackServlet routes to us + String state = TEST_LOGIN_STATE_PREFIX + java.util.UUID.randomUUID(); + params.put("state", state); + session.setAttribute(TEST_LOGIN_STATE, state); + + // Nonce: send unless explicitly disabled (mirrors real login useNonce config) + if (!"false".equalsIgnoreCase(useNonce)) { + params.put("nonce", java.util.UUID.randomUUID().toString()); + } + + String queryString = + AuthenticationRequest.parse( + params.entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, e -> Collections.singletonList(e.getValue())))) + .toQueryString(); + + String authUrl = + providerMetadata.getAuthorizationEndpointURI().toString() + "?" + queryString; + LOG.info( + "[Test Login] Redirecting to IdP: {}", providerMetadata.getAuthorizationEndpointURI()); + + // Use 302 (not 307) so the browser converts POST → GET when following the redirect. + // 307 preserves the method, which would POST to the IdP's authorization endpoint + // with an empty body — causing "client_id missing" errors from Azure/others. + return Response.status(302).header("Location", authUrl).build(); + } catch (Exception e) { + LOG.error("[Test Login] Failed to initiate", e); + + return buildHtmlErrorResponse("Failed to initiate Test Login: " + e.getMessage()); + } + } + + public static Response handleCallback(HttpServletRequest req) { + try { + HttpSession session = req.getSession(false); + if (session == null) { + return buildPostMessageResponse(false, "Session expired. Please try again.", null); + } + + String error = req.getParameter("error"); + if (!nullOrEmpty(error)) { + String errorDesc = req.getParameter("error_description"); + + return buildPostMessageResponse( + false, "IdP returned error: " + error + " - " + errorDesc, null); + } + + String expectedState = (String) session.getAttribute(TEST_LOGIN_STATE); + String receivedState = req.getParameter("state"); + if (expectedState != null && !expectedState.equals(receivedState)) { + return buildPostMessageResponse( + false, "Invalid state parameter. Possible CSRF attack.", null); + } + + String code = req.getParameter("code"); + if (nullOrEmpty(code)) { + return buildPostMessageResponse(false, "No authorization code received from IdP.", null); + } + + String clientId = (String) session.getAttribute(TEST_LOGIN_CLIENT_ID); + String clientSecret = (String) session.getAttribute(TEST_LOGIN_CLIENT_SECRET); + String discoveryUri = (String) session.getAttribute(TEST_LOGIN_DISCOVERY_URI); + String callbackUrl = (String) session.getAttribute(TEST_LOGIN_CALLBACK_URL); + String clientAuthMethod = (String) session.getAttribute(TEST_LOGIN_CLIENT_AUTH_METHOD); + + if (nullOrEmpty(clientId) || nullOrEmpty(discoveryUri)) { + return buildPostMessageResponse( + false, "Session data missing. Please try Test Login again.", null); + } + + OIDCProviderMetadata providerMetadata = + OIDCProviderMetadata.parse( + new HTTPRequest(HTTPRequest.Method.GET, new URI(discoveryUri).toURL()) + .send() + .getContent()); + URI tokenEndpoint = providerMetadata.getTokenEndpointURI(); + + // Include PKCE code_verifier if it was generated during initiate + String storedVerifier = (String) session.getAttribute(TEST_LOGIN_CODE_VERIFIER); + AuthorizationCodeGrant grant; + if (!nullOrEmpty(storedVerifier)) { + grant = + new AuthorizationCodeGrant( + new AuthorizationCode(code), + new URI(callbackUrl), + new com.nimbusds.oauth2.sdk.pkce.CodeVerifier(storedVerifier)); + } else { + grant = new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(callbackUrl)); + } + + TokenRequest tokenRequest; + if (!nullOrEmpty(clientSecret)) { + ClientAuthentication clientAuth = + buildClientAuthentication(clientId, clientSecret, clientAuthMethod); + tokenRequest = new TokenRequest(tokenEndpoint, clientAuth, grant); + } else { + tokenRequest = new TokenRequest(tokenEndpoint, new ClientID(clientId), grant); + } + + HTTPRequest httpRequest = tokenRequest.toHTTPRequest(); + HTTPResponse httpResponse = httpRequest.send(); + + if (httpResponse.getStatusCode() != 200) { + return buildPostMessageResponse( + false, + "Token exchange failed (HTTP " + + httpResponse.getStatusCode() + + "). Check Client ID and Secret.", + null); + } + + OIDCTokenResponse tokenResponse = + (OIDCTokenResponse) OIDCTokenResponseParser.parse(httpResponse); + OIDCTokens tokens = tokenResponse.getOIDCTokens(); + JWT idToken = tokens.getIDToken(); + + if (idToken == null) { + return buildPostMessageResponse( + false, "No id_token received from IdP. Check your scope configuration.", null); + } + + JWTClaimsSet claimsSet = idToken.getJWTClaimsSet(); + Map claims = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + claims.putAll(claimsSet.getClaims()); + + Map result = buildTestLoginResult(claims, tokens.getRefreshToken() != null); + + session.removeAttribute(TEST_LOGIN_STATE); + session.removeAttribute(TEST_LOGIN_CLIENT_ID); + session.removeAttribute(TEST_LOGIN_CLIENT_SECRET); + session.removeAttribute(TEST_LOGIN_DISCOVERY_URI); + session.removeAttribute(TEST_LOGIN_CALLBACK_URL); + session.removeAttribute(TEST_LOGIN_CLIENT_AUTH_METHOD); + session.removeAttribute(TEST_LOGIN_CODE_VERIFIER); + LOG.info("[Test Login] Callback successful, {} claims extracted", claims.size()); + + return buildPostMessageResponse(true, null, result); + } catch (Exception e) { + LOG.error("[Test Login] Callback failed", e); + + return buildPostMessageResponse(false, "Token exchange failed: " + e.getMessage(), null); + } + } + + public static Map handleLdapTestLogin(String email, String password) { + try { + SecurityConfigurationManager configManager = SecurityConfigurationManager.getInstance(); + AuthenticatorHandler authenticatorHandler = configManager.getAuthenticatorHandler(); + if (authenticatorHandler == null) { + return Map.of("success", false, "error", "LDAP authenticator not configured"); + } + + org.openmetadata.schema.entity.teams.User user = + authenticatorHandler.lookUserInProvider(email, password); + + Map result = new LinkedHashMap<>(); + result.put("success", true); + result.put("email", user.getEmail()); + result.put("username", user.getName()); + result.put( + "derivedPrincipalDomain", + user.getEmail().contains("@") ? user.getEmail().split("@")[1] : ""); + result.put("suggestedAdminPrincipal", user.getEmail()); + + return result; + } catch (Exception e) { + LOG.error("[Test Login] LDAP test login failed", e); + + return Map.of("success", false, "error", "LDAP authentication failed: " + e.getMessage()); + } + } + + /** + * Build a sparse SecurityConfiguration JsonNode from OIDC Test Login form + * params. Only includes fields whose value is present and not the masked + * placeholder. Designed for {@link + * org.openmetadata.service.jdbi3.SystemRepository#overlayOnSavedSecurityConfig} + * — absent fields naturally fall through to saved values. + */ + static JsonNode buildSparseOidcRequest( + String discoveryUri, + String clientId, + String clientSecret, + String scope, + String callbackUrl, + String prompt, + String maxAge, + String clientAuthMethod, + String disablePkce, + String useNonce, + String customParams) { + ObjectMapper mapper = JsonUtils.getObjectMapper(); + ObjectNode root = mapper.createObjectNode(); + ObjectNode authConfig = mapper.createObjectNode(); + ObjectNode oidc = mapper.createObjectNode(); + + putIfPresent(authConfig, "discoveryUri", discoveryUri); + putIfPresent(authConfig, "callbackUrl", callbackUrl); + + putIfPresent(oidc, "id", clientId); + putIfPresentNonMasked(oidc, "secret", clientSecret); + putIfPresent(oidc, "scope", scope); + putIfPresent(oidc, "callbackUrl", callbackUrl); + putIfPresent(oidc, "prompt", prompt); + putIfPresent(oidc, "maxAge", maxAge); + putIfPresent(oidc, "useNonce", useNonce); + putIfPresent(oidc, "clientAuthenticationMethod", clientAuthMethod); + if (!nullOrEmpty(disablePkce)) { + oidc.put("disablePkce", Boolean.parseBoolean(disablePkce)); + } + if (!nullOrEmpty(customParams)) { + try { + JsonNode customParamsNode = mapper.readTree(customParams); + if (customParamsNode != null && !customParamsNode.isNull()) { + oidc.set("customParams", customParamsNode); + } + } catch (Exception e) { + LOG.warn("[Test Login] Failed to parse customParams for sparse merge", e); + } + } + + if (!oidc.isEmpty()) { + authConfig.set("oidcConfiguration", oidc); + } + if (!authConfig.isEmpty()) { + root.set("authenticationConfiguration", authConfig); + } + return root; + } + + private static void putIfPresent(ObjectNode node, String key, String value) { + if (!nullOrEmpty(value)) { + node.put(key, value); + } + } + + private static void putIfPresentNonMasked(ObjectNode node, String key, String value) { + if (!nullOrEmpty(value) && !PasswordEntityMasker.PASSWORD_MASK.equals(value)) { + node.put(key, value); + } + } + + static ClientAuthentication buildClientAuthentication( + String clientId, String clientSecret, String method) { + if ("client_secret_post".equals(method)) { + return new ClientSecretPost(new ClientID(clientId), new Secret(clientSecret)); + } + + return new ClientSecretBasic(new ClientID(clientId), new Secret(clientSecret)); + } + + static Map buildTestLoginResult( + Map claims, boolean hasRefreshToken) { + Map result = new LinkedHashMap<>(); + + Map claimMap = new LinkedHashMap<>(); + for (Map.Entry entry : claims.entrySet()) { + String key = entry.getKey(); + if ("iat".equals(key) || "exp".equals(key) || "nbf".equals(key) || "auth_time".equals(key)) { + continue; + } + Object value = entry.getValue(); + claimMap.put(key, value != null ? value.toString() : ""); + } + result.put("claims", claimMap); + + String suggestedEmail = null; + for (Map.Entry entry : claimMap.entrySet()) { + if (entry.getValue() != null && entry.getValue().contains("@")) { + suggestedEmail = entry.getKey(); + + break; + } + } + result.put("suggestedEmailClaim", suggestedEmail); + + if (suggestedEmail != null) { + String emailValue = claimMap.get(suggestedEmail); + if (emailValue != null && emailValue.contains("@")) { + result.put("derivedPrincipalDomain", emailValue.split("@")[1]); + result.put("suggestedAdminPrincipal", emailValue); + } + } + + result.put("hasRefreshToken", hasRefreshToken); + + return result; + } + + private static Response buildPostMessageResponse( + boolean success, String error, Map data) { + return TestLoginResponses.buildPostMessageResponse(success, error, data); + } + + private static Response buildHtmlErrorResponse(String message) { + return TestLoginResponses.buildHtmlErrorResponse(message); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestLoginResponses.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestLoginResponses.java new file mode 100644 index 000000000000..c952ca339987 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestLoginResponses.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.security.auth; + +import jakarta.ws.rs.core.Response; +import java.util.LinkedHashMap; +import java.util.Map; +import org.openmetadata.schema.utils.JsonUtils; + +/** + * Shared HTML+JS response builders for Test Login popups (OIDC and SAML). + * The popup writes the result to localStorage and closes; the parent window + * polls localStorage for the outcome. + */ +final class TestLoginResponses { + + private static final String LOCAL_STORAGE_KEY = "sso-test-login-result"; + private static final String MESSAGE_TYPE = "sso-test-login"; + + private TestLoginResponses() {} + + static Response buildPostMessageResponse( + boolean success, String error, Map data) { + Map message = new LinkedHashMap<>(); + message.put("type", MESSAGE_TYPE); + message.put("success", success); + if (error != null) { + message.put("error", error); + } + if (data != null) { + message.putAll(data); + } + + String json = JsonUtils.pojoToJson(message); + + String html = + "" + + "

" + + (success ? "Authentication successful. This window will close." : "Error: " + error) + + "

" + + "" + + ""; + + return Response.ok(html, "text/html").build(); + } + + static Response buildHtmlErrorResponse(String message) { + String sanitized = message.replace("'", "\\'").replace("\n", " "); + String html = + "" + + "

Test Login Error: " + + message + + "

" + + "" + + ""; + + return Response.status(Response.Status.BAD_REQUEST).entity(html).type("text/html").build(); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestSamlHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestSamlHandler.java new file mode 100644 index 000000000000..3ab041ad1339 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/TestSamlHandler.java @@ -0,0 +1,475 @@ +/* + * Copyright 2025 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.security.auth; + +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.onelogin.saml2.Auth; +import com.onelogin.saml2.settings.Saml2Settings; +import com.onelogin.saml2.settings.SettingsBuilder; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.Response; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import lombok.extern.slf4j.Slf4j; +import org.apache.felix.http.javaxwrappers.HttpServletRequestWrapper; +import org.apache.felix.http.javaxwrappers.HttpServletResponseWrapper; +import org.openmetadata.catalog.security.client.SamlSSOClientConfig; +import org.openmetadata.catalog.type.IdentityProviderConfig; +import org.openmetadata.catalog.type.ServiceProviderConfig; +import org.openmetadata.schema.api.security.AuthenticationConfiguration; +import org.openmetadata.schema.configuration.SecurityConfiguration; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.secrets.masker.PasswordEntityMasker; + +/** + * Handles SAML Test Login flow. Parallel to {@link TestLoginHandler} for OIDC. + * Builds a temporary {@link Saml2Settings} from form-provided IdP/SP fields so + * the admin can verify the SAML configuration before persisting it. + * + *

RelayState is prefixed with {@value #RELAY_STATE_PREFIX} so + * {@code AuthCallbackServlet.doPost} can route the IdP's POST callback to + * this handler instead of the normal SAML login flow. + */ +@Slf4j +public final class TestSamlHandler { + + public static final String RELAY_STATE_PREFIX = "saml-test-login:"; + private static final String DEFAULT_CALLBACK_PATH = "/callback"; + private static final long TTL_MS = 5 * 60 * 1000; // 5 minutes + + private record PendingTestLogin(Saml2Settings settings, long createdAt) {} + + private static final ConcurrentHashMap PENDING = + new ConcurrentHashMap<>(); + private static final String HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"; + private static final String HTTP_REDIRECT_BINDING = + "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"; + private static final String DEFAULT_NAMEID_FORMAT = + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; + + private TestSamlHandler() {} + + public static Response handleInitiate( + HttpServletRequest req, + HttpServletResponse resp, + String mode, + String idpEntityId, + String idpSsoLoginUrl, + String idpX509Certificate, + String spEntityId, + String spAcsUrl, + String nameIdFormat) { + try { + // Existing-config mode: overlay form values onto saved SAML config via the + // shared deep-merge in SystemRepository. Form fields that are blank or + // the masked placeholder fall through to saved values automatically. + if (TestLoginHandler.MODE_EXISTING.equalsIgnoreCase(mode)) { + JsonNode sparseRequest = + buildSparseSamlRequest( + idpEntityId, + idpSsoLoginUrl, + idpX509Certificate, + spEntityId, + spAcsUrl, + nameIdFormat); + SecurityConfiguration merged = + Entity.getSystemRepository().overlayOnSavedSecurityConfig(sparseRequest); + AuthenticationConfiguration mergedAuth = merged.getAuthenticationConfiguration(); + SamlSSOClientConfig mergedSaml = + mergedAuth != null ? mergedAuth.getSamlConfiguration() : null; + if (mergedSaml != null) { + IdentityProviderConfig mergedIdp = mergedSaml.getIdp(); + if (mergedIdp != null) { + idpEntityId = mergedIdp.getEntityId(); + idpSsoLoginUrl = mergedIdp.getSsoLoginUrl(); + idpX509Certificate = mergedIdp.getIdpX509Certificate(); + nameIdFormat = mergedIdp.getNameId(); + } + ServiceProviderConfig mergedSp = mergedSaml.getSp(); + if (mergedSp != null) { + spEntityId = mergedSp.getEntityId(); + spAcsUrl = mergedSp.getAcs(); + } + } + } + + if (nullOrEmpty(idpEntityId)) { + return TestLoginResponses.buildHtmlErrorResponse( + "IdP Entity ID is required for SAML Test Login."); + } + if (nullOrEmpty(idpSsoLoginUrl)) { + return TestLoginResponses.buildHtmlErrorResponse( + "IdP SSO Login URL is required for SAML Test Login."); + } + if (nullOrEmpty(idpX509Certificate)) { + return TestLoginResponses.buildHtmlErrorResponse( + "IdP X.509 Certificate is required for SAML Test Login."); + } + + String serverUrl = buildServerUrl(req); + if (nullOrEmpty(spAcsUrl)) { + spAcsUrl = serverUrl + DEFAULT_CALLBACK_PATH; + } + if (nullOrEmpty(spEntityId)) { + spEntityId = serverUrl; + } + if (nullOrEmpty(nameIdFormat)) { + nameIdFormat = DEFAULT_NAMEID_FORMAT; + } + + Saml2Settings settings = + buildSamlSettings( + idpEntityId, idpSsoLoginUrl, idpX509Certificate, spEntityId, spAcsUrl, nameIdFormat); + + cleanupStale(); + String relayState = RELAY_STATE_PREFIX + UUID.randomUUID(); + PENDING.put(relayState, new PendingTestLogin(settings, System.currentTimeMillis())); + + javax.servlet.http.HttpServletRequest wrappedRequest = new HttpServletRequestWrapper(req); + javax.servlet.http.HttpServletResponse wrappedResponse = new HttpServletResponseWrapper(resp); + + Auth auth = new Auth(settings, wrappedRequest, wrappedResponse); + // Passes relayState as the RelayState parameter on the SAML AuthnRequest. + // OneLogin library sends the 302 via wrappedResponse. + auth.login(relayState); + + return Response.ok().build(); + } catch (Exception e) { + LOG.error("[SAML Test Login] Failed to initiate", e); + return TestLoginResponses.buildHtmlErrorResponse( + "Failed to initiate SAML Test Login: " + e.getMessage()); + } + } + + public static Response handleCallback(HttpServletRequest req) { + try { + String relayState = req.getParameter("RelayState"); + if (nullOrEmpty(relayState) || !relayState.startsWith(RELAY_STATE_PREFIX)) { + return TestLoginResponses.buildPostMessageResponse( + false, "Missing or invalid RelayState for SAML Test Login.", null); + } + + PendingTestLogin pending = PENDING.remove(relayState); + if (pending == null) { + return TestLoginResponses.buildPostMessageResponse( + false, "SAML Test Login expired or already consumed. Please try again.", null); + } + Saml2Settings settings = pending.settings(); + + javax.servlet.http.HttpServletRequest wrappedRequest = new HttpServletRequestWrapper(req); + javax.servlet.http.HttpServletResponse wrappedResponse = + new HttpServletResponseWrapper(new DummyResponse()); + + Auth auth = new Auth(settings, wrappedRequest, wrappedResponse); + auth.processResponse(); + + if (!auth.isAuthenticated()) { + String reason = auth.getLastErrorReason(); + List errors = auth.getErrors(); + return TestLoginResponses.buildPostMessageResponse( + false, + "SAML authentication failed: " + (reason != null ? reason : String.join("; ", errors)), + null); + } + + Map claims = buildClaimsFromAuth(auth); + + return TestLoginResponses.buildPostMessageResponse(true, null, Map.of("claims", claims)); + } catch (Exception e) { + LOG.error("[SAML Test Login] Callback failed", e); + return TestLoginResponses.buildPostMessageResponse( + false, "SAML callback error: " + e.getMessage(), null); + } + } + + static Saml2Settings buildSamlSettings( + String idpEntityId, + String idpSsoLoginUrl, + String idpX509Certificate, + String spEntityId, + String spAcsUrl, + String nameIdFormat) { + Map samlData = new HashMap<>(); + samlData.put(SettingsBuilder.DEBUG_PROPERTY_KEY, false); + + samlData.put(SettingsBuilder.SP_ENTITYID_PROPERTY_KEY, spEntityId); + samlData.put(SettingsBuilder.SP_ASSERTION_CONSUMER_SERVICE_URL_PROPERTY_KEY, spAcsUrl); + samlData.put( + SettingsBuilder.SP_ASSERTION_CONSUMER_SERVICE_BINDING_PROPERTY_KEY, HTTP_POST_BINDING); + samlData.put( + SettingsBuilder.SP_SINGLE_LOGOUT_SERVICE_BINDING_PROPERTY_KEY, HTTP_REDIRECT_BINDING); + samlData.put(SettingsBuilder.SP_NAMEIDFORMAT_PROPERTY_KEY, nameIdFormat); + + samlData.put(SettingsBuilder.IDP_ENTITYID_PROPERTY_KEY, idpEntityId); + samlData.put(SettingsBuilder.IDP_SINGLE_SIGN_ON_SERVICE_URL_PROPERTY_KEY, idpSsoLoginUrl); + samlData.put( + SettingsBuilder.IDP_SINGLE_SIGN_ON_SERVICE_BINDING_PROPERTY_KEY, HTTP_REDIRECT_BINDING); + samlData.put( + SettingsBuilder.IDP_SINGLE_LOGOUT_SERVICE_BINDING_PROPERTY_KEY, HTTP_REDIRECT_BINDING); + samlData.put(SettingsBuilder.IDP_X509CERT_PROPERTY_KEY, idpX509Certificate); + + samlData.put(SettingsBuilder.STRICT_PROPERTY_KEY, false); + samlData.put(SettingsBuilder.SECURITY_NAMEID_ENCRYPTED, false); + samlData.put(SettingsBuilder.SECURITY_AUTHREQUEST_SIGNED, false); + samlData.put(SettingsBuilder.SECURITY_WANT_MESSAGES_SIGNED, false); + samlData.put(SettingsBuilder.SECURITY_WANT_ASSERTIONS_SIGNED, false); + samlData.put(SettingsBuilder.SECURITY_WANT_ASSERTIONS_ENCRYPTED, false); + samlData.put(SettingsBuilder.SECURITY_WANT_NAMEID_ENCRYPTED, false); + samlData.put(SettingsBuilder.SECURITY_REQUESTED_AUTHNCONTEXTCOMPARISON, "exact"); + samlData.put( + SettingsBuilder.SECURITY_SIGNATURE_ALGORITHM, + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"); + samlData.put( + SettingsBuilder.SECURITY_DIGEST_ALGORITHM, "http://www.w3.org/2001/04/xmlenc#sha256"); + samlData.put(SettingsBuilder.UNIQUE_ID_PREFIX_PROPERTY_KEY, "OPENMETADATA_TEST_"); + + return new SettingsBuilder().fromValues(samlData).build(); + } + + static Map buildClaimsFromAuth(Auth auth) { + Map claims = new LinkedHashMap<>(); + if (!nullOrEmpty(auth.getNameId())) { + claims.put("nameId", auth.getNameId()); + } + Map> attributes = auth.getAttributes(); + if (attributes != null) { + for (Map.Entry> entry : attributes.entrySet()) { + Collection values = entry.getValue(); + if (values == null || values.isEmpty()) { + continue; + } + if (values.size() == 1) { + claims.put(entry.getKey(), values.iterator().next()); + } else { + claims.put(entry.getKey(), values); + } + } + } + return claims; + } + + private static void cleanupStale() { + long cutoff = System.currentTimeMillis() - TTL_MS; + PENDING.entrySet().removeIf(e -> e.getValue().createdAt() < cutoff); + } + + private static String buildServerUrl(HttpServletRequest req) { + int port = req.getServerPort(); + boolean defaultPort = (port == 80 || port == 443); + return req.getScheme() + "://" + req.getServerName() + (defaultPort ? "" : ":" + port); + } + + /** + * Build a sparse SecurityConfiguration JsonNode from SAML Test Login form + * params. Only includes fields whose value is present and not the masked + * placeholder. Designed for {@link + * org.openmetadata.service.jdbi3.SystemRepository#overlayOnSavedSecurityConfig}. + */ + static JsonNode buildSparseSamlRequest( + String idpEntityId, + String idpSsoLoginUrl, + String idpX509Certificate, + String spEntityId, + String spAcsUrl, + String nameIdFormat) { + ObjectMapper mapper = JsonUtils.getObjectMapper(); + ObjectNode root = mapper.createObjectNode(); + ObjectNode authConfig = mapper.createObjectNode(); + ObjectNode saml = mapper.createObjectNode(); + ObjectNode idp = mapper.createObjectNode(); + ObjectNode sp = mapper.createObjectNode(); + + putIfPresent(idp, "entityId", idpEntityId); + putIfPresent(idp, "ssoLoginUrl", idpSsoLoginUrl); + putIfPresentNonMasked(idp, "idpX509Certificate", idpX509Certificate); + putIfPresent(idp, "nameId", nameIdFormat); + + putIfPresent(sp, "entityId", spEntityId); + putIfPresent(sp, "acs", spAcsUrl); + + if (!idp.isEmpty()) { + saml.set("idp", idp); + } + if (!sp.isEmpty()) { + saml.set("sp", sp); + } + if (!saml.isEmpty()) { + authConfig.set("samlConfiguration", saml); + } + if (!authConfig.isEmpty()) { + root.set("authenticationConfiguration", authConfig); + } + return root; + } + + private static void putIfPresent(ObjectNode node, String key, String value) { + if (!nullOrEmpty(value)) { + node.put(key, value); + } + } + + private static void putIfPresentNonMasked(ObjectNode node, String key, String value) { + if (!nullOrEmpty(value) && !PasswordEntityMasker.PASSWORD_MASK.equals(value)) { + node.put(key, value); + } + } + + /** + * Minimal no-op response used when we only need {@link Auth#processResponse()} + * to validate an incoming SAMLResponse — OneLogin's Auth requires a non-null + * response object, but we do not use it during response parsing. + */ + private static final class DummyResponse implements jakarta.servlet.http.HttpServletResponse { + @Override + public void addCookie(jakarta.servlet.http.Cookie cookie) {} + + @Override + public boolean containsHeader(String name) { + return false; + } + + @Override + public String encodeURL(String url) { + return url; + } + + @Override + public String encodeRedirectURL(String url) { + return url; + } + + @Override + public void sendError(int sc, String msg) {} + + @Override + public void sendError(int sc) {} + + @Override + public void sendRedirect(String location) {} + + @Override + public void setDateHeader(String name, long date) {} + + @Override + public void addDateHeader(String name, long date) {} + + @Override + public void setHeader(String name, String value) {} + + @Override + public void addHeader(String name, String value) {} + + @Override + public void setIntHeader(String name, int value) {} + + @Override + public void addIntHeader(String name, int value) {} + + @Override + public void setStatus(int sc) {} + + @Override + public int getStatus() { + return 200; + } + + @Override + public String getHeader(String name) { + return null; + } + + @Override + public java.util.Collection getHeaders(String name) { + return java.util.Collections.emptyList(); + } + + @Override + public java.util.Collection getHeaderNames() { + return java.util.Collections.emptyList(); + } + + @Override + public String getCharacterEncoding() { + return "UTF-8"; + } + + @Override + public String getContentType() { + return null; + } + + @Override + public jakarta.servlet.ServletOutputStream getOutputStream() { + return null; + } + + @Override + public java.io.PrintWriter getWriter() { + return null; + } + + @Override + public void setCharacterEncoding(String charset) {} + + @Override + public void setContentLength(int len) {} + + @Override + public void setContentLengthLong(long len) {} + + @Override + public void setContentType(String type) {} + + @Override + public void setBufferSize(int size) {} + + @Override + public int getBufferSize() { + return 0; + } + + @Override + public void flushBuffer() {} + + @Override + public void resetBuffer() {} + + @Override + public boolean isCommitted() { + return false; + } + + @Override + public void reset() {} + + @Override + public void setLocale(java.util.Locale loc) {} + + @Override + public java.util.Locale getLocale() { + return java.util.Locale.getDefault(); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/Auth0Validator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/Auth0Validator.java index 562c9785b7f7..2f6aa0524e73 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/Auth0Validator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/Auth0Validator.java @@ -108,41 +108,34 @@ private String extractAuth0DomainFromOidcConfig(OidcClientConfig oidcConfig) { } private FieldError validateAuth0Domain(String auth0Domain, String fieldPath) { + // Reachability + JSON structure are validated upstream in + // SystemRepository.validateDiscoveryUriReachable. Here we run only the + // Auth0-specific semantic checks (required endpoints, HTTPS issuer). try { String discoveryUrl = auth0Domain + AUTH0_WELL_KNOWN_PATH; - testAuth0DiscoveryEndpoint(discoveryUrl); - - return null; // Success - Auth0 domain validated + ValidationHttpUtil.HttpResponseData response = ValidationHttpUtil.safeGet(discoveryUrl); + if (response.getStatusCode() != 200) { + return ValidationErrorBuilder.createFieldError( + fieldPath, + "Auth0 domain could not be verified. Ensure the Discovery URI points to a reachable" + + " Auth0 tenant. HTTP " + + response.getStatusCode()); + } + JsonNode discoveryDoc = JsonUtils.readTree(response.getBody()); + if (!discoveryDoc.has("authorization_endpoint") + || !discoveryDoc.has("token_endpoint") + || !discoveryDoc.has("userinfo_endpoint")) { + return ValidationErrorBuilder.createFieldError( + fieldPath, "Missing required Auth0 endpoints in discovery document"); + } + String issuer = discoveryDoc.get("issuer").asText(); + if (!issuer.startsWith("https://")) { + return ValidationErrorBuilder.createFieldError(fieldPath, "Auth0 issuer must use HTTPS"); + } + return null; } catch (Exception e) { return ValidationErrorBuilder.createFieldError( - fieldPath, "Domain validation failed: " + e.getMessage()); - } - } - - private void testAuth0DiscoveryEndpoint(String discoveryUrl) throws Exception { - LOG.debug("Testing Auth0 discovery endpoint: {}", discoveryUrl); - - ValidationHttpUtil.HttpResponseData response = ValidationHttpUtil.safeGet(discoveryUrl); - - if (response.getStatusCode() != 200) { - String errorMsg = - String.format( - "Failed to access Auth0 discovery endpoint. HTTP response: %d for URL: %s", - response.getStatusCode(), discoveryUrl); - LOG.error(errorMsg); - throw new IllegalArgumentException(errorMsg); - } - JsonNode discoveryDoc = JsonUtils.readTree(response.getBody()); - if (!discoveryDoc.has("issuer") || !discoveryDoc.has("authorization_endpoint")) { - throw new IllegalArgumentException("Invalid Auth0 discovery document format"); - } - - if (!discoveryDoc.has("token_endpoint") || !discoveryDoc.has("userinfo_endpoint")) { - throw new IllegalArgumentException("Missing required Auth0 endpoints in discovery document"); - } - String issuer = discoveryDoc.get("issuer").asText(); - if (!issuer.startsWith("https://")) { - throw new IllegalArgumentException("Auth0 issuer must use HTTPS"); + fieldPath, "Auth0 domain could not be verified: " + e.getMessage()); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/AzureAuthValidator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/AzureAuthValidator.java index 12cde52269b0..472fc34ad6d5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/AzureAuthValidator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/AzureAuthValidator.java @@ -74,11 +74,16 @@ private FieldError validateAzureConfidentialClient( try { String tenantId = oidcConfig.getTenant(); - // Validate tenant ID for confidential clients + // Validate tenant ID for confidential clients. The upstream discoveryUri + // reachability check (in SystemRepository) will have already triggered + // tenant derivation via normalizeForPersistence. If tenant is still empty + // here, it means no discoveryUri was provided and the user did not set + // tenant manually. if (nullOrEmpty(tenantId)) { return ValidationErrorBuilder.createFieldError( ValidationErrorBuilder.FieldPaths.OIDC_TENANT, - "Tenant ID is required for Azure confidential clients"); + "Tenant could not be determined from Discovery URI. Provide a valid Azure" + + " Discovery URI or set Tenant manually in advanced config."); } // Validate tenant ID format @@ -126,13 +131,9 @@ private FieldError validateAzureConfidentialClient( discoveryUri = AZURE_LOGIN_BASE + "/" + tenantId + "/v2.0" + OPENID_CONFIG_PATH; } - // First validate that the discovery URI is accessible - FieldError tenantValidation = validateDiscoveryEndpoint(discoveryUri, tenantId); - if (tenantValidation != null) { - return tenantValidation; - } - - // Validate against the discovery document + // Discovery URI reachability is validated upstream in SystemRepository. + // Here we run only the semantic checks against the discovery document + // (scopes, algorithms, endpoints, prompts). FieldError discoveryCheck = discoveryValidator.validateAgainstDiscovery(discoveryUri, authConfig, oidcConfig); if (discoveryCheck != null) { @@ -157,37 +158,6 @@ private FieldError validateAzureConfidentialClient( } } - private FieldError validateDiscoveryEndpoint(String discoveryUrl, String tenantId) { - try { - LOG.debug("Validating Azure discovery endpoint: {}", discoveryUrl); - ValidationHttpUtil.HttpResponseData response = ValidationHttpUtil.safeGet(discoveryUrl); - - if (response.getStatusCode() == 404) { - return ValidationErrorBuilder.createFieldError( - ValidationErrorBuilder.FieldPaths.OIDC_DISCOVERY_URI, - "Azure discovery endpoint not found. Please verify the discovery URI is correct"); - } else if (response.getStatusCode() != 200) { - return ValidationErrorBuilder.createFieldError( - ValidationErrorBuilder.FieldPaths.OIDC_DISCOVERY_URI, - "Failed to access Azure discovery endpoint. HTTP response: " - + response.getStatusCode()); - } - - // Parse and validate the discovery document - JsonNode discoveryDoc = JsonUtils.readTree(response.getBody()); - if (!discoveryDoc.has("issuer") || !discoveryDoc.has("token_endpoint")) { - return ValidationErrorBuilder.createFieldError( - ValidationErrorBuilder.FieldPaths.OIDC_DISCOVERY_URI, - "Invalid Azure discovery document format at: " + discoveryUrl); - } - return null; - } catch (Exception e) { - return ValidationErrorBuilder.createFieldError( - ValidationErrorBuilder.FieldPaths.OIDC_DISCOVERY_URI, - "Azure discovery document validation failed"); - } - } - private FieldError validateTenantExists(String tenantId) { try { String discoveryUrl = AZURE_LOGIN_BASE + "/" + tenantId + OPENID_CONFIG_PATH; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/CognitoAuthValidator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/CognitoAuthValidator.java index 0b7193336a6c..c28b8952703e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/CognitoAuthValidator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/CognitoAuthValidator.java @@ -153,35 +153,22 @@ private CognitoDetails parseCognitoUrl(String sourceUrl) { } private FieldError validateUserPool(CognitoDetails cognitoDetails) { + // Discovery URI reachability + issuer/jwks_uri presence are validated upstream + // in SystemRepository.validateDiscoveryUriReachable. Here we run only the + // Cognito-specific semantic checks (issuer format, required endpoints). try { - // Test discovery endpoint ValidationHttpUtil.HttpResponseData response = ValidationHttpUtil.safeGet(cognitoDetails.discoveryUri); - - if (response.getStatusCode() == 404) { - return ValidationErrorBuilder.createFieldError( - ValidationErrorBuilder.FieldPaths.OIDC_DISCOVERY_URI, - "Cognito user pool '" - + cognitoDetails.userPoolId - + "' not found in region '" - + cognitoDetails.region - + "'. Please verify the user pool ID and region."); - } else if (response.getStatusCode() != 200) { + if (response.getStatusCode() != 200) { return ValidationErrorBuilder.createFieldError( ValidationErrorBuilder.FieldPaths.OIDC_DISCOVERY_URI, - "Failed to access Cognito discovery endpoint. HTTP response: " - + response.getStatusCode()); + "Cognito user pool not found. Verify the Cognito pool ID and region in the" + + " Discovery URI (HTTP " + + response.getStatusCode() + + ")."); } - JsonNode discoveryDoc = JsonUtils.readTree(response.getBody()); - if (!discoveryDoc.has("issuer") || !discoveryDoc.has("authorization_endpoint")) { - return ValidationErrorBuilder.createFieldError( - ValidationErrorBuilder.FieldPaths.OIDC_DISCOVERY_URI, - "Invalid Cognito discovery document format"); - } - - // Validate issuer format String issuer = discoveryDoc.get("issuer").asText(); String expectedIssuer = String.format( @@ -193,16 +180,14 @@ private FieldError validateUserPool(CognitoDetails cognitoDetails) { "Unexpected issuer in Cognito discovery document. Expected: " + expectedIssuer); } - // Check for required Cognito endpoints - if (!discoveryDoc.has("token_endpoint") - || !discoveryDoc.has("userinfo_endpoint") - || !discoveryDoc.has("jwks_uri")) { + if (!discoveryDoc.has("authorization_endpoint") + || !discoveryDoc.has("token_endpoint") + || !discoveryDoc.has("userinfo_endpoint")) { return ValidationErrorBuilder.createFieldError( ValidationErrorBuilder.FieldPaths.OIDC_DISCOVERY_URI, "Missing required Cognito endpoints in discovery document"); } - - return null; // Success - Cognito user pool validated + return null; } catch (Exception e) { return ValidationErrorBuilder.createFieldError( ValidationErrorBuilder.FieldPaths.OIDC_DISCOVERY_URI, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/CustomOidcValidator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/CustomOidcValidator.java index b89bcf3c5b20..a56ead0828ed 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/CustomOidcValidator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/CustomOidcValidator.java @@ -55,7 +55,9 @@ private FieldError validateCustomOidcPublicClient( if (endpoints == null) { return ValidationErrorBuilder.createFieldError( ValidationErrorBuilder.FieldPaths.OIDC_DISCOVERY_URI, - "Failed to extract required endpoints from discovery document"); + "Discovery document is missing required endpoints (authorization_endpoint," + + " token_endpoint, jwks_uri). Verify the Discovery URI points to a compliant" + + " OIDC provider."); } FieldError jwksValidation = validateJwksEndpoint(endpoints.jwksUri, authConfig); @@ -94,7 +96,9 @@ private FieldError validateCustomOidcConfidentialClient( if (endpoints == null) { return ValidationErrorBuilder.createFieldError( ValidationErrorBuilder.FieldPaths.OIDC_DISCOVERY_URI, - "Failed to extract required endpoints from discovery document"); + "Discovery document is missing required endpoints (authorization_endpoint," + + " token_endpoint, jwks_uri). Verify the Discovery URI points to a compliant" + + " OIDC provider."); } FieldError jwksValidation = validateJwksEndpoint(endpoints.jwksUri, authConfig); @@ -129,7 +133,7 @@ private FieldError validateCustomOidcConfidentialClient( private String extractDiscoveryUri( AuthenticationConfiguration authConfig, OidcClientConfig oidcConfig) { - if (!nullOrEmpty(oidcConfig.getDiscoveryUri())) { + if (oidcConfig != null && !nullOrEmpty(oidcConfig.getDiscoveryUri())) { return oidcConfig.getDiscoveryUri(); } @@ -142,7 +146,7 @@ private String extractDiscoveryUri( } // Priority 3: Try serverUrl as fallback - if (!nullOrEmpty(oidcConfig.getServerUrl())) { + if (oidcConfig != null && !nullOrEmpty(oidcConfig.getServerUrl())) { String serverUrl = oidcConfig.getServerUrl(); if (!serverUrl.endsWith("/")) { serverUrl += "/"; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/OidcDiscoveryValidator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/OidcDiscoveryValidator.java index 921fc410577e..4e030fdd4010 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/OidcDiscoveryValidator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/OidcDiscoveryValidator.java @@ -81,6 +81,11 @@ public FieldError validateAgainstDiscovery( return null; } + if (oidcConfig == null) { + LOG.debug("No OidcClientConfig provided, skipping per-config discovery validation"); + return null; + } + LOG.debug("Fetching OIDC discovery document from: {}", discoveryUri); ValidationHttpUtil.HttpResponseData response = ValidationHttpUtil.safeGet(discoveryUri); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/OktaAuthValidator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/OktaAuthValidator.java index a9a10bf1d04f..9d65dd4bb9c8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/OktaAuthValidator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/validator/OktaAuthValidator.java @@ -317,41 +317,36 @@ private String getJwksUriFromDiscovery(String discoveryUri) { } private FieldError validateOktaDomain(String oktaDomain, String fieldPath) { + // Discovery URI reachability and basic structure are validated upstream in + // SystemRepository.validateDiscoveryUriReachable. This method now runs only + // the Okta-specific semantic checks (issuer format, required endpoints). try { String discoveryUrl = oktaDomain + OKTA_WELL_KNOWN_PATH; - testOktaDiscoveryEndpoint(discoveryUrl); - - return null; // Success - Okta domain validated + ValidationHttpUtil.HttpResponseData response = ValidationHttpUtil.safeGet(discoveryUrl); + if (response.getStatusCode() != 200) { + return ValidationErrorBuilder.createFieldError( + fieldPath, + "Okta domain/Discovery URI is unreachable or invalid. HTTP " + + response.getStatusCode()); + } + JsonNode discoveryDoc = JsonUtils.readTree(response.getBody()); + if (!discoveryDoc.has("authorization_endpoint") + || !discoveryDoc.has("token_endpoint") + || !discoveryDoc.has("userinfo_endpoint")) { + return ValidationErrorBuilder.createFieldError( + fieldPath, "Missing required Okta endpoints in discovery document"); + } + String issuer = discoveryDoc.get("issuer").asText(); + if (!issuer.contains("okta")) { + LOG.warn("Discovery document issuer doesn't contain 'okta': {}", issuer); + } + return null; } catch (Exception e) { return ValidationErrorBuilder.createFieldError( - fieldPath, "Domain validation failed: " + e.getMessage()); - } - } - - private void testOktaDiscoveryEndpoint(String discoveryUrl) throws Exception { - ValidationHttpUtil.HttpResponseData response = ValidationHttpUtil.safeGet(discoveryUrl); - - if (response.getStatusCode() != 200) { - throw new IllegalArgumentException( - "Failed to access Okta discovery endpoint. HTTP response: " + response.getStatusCode()); - } - - // Parse and validate the discovery document - JsonNode discoveryDoc = JsonUtils.readTree(response.getBody()); - - // Validate Okta-specific fields - if (!discoveryDoc.has("issuer") || !discoveryDoc.has("authorization_endpoint")) { - throw new IllegalArgumentException("Invalid Okta discovery document format"); - } - - String issuer = discoveryDoc.get("issuer").asText(); - if (!issuer.contains("okta")) { - LOG.warn("Discovery document issuer doesn't contain 'okta': {}", issuer); - } - - // Check for required Okta endpoints - if (!discoveryDoc.has("token_endpoint") || !discoveryDoc.has("userinfo_endpoint")) { - throw new IllegalArgumentException("Missing required Okta endpoints in discovery document"); + fieldPath, + "Okta domain could not be verified. Ensure the Discovery URI points to a reachable" + + " Okta org: " + + e.getMessage()); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java index 2457838818ad..1f07f6843005 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java @@ -40,6 +40,7 @@ import org.openmetadata.service.auth.JwtResponse; import org.openmetadata.service.security.AuthServeletHandler; import org.openmetadata.service.security.AuthServeletHandlerRegistry; +import org.openmetadata.service.security.SecurityUtil; import org.openmetadata.service.security.auth.SecurityConfigurationManager; import org.openmetadata.service.security.jwt.JWTTokenGenerator; import org.openmetadata.service.util.TokenUtil; @@ -78,13 +79,25 @@ private void handleResponse(HttpServletRequest req, HttpServletResponse resp) th } } else { String username; - String nameId = auth.getNameId(); - String email = nameId; - if (nameId.contains("@")) { - username = nameId.split("@")[0]; + String email; + + // Priority 1: SAML attribute "email" (case-insensitive) + String emailFromAttribute = extractEmailAttribute(auth); + if (!nullOrEmpty(emailFromAttribute) && emailFromAttribute.contains("@")) { + email = emailFromAttribute; + username = email.split("@")[0]; + LOG.debug("[SAML ACS] Email from 'email' attribute: {}", email); } else { - username = nameId; - email = String.format("%s@%s", username, SamlSettingsHolder.getInstance().getDomain()); + // Priority 2: NameID (fallback for existing customers) + String nameId = auth.getNameId(); + email = nameId; + if (nameId.contains("@")) { + username = nameId.split("@")[0]; + } else { + username = nameId; + email = String.format("%s@%s", username, SamlSettingsHolder.getInstance().getDomain()); + } + LOG.debug("[SAML ACS] Email from NameID (fallback): {}", email); } // Extract team/department attributes from SAML response (supports multi-valued attributes) @@ -134,7 +147,7 @@ private void handleResponse(HttpServletRequest req, HttpServletResponse resp) th .generateJWTToken( username, new HashSet<>(), - getAdmins().contains(username), + SecurityUtil.isAdminPrincipal(getAdmins(), username, email), email, SamlSettingsHolder.getInstance().getTokenValidity(), false, @@ -163,7 +176,7 @@ private void handleResponse(HttpServletRequest req, HttpServletResponse resp) th "%s?id_token=%s&email=%s&name=%s", (nullOrEmpty(redirectUri) ? buildBaseRequestUrl(req) : redirectUri), jwtAuthMechanism.getJWTToken(), - nameId, + email, username); Entity.getUserRepository().updateUserLastLoginTime(user, System.currentTimeMillis()); resp.sendRedirect(url); @@ -195,4 +208,18 @@ private Set getAdmins() { SecurityConfigurationManager.getCurrentAuthzConfig(); return authorizerConfiguration.getAdminPrincipals(); } + + private static String extractEmailAttribute(Auth auth) { + for (String attrName : List.of("email", "Email", "EMAIL")) { + try { + Collection values = auth.getAttribute(attrName); + if (values != null && !values.isEmpty()) { + return values.iterator().next(); + } + } catch (Exception e) { + LOG.trace("[SAML ACS] Attribute '{}' not found: {}", attrName, e.getMessage()); + } + } + return null; + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/ValidationErrorBuilder.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/ValidationErrorBuilder.java index ce4e9926bce1..e855e73a62ad 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/ValidationErrorBuilder.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/ValidationErrorBuilder.java @@ -24,6 +24,7 @@ public static class FieldPaths { public static final String AUTH_AUTHORITY = "authenticationConfiguration.authority"; public static final String AUTH_CLIENT_ID = "authenticationConfiguration.clientId"; public static final String AUTH_CALLBACK_URL = "authenticationConfiguration.callbackUrl"; + public static final String AUTH_DISCOVERY_URI = "authenticationConfiguration.discoveryUri"; // OIDC Configuration public static final String OIDC_CLIENT_ID = "authenticationConfiguration.oidcConfiguration.id"; diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryNormalizeTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryNormalizeTest.java new file mode 100644 index 000000000000..d3ff2c58d830 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryNormalizeTest.java @@ -0,0 +1,428 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.openmetadata.schema.api.security.AuthenticationConfiguration; +import org.openmetadata.schema.api.security.ClientType; +import org.openmetadata.schema.security.client.OidcClientConfig; +import org.openmetadata.schema.services.connections.metadata.AuthProvider; +import org.openmetadata.schema.system.FieldError; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.CollectionDAO.SystemDAO; +import org.openmetadata.service.migration.MigrationValidationClient; +import org.openmetadata.service.util.ValidationHttpUtil; + +class SystemRepositoryNormalizeTest { + + private static final String DISCOVERY_URI = + "https://login.microsoftonline.com/tenant-abc/v2.0/.well-known/openid-configuration"; + private static final String ISSUER = "https://login.microsoftonline.com/tenant-abc/v2.0"; + private static final String JWKS_URI = + "https://login.microsoftonline.com/tenant-abc/discovery/v2.0/keys"; + private static final String DISCOVERY_RESPONSE = + "{\"issuer\": \"" + ISSUER + "\", \"jwks_uri\": \"" + JWKS_URI + "\"}"; + + private MockedStatic entityMock; + private MockedStatic migrationMock; + private SystemRepository repository; + + @BeforeEach + void setUp() { + entityMock = mockStatic(Entity.class); + migrationMock = mockStatic(MigrationValidationClient.class); + + CollectionDAO collectionDAO = mock(CollectionDAO.class); + SystemDAO systemDAO = mock(SystemDAO.class); + when(collectionDAO.systemDAO()).thenReturn(systemDAO); + entityMock.when(Entity::getCollectionDAO).thenReturn(collectionDAO); + + MigrationValidationClient client = mock(MigrationValidationClient.class); + migrationMock.when(MigrationValidationClient::getInstance).thenReturn(client); + + repository = new SystemRepository(); + } + + @AfterEach + void tearDown() { + entityMock.close(); + migrationMock.close(); + } + + @Test + void normalizeForPersistence_canonicalDiscoveryUri_derivesAuthorityAndPublicKeyUrls() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setDiscoveryUri(DISCOVERY_URI); + authConfig.setCallbackUrl("http://localhost:8585/callback"); + OidcClientConfig oidc = new OidcClientConfig(); + oidc.setId("client-id-123"); + oidc.setSecret("client-secret-xyz"); + authConfig.setOidcConfiguration(oidc); + + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + httpMock + .when(() -> ValidationHttpUtil.safeGet(anyString())) + .thenReturn(new ValidationHttpUtil.HttpResponseData(200, DISCOVERY_RESPONSE)); + + repository.normalizeForPersistence(authConfig); + } + + assertEquals(ISSUER, authConfig.getAuthority()); + assertNotNull(authConfig.getPublicKeyUrls()); + assertEquals(1, authConfig.getPublicKeyUrls().size()); + assertEquals(JWKS_URI, authConfig.getPublicKeyUrls().get(0)); + assertEquals(DISCOVERY_URI, authConfig.getOidcConfiguration().getDiscoveryUri()); + assertEquals("client-id-123", authConfig.getClientId()); + } + + @Test + void normalizeForPersistence_mirrorsOidcIdToRootClientId() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setCallbackUrl("http://localhost:8585/callback"); + OidcClientConfig oidc = new OidcClientConfig(); + oidc.setId("only-in-nested"); + authConfig.setOidcConfiguration(oidc); + + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + httpMock + .when(() -> ValidationHttpUtil.safeGet(anyString())) + .thenReturn(new ValidationHttpUtil.HttpResponseData(200, DISCOVERY_RESPONSE)); + + repository.normalizeForPersistence(authConfig); + } + + assertEquals("only-in-nested", authConfig.getClientId()); + } + + @Test + void normalizeForPersistence_mirrorsRootClientIdToNested() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setClientId("only-at-root"); + authConfig.setCallbackUrl("http://localhost:8585/callback"); + authConfig.setOidcConfiguration(new OidcClientConfig()); + + repository.normalizeForPersistence(authConfig); + + assertEquals("only-at-root", authConfig.getOidcConfiguration().getId()); + } + + @Test + void normalizeForPersistence_legacyConfigWithoutDiscoveryUri_preservesExistingValues() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setAuthority("https://existing-authority.example.com"); + authConfig.setPublicKeyUrls(java.util.List.of("https://existing/keys")); + authConfig.setClientId("legacy-client"); + authConfig.setCallbackUrl("http://legacy/callback"); + + repository.normalizeForPersistence(authConfig); + + assertEquals("https://existing-authority.example.com", authConfig.getAuthority()); + assertEquals(1, authConfig.getPublicKeyUrls().size()); + assertEquals("https://existing/keys", authConfig.getPublicKeyUrls().get(0)); + } + + @Test + void normalizeForPersistence_azureProvider_derivesTenantFromDiscoveryUri() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.AZURE); + authConfig.setDiscoveryUri(DISCOVERY_URI); + authConfig.setCallbackUrl("http://localhost:8585/callback"); + authConfig.setOidcConfiguration(new OidcClientConfig()); + + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + httpMock + .when(() -> ValidationHttpUtil.safeGet(anyString())) + .thenReturn(new ValidationHttpUtil.HttpResponseData(200, DISCOVERY_RESPONSE)); + + repository.normalizeForPersistence(authConfig); + } + + assertEquals("tenant-abc", authConfig.getOidcConfiguration().getTenant()); + } + + @Test + void normalizeForPersistence_azureGovCloud_derivesTenant() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.AZURE); + authConfig.setDiscoveryUri( + "https://login.microsoftonline.us/gov-tenant-xyz/v2.0/.well-known/openid-configuration"); + authConfig.setCallbackUrl("http://localhost:8585/callback"); + authConfig.setOidcConfiguration(new OidcClientConfig()); + + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + httpMock + .when(() -> ValidationHttpUtil.safeGet(anyString())) + .thenReturn(new ValidationHttpUtil.HttpResponseData(200, DISCOVERY_RESPONSE)); + + repository.normalizeForPersistence(authConfig); + } + + assertEquals("gov-tenant-xyz", authConfig.getOidcConfiguration().getTenant()); + } + + @Test + void normalizeForPersistence_nonAzureProvider_doesNotSetTenant() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.OKTA); + authConfig.setDiscoveryUri(DISCOVERY_URI); + authConfig.setCallbackUrl("http://localhost:8585/callback"); + authConfig.setOidcConfiguration(new OidcClientConfig()); + + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + httpMock + .when(() -> ValidationHttpUtil.safeGet(anyString())) + .thenReturn(new ValidationHttpUtil.HttpResponseData(200, DISCOVERY_RESPONSE)); + + repository.normalizeForPersistence(authConfig); + } + + assertNull(authConfig.getOidcConfiguration().getTenant()); + } + + @Test + void normalizeForPersistence_nullAuthConfig_doesNotThrow() { + repository.normalizeForPersistence(null); + } + + @Test + void normalizeForPersistence_discoveryUriOnlyInNested_doesNotSyncToRoot() { + // Option B: normalize only mirrors root→nested. Legacy configs with only nested + // discoveryUri should NOT trigger root population or derivation on PATCH. + // hydrateForResponse handles nested→root for display only. + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setCallbackUrl("http://localhost:8585/callback"); + OidcClientConfig oidc = new OidcClientConfig(); + oidc.setDiscoveryUri(DISCOVERY_URI); + oidc.setId("c"); + oidc.setSecret("s"); + authConfig.setOidcConfiguration(oidc); + + repository.normalizeForPersistence(authConfig); + + assertNull(authConfig.getDiscoveryUri()); + assertEquals(DISCOVERY_URI, authConfig.getOidcConfiguration().getDiscoveryUri()); + } + + @Test + void hydrateForResponse_legacyNestedDiscoveryUri_copiedToRoot() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + OidcClientConfig oidc = new OidcClientConfig(); + oidc.setDiscoveryUri(DISCOVERY_URI); + authConfig.setOidcConfiguration(oidc); + + repository.hydrateForResponse(authConfig); + + assertEquals(DISCOVERY_URI, authConfig.getDiscoveryUri()); + } + + @Test + void hydrateForResponse_newConfigBothPopulated_noChange() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setDiscoveryUri(DISCOVERY_URI); + authConfig.setClientId("root-client"); + OidcClientConfig oidc = new OidcClientConfig(); + oidc.setDiscoveryUri(DISCOVERY_URI); + oidc.setId("root-client"); + authConfig.setOidcConfiguration(oidc); + + repository.hydrateForResponse(authConfig); + + assertEquals(DISCOVERY_URI, authConfig.getDiscoveryUri()); + assertEquals("root-client", authConfig.getClientId()); + assertEquals("root-client", authConfig.getOidcConfiguration().getId()); + } + + @Test + void hydrateForResponse_legacyRootClientId_copiedToNested() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setClientId("legacy-root-id"); + authConfig.setOidcConfiguration(new OidcClientConfig()); + + repository.hydrateForResponse(authConfig); + + assertEquals("legacy-root-id", authConfig.getOidcConfiguration().getId()); + } + + @Test + void hydrateForResponse_noNetworkCall() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + OidcClientConfig oidc = new OidcClientConfig(); + oidc.setDiscoveryUri(DISCOVERY_URI); + authConfig.setOidcConfiguration(oidc); + + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + repository.hydrateForResponse(authConfig); + + httpMock.verifyNoInteractions(); + } + } + + @Test + void hydrateForResponse_nullAuthConfig_doesNotThrow() { + repository.hydrateForResponse(null); + } + + @Test + void normalizeForPersistence_derivedClientType_whenNullAndSecretPresent_setsConfidential() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setCallbackUrl("http://localhost:8585/callback"); + authConfig.setClientType(null); + OidcClientConfig oidc = new OidcClientConfig(); + oidc.setId("c"); + oidc.setSecret("s"); + authConfig.setOidcConfiguration(oidc); + + repository.normalizeForPersistence(authConfig); + + assertEquals(ClientType.CONFIDENTIAL, authConfig.getClientType()); + } + + @Test + void validateDiscoveryUriReachable_unreachable_returnsError() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setDiscoveryUri(DISCOVERY_URI); + + FieldError error; + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + httpMock + .when(() -> ValidationHttpUtil.safeGet(anyString())) + .thenReturn(new ValidationHttpUtil.HttpResponseData(503, "Service Unavailable")); + error = repository.validateDiscoveryUriReachable(authConfig); + } + + assertNotNull(error); + assertEquals("authenticationConfiguration.discoveryUri", error.getField()); + assertTrue(error.getError().contains("Could not reach Discovery URI")); + } + + @Test + void validateDiscoveryUriReachable_invalidJson_returnsError() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setDiscoveryUri(DISCOVERY_URI); + + FieldError error; + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + httpMock + .when(() -> ValidationHttpUtil.safeGet(anyString())) + .thenReturn(new ValidationHttpUtil.HttpResponseData(200, "not-valid-json{")); + error = repository.validateDiscoveryUriReachable(authConfig); + } + + assertNotNull(error); + assertTrue(error.getError().contains("Failed to fetch Discovery URI")); + } + + @Test + void validateDiscoveryUriReachable_missingIssuer_returnsError() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setDiscoveryUri(DISCOVERY_URI); + + FieldError error; + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + httpMock + .when(() -> ValidationHttpUtil.safeGet(anyString())) + .thenReturn( + new ValidationHttpUtil.HttpResponseData( + 200, "{\"jwks_uri\": \"https://example.com/keys\"}")); + error = repository.validateDiscoveryUriReachable(authConfig); + } + + assertNotNull(error); + assertTrue(error.getError().contains("missing required fields")); + } + + @Test + void validateDiscoveryUriReachable_azureNonAzureUri_returnsShapeError() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.AZURE); + authConfig.setDiscoveryUri( + "https://dev-123456.okta.com/oauth2/default/.well-known/openid-configuration"); + + FieldError error; + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + httpMock + .when(() -> ValidationHttpUtil.safeGet(anyString())) + .thenReturn(new ValidationHttpUtil.HttpResponseData(200, DISCOVERY_RESPONSE)); + error = repository.validateDiscoveryUriReachable(authConfig); + } + + assertNotNull(error); + assertTrue(error.getError().contains("Azure AD format")); + } + + @Test + void validateDiscoveryUriReachable_legacyConfigNoDiscoveryUri_returnsNull() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setAuthority("https://legacy-authority.example.com"); + + FieldError error = repository.validateDiscoveryUriReachable(authConfig); + + assertNull(error); + } + + @Test + void validateDiscoveryUriReachable_ldapProvider_noCheckRuns() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.LDAP); + authConfig.setDiscoveryUri("https://should-not-be-fetched"); + + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + FieldError error = repository.validateDiscoveryUriReachable(authConfig); + assertNull(error); + httpMock.verifyNoInteractions(); + } + } + + @Test + void validateDiscoveryUriReachable_validAzureUri_returnsNull() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.AZURE); + authConfig.setDiscoveryUri(DISCOVERY_URI); + + FieldError error; + try (MockedStatic httpMock = mockStatic(ValidationHttpUtil.class)) { + httpMock + .when(() -> ValidationHttpUtil.safeGet(anyString())) + .thenReturn(new ValidationHttpUtil.HttpResponseData(200, DISCOVERY_RESPONSE)); + error = repository.validateDiscoveryUriReachable(authConfig); + } + + assertNull(error); + } + + @Test + void validateDiscoveryUriReachable_invalidUrlFormat_returnsError() { + AuthenticationConfiguration authConfig = new AuthenticationConfiguration(); + authConfig.setProvider(AuthProvider.CUSTOM_OIDC); + authConfig.setDiscoveryUri("not-a-url"); + + FieldError error = repository.validateDiscoveryUriReachable(authConfig); + + assertNotNull(error); + assertTrue(error.getError().contains("not a valid HTTP(S) URL")); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/TestLdapHandlerTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/TestLdapHandlerTest.java new file mode 100644 index 000000000000..8bf841d9ac16 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/TestLdapHandlerTest.java @@ -0,0 +1,257 @@ +/* + * Copyright 2025 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.security.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.unboundid.ldap.listener.InMemoryDirectoryServer; +import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; +import com.unboundid.ldap.listener.InMemoryListenerConfig; +import com.unboundid.ldap.sdk.Entry; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.auth.LdapConfiguration; + +/** + * Unit tests for {@link TestLdapHandler}. Uses UnboundID's + * {@link InMemoryDirectoryServer} — a real LDAP server running in-process, + * so we exercise the actual bind/search/attribute-read paths without mocks. + * + *

Directory tree seeded in {@link #setUp()}: + *

+ *   dc=company,dc=com
+ *   ├─ cn=admin (password: admin-pass)
+ *   └─ ou=users
+ *      ├─ cn=alice         (mail: alice@company.com, password: alice-pass)
+ *      └─ cn=bob-no-mail   (no mail attribute, password: bob-pass)
+ * 
+ */ +class TestLdapHandlerTest { + + private static final String BASE_DN = "dc=company,dc=com"; + private static final String ADMIN_DN = "cn=admin," + BASE_DN; + private static final String ADMIN_PASSWORD = "admin-pass"; + private static final String USER_BASE_DN = "ou=users," + BASE_DN; + private static final String ALICE_DN = "cn=alice," + USER_BASE_DN; + private static final String ALICE_PASSWORD = "alice-pass"; + private static final String ALICE_EMAIL = "alice@company.com"; + + private InMemoryDirectoryServer server; + private int port; + + @BeforeEach + void setUp() throws Exception { + InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(BASE_DN); + config.addAdditionalBindCredentials(ADMIN_DN, ADMIN_PASSWORD); + config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", 0)); + config.setSchema(null); // Allow arbitrary schema for test simplicity + + server = new InMemoryDirectoryServer(config); + server.add( + new Entry("dn: " + BASE_DN, "objectClass: top", "objectClass: domain", "dc: company")); + server.add( + new Entry( + "dn: " + USER_BASE_DN, + "objectClass: top", + "objectClass: organizationalUnit", + "ou: users")); + server.add( + new Entry( + "dn: " + ALICE_DN, + "objectClass: top", + "objectClass: inetOrgPerson", + "cn: alice", + "sn: Smith", + "mail: " + ALICE_EMAIL, + "userPassword: " + ALICE_PASSWORD)); + server.add( + new Entry( + "dn: cn=bob-no-mail," + USER_BASE_DN, + "objectClass: top", + "objectClass: inetOrgPerson", + "cn: bob-no-mail", + "sn: NoMail", + "userPassword: bob-pass")); + + server.startListening(); + port = server.getListenPort(); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.shutDown(true); + } + } + + private LdapConfiguration buildLdapConfig() { + return new LdapConfiguration() + .withHost("localhost") + .withPort(port) + .withDnAdminPrincipal(ADMIN_DN) + .withDnAdminPassword(ADMIN_PASSWORD) + .withUserBaseDN(USER_BASE_DN) + .withMailAttributeName("mail") + .withSslEnabled(false); + } + + // ---------- Input validation ---------- + + @Test + void handleLdapTestLogin_nullConfig_returnsError() { + Map result = + TestLdapHandler.handleLdapTestLogin(null, null, ALICE_EMAIL, ALICE_PASSWORD); + + assertEquals(false, result.get("success")); + assertTrue(((String) result.get("error")).contains("LDAP configuration is required")); + } + + @Test + void handleLdapTestLogin_emptyEmail_returnsError() { + Map result = + TestLdapHandler.handleLdapTestLogin(null, buildLdapConfig(), "", ALICE_PASSWORD); + + assertEquals(false, result.get("success")); + assertTrue(((String) result.get("error")).contains("Email and password are required")); + } + + @Test + void handleLdapTestLogin_emptyPassword_returnsError() { + Map result = + TestLdapHandler.handleLdapTestLogin(null, buildLdapConfig(), ALICE_EMAIL, ""); + + assertEquals(false, result.get("success")); + assertTrue(((String) result.get("error")).contains("Email and password are required")); + } + + @Test + void handleLdapTestLogin_missingHost_returnsError() { + LdapConfiguration config = buildLdapConfig().withHost(null); + + Map result = + TestLdapHandler.handleLdapTestLogin(null, config, ALICE_EMAIL, ALICE_PASSWORD); + + assertEquals(false, result.get("success")); + assertTrue(((String) result.get("error")).contains("missing required fields")); + } + + @Test + void handleLdapTestLogin_missingMailAttributeName_returnsError() { + LdapConfiguration config = buildLdapConfig().withMailAttributeName(null); + + Map result = + TestLdapHandler.handleLdapTestLogin(null, config, ALICE_EMAIL, ALICE_PASSWORD); + + assertEquals(false, result.get("success")); + assertTrue(((String) result.get("error")).contains("missing required fields")); + } + + // ---------- Happy path ---------- + + @Test + void handleLdapTestLogin_validCredentials_returnsSuccessWithDerivedFields() { + Map result = + TestLdapHandler.handleLdapTestLogin(null, buildLdapConfig(), ALICE_EMAIL, ALICE_PASSWORD); + + assertEquals(true, result.get("success")); + assertEquals(ALICE_EMAIL, result.get("email")); + assertEquals("alice", result.get("username")); + assertEquals("company.com", result.get("derivedPrincipalDomain")); + assertEquals(ALICE_EMAIL, result.get("suggestedAdminPrincipal")); + } + + // ---------- Failure paths ---------- + + @Test + void handleLdapTestLogin_wrongAdminPassword_returnsError() { + LdapConfiguration config = buildLdapConfig().withDnAdminPassword("wrong-admin-pass"); + + Map result = + TestLdapHandler.handleLdapTestLogin(null, config, ALICE_EMAIL, ALICE_PASSWORD); + + assertEquals(false, result.get("success")); + assertTrue(((String) result.get("error")).contains("LDAP test failed")); + } + + @Test + void handleLdapTestLogin_userNotFound_returnsError() { + Map result = + TestLdapHandler.handleLdapTestLogin( + null, buildLdapConfig(), "nobody@company.com", ALICE_PASSWORD); + + assertEquals(false, result.get("success")); + assertTrue(((String) result.get("error")).contains("not found")); + } + + @Test + void handleLdapTestLogin_wrongUserPassword_returnsInvalidCredentialsError() { + Map result = + TestLdapHandler.handleLdapTestLogin( + null, buildLdapConfig(), ALICE_EMAIL, "wrong-alice-password"); + + assertEquals(false, result.get("success")); + assertEquals("Invalid username or password", result.get("error")); + } + + @Test + void handleLdapTestLogin_wrongUserBaseDN_userNotFound() { + LdapConfiguration config = buildLdapConfig().withUserBaseDN("ou=nonexistent," + BASE_DN); + + Map result = + TestLdapHandler.handleLdapTestLogin(null, config, ALICE_EMAIL, ALICE_PASSWORD); + + assertEquals(false, result.get("success")); + assertTrue(((String) result.get("error")).contains("LDAP test failed")); + } + + @Test + void handleLdapTestLogin_wrongMailAttributeName_userNotFound() { + LdapConfiguration config = buildLdapConfig().withMailAttributeName("emailAddress"); + + Map result = + TestLdapHandler.handleLdapTestLogin(null, config, ALICE_EMAIL, ALICE_PASSWORD); + + assertEquals(false, result.get("success")); + assertTrue(((String) result.get("error")).contains("not found")); + } + + // ---------- Edge case: user exists but no mail attribute ---------- + + @Test + void handleLdapTestLogin_userFoundByDifferentAttribute_returnsError() { + // "bob-no-mail" has no mail attribute — search by mail=... won't find him. + Map result = + TestLdapHandler.handleLdapTestLogin(null, buildLdapConfig(), "bob@company.com", "bob-pass"); + + assertEquals(false, result.get("success")); + assertTrue(((String) result.get("error")).contains("not found")); + } + + // ---------- Unreachable server ---------- + + @Test + void handleLdapTestLogin_serverUnreachable_returnsError() { + LdapConfiguration config = buildLdapConfig().withHost("localhost").withPort(1); + + Map result = + TestLdapHandler.handleLdapTestLogin(null, config, ALICE_EMAIL, ALICE_PASSWORD); + + assertEquals(false, result.get("success")); + assertFalse(((String) result.get("error")).isEmpty()); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/TestLoginHandlerTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/TestLoginHandlerTest.java new file mode 100644 index 000000000000..df871a87cc0e --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/TestLoginHandlerTest.java @@ -0,0 +1,281 @@ +/* + * Copyright 2025 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.security.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.ClientSecretPost; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.core.Response; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TestLoginHandler}. Focuses on validation branches + * and pure helper methods. End-to-end IdP interaction (discovery doc fetch, + * token exchange) is exercised via integration tests separately. + */ +class TestLoginHandlerTest { + + // ---------- handleInitiate: validation branches ---------- + + @Test + void handleInitiate_missingDiscoveryUri_returnsHtmlError() { + HttpServletRequest req = mock(HttpServletRequest.class); + Response resp = + TestLoginHandler.handleInitiate( + req, null, null, "client-id", "secret", null, null, null, null, null, null, null, null); + + assertEquals(400, resp.getStatus()); + String body = (String) resp.getEntity(); + assertTrue(body.contains("Discovery URI is required")); + } + + @Test + void handleInitiate_emptyDiscoveryUri_returnsHtmlError() { + HttpServletRequest req = mock(HttpServletRequest.class); + Response resp = + TestLoginHandler.handleInitiate( + req, null, "", "client-id", "secret", null, null, null, null, null, null, null, null); + + assertEquals(400, resp.getStatus()); + assertTrue(((String) resp.getEntity()).contains("Discovery URI is required")); + } + + @Test + void handleInitiate_missingClientId_returnsHtmlError() { + HttpServletRequest req = mock(HttpServletRequest.class); + Response resp = + TestLoginHandler.handleInitiate( + req, + null, + "https://example.com/.well-known/openid-configuration", + null, + "secret", + null, + null, + null, + null, + null, + null, + null, + null); + + assertEquals(400, resp.getStatus()); + assertTrue(((String) resp.getEntity()).contains("Client ID is required")); + } + + // ---------- handleCallback: validation branches ---------- + + @Test + void handleCallback_noSession_returnsPostMessageError() { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getSession(false)).thenReturn(null); + + Response resp = TestLoginHandler.handleCallback(req); + + assertEquals(200, resp.getStatus()); + String body = (String) resp.getEntity(); + assertTrue(body.contains("Session expired")); + } + + @Test + void handleCallback_idpError_returnsPostMessageError() { + HttpServletRequest req = mock(HttpServletRequest.class); + HttpSession session = mock(HttpSession.class); + when(req.getSession(false)).thenReturn(session); + when(req.getParameter("error")).thenReturn("access_denied"); + when(req.getParameter("error_description")).thenReturn("User denied consent"); + + Response resp = TestLoginHandler.handleCallback(req); + + String body = (String) resp.getEntity(); + assertTrue(body.contains("IdP returned error")); + assertTrue(body.contains("access_denied")); + } + + @Test + void handleCallback_stateMismatch_returnsPostMessageError() { + HttpServletRequest req = mock(HttpServletRequest.class); + HttpSession session = mock(HttpSession.class); + when(req.getSession(false)).thenReturn(session); + when(req.getParameter("error")).thenReturn(null); + when(session.getAttribute("testLoginState")).thenReturn("test-login:expected-uuid"); + when(req.getParameter("state")).thenReturn("test-login:different-uuid"); + + Response resp = TestLoginHandler.handleCallback(req); + + String body = (String) resp.getEntity(); + assertTrue(body.contains("Invalid state parameter")); + } + + @Test + void handleCallback_missingCode_returnsPostMessageError() { + HttpServletRequest req = mock(HttpServletRequest.class); + HttpSession session = mock(HttpSession.class); + when(req.getSession(false)).thenReturn(session); + when(req.getParameter("error")).thenReturn(null); + when(session.getAttribute("testLoginState")).thenReturn("test-login:uuid"); + when(req.getParameter("state")).thenReturn("test-login:uuid"); + when(req.getParameter("code")).thenReturn(null); + + Response resp = TestLoginHandler.handleCallback(req); + + String body = (String) resp.getEntity(); + assertTrue(body.contains("No authorization code received")); + } + + @Test + void handleCallback_missingSessionData_returnsPostMessageError() { + HttpServletRequest req = mock(HttpServletRequest.class); + HttpSession session = mock(HttpSession.class); + when(req.getSession(false)).thenReturn(session); + when(req.getParameter("error")).thenReturn(null); + when(session.getAttribute("testLoginState")).thenReturn("test-login:uuid"); + when(req.getParameter("state")).thenReturn("test-login:uuid"); + when(req.getParameter("code")).thenReturn("auth-code-123"); + // clientId and discoveryUri missing from session + when(session.getAttribute("testLoginClientId")).thenReturn(null); + when(session.getAttribute("testLoginDiscoveryUri")).thenReturn(null); + + Response resp = TestLoginHandler.handleCallback(req); + + String body = (String) resp.getEntity(); + assertTrue(body.contains("Session data missing")); + } + + // ---------- buildClientAuthentication: helper ---------- + + @Test + void buildClientAuthentication_defaultsToBasic() { + ClientAuthentication auth = + TestLoginHandler.buildClientAuthentication("client-id", "secret", null); + + assertInstanceOf(ClientSecretBasic.class, auth); + } + + @Test + void buildClientAuthentication_basicExplicit() { + ClientAuthentication auth = + TestLoginHandler.buildClientAuthentication("client-id", "secret", "client_secret_basic"); + + assertInstanceOf(ClientSecretBasic.class, auth); + } + + @Test + void buildClientAuthentication_postWhenSpecified() { + ClientAuthentication auth = + TestLoginHandler.buildClientAuthentication("client-id", "secret", "client_secret_post"); + + assertInstanceOf(ClientSecretPost.class, auth); + } + + @Test + void buildClientAuthentication_unknownMethodFallsBackToBasic() { + ClientAuthentication auth = + TestLoginHandler.buildClientAuthentication( + "client-id", "secret", "private_key_jwt-unsupported"); + + assertInstanceOf(ClientSecretBasic.class, auth); + } + + // ---------- buildTestLoginResult: claim processing ---------- + + @Test + void buildTestLoginResult_filtersTimestampClaims() { + Map claims = new LinkedHashMap<>(); + claims.put("email", "alice@company.com"); + claims.put("iat", 1700000000L); + claims.put("exp", 1700003600L); + claims.put("nbf", 1700000000L); + claims.put("auth_time", 1700000000L); + claims.put("sub", "user-123"); + + Map result = TestLoginHandler.buildTestLoginResult(claims, false); + + @SuppressWarnings("unchecked") + Map claimMap = (Map) result.get("claims"); + assertTrue(claimMap.containsKey("email")); + assertTrue(claimMap.containsKey("sub")); + assertFalse(claimMap.containsKey("iat")); + assertFalse(claimMap.containsKey("exp")); + assertFalse(claimMap.containsKey("nbf")); + assertFalse(claimMap.containsKey("auth_time")); + } + + @Test + void buildTestLoginResult_detectsEmailClaimAndDerivesDomain() { + Map claims = new LinkedHashMap<>(); + claims.put("sub", "user-123"); + claims.put("preferred_username", "alice"); + claims.put("email", "alice@company.com"); + + Map result = TestLoginHandler.buildTestLoginResult(claims, true); + + assertEquals("email", result.get("suggestedEmailClaim")); + assertEquals("company.com", result.get("derivedPrincipalDomain")); + assertEquals("alice@company.com", result.get("suggestedAdminPrincipal")); + assertEquals(true, result.get("hasRefreshToken")); + } + + @Test + void buildTestLoginResult_findsFirstEmailClaimInOrder() { + Map claims = new LinkedHashMap<>(); + claims.put("upn", "alice@company.com"); + claims.put("email", "other@company.com"); + + Map result = TestLoginHandler.buildTestLoginResult(claims, false); + + assertEquals("upn", result.get("suggestedEmailClaim")); + } + + @Test + void buildTestLoginResult_noEmailClaim_returnsNull() { + Map claims = new LinkedHashMap<>(); + claims.put("sub", "user-123"); + claims.put("name", "Alice"); + + Map result = TestLoginHandler.buildTestLoginResult(claims, false); + + assertNull(result.get("suggestedEmailClaim")); + assertNull(result.get("derivedPrincipalDomain")); + assertNull(result.get("suggestedAdminPrincipal")); + } + + @Test + void buildTestLoginResult_nullClaimValuesHandledGracefully() { + Map claims = new LinkedHashMap<>(); + claims.put("email", "alice@company.com"); + claims.put("nullable", null); + + Map result = TestLoginHandler.buildTestLoginResult(claims, false); + + @SuppressWarnings("unchecked") + Map claimMap = (Map) result.get("claims"); + assertEquals("alice@company.com", claimMap.get("email")); + assertEquals("", claimMap.get("nullable")); + assertNotNull(result.get("suggestedEmailClaim")); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/TestSamlHandlerTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/TestSamlHandlerTest.java new file mode 100644 index 000000000000..91870c9bb125 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/TestSamlHandlerTest.java @@ -0,0 +1,251 @@ +/* + * Copyright 2025 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.security.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.onelogin.saml2.Auth; +import com.onelogin.saml2.settings.Saml2Settings; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.Response; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TestSamlHandler}. Covers input validation, helper + * methods (settings construction, attribute flattening, server URL derivation), + * and error paths in the callback. SAML round-trip with a real IdP is covered + * by integration tests. + */ +class TestSamlHandlerTest { + + private static final String IDP_ENTITY_ID = "https://idp.example.com/entity"; + private static final String IDP_SSO_URL = "https://idp.example.com/sso"; + // Minimal fake X.509 cert string (content not validated in settings construction) + private static final String IDP_CERT = + "-----BEGIN CERTIFICATE-----\nMIIFake==\n-----END CERTIFICATE-----"; + + // ---------- handleInitiate: validation branches ---------- + + @Test + void handleInitiate_missingIdpEntityId_returnsHtmlError() { + HttpServletRequest req = mock(HttpServletRequest.class); + Response resp = + TestSamlHandler.handleInitiate( + req, null, null, null, IDP_SSO_URL, IDP_CERT, null, null, null); + + assertEquals(400, resp.getStatus()); + assertTrue(((String) resp.getEntity()).contains("IdP Entity ID is required")); + } + + @Test + void handleInitiate_missingIdpSsoLoginUrl_returnsHtmlError() { + HttpServletRequest req = mock(HttpServletRequest.class); + Response resp = + TestSamlHandler.handleInitiate( + req, null, null, IDP_ENTITY_ID, null, IDP_CERT, null, null, null); + + assertEquals(400, resp.getStatus()); + assertTrue(((String) resp.getEntity()).contains("IdP SSO Login URL is required")); + } + + @Test + void handleInitiate_missingIdpX509Certificate_returnsHtmlError() { + HttpServletRequest req = mock(HttpServletRequest.class); + Response resp = + TestSamlHandler.handleInitiate( + req, null, null, IDP_ENTITY_ID, IDP_SSO_URL, null, null, null, null); + + assertEquals(400, resp.getStatus()); + assertTrue(((String) resp.getEntity()).contains("IdP X.509 Certificate is required")); + } + + // ---------- handleCallback: validation branches ---------- + + @Test + void handleCallback_missingRelayState_returnsPostMessageError() { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getParameter("RelayState")).thenReturn(null); + + Response resp = TestSamlHandler.handleCallback(req); + + String body = (String) resp.getEntity(); + assertTrue(body.contains("Missing or invalid RelayState")); + } + + @Test + void handleCallback_wrongPrefix_returnsPostMessageError() { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getParameter("RelayState")).thenReturn("some-other-prefix:uuid"); + + Response resp = TestSamlHandler.handleCallback(req); + + String body = (String) resp.getEntity(); + assertTrue(body.contains("Missing or invalid RelayState")); + } + + @Test + void handleCallback_relayStateNotInMap_returnsPostMessageError() { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getParameter("RelayState")) + .thenReturn(TestSamlHandler.RELAY_STATE_PREFIX + "never-added"); + + Response resp = TestSamlHandler.handleCallback(req); + + String body = (String) resp.getEntity(); + assertTrue(body.contains("expired or already consumed")); + } + + // ---------- buildSamlSettings ---------- + + @Test + void buildSamlSettings_populatesIdpFields() { + Saml2Settings settings = + TestSamlHandler.buildSamlSettings( + IDP_ENTITY_ID, + IDP_SSO_URL, + IDP_CERT, + "http://localhost:8585", + "http://localhost:8585/callback", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"); + + assertEquals(IDP_ENTITY_ID, settings.getIdpEntityId()); + assertEquals(IDP_SSO_URL, settings.getIdpSingleSignOnServiceUrl().toString()); + assertEquals("http://localhost:8585", settings.getSpEntityId()); + assertEquals( + "http://localhost:8585/callback", settings.getSpAssertionConsumerServiceUrl().toString()); + } + + @Test + void buildSamlSettings_emailNameIdFormat() { + Saml2Settings settings = + TestSamlHandler.buildSamlSettings( + IDP_ENTITY_ID, + IDP_SSO_URL, + IDP_CERT, + "http://localhost:8585", + "http://localhost:8585/callback", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"); + + assertEquals( + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", settings.getSpNameIDFormat()); + } + + @Test + void buildSamlSettings_strictModeDisabledForTestLogin() { + Saml2Settings settings = + TestSamlHandler.buildSamlSettings( + IDP_ENTITY_ID, + IDP_SSO_URL, + IDP_CERT, + "http://localhost:8585", + "http://localhost:8585/callback", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"); + + // Test Login does not sign AuthnRequests — verify security flags are off. + assertFalse(settings.getAuthnRequestsSigned()); + assertFalse(settings.getWantMessagesSigned()); + assertFalse(settings.getWantAssertionsSigned()); + assertFalse(settings.isStrict()); + } + + // ---------- buildClaimsFromAuth ---------- + + @Test + void buildClaimsFromAuth_flattensSingleValueAttributes() { + Auth auth = mock(Auth.class); + when(auth.getNameId()).thenReturn("alice@company.com"); + Map> attrs = new LinkedHashMap<>(); + attrs.put("email", Collections.singletonList("alice@company.com")); + attrs.put("displayName", Collections.singletonList("Alice")); + when(auth.getAttributes()).thenReturn(attrs); + + Map claims = TestSamlHandler.buildClaimsFromAuth(auth); + + assertEquals("alice@company.com", claims.get("nameId")); + assertEquals("alice@company.com", claims.get("email")); + assertEquals("Alice", claims.get("displayName")); + } + + @Test + void buildClaimsFromAuth_keepsMultiValueAttributesAsList() { + Auth auth = mock(Auth.class); + when(auth.getNameId()).thenReturn("alice@company.com"); + Map> attrs = new LinkedHashMap<>(); + attrs.put("groups", Arrays.asList("admins", "engineers", "users")); + when(auth.getAttributes()).thenReturn(attrs); + + Map claims = TestSamlHandler.buildClaimsFromAuth(auth); + + Object groups = claims.get("groups"); + assertInstanceOf(List.class, groups); + @SuppressWarnings("unchecked") + List groupList = (List) groups; + assertEquals(3, groupList.size()); + assertTrue(groupList.contains("admins")); + } + + @Test + void buildClaimsFromAuth_skipsEmptyAttributes() { + Auth auth = mock(Auth.class); + when(auth.getNameId()).thenReturn("alice@company.com"); + Map> attrs = new LinkedHashMap<>(); + attrs.put("email", Collections.singletonList("alice@company.com")); + attrs.put("phone", Collections.emptyList()); + attrs.put("bogus", null); + when(auth.getAttributes()).thenReturn(attrs); + + Map claims = TestSamlHandler.buildClaimsFromAuth(auth); + + assertEquals("alice@company.com", claims.get("email")); + assertFalse(claims.containsKey("phone")); + assertFalse(claims.containsKey("bogus")); + } + + @Test + void buildClaimsFromAuth_noNameId_omitsNameIdKey() { + Auth auth = mock(Auth.class); + when(auth.getNameId()).thenReturn(null); + Map> attrs = new LinkedHashMap<>(); + attrs.put("email", Collections.singletonList("alice@company.com")); + when(auth.getAttributes()).thenReturn(attrs); + + Map claims = TestSamlHandler.buildClaimsFromAuth(auth); + + assertNull(claims.get("nameId")); + assertEquals("alice@company.com", claims.get("email")); + } + + @Test + void buildClaimsFromAuth_nullAttributes_returnsOnlyNameId() { + Auth auth = mock(Auth.class); + when(auth.getNameId()).thenReturn("alice@company.com"); + when(auth.getAttributes()).thenReturn(null); + + Map claims = TestSamlHandler.buildClaimsFromAuth(auth); + + assertEquals("alice@company.com", claims.get("nameId")); + assertEquals(1, claims.size()); + } +} diff --git a/openmetadata-spec/src/main/resources/json/schema/configuration/authenticationConfiguration.json b/openmetadata-spec/src/main/resources/json/schema/configuration/authenticationConfiguration.json index b8f30d7dcbff..c6fd54dfad24 100644 --- a/openmetadata-spec/src/main/resources/json/schema/configuration/authenticationConfiguration.json +++ b/openmetadata-spec/src/main/resources/json/schema/configuration/authenticationConfiguration.json @@ -124,6 +124,16 @@ "description": "Enable automatic redirect from the sign-in page to the configured SSO provider.", "type": "boolean", "default": false + }, + "discoveryUri": { + "title": "Discovery URI", + "description": "OIDC Discovery endpoint URL. Primary input for both public and confidential flows. Authority, publicKeyUrls, and other endpoints are auto-derived from this.", + "type": "string" + }, + "emailClaim": { + "title": "Email Claim", + "description": "JWT claim name containing the user's email address. Set via Test Login. Takes priority over jwtPrincipalClaims fallback but not over jwtPrincipalClaimsMapping.", + "type": "string" } }, "required": ["provider", "providerName", "jwtPrincipalClaims"], diff --git a/openmetadata-spec/src/main/resources/json/schema/configuration/authorizerConfiguration.json b/openmetadata-spec/src/main/resources/json/schema/configuration/authorizerConfiguration.json index 60502f417a39..58d16aa13473 100644 --- a/openmetadata-spec/src/main/resources/json/schema/configuration/authorizerConfiguration.json +++ b/openmetadata-spec/src/main/resources/json/schema/configuration/authorizerConfiguration.json @@ -89,6 +89,6 @@ "type": "string" } }, - "required": ["className", "containerRequestFilter", "adminPrincipals", "principalDomain", "enforcePrincipalDomain", "enableSecureSocketConnection"], + "required": ["className", "containerRequestFilter", "enforcePrincipalDomain", "enableSecureSocketConnection"], "additionalProperties": false } \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/security/client/oidcClientConfig.json b/openmetadata-spec/src/main/resources/json/schema/security/client/oidcClientConfig.json index 54acb5ba3495..e7e8a67e24a3 100644 --- a/openmetadata-spec/src/main/resources/json/schema/security/client/oidcClientConfig.json +++ b/openmetadata-spec/src/main/resources/json/schema/security/client/oidcClientConfig.json @@ -98,6 +98,6 @@ "default": "604800" } }, - "required": ["id", "secret", "discoveryUri", "tenant"], + "required": ["id", "discoveryUri", "tenant"], "additionalProperties": false } diff --git a/openmetadata-ui/src/main/resources/ui/playwright.config.ts b/openmetadata-ui/src/main/resources/ui/playwright.config.ts index e6d190a77ff2..582536868f6e 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright.config.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright.config.ts @@ -90,6 +90,7 @@ export default defineConfig({ '**/SystemCertificationTags.spec.ts', '**/SearchRBAC.spec.ts', '**/SSOLogin.spec.ts', + '**/SSOTestLogin-*.spec.ts', ], }, { @@ -99,6 +100,19 @@ export default defineConfig({ fullyParallel: false, workers: 1, }, + // SSO Test Login UI specs (Custom OIDC, LDAP, SAML). These need the + // sso-test docker profile (mock OIDC + OpenLDAP) running and, for SAML, + // a Keycloak fixture. Run via: + // docker compose --profile sso-test up -d + // yarn playwright test --project=sso-test-login + { + name: 'sso-test-login', + testMatch: '**/SSOTestLogin-*.spec.ts', + use: { ...devices['Desktop Chrome'] }, + dependencies: ['setup'], + fullyParallel: false, + workers: 1, + }, { name: 'entity-data-teardown', testMatch: '**/entity-data.teardown.ts', diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoConfiguration.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoConfiguration.ts index 2713c96c58ef..6c306fae0b7f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoConfiguration.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoConfiguration.ts @@ -10,69 +10,34 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export const SSO_COMMON_FIELDS = [ - 'Provider Name', - 'Authority', - 'Client ID', - 'Callback URL', - 'JWT Principal Claims', - 'Enable Self Signup', - 'Allowed Domains', - 'Use Roles From Provider', -]; +// Visible main-tier form fields for OIDC providers. Provider Name, Authority, +// root Client ID and root JWT Principal Claims are now hidden via UI schema +// (SSOConfigurationForm.tsx); the OIDC subsection renders Client ID under +// `oidcConfiguration.id`. +export const SSO_COMMON_FIELDS = ['Callback URL']; -export const OIDC_COMMON_FIELDS = [ - 'OIDC Client ID', - 'OIDC Client Secret', - 'OIDC Request Scopes', - 'OIDC Discovery URI', - 'OIDC Use Nonce', - 'OIDC Preferred JWS Algorithm', - 'OIDC Response Type', - 'OIDC Disable PKCE', - 'OIDC Max Clock Skew', - 'OIDC Client Authentication Method', - 'OIDC Token Validity', - 'OIDC Server URL', - 'OIDC Callback URL', - 'OIDC Max Age', - 'OIDC Prompt', - 'OIDC Session Expiry', -]; +// Main-tier OIDC fields rendered inside `oidcConfiguration` for confidential +// providers. Other OIDC settings (scope, useNonce, preferredJwsAlgorithm, +// disablePkce, etc.) live in the Advanced Fields accordion. +export const OIDC_COMMON_FIELDS = ['Client ID', 'Client Secret', 'Discovery URI']; +// Main-tier SAML form inputs rendered when the IdP section is expanded. +// Service Provider details (entityId, acs) are surfaced via the +// "Register with your Identity Provider" copyable banner, not the form. export const SAML_VISIBLE_FIELDS = [ 'IdP Entity ID', 'IdP SSO Login URL', 'IdP X.509 Certificate', 'Name ID Format', - 'SP Entity ID', - 'Assertion Consumer Service URL', - 'SP X.509 Certificate', - 'SP Private Key', - 'Debug Mode', - 'Strict Mode', - 'Want Assertions Signed', - 'Want Messages Signed', - 'Send Signed Auth Request', - 'Allowed Domains', ]; +// Main-tier LDAP form inputs (LDAP_SUBSECTION in SSO.constant.ts). Group +// configuration, role mapping, and SSL settings live in Advanced Fields. export const LDAP_VISIBLE_FIELDS = [ 'LDAP Host', 'LDAP Port', 'Admin Principal DN', 'Admin Password', - 'Enable SSL', - 'Max Pool Size', 'User Base DN', - 'Group Base DN', - 'Full DN Required', - 'Admin Role Name', - 'All Attribute Name', 'Mail Attribute Name', - 'Group Attribute Name', - 'Group Attribute Value', - 'Group Member Attribute Name', - 'Auth Reassign Roles', - 'Allowed Domains', ]; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts index 7204dbeda57f..88f49919177f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts @@ -22,6 +22,7 @@ import { import { redirectToHomePage } from '../../utils/common'; import { enableSSOEditMode, + expandSSOAdvancedFields, selectSSOProvider, verifyProviderFields, } from '../../utils/sso'; @@ -109,25 +110,20 @@ test.describe('SSO Configuration Tests', () => { }) => { await selectSSOProvider(page, 'google'); - // Verify Confidential client type is selected by default - const confidentialRadio = page.getByRole('radio', { - name: /confidential/i, - }); - - await expect(confidentialRadio).toBeChecked(); - - // Verify common fields are visible - await verifyProviderFields(page, SSO_COMMON_FIELDS); + // Confidential mode is signaled by the OIDC client secret input being + // present — clientType radio is hidden and derived from secret presence + // at save time. + const secretField = page.locator( + '[id="root/authenticationConfiguration/oidcConfiguration/secret"]' + ); - // Verify OIDC specific fields with OIDC prefix in labels + await expect(secretField).toBeVisible(); - for (const field of OIDC_COMMON_FIELDS) { - const fieldElement = page.getByLabel(field); - const fieldCount = await fieldElement.count(); - if (fieldCount > 0) { - await expect(fieldElement.first()).toBeVisible(); - } - } + // Verify common (root-level) fields and OIDC subsection main fields + await verifyProviderFields(page, [ + ...SSO_COMMON_FIELDS, + ...OIDC_COMMON_FIELDS, + ]); }); test('should show correct fields for Auth0 provider with confidential client', async ({ @@ -135,26 +131,16 @@ test.describe('SSO Configuration Tests', () => { }) => { await selectSSOProvider(page, 'auth0'); - // Verify Confidential client type is selected by default - const confidentialRadio = page.getByRole('radio', { - name: /confidential/i, - }); - - await expect(confidentialRadio).toBeChecked(); - - // Verify common fields are visible - await verifyProviderFields(page, SSO_COMMON_FIELDS); + const secretField = page.locator( + '[id="root/authenticationConfiguration/oidcConfiguration/secret"]' + ); - // Verify OIDC specific fields with OIDC prefix in labels - const oidcFields = [...OIDC_COMMON_FIELDS, 'OIDC Tenant']; + await expect(secretField).toBeVisible(); - for (const field of oidcFields) { - const fieldElement = page.getByLabel(field); - const fieldCount = await fieldElement.count(); - if (fieldCount > 0) { - await expect(fieldElement.first()).toBeVisible(); - } - } + await verifyProviderFields(page, [ + ...SSO_COMMON_FIELDS, + ...OIDC_COMMON_FIELDS, + ]); }); test('should show correct fields for Okta provider with confidential client', async ({ @@ -162,56 +148,61 @@ test.describe('SSO Configuration Tests', () => { }) => { await selectSSOProvider(page, 'okta'); - // Verify Confidential client type is selected by default - const confidentialRadio = page.getByRole('radio', { - name: /confidential/i, - }); - - await expect(confidentialRadio).toBeChecked(); - - // Verify common fields are visible - await verifyProviderFields(page, SSO_COMMON_FIELDS); + const secretField = page.locator( + '[id="root/authenticationConfiguration/oidcConfiguration/secret"]' + ); - // Verify OIDC specific fields with OIDC prefix in labels - const oidcFields = [...OIDC_COMMON_FIELDS, 'OIDC Tenant']; + await expect(secretField).toBeVisible(); - for (const field of oidcFields) { - const fieldElement = page.getByLabel(field); - const fieldCount = await fieldElement.count(); - if (fieldCount > 0) { - await expect(fieldElement.first()).toBeVisible(); - } - } + await verifyProviderFields(page, [ + ...SSO_COMMON_FIELDS, + ...OIDC_COMMON_FIELDS, + ]); }); }); - test.describe('Provider Field Visibility Checks - Public Client', () => { + test.describe('Non-OIDC Provider Field Visibility Checks', () => { test('should show correct fields when selecting SAML provider', async ({ page, }) => { await selectSSOProvider(page, 'saml'); + // Main-tier IdP fields are visible inline await verifyProviderFields(page, SAML_VISIBLE_FIELDS); - const commonFields = ['Provider Name']; - - await verifyProviderFields(page, commonFields); + // SP entity ID and ACS URL are surfaced via the "Register with your + // Identity Provider" copyable banner, not the form. + await expect(page.getByTestId('saml-sp-entity-id')).toBeVisible(); + await expect(page.getByTestId('saml-acs-url')).toBeVisible(); + // OIDC and LDAP-only inputs must be absent for SAML const hiddenFields = [ 'LDAP Host', 'LDAP Port', - 'OIDC Client ID', - 'OIDC Client Secret', - 'Use Roles From Provider', 'Allowed Email Registration Domains', - 'Token Validity (seconds)', - 'Client ID', - 'Callback URL', - 'Public Key URLs', - 'JWT Principal Claims', ]; await verifyProviderFields(page, [], hiddenFields); + + // Root-level OIDC inputs must not render for SAML + await expect( + page.locator( + '[id="root/authenticationConfiguration/oidcConfiguration/id"]' + ) + ).toHaveCount(0); + await expect( + page.locator( + '[id="root/authenticationConfiguration/oidcConfiguration/secret"]' + ) + ).toHaveCount(0); + await expect( + page.locator('[id="root/authenticationConfiguration/publicKeyUrls"]') + ).toHaveCount(0); + await expect( + page.locator( + '[id="root/authenticationConfiguration/jwtPrincipalClaims"]' + ) + ).toHaveCount(0); }); test('should show correct fields when selecting LDAP provider', async ({ @@ -227,234 +218,166 @@ test.describe('SSO Configuration Tests', () => { 'Allowed Email Registration Domains', 'Use Roles From Provider', 'Username Attribute Name', - 'Auth Roles Mapping', - 'Allowed Email Registration Domains', - 'Use Roles From Provider', - ]; - - await verifyProviderFields(page, [], hiddenFields); - }); - - test('should show correct fields when selecting Google provider', async ({ - page, - }) => { - await selectSSOProvider(page, 'google'); - - // Click on Public client type - const publicRadio = page.getByRole('radio', { name: /public/i }); - await publicRadio.click(); - - await expect(publicRadio).toBeChecked(); - - // Verify public client fields are visible - await verifyProviderFields(page, [ - ...SSO_COMMON_FIELDS, - 'Public Key URLs', - ]); - - // Verify Client Type radio group is visible - await expect(page.locator('.field-radio-group').first()).toBeVisible(); - - // Verify Secret field is NOT visible for public client - await expect(page.getByLabel('Secret Key')).not.toBeVisible(); - - // Verify OIDC configuration fields are NOT visible for public client - await expect(page.locator('[id*="oidcConfiguration"]')).not.toBeVisible(); - }); - - test('should show correct fields when selecting Auth0 provider', async ({ - page, - }) => { - await selectSSOProvider(page, 'auth0'); - - // Click on Public client type - const publicRadio = page.getByRole('radio', { name: /public/i }); - await publicRadio.click(); - - await expect(publicRadio).toBeChecked(); - - // Verify public client fields are visible - await verifyProviderFields(page, [ - ...SSO_COMMON_FIELDS, - 'Public Key URLs', - ]); - - // Verify Client Type radio group is visible - await expect(page.locator('.field-radio-group').first()).toBeVisible(); - - const hiddenFields = [ - 'LDAP Host', - 'IdP Entity ID', - 'IdP SSO Login URL', - 'Token Validation Algorithm', - 'Allowed Email Registration Domains', ]; - await verifyProviderFields(page, [], hiddenFields); - - // Verify Secret field is NOT visible for public client - await expect(page.getByLabel('Secret Key')).not.toBeVisible(); - // Verify OIDC configuration fields are NOT visible for public client - await expect(page.locator('[id*="oidcConfiguration"]')).not.toBeVisible(); - }); - - test('should show correct fields when selecting Okta provider', async ({ - page, - }) => { - await selectSSOProvider(page, 'okta'); - - // Click on Public client type - const publicRadio = page.getByRole('radio', { name: /public/i }); - await publicRadio.click(); - - await expect(publicRadio).toBeChecked(); - - // Verify public client fields are visible - await verifyProviderFields(page, [ - ...SSO_COMMON_FIELDS, - 'Public Key URLs', - ]); - - // Verify Client Type radio group is visible - await expect(page.locator('.field-radio-group').first()).toBeVisible(); - - const hiddenFields = [ - 'LDAP Host', - 'IdP Entity ID', - 'IdP SSO Login URL', - 'Token Validation Algorithm', - 'Allowed Email Registration Domains', - ]; await verifyProviderFields(page, [], hiddenFields); - // Verify Secret field is NOT visible for public client - await expect(page.getByLabel('Secret Key')).not.toBeVisible(); - - // Verify OIDC configuration fields are NOT visible for public client - await expect(page.locator('[id*="oidcConfiguration"]')).not.toBeVisible(); + // Username Attribute Name is hidden via UI schema for LDAP + await expect( + page.locator( + '[id="root/authenticationConfiguration/ldapConfiguration/usernameAttributeName"]' + ) + ).toHaveCount(0); }); }); test.describe('Form Field Changes Tests', () => { - test('should show OIDC Callback URL as readonly for Google provider', async ({ + test('should show callback URL as a copyable read-only widget for Google provider', async ({ page, }) => { await selectSSOProvider(page, 'google'); - const confidentialRadio = page.getByRole('radio', { - name: /confidential/i, - }); - - await expect(confidentialRadio).toBeChecked(); - - const callbackUrlField = page.locator( - '[id="root/authenticationConfiguration/oidcConfiguration/callbackUrl"]' + // Root-level callbackUrl renders via the CallbackUrlWidget + // (CopyableUrlField) — read-only by design, with a Copy action. + const callbackUrlWidget = page.getByTestId( + 'root/authenticationConfiguration/callbackUrl' ); - await expect(callbackUrlField).toBeVisible(); - await expect(callbackUrlField).toHaveAttribute('readonly'); - - const helpText = page.getByText(/auto-generated callback url/i); + await expect(callbackUrlWidget).toBeVisible(); + await expect( + callbackUrlWidget.getByTestId( + 'root/authenticationConfiguration/callbackUrl-value' + ) + ).toContainText('/callback'); + await expect( + callbackUrlWidget.getByTestId( + 'root/authenticationConfiguration/callbackUrl-copy' + ) + ).toBeVisible(); - await expect(helpText).toBeVisible(); + // The nested OIDC callbackUrl is hidden — it would be redundant. + await expect( + page.locator( + '[id="root/authenticationConfiguration/oidcConfiguration/callbackUrl"]' + ) + ).toHaveCount(0); }); - test('should show OIDC Callback URL as readonly for Auth0 provider', async ({ + test('should show callback URL as a copyable read-only widget for Auth0 provider', async ({ page, }) => { await selectSSOProvider(page, 'auth0'); - const callbackUrlField = page.locator( - '[id="root/authenticationConfiguration/oidcConfiguration/callbackUrl"]' + const callbackUrlWidget = page.getByTestId( + 'root/authenticationConfiguration/callbackUrl' ); - await expect(callbackUrlField).toBeVisible(); - await expect(callbackUrlField).toHaveAttribute('readonly'); + await expect(callbackUrlWidget).toBeVisible(); + await expect( + callbackUrlWidget.getByTestId( + 'root/authenticationConfiguration/callbackUrl-copy' + ) + ).toBeVisible(); }); - test('should show OIDC Callback URL as readonly for Okta provider', async ({ + test('should show callback URL as a copyable read-only widget for Okta provider', async ({ page, }) => { await selectSSOProvider(page, 'okta'); - const callbackUrlField = page.locator( - '[id="root/authenticationConfiguration/oidcConfiguration/callbackUrl"]' + const callbackUrlWidget = page.getByTestId( + 'root/authenticationConfiguration/callbackUrl' ); - await expect(callbackUrlField).toBeVisible(); - await expect(callbackUrlField).toHaveAttribute('readonly'); + await expect(callbackUrlWidget).toBeVisible(); + await expect( + callbackUrlWidget.getByTestId( + 'root/authenticationConfiguration/callbackUrl-copy' + ) + ).toBeVisible(); }); - test('should show OIDC Callback URL as readonly for Azure AD provider', async ({ + test('should show callback URL as a copyable read-only widget for Azure AD provider', async ({ page, }) => { await selectSSOProvider(page, 'azure'); - const callbackUrlField = page.locator( - '[id="root/authenticationConfiguration/oidcConfiguration/callbackUrl"]' + const callbackUrlWidget = page.getByTestId( + 'root/authenticationConfiguration/callbackUrl' ); - await expect(callbackUrlField).toBeVisible(); - await expect(callbackUrlField).toHaveAttribute('readonly'); + await expect(callbackUrlWidget).toBeVisible(); + await expect( + callbackUrlWidget.getByTestId( + 'root/authenticationConfiguration/callbackUrl-copy' + ) + ).toBeVisible(); }); - test('should show SAML SP Entity ID and ACS URL as readonly', async ({ + test('should show SAML SP Entity ID and ACS URL as copyable read-only widgets', async ({ page, }) => { await selectSSOProvider(page, 'saml'); - const spEntityIdField = page.locator( - '[id="root/authenticationConfiguration/samlConfiguration/sp/entityId"]' - ); - - await expect(spEntityIdField).toBeVisible(); - await expect(spEntityIdField).toHaveAttribute('readonly'); + // SP details now render in the "Register with your Identity Provider" + // banner above the form, via dedicated CopyableUrlField components — + // not as RJSF input fields. + const banner = page.getByTestId('saml-acs-info-banner'); - const helpTextEntityId = page.getByText( - /auto-generated service provider entity id/i - ); + await expect(banner).toBeVisible(); - await expect(helpTextEntityId).toBeVisible(); + const acsUrl = banner.getByTestId('saml-acs-url'); - const acsUrlField = page.locator( - '[id="root/authenticationConfiguration/samlConfiguration/sp/acs"]' + await expect(acsUrl).toBeVisible(); + await expect(banner.getByTestId('saml-acs-url-value')).toContainText( + '/callback' ); + await expect(banner.getByTestId('saml-acs-url-copy')).toBeVisible(); - await expect(acsUrlField).toBeVisible(); - await expect(acsUrlField).toHaveAttribute('readonly'); + const spEntityId = banner.getByTestId('saml-sp-entity-id'); - const helpTextAcs = page.getByText( - /auto-generated assertion consumer service url/i - ); + await expect(spEntityId).toBeVisible(); + await expect( + banner.getByTestId('saml-sp-entity-id-value') + ).not.toBeEmpty(); + await expect(banner.getByTestId('saml-sp-entity-id-copy')).toBeVisible(); - await expect(helpTextAcs).toBeVisible(); + // Underlying SP form fields are hidden via UI schema + await expect( + page.locator( + '[id="root/authenticationConfiguration/samlConfiguration/sp/entityId"]' + ) + ).toHaveCount(0); + await expect( + page.locator( + '[id="root/authenticationConfiguration/samlConfiguration/sp/acs"]' + ) + ).toHaveCount(0); }); - test('should display advanced config collapse for OIDC provider', async ({ + test('should display Advanced Fields accordion for OIDC provider', async ({ page, }) => { await selectSSOProvider(page, 'google'); - const advancedConfigCollapse = page.locator( - '.sso-advanced-properties-collapse' + const advancedFieldsToggle = page.getByTestId( + 'sso-advanced-fields-toggle' ); - await expect(advancedConfigCollapse).toBeVisible(); - - const advancedConfigHeader = page.getByText(/advanced config/i); - - await expect(advancedConfigHeader).toBeVisible(); + await expect(advancedFieldsToggle).toBeVisible(); + await expect(advancedFieldsToggle).toContainText(/advanced fields/i); }); - test('should show advanced fields when advanced config is expanded', async ({ + test('should show advanced fields when Advanced Fields accordion is expanded', async ({ page, }) => { await selectSSOProvider(page, 'google'); - const advancedConfigHeader = page.getByText(/advanced config/i); - - await advancedConfigHeader.click(); + await expandSSOAdvancedFields(page); + // Sample of advanced-tier OIDC fields that are expected to render in + // the Advanced Fields panel — see OIDC_CONFIDENTIAL_SUBSECTION in + // SSO.constant.ts. const advancedFields = [ 'useNonce', 'disablePkce', @@ -465,12 +388,9 @@ test.describe('SSO Configuration Tests', () => { ]; for (const fieldName of advancedFields) { - const field = page.locator(`[id*="${fieldName}"]`); - const fieldCount = await field.count(); + const field = page.locator(`[id*="${fieldName}"]`).first(); - if (fieldCount > 0) { - await expect(field.first()).toBeVisible(); - } + await expect(field).toBeVisible(); } }); @@ -479,11 +399,13 @@ test.describe('SSO Configuration Tests', () => { }) => { await selectSSOProvider(page, 'google'); - const confidentialRadio = page.getByRole('radio', { - name: /confidential/i, - }); + // Confidential mode is signaled by secret input presence — clientType + // radio is hidden and derived at save time from secret presence. + const secretField = page.locator( + '[id="root/authenticationConfiguration/oidcConfiguration/secret"]' + ); - await expect(confidentialRadio).toBeChecked(); + await expect(secretField).toBeVisible(); const publicKeyUrlsField = page.locator('[id*="publicKeyUrls"]').first(); @@ -500,24 +422,29 @@ test.describe('SSO Configuration Tests', () => { await expect(serverUrlField).not.toBeVisible(); }); - test('should hide preferredJwsAlgorithm and responseType for OIDC providers', async ({ + test('should hide responseType for OIDC providers and surface preferredJwsAlgorithm only in Advanced Fields', async ({ page, }) => { await selectSSOProvider(page, 'google'); - const advancedConfigHeader = page.getByText(/advanced config/i); + // responseType is stripped from the OIDC schema entirely + // (getProviderSpecificSchema in SSOConfigurationForm.tsx). + const responseTypeField = page.locator('[id*="responseType"]'); - await advancedConfigHeader.click(); + await expect(responseTypeField).toHaveCount(0); + // preferredJwsAlgorithm lives in the Advanced Fields panel for Google + // (advanced tier in OIDC_CONFIDENTIAL_SUBSECTION). It must be hidden + // until the panel is expanded, then visible afterwards. const preferredJwsAlgorithmField = page.locator( '[id*="preferredJwsAlgorithm"]' ); await expect(preferredJwsAlgorithmField).not.toBeVisible(); - const responseTypeField = page.locator('[id*="responseType"]'); + await expandSSOAdvancedFields(page); - await expect(responseTypeField).not.toBeVisible(); + await expect(preferredJwsAlgorithmField.first()).toBeVisible(); }); test('should hide tokenValidationAlgorithm for OIDC providers', async ({ @@ -587,15 +514,15 @@ test.describe('SSO Configuration Tests', () => { }) => { await selectSSOProvider(page, 'auth0'); - const advancedConfigHeader = page.getByText(/advanced config/i); - - await advancedConfigHeader.click(); + // clientAuthenticationMethod sits in the Advanced Fields panel — + // expand it before asserting visibility. + await expandSSOAdvancedFields(page); const clientAuthMethodField = page.locator( '[id*="clientAuthenticationMethod"]' ); - await expect(clientAuthMethodField).not.toBeVisible(); + await expect(clientAuthMethodField).toHaveCount(0); }); test('should show clientAuthenticationMethod for Okta provider', async ({ @@ -603,77 +530,76 @@ test.describe('SSO Configuration Tests', () => { }) => { await selectSSOProvider(page, 'okta'); - const advancedConfigHeader = page.getByText(/advanced config/i); - - await advancedConfigHeader.click(); + await expandSSOAdvancedFields(page); const clientAuthMethodField = page.locator( '[id*="clientAuthenticationMethod"]' ); - const fieldCount = await clientAuthMethodField.count(); - if (fieldCount > 0) { - await expect(clientAuthMethodField.first()).toBeVisible(); - } + await expect(clientAuthMethodField.first()).toBeVisible(); }); test('should hide tenant field for Auth0 provider', async ({ page }) => { await selectSSOProvider(page, 'auth0'); - const advancedConfigHeader = page.getByText(/advanced config/i); - - await advancedConfigHeader.click(); + await expandSSOAdvancedFields(page); - const tenantField = page.locator('[id*="/tenant"]'); + // `tenant` is stripped from the schema for non-SAML/LDAP providers + // (getProviderSpecificSchema in SSOConfigurationForm.tsx). + const tenantField = page.locator( + '[id="root/authenticationConfiguration/oidcConfiguration/tenant"]' + ); - await expect(tenantField).not.toBeVisible(); + await expect(tenantField).toHaveCount(0); }); - test('should show tenant field for Azure provider', async ({ page }) => { + test('should hide tenant field for Azure provider', async ({ page }) => { await selectSSOProvider(page, 'azure'); + await expandSSOAdvancedFields(page); + + // Azure uses `authority` and `discoveryUri` rather than a separate + // tenant input — the schema removes `tenant` for all OIDC providers. const tenantField = page.locator( '[id="root/authenticationConfiguration/oidcConfiguration/tenant"]' ); - await expect(tenantField).toBeVisible(); + await expect(tenantField).toHaveCount(0); }); - test('should collapse advanced config by default', async ({ page }) => { + test('should collapse Advanced Fields by default', async ({ page }) => { await selectSSOProvider(page, 'google'); - const advancedConfigPanel = page.locator( - '.sso-advanced-properties-collapse .ant-collapse-item' + const advancedFieldsToggle = page.getByTestId( + 'sso-advanced-fields-toggle' ); - await expect(advancedConfigPanel).not.toHaveClass( - /ant-collapse-item-active/ - ); + await expect(advancedFieldsToggle).toBeVisible(); + + const advancedFieldsPanel = page.getByTestId('sso-advanced-fields-panel'); + + await expect(advancedFieldsPanel).toBeHidden(); }); - test('should expand and collapse advanced config when clicked', async ({ + test('should expand and collapse Advanced Fields when clicked', async ({ page, }) => { await selectSSOProvider(page, 'google'); - const advancedConfigHeader = page.getByText(/advanced config/i); - const advancedConfigPanel = page.locator( - '.sso-advanced-properties-collapse .ant-collapse-item' + const advancedFieldsToggle = page.getByTestId( + 'sso-advanced-fields-toggle' ); + const advancedFieldsPanel = page.getByTestId('sso-advanced-fields-panel'); - await expect(advancedConfigPanel).not.toHaveClass( - /ant-collapse-item-active/ - ); + await expect(advancedFieldsPanel).toBeHidden(); - await advancedConfigHeader.click(); + await advancedFieldsToggle.click(); - await expect(advancedConfigPanel).toHaveClass(/ant-collapse-item-active/); + await expect(advancedFieldsPanel).toBeVisible(); - await advancedConfigHeader.click(); + await advancedFieldsToggle.click(); - await expect(advancedConfigPanel).not.toHaveClass( - /ant-collapse-item-active/ - ); + await expect(advancedFieldsPanel).toBeHidden(); }); test('should support full LDAP role mapping flow: add, fill, open roles dropdown, detect and resolve duplicates, and remove', async ({ @@ -681,6 +607,9 @@ test.describe('SSO Configuration Tests', () => { }) => { await selectSSOProvider(page, 'ldap'); + // authRolesMapping lives in LDAP Advanced Fields — expand to access. + await expandSSOAdvancedFields(page); + const addMappingButton = page.getByTestId('add-mapping-btn'); const ldapGroupInputs = page.locator( '[data-testid^="ldap-group-input-"]' @@ -748,6 +677,9 @@ test.describe('SSO Configuration Tests', () => { }) => { await selectSSOProvider(page, 'ldap'); + // authReassignRoles is in LDAP Advanced Fields. + await expandSSOAdvancedFields(page); + const field = page.getByTestId( 'sso-configuration-form-array-field-template-authReassignRoles' ); @@ -836,7 +768,10 @@ test.describe('SAML Metadata XML Upload', () => { await test.step('Wait for drop zone and upload valid SAML metadata XML', async () => { await expect(page.getByTestId('file-upload-drop-zone')).toBeVisible(); - const fileInput = page.getByTestId('file-uploader'); + + // FileTrigger renders a hidden as a sibling of the + // visible drop-zone (file-uploader testid is on the wrapper div). + const fileInput = page.locator('input[type="file"]'); await fileInput.setInputFiles(VALID_SAML_XML); }); @@ -876,7 +811,7 @@ test.describe('SAML Metadata XML Upload', () => { await page.getByTestId('change-metadata-xml-btn').click(); await expect(page.getByTestId('file-upload-drop-zone')).toBeVisible(); - const fileInput = page.getByTestId('file-uploader'); + const fileInput = page.locator('input[type="file"]'); await fileInput.setInputFiles(INVALID_SAML_XML); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOTestLogin-CustomOIDC.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOTestLogin-CustomOIDC.spec.ts new file mode 100644 index 000000000000..26ddb5c0e836 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOTestLogin-CustomOIDC.spec.ts @@ -0,0 +1,208 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { APIRequestContext, expect } from '@playwright/test'; +import { + resetMockOidc, + setTokenEndpointError, + waitForMockOidcReady, +} from '../../utils/mockOidc'; +import { + clickTestLoginButton, + enableSSOEditMode, + expectEmailClaimStatusSet, + expectSaveBlockedAtClick, + expectSaveDisabledForLockoutRisk, + expectSaveEnabled, + pickEmailClaim, + readTestLoginResultFromStorage, + runTestLoginViaPopup, + selectSSOProvider, + TEST_LOGIN_CAPTURE_WINDOW_KEY, + TEST_LOGIN_NETWORK_TIMEOUT_MS, +} from '../../utils/sso'; +import { customOidcProviderHelper } from '../../utils/sso-providers/custom-oidc'; +import { ssoTest as test } from '../../utils/sso-test-fixtures'; +import { + applyProviderConfig, + expectPersistedSecurityConfig, +} from '../../utils/ssoAuth'; + +const PROVIDER_CREDENTIALS = { username: 'admin', password: 'unused' }; + +const ensureFixtureReady = async (request: APIRequestContext) => { + try { + await waitForMockOidcReady(request, 5_000); + } catch (error) { + // eslint-disable-next-line playwright/no-skipped-test -- conditional skip when the mock OIDC fixture isn't reachable; this spec only runs against the sso-test docker profile + test.skip( + true, + `Mock OIDC provider not reachable at localhost:9090. ` + + `Bring it up with 'docker compose --profile sso-test up -d' first. ` + + `Underlying error: ${(error as Error).message}` + ); + } +}; + +test.describe('SSO Test Login — Custom OIDC', () => { + test.beforeEach(async ({ adminApiContext, originalConfig: _ }) => { + await ensureFixtureReady(adminApiContext); + await resetMockOidc(adminApiContext); + }); + + test('happy path: configure → test login → claim selector → save', async ({ + page, + adminApiContext: apiContext, + }) => { + await enableSSOEditMode(page); + await selectSSOProvider(page, 'custom-oidc'); + await customOidcProviderHelper.fillForm?.(page); + + const result = await runTestLoginViaPopup( + page, + customOidcProviderHelper, + PROVIDER_CREDENTIALS + ); + + expect(result.type).toBe('sso-test-login'); + expect(result.success).toBe(true); + expect(result.claims).toBeDefined(); + expect(Object.keys(result.claims ?? {}).length).toBeGreaterThan(0); + + if (result.suggestedEmailClaim) { + await pickEmailClaim(page, result.suggestedEmailClaim); + await expectEmailClaimStatusSet(page, result.suggestedEmailClaim); + } else { + await page.getByTestId('sso-claim-selector-modal').waitFor({ + state: 'visible', + }); + await page.keyboard.press('Escape'); + } + await expectSaveEnabled(page); + + const saveResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/system/security/config') && + ['PUT', 'PATCH'].includes(response.request().method()), + { timeout: TEST_LOGIN_NETWORK_TIMEOUT_MS } + ); + await page.getByTestId('save-sso-configuration').click(); + const saveResponse = await saveResponsePromise; + expect(saveResponse.ok()).toBe(true); + + await expectPersistedSecurityConfig(apiContext, (auth) => { + expect(auth.provider).toBe('custom-oidc'); + expect(auth.emailClaim).toBe('email'); + }); + }); + + test('failure path: invalid client secret surfaces error and disables save', async ({ + page, + adminApiContext, + }) => { + await setTokenEndpointError(adminApiContext, 'invalid_client', 401); + + await enableSSOEditMode(page); + await selectSSOProvider(page, 'custom-oidc'); + await customOidcProviderHelper.fillForm?.(page, { secret: 'wrong-secret' }); + + await runTestLoginViaPopup( + page, + customOidcProviderHelper, + PROVIDER_CREDENTIALS + ); + const stored = await readTestLoginResultFromStorage(page); + expect(stored.success).toBe(false); + expect(stored.error).toBeTruthy(); + + await expectSaveBlockedAtClick(page); + }); + + test('mode=existing overlay: re-test after save without retyping secret', async ({ + page, + adminApiContext, + originalConfig, + }) => { + const seedPayload = await customOidcProviderHelper.buildConfigPayload(); + await applyProviderConfig(adminApiContext, originalConfig, seedPayload); + + await enableSSOEditMode(page); + await expect(page.getByTestId('save-sso-configuration')).toBeVisible(); + + let initiateBody: string | undefined; + const initiateSeenPromise = new Promise((resolve) => { + page.context().on('request', (request) => { + if ( + request.url().includes('/system/config/auth/test-login/initiate') && + request.method() === 'POST' + ) { + initiateBody = request.postData() ?? ''; + resolve(); + } + }); + }); + + const popupPromise = page.context().waitForEvent('page', { + timeout: TEST_LOGIN_NETWORK_TIMEOUT_MS, + }); + await clickTestLoginButton(page); + + await Promise.race([ + initiateSeenPromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('initiate POST never observed')), + TEST_LOGIN_NETWORK_TIMEOUT_MS + ) + ), + ]); + expect(initiateBody ?? '').toContain('mode=existing'); + + const popup = await popupPromise; + const closePromise = popup.waitForEvent('close', { timeout: 60_000 }); + await popup.waitForLoadState('domcontentloaded').catch(() => undefined); + await closePromise; + + const stored = await readTestLoginResultFromStorage(page); + expect(stored.success).toBe(true); + }); + + test('lockout-risk gate: editing clientId disables save until re-tested', async ({ + page, + }) => { + await enableSSOEditMode(page); + await selectSSOProvider(page, 'custom-oidc'); + await customOidcProviderHelper.fillForm?.(page); + + const first = await runTestLoginViaPopup( + page, + customOidcProviderHelper, + PROVIDER_CREDENTIALS + ); + expect(first.success).toBe(true); + await pickEmailClaim(page, 'email'); + await expectSaveEnabled(page); + + await page + .getByRole('textbox', { name: /^Client ID/ }) + .fill('changed-client-id'); + await expectSaveDisabledForLockoutRisk(page); + + const captured = await page.evaluate( + (captureKey) => + (window as unknown as Record)[captureKey] ?? null, + TEST_LOGIN_CAPTURE_WINDOW_KEY + ); + expect(captured).not.toBeNull(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOTestLogin-LDAP.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOTestLogin-LDAP.spec.ts new file mode 100644 index 000000000000..b0e514d24f31 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOTestLogin-LDAP.spec.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, Page } from '@playwright/test'; +import { + enableSSOEditMode, + expectSaveDisabledForLockoutRisk, + expectSaveEnabled, + runTestLoginViaLdapModal, + selectSSOProvider, + TEST_LOGIN_NETWORK_TIMEOUT_MS, +} from '../../utils/sso'; +import { + openldapProviderHelper, + OPENLDAP_FIXTURE, +} from '../../utils/sso-providers/openldap'; +import { ssoTest as test } from '../../utils/sso-test-fixtures'; +import { + applyProviderConfig, + expectPersistedSecurityConfig, +} from '../../utils/ssoAuth'; + +const ensureLdapFixtureReady = async (page: Page) => { + try { + const response = await page.request.head( + `http://localhost:${OPENLDAP_FIXTURE.port}`, + { timeout: 3_000 } + ); + if (response.status() >= 500) { + throw new Error(`status ${response.status()}`); + } + } catch { + // HEAD against ldap:// won't speak HTTP; the connection refusing here + // means the port isn't bound. Skip rather than fail. + } +}; + +test.describe('SSO Test Login — LDAP', () => { + test.beforeEach(async ({ page, originalConfig: _ }) => { + await ensureLdapFixtureReady(page); + }); + + test('happy path: configure → test login modal → success → save', async ({ + page, + adminApiContext: apiContext, + }) => { + await enableSSOEditMode(page); + await selectSSOProvider(page, 'ldap'); + await openldapProviderHelper.fillForm?.(page); + + const result = await runTestLoginViaLdapModal(page, { + email: OPENLDAP_FIXTURE.validUser.email, + password: OPENLDAP_FIXTURE.validUser.password, + }); + expect(result.success).toBe(true); + expect(result.derivedPrincipalDomain).toBe('company.com'); + expect(result.suggestedAdminPrincipal).toBe( + OPENLDAP_FIXTURE.validUser.email + ); + + await expect(page.getByTestId('ldap-test-login-modal')).toBeHidden(); + await expectSaveEnabled(page); + + const saveResponsePromise = page.waitForResponse( + (r) => + r.url().includes('/system/security/config') && + ['PUT', 'PATCH'].includes(r.request().method()), + { timeout: TEST_LOGIN_NETWORK_TIMEOUT_MS } + ); + await page.getByTestId('save-sso-configuration').click(); + expect((await saveResponsePromise).ok()).toBe(true); + + await expectPersistedSecurityConfig(apiContext, (auth) => { + const ldap = (auth.ldapConfiguration ?? {}) as Record; + expect(ldap.host).toBe(OPENLDAP_FIXTURE.host); + expect(ldap.port).toBe(OPENLDAP_FIXTURE.port); + expect(ldap.dnAdminPrincipal).toBe(OPENLDAP_FIXTURE.adminDn); + expect(ldap.userBaseDN).toBe(OPENLDAP_FIXTURE.userBaseDN); + expect(ldap.mailAttributeName).toBe(OPENLDAP_FIXTURE.mailAttributeName); + expect(ldap.dnAdminPassword).not.toBe(OPENLDAP_FIXTURE.adminPassword); + }); + }); + + test('failure path: wrong user password keeps modal open with error', async ({ + page, + }) => { + await enableSSOEditMode(page); + await selectSSOProvider(page, 'ldap'); + await openldapProviderHelper.fillForm?.(page); + + const result = await runTestLoginViaLdapModal(page, { + email: OPENLDAP_FIXTURE.validUser.email, + password: 'wrong-password', + }); + expect(result.success).toBe(false); + expect(result.error).toBeTruthy(); + + await expect(page.getByTestId('ldap-test-login-modal')).toBeVisible(); + await page + .getByTestId('ldap-test-login-modal') + .getByRole('button', { name: /^Cancel/ }) + .click(); + await expect(page.getByTestId('ldap-test-login-modal')).toBeHidden(); + await expectSaveDisabledForLockoutRisk(page); + }); + + test('mode=existing overlay: re-test after save uses existing dnAdminPassword', async ({ + page, + adminApiContext, + originalConfig, + }) => { + const seedPayload = await openldapProviderHelper.buildConfigPayload(); + await applyProviderConfig(adminApiContext, originalConfig, seedPayload); + + await enableSSOEditMode(page); + await expect(page.getByTestId('save-sso-configuration')).toBeVisible(); + + const initiateRequestPromise = page.waitForRequest( + (request) => + request + .url() + .includes('/system/config/auth/test-login/ldap-initiate') && + request.method() === 'POST', + { timeout: TEST_LOGIN_NETWORK_TIMEOUT_MS } + ); + + const result = await runTestLoginViaLdapModal(page, { + email: OPENLDAP_FIXTURE.validUser.email, + password: OPENLDAP_FIXTURE.validUser.password, + }); + + const initiateRequest = await initiateRequestPromise; + const body = JSON.parse(initiateRequest.postData() ?? '{}'); + expect(body.mode).toBe('existing'); + + expect(result.success).toBe(true); + }); + + test('lockout-risk gate: editing dnAdminPassword disables save until re-tested', async ({ + page, + }) => { + await enableSSOEditMode(page); + await selectSSOProvider(page, 'ldap'); + await openldapProviderHelper.fillForm?.(page); + + const first = await runTestLoginViaLdapModal(page, { + email: OPENLDAP_FIXTURE.validUser.email, + password: OPENLDAP_FIXTURE.validUser.password, + }); + expect(first.success).toBe(true); + await expectSaveEnabled(page); + + await page + .getByRole('textbox', { name: /^Admin Password/ }) + .fill('changed-admin-pass'); + await expectSaveDisabledForLockoutRisk(page); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOTestLogin-SAML.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOTestLogin-SAML.spec.ts new file mode 100644 index 000000000000..c5b0f8fed901 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOTestLogin-SAML.spec.ts @@ -0,0 +1,211 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from '@playwright/test'; +import { SSO_ENV } from '../../constant/ssoAuth'; +import { + clickTestLoginButton, + enableSSOEditMode, + expectSaveDisabledForLockoutRisk, + expectSaveEnabled, + pickEmailClaim, + readTestLoginResultFromStorage, + runTestLoginViaPopup, + selectSSOProvider, + TEST_LOGIN_NETWORK_TIMEOUT_MS, +} from '../../utils/sso'; +import { keycloakAzureSamlProviderHelper } from '../../utils/sso-providers/keycloak-saml'; +import { ssoTest as test } from '../../utils/sso-test-fixtures'; +import { + applyProviderConfig, + expectPersistedSecurityConfig, + ProviderConfigOverride, +} from '../../utils/ssoAuth'; + +const username = process.env[SSO_ENV.USERNAME] ?? ''; +const password = process.env[SSO_ENV.PASSWORD] ?? ''; + +test.describe('SSO Test Login — SAML', () => { + // eslint-disable-next-line playwright/no-skipped-test -- conditional skip when Keycloak SAML credentials aren't provided; the suite only runs when CI or the developer supplies them + test.skip( + !username || !password, + `SAML Test Login spec needs ${SSO_ENV.USERNAME} and ${SSO_ENV.PASSWORD}` + + ' (Keycloak realm credentials). Set them or run via the nightly workflow.' + ); + + let cachedSamlPayload: ProviderConfigOverride | undefined; + const getSamlPayload = async (): Promise => { + if (!cachedSamlPayload) { + cachedSamlPayload = + await keycloakAzureSamlProviderHelper.buildConfigPayload(); + } + + return cachedSamlPayload; + }; + + test.beforeEach(async ({ originalConfig: _ }) => {}); + + test('happy path: configure → test login → claim selector → save', async ({ + page, + adminApiContext: apiContext, + }) => { + const samlPayload = await getSamlPayload(); + + await enableSSOEditMode(page); + await selectSSOProvider(page, 'saml'); + await keycloakAzureSamlProviderHelper.fillForm?.(page); + + const result = await runTestLoginViaPopup( + page, + keycloakAzureSamlProviderHelper, + { username, password } + ); + + expect(result.success).toBe(true); + expect(result.claims).toBeDefined(); + const emailClaim = result.suggestedEmailClaim ?? 'email'; + await pickEmailClaim(page, emailClaim); + await expectSaveEnabled(page); + + const saveResponsePromise = page.waitForResponse( + (r) => + r.url().includes('/system/security/config') && + ['PUT', 'PATCH'].includes(r.request().method()), + { timeout: TEST_LOGIN_NETWORK_TIMEOUT_MS } + ); + await page.getByTestId('save-sso-configuration').click(); + expect((await saveResponsePromise).ok()).toBe(true); + + const expectedEntityId = ( + samlPayload.authenticationConfiguration as { + samlConfiguration: { idp: { entityId: string } }; + } + ).samlConfiguration.idp.entityId; + await expectPersistedSecurityConfig(apiContext, (auth) => { + expect(auth.provider).toBe('saml'); + const samlIdp = ( + auth.samlConfiguration as { idp?: { entityId?: string } } | undefined + )?.idp; + expect(samlIdp?.entityId).toBe(expectedEntityId); + }); + }); + + test('failure path: tampered IdP cert surfaces signature error', async ({ + page, + }) => { + await getSamlPayload(); + await enableSSOEditMode(page); + await selectSSOProvider(page, 'saml'); + await keycloakAzureSamlProviderHelper.fillForm?.(page); + + const tamperedCert = + '-----BEGIN CERTIFICATE-----\nMIIBIjANBg=\n-----END CERTIFICATE-----'; + await page + .getByRole('textbox', { name: /^IdP X\.509 Certificate/ }) + .fill(tamperedCert); + + // java-saml's SettingsBuilder rejects malformed certs pre-flight, so the + // popup never opens — wait for the inline error banner instead. + await clickTestLoginButton(page); + + const errorBanner = page + .getByRole('alert') + .or(page.locator('.ant-message-error')) + .or(page.locator('.ant-notification-notice-error')) + .first(); + await expect(errorBanner).toBeVisible({ timeout: 15_000 }); + + await expectSaveDisabledForLockoutRisk(page); + }); + + test('mode=existing overlay: re-test after save without retyping cert', async ({ + page, + adminApiContext, + originalConfig, + }) => { + const samlPayload = await getSamlPayload(); + await applyProviderConfig(adminApiContext, originalConfig, samlPayload); + + await enableSSOEditMode(page); + await expect(page.getByTestId('save-sso-configuration')).toBeVisible(); + + let initiateBody: string | undefined; + const initiateSeenPromise = new Promise((resolve) => { + page.context().on('request', (request) => { + if ( + request + .url() + .includes('/system/config/auth/test-login/saml-initiate') && + request.method() === 'POST' + ) { + initiateBody = request.postData() ?? ''; + resolve(); + } + }); + }); + const popupPromise = page.context().waitForEvent('page', { + timeout: TEST_LOGIN_NETWORK_TIMEOUT_MS, + }); + await clickTestLoginButton(page); + + await Promise.race([ + initiateSeenPromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('saml-initiate POST never observed')), + TEST_LOGIN_NETWORK_TIMEOUT_MS + ) + ), + ]); + expect(initiateBody ?? '').toContain('mode=existing'); + + const popup = await popupPromise; + const closePromise = popup.waitForEvent('close', { timeout: 60_000 }); + await popup.waitForLoadState('domcontentloaded').catch(() => undefined); + if (!popup.isClosed()) { + await keycloakAzureSamlProviderHelper.performProviderLogin(popup, { + username, + password, + }); + } + await closePromise; + + const stored = await readTestLoginResultFromStorage(page); + expect(stored.success).toBe(true); + }); + + test('lockout-risk gate: editing idpX509Certificate disables save until re-tested', async ({ + page, + }) => { + await getSamlPayload(); + await enableSSOEditMode(page); + await selectSSOProvider(page, 'saml'); + await keycloakAzureSamlProviderHelper.fillForm?.(page); + + const first = await runTestLoginViaPopup( + page, + keycloakAzureSamlProviderHelper, + { username, password } + ); + expect(first.success).toBe(true); + await pickEmailClaim(page, first.suggestedEmailClaim ?? 'email'); + await expectSaveEnabled(page); + + const altCert = + '-----BEGIN CERTIFICATE-----\nMIICchanged=\n-----END CERTIFICATE-----'; + await page + .getByRole('textbox', { name: /^IdP X\.509 Certificate/ }) + .fill(altCert); + await expectSaveDisabledForLockoutRisk(page); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/custom-oidc.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/custom-oidc.ts new file mode 100644 index 000000000000..193c6c4cab8b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/custom-oidc.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Page } from '@playwright/test'; +import { OM_BASE_URL } from '../../constant/ssoAuth'; +import { + MOCK_OIDC_CLIENT_ID, + MOCK_OIDC_CLIENT_SECRET, + MOCK_OIDC_DISCOVERY_URL, +} from '../mockOidc'; +import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; +import { FillFormOverrides, ProviderHelper } from './index'; + +// pac4j inside the OM container can't reach localhost:9090 — must use the +// docker-network hostname for server-side discovery. +const SERVER_DISCOVERY_URI = + process.env.MOCK_OIDC_SERVER_DISCOVERY_URI ?? + 'http://mock-oidc-provider:9090/.well-known/openid-configuration'; + +const buildConfigPayload = (): ProviderConfigOverride => { + const browserAuthority = MOCK_OIDC_DISCOVERY_URL.replace( + /\/\.well-known\/openid-configuration$/, + '' + ); + const serverAuthority = SERVER_DISCOVERY_URI.replace( + /\/\.well-known\/openid-configuration$/, + '' + ); + + return { + authenticationConfiguration: { + provider: 'custom-oidc', + providerName: 'mock-oidc', + clientType: 'confidential', + authority: browserAuthority, + clientId: MOCK_OIDC_CLIENT_ID, + callbackUrl: `${OM_BASE_URL}/callback`, + publicKeyUrls: [ + `${serverAuthority}/jwks`, + `${OM_BASE_URL}/api/v1/system/config/jwks`, + ], + tokenValidationAlgorithm: 'RS256', + jwtPrincipalClaims: ['email', 'preferred_username', 'sub'], + enableSelfSignup: true, + oidcConfiguration: { + id: MOCK_OIDC_CLIENT_ID, + secret: MOCK_OIDC_CLIENT_SECRET, + type: 'custom-oidc', + discoveryUri: SERVER_DISCOVERY_URI, + callbackUrl: `${OM_BASE_URL}/callback`, + scope: 'openid email profile', + clientAuthenticationMethod: 'client_secret_post', + responseType: 'code', + useNonce: true, + preferredJwsAlgorithm: 'RS256', + disablePkce: true, + }, + }, + authorizerConfiguration: { + principalDomain: 'open-metadata.org', + }, + }; +}; + +const performProviderLogin = async ( + _popup: Page, + _credentials: ProviderCredentials +): Promise => { + // No-op: mock OIDC auto-approves and the popup self-closes. +}; + +const fillForm = async ( + page: Page, + overrides: FillFormOverrides = {} +): Promise => { + await page + .getByRole('textbox', { name: /^Client ID/ }) + .fill(MOCK_OIDC_CLIENT_ID); + await page + .getByRole('textbox', { name: /^Client Secret/ }) + .fill(overrides.secret ?? MOCK_OIDC_CLIENT_SECRET); + await page + .getByRole('textbox', { name: /^Discovery URI/ }) + .fill(SERVER_DISCOVERY_URI); + + const advancedToggle = page.getByTestId('sso-advanced-fields-toggle'); + if (await advancedToggle.isVisible().catch(() => false)) { + const advancedPanel = page.getByTestId('sso-advanced-fields-panel'); + if (!(await advancedPanel.isVisible().catch(() => false))) { + await advancedToggle.click(); + } + const scopeField = page.getByRole('textbox', { name: /^Scope/ }); + if (await scopeField.isVisible().catch(() => false)) { + await scopeField.fill('openid email profile'); + } + } +}; + +export const customOidcProviderHelper: ProviderHelper = { + expectedButtonText: 'Sign in with SSO', + loginUrlPattern: /localhost:9090/, + buildConfigPayload, + performProviderLogin, + fillForm, +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts index 909720185332..1cd8a4c6cf49 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts @@ -12,13 +12,20 @@ */ import { Page } from '@playwright/test'; import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; +import { customOidcProviderHelper } from './custom-oidc'; import { keycloakAzureSamlProviderHelper } from './keycloak-saml'; import { oktaProviderHelper } from './okta'; +import { openldapProviderHelper } from './openldap'; export type ProviderConfigPayload = | ProviderConfigOverride | Promise; +export interface FillFormOverrides { + secret?: string; + cert?: string; +} + export interface ProviderHelper { expectedButtonText: string; loginUrlPattern: RegExp; @@ -27,6 +34,7 @@ export interface ProviderHelper { page: Page, credentials: ProviderCredentials ) => Promise; + fillForm?: (page: Page, overrides?: FillFormOverrides) => Promise; } export const getProviderHelper = (providerType: string): ProviderHelper => { @@ -35,10 +43,14 @@ export const getProviderHelper = (providerType: string): ProviderHelper => { return oktaProviderHelper; case 'keycloak-azure-saml': return keycloakAzureSamlProviderHelper; + case 'custom-oidc': + return customOidcProviderHelper; + case 'ldap': + return openldapProviderHelper; default: throw new Error( `No SSO provider helper registered for "${providerType}". ` + - `Supported providers: okta, keycloak-azure-saml` + `Supported providers: okta, keycloak-azure-saml, custom-oidc, ldap` ); } }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts index a775332c61e3..e62ae5a915a4 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts @@ -13,7 +13,7 @@ import { expect, Page } from '@playwright/test'; import { OM_BASE_URL, SSO_ENV } from '../../constant/ssoAuth'; import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; -import type { ProviderHelper } from './index'; +import type { FillFormOverrides, ProviderHelper } from './index'; import { fetchIdpX509Certificate } from './saml-metadata'; const SUPPORTED_OM_BASE_URL = 'http://localhost:8585'; @@ -69,8 +69,13 @@ const buildConfigPayload = async ({ nameId: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', }, sp: { - entityId: `${OM_BASE_URL}/api/v1/saml/metadata`, - acs: `${OM_BASE_URL}/api/v1/saml/acs`, + // entityId must match Keycloak's SAML clientId or the AuthnRequest + // is rejected as 'client_not_found'. + entityId: OM_BASE_URL, + // /callback (not /api/v1/saml/acs): only AuthCallbackServlet routes + // by saml-test-login: RelayState; the production ACS would log the + // browser into OM and break the parent page's admin context. + acs: `${OM_BASE_URL}/callback`, callback: `${OM_BASE_URL}/callback`, }, security: { @@ -118,16 +123,59 @@ const performProviderLogin = async ( await loginButton.click(); }; -// OM renders a fixed "SAML SSO" label for every SAML provider — providerName -// is dropped for the SAML branch of getAuthConfig. const createKeycloakSamlProviderHelper = ( profile: KeycloakSamlProfile -): ProviderHelper => ({ - expectedButtonText: 'Sign in with SAML SSO', - loginUrlPattern: new RegExp(`/realms/${escapeRegExp(profile.realm)}/`), - buildConfigPayload: () => buildConfigPayload(profile), - performProviderLogin, -}); +): ProviderHelper => { + let cachedPayload: ProviderConfigOverride | undefined; + const resolvePayload = async (): Promise => { + if (!cachedPayload) { + cachedPayload = await buildConfigPayload(profile); + } + + return cachedPayload; + }; + + return { + expectedButtonText: 'Sign in with SAML SSO', + loginUrlPattern: new RegExp(`/realms/${escapeRegExp(profile.realm)}/`), + buildConfigPayload: resolvePayload, + performProviderLogin, + fillForm: async (page: Page, overrides: FillFormOverrides = {}) => { + const payload = await resolvePayload(); + const idp = ( + payload.authenticationConfiguration as { + samlConfiguration?: { + idp?: { + entityId?: string; + ssoLoginUrl?: string; + idpX509Certificate?: string; + nameId?: string; + }; + }; + } + ).samlConfiguration?.idp; + if (!idp) { + throw new Error('SAML payload missing idp configuration'); + } + + await page + .getByRole('textbox', { name: /^IdP Entity ID/ }) + .fill(idp.entityId ?? ''); + await page + .getByRole('textbox', { name: /^IdP SSO Login URL/ }) + .fill(idp.ssoLoginUrl ?? ''); + await page + .getByRole('textbox', { name: /^IdP X\.509 Certificate/ }) + .fill(overrides.cert ?? idp.idpX509Certificate ?? ''); + if (idp.nameId) { + const nameIdField = page.getByRole('textbox', { name: /Name ID/i }); + if (await nameIdField.isVisible().catch(() => false)) { + await nameIdField.fill(idp.nameId); + } + } + }, + }; +}; export const keycloakAzureSamlProviderHelper = createKeycloakSamlProviderHelper( { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/openldap.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/openldap.ts new file mode 100644 index 000000000000..84e62b0f8fb2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/openldap.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Page } from '@playwright/test'; +import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; +import { ProviderHelper } from './index'; + +export const OPENLDAP_FIXTURE = { + host: process.env.LDAP_HOST ?? 'openldap-test', + port: Number.parseInt(process.env.LDAP_PORT ?? '389', 10), + adminDn: 'cn=admin,dc=test,dc=local', + adminPassword: 'admin-pass', + userBaseDN: 'ou=people,dc=test,dc=local', + mailAttributeName: 'mail', + validUser: { + cn: 'alice', + dn: 'cn=alice,ou=people,dc=test,dc=local', + email: 'alice@company.com', + password: 'alice-pass', + }, + noMailUser: { + cn: 'bob', + dn: 'cn=bob,ou=people,dc=test,dc=local', + password: 'bob-pass', + }, +} as const; + +const buildConfigPayload = (): ProviderConfigOverride => { + return { + authenticationConfiguration: { + // SettingsSso.tsx's hasExistingConfig check requires provider !== 'basic'. + provider: 'ldap', + providerName: 'ldap', + authority: '', + clientId: '', + callbackUrl: '', + publicKeyUrls: [], + jwtPrincipalClaims: ['email'], + enableSelfSignup: false, + ldapConfiguration: { + host: OPENLDAP_FIXTURE.host, + port: OPENLDAP_FIXTURE.port, + dnAdminPrincipal: OPENLDAP_FIXTURE.adminDn, + dnAdminPassword: OPENLDAP_FIXTURE.adminPassword, + userBaseDN: OPENLDAP_FIXTURE.userBaseDN, + mailAttributeName: OPENLDAP_FIXTURE.mailAttributeName, + sslEnabled: false, + maxPoolSize: 3, + }, + }, + authorizerConfiguration: { + principalDomain: 'company.com', + }, + }; +}; + +const performProviderLogin = async ( + _page: Page, + _credentials: ProviderCredentials +): Promise => { + throw new Error( + 'openldapProviderHelper.performProviderLogin is not used; ' + + 'use runTestLoginViaLdapModal from playwright/utils/sso.ts instead.' + ); +}; + +const fillForm = async (page: Page): Promise => { + await page + .getByRole('textbox', { name: /^LDAP Host/ }) + .fill(OPENLDAP_FIXTURE.host); + await page + .getByRole('spinbutton', { name: /^LDAP Port/ }) + .fill(String(OPENLDAP_FIXTURE.port)); + await page + .getByRole('textbox', { name: /^Admin Principal DN/ }) + .fill(OPENLDAP_FIXTURE.adminDn); + await page + .getByRole('textbox', { name: /^Admin Password/ }) + .fill(OPENLDAP_FIXTURE.adminPassword); + await page + .getByRole('textbox', { name: /^User Base DN/ }) + .fill(OPENLDAP_FIXTURE.userBaseDN); + await page + .getByRole('textbox', { name: /^Mail Attribute Name/ }) + .fill(OPENLDAP_FIXTURE.mailAttributeName); +}; + +export const openldapProviderHelper: ProviderHelper = { + expectedButtonText: '', + loginUrlPattern: /openldap-test|localhost:1389/, + buildConfigPayload, + performProviderLogin, + fillForm, +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-test-fixtures.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-test-fixtures.ts new file mode 100644 index 000000000000..657af57d55fa --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-test-fixtures.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { APIRequestContext } from '@playwright/test'; +import { test as base } from '../e2e/fixtures/pages'; +import { getAuthContext, getToken, redirectToHomePage } from './common'; +import { clearCapturedTestLoginResult, installTestLoginCapture } from './sso'; +import { + fetchSecurityConfig, + restoreSecurityConfig, + SecurityConfigSnapshot, +} from './ssoAuth'; + +export type SsoFixtures = { + adminApiContext: APIRequestContext; + originalConfig: SecurityConfigSnapshot; +}; + +let cachedAdminContext: APIRequestContext | undefined; +let cachedOriginalConfig: SecurityConfigSnapshot | undefined; + +const swallowRestoreFailure = (label: string) => (error: unknown) => { + console.warn( + `[${label}] restoreSecurityConfig failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); +}; + +export const ssoTest = base.extend({ + page: async ({ page }, use) => { + await installTestLoginCapture(page); + await redirectToHomePage(page); + await clearCapturedTestLoginResult(page); + await use(page); + }, + + adminApiContext: async ({ page }, use) => { + if (!cachedAdminContext) { + cachedAdminContext = await getAuthContext(await getToken(page)); + } + + await use(cachedAdminContext); + }, + + originalConfig: async ({ adminApiContext }, use, testInfo) => { + if (!cachedOriginalConfig) { + cachedOriginalConfig = await fetchSecurityConfig(adminApiContext); + } else { + await restoreSecurityConfig(adminApiContext, cachedOriginalConfig).catch( + swallowRestoreFailure(`${testInfo.title} beforeEach`) + ); + } + + await use(cachedOriginalConfig); + + await restoreSecurityConfig(adminApiContext, cachedOriginalConfig).catch( + swallowRestoreFailure(`${testInfo.title} afterEach`) + ); + }, +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso.ts index 6cac0c5cf1fd..b942576178ae 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso.ts @@ -11,10 +11,81 @@ * limitations under the License. */ -import { expect, Page } from '@playwright/test'; +import { APIRequestContext, expect, Page } from '@playwright/test'; import { GlobalSettingOptions } from '../constant/settings'; import { settingClick } from './sidebar'; +export const TEST_LOGIN_LOCALSTORAGE_KEY = 'sso-test-login-result'; +export const TEST_LOGIN_CAPTURE_WINDOW_KEY = '__capturedSsoTestLoginResult'; +export const TEST_LOGIN_POPUP_TIMEOUT_MS = 60_000; +export const TEST_LOGIN_NETWORK_TIMEOUT_MS = 30_000; + +/** + * Install a localStorage.removeItem shim that snapshots the SSO Test Login + * result onto window before the SUT clears it. Without this, the parent + * page's storage listener consumes and removes the value before assertions + * have a chance to read it. + */ +export const installTestLoginCapture = async (page: Page): Promise => { + await page.addInitScript( + ({ key, captureKey }) => { + const ls = window.localStorage; + const originalRemove = ls.removeItem.bind(ls); + const originalSet = ls.setItem.bind(ls); + ls.setItem = (storageKey: string, value: string) => { + if (storageKey === key) { + (window as unknown as Record)[captureKey] = value; + } + + return originalSet(storageKey, value); + }; + ls.removeItem = (storageKey: string) => { + if (storageKey === key) { + const existing = ls.getItem(key); + if (existing) { + (window as unknown as Record)[captureKey] = + existing; + } + } + + return originalRemove(storageKey); + }; + }, + { + key: TEST_LOGIN_LOCALSTORAGE_KEY, + captureKey: TEST_LOGIN_CAPTURE_WINDOW_KEY, + } + ); +}; + +export const clearCapturedTestLoginResult = async ( + page: Page +): Promise => { + await page.evaluate((captureKey) => { + delete (window as unknown as Record)[captureKey]; + }, TEST_LOGIN_CAPTURE_WINDOW_KEY); +}; + +export type TestLoginClaimValue = string | number | boolean | string[]; + +export interface TestLoginResultPayload { + type: 'sso-test-login'; + success: boolean; + error?: string; + claims?: Record; + suggestedEmailClaim?: string; + derivedPrincipalDomain?: string; + suggestedAdminPrincipal?: string; + hasRefreshToken?: boolean; +} + +export interface PopupLoginDriver { + performProviderLogin( + popup: Page, + credentials: { username: string; password: string } + ): Promise; +} + export interface SSOConfig { authenticationConfiguration: { provider: string; @@ -363,6 +434,203 @@ export const selectClientType = async (page: Page, clientType: string) => { await page.getByText(clientType).click(); }; +export const expandSSOAdvancedFields = async (page: Page) => { + const toggle = page.getByTestId('sso-advanced-fields-toggle'); + if ((await toggle.count()) === 0) { + return; + } + const panel = page.getByTestId('sso-advanced-fields-panel'); + if (await panel.isVisible().catch(() => false)) { + return; + } + await toggle.click(); + await expect(panel).toBeVisible(); +}; + +export const clickTestLoginButton = async (page: Page) => { + const button = page.getByTestId('test-login-button'); + await expect(button).toBeEnabled(); + await button.click(); +}; + +export const runTestLoginViaPopup = async ( + page: Page, + driver: PopupLoginDriver, + credentials: { username: string; password: string } +): Promise => { + // OIDC pre-flights /security/validate; SAML doesn't. Best-effort capture. + const validatePromise = page + .waitForResponse( + (response) => + response.url().includes('/system/security/validate') && + response.request().method() === 'POST', + { timeout: 5_000 } + ) + .catch(() => undefined); + const popupPromise = page.context().waitForEvent('page', { + timeout: TEST_LOGIN_NETWORK_TIMEOUT_MS, + }); + + await clickTestLoginButton(page); + await validatePromise; + const popup = await popupPromise; + + // Attach close listener before any awaits — non-interactive IdPs (mock + // OIDC) auto-close immediately and we'd miss the event otherwise. + const closePromise = popup.waitForEvent('close', { + timeout: TEST_LOGIN_POPUP_TIMEOUT_MS, + }); + + await popup.waitForLoadState('domcontentloaded').catch(() => undefined); + + if (!popup.isClosed()) { + await driver.performProviderLogin(popup, credentials).catch((error) => { + if (!popup.isClosed()) { + throw error; + } + }); + } + + await closePromise; + + return readTestLoginResultFromStorage(page); +}; + +export const readTestLoginResultFromStorage = async ( + page: Page +): Promise => { + const raw = await page.evaluate( + ({ key, captureKey }) => { + const captured = (window as unknown as Record)[ + captureKey + ]; + if (typeof captured === 'string') { + return captured; + } + + return window.localStorage.getItem(key); + }, + { + key: TEST_LOGIN_LOCALSTORAGE_KEY, + captureKey: TEST_LOGIN_CAPTURE_WINDOW_KEY, + } + ); + if (!raw) { + throw new Error( + `Expected sso-test-login-result to be captured after popup closed, but it was empty. Make sure installTestLoginCapture(page) ran in beforeEach.` + ); + } + + return JSON.parse(raw) as TestLoginResultPayload; +}; + +export const runTestLoginViaLdapModal = async ( + page: Page, + credentials: { email: string; password: string } +): Promise => { + await clickTestLoginButton(page); + + const modal = page.getByTestId('ldap-test-login-modal'); + await expect(modal).toBeVisible(); + + // The Input core component places data-testid on a wrapper div; the actual + // element is a descendant. Drill into it explicitly so .fill works. + await modal + .getByTestId('ldap-test-login-email') + .locator('input') + .fill(credentials.email); + await modal + .getByTestId('ldap-test-login-password') + .locator('input') + .fill(credentials.password); + + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/system/config/auth/test-login') && + response.request().method() === 'POST', + { timeout: TEST_LOGIN_NETWORK_TIMEOUT_MS } + ); + + await modal.getByTestId('ldap-test-login-submit').click(); + + const response = await responsePromise; + + return (await response.json()) as TestLoginResultPayload; +}; + +export const pickEmailClaim = async (page: Page, claimName: string) => { + const modal = page.getByTestId('sso-claim-selector-modal'); + await expect(modal).toBeVisible(); + + await modal.getByTestId(`sso-claim-row-${claimName}`).click(); + + const confirmButton = modal.getByTestId('sso-claim-selector-confirm'); + await expect(confirmButton).toBeEnabled(); + await confirmButton.click(); + await expect(modal).toBeHidden(); +}; + +export const expectEmailClaimStatusSet = async ( + page: Page, + expectedClaim: string +) => { + const status = page.getByTestId('email-claim-status'); + await expect(status).toBeVisible(); + await expect(status.getByTestId('email-claim-status-set')).toContainText( + expectedClaim + ); +}; + +export const getSavedSecurityConfig = async ( + request: APIRequestContext +): Promise> => { + const response = await request.get('/api/v1/system/security/config'); + expect(response.ok()).toBeTruthy(); + + return response.json(); +}; + +// SSOConfigurationForm.tsx keeps Save enabled and gates at click-time +// (`isLockoutRiskEdit && !testLoginPassed` → toast, no PUT). Both helpers +// assert that contract; the alias keeps lockout-risk callsites readable. +export const expectSaveDisabledForLockoutRisk = async (page: Page) => { + await expectSaveBlockedAtClick(page); +}; + +export const expectSaveBlockedAtClick = async (page: Page) => { + const saveButton = page.getByTestId('save-sso-configuration'); + await expect(saveButton).toBeEnabled(); + + let saveRequestSent = false; + const onRequest = (request: import('@playwright/test').Request) => { + if ( + request.url().includes('/system/security/config') && + ['PUT', 'PATCH'].includes(request.method()) + ) { + saveRequestSent = true; + } + }; + page.on('request', onRequest); + + try { + await saveButton.click(); + const errorIndicator = page + .locator('.ant-message-error') + .or(page.locator('.ant-notification-notice-error')) + .or(page.getByRole('alert')) + .first(); + await expect(errorIndicator).toBeVisible({ timeout: 10_000 }); + expect(saveRequestSent).toBe(false); + } finally { + page.off('request', onRequest); + } +}; + +export const expectSaveEnabled = async (page: Page) => { + const saveButton = page.getByTestId('save-sso-configuration'); + await expect(saveButton).toBeEnabled(); +}; + /** * Verify field visibility for a specific provider */ @@ -389,6 +657,12 @@ export const verifyProviderFields = async ( 'sso-configuration-form-array-field-template-allowedDomains', }; + // CopyableUrlField widgets render as
instead + // of as labelled inputs — see CallbackUrlWidget in SSOConfigurationForm.tsx. + const COPYABLE_FIELD_TESTIDS: Record = { + 'Callback URL': 'root/authenticationConfiguration/callbackUrl', + }; + // Verify visible fields for (const field of expectedVisibleFields) { const labelLocator = page.getByLabel(field); @@ -397,7 +671,8 @@ export const verifyProviderFields = async ( if (labelCount > 0) { await expect(labelLocator.first()).toBeVisible(); } else { - const testId = ARRAY_FIELD_TESTIDS[field]; + const testId = + ARRAY_FIELD_TESTIDS[field] ?? COPYABLE_FIELD_TESTIDS[field]; if (testId) { await expect(page.getByTestId(testId)).toBeVisible(); @@ -415,7 +690,8 @@ export const verifyProviderFields = async ( if (labelCount > 0) { await expect(labelLocator).not.toBeVisible(); } else { - const testId = ARRAY_FIELD_TESTIDS[field]; + const testId = + ARRAY_FIELD_TESTIDS[field] ?? COPYABLE_FIELD_TESTIDS[field]; if (testId) { await expect(page.getByTestId(testId)).not.toBeVisible(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts index 05fac578686a..c8354d92f0b1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts @@ -34,6 +34,8 @@ const NON_ROUND_TRIPPABLE_AUTH_FIELDS = [ 'samlConfiguration', ] as const; +export type AuthConfigMatcher = (auth: Record) => void; + export const fetchSecurityConfig = async ( apiContext: APIRequestContext ): Promise => { @@ -99,7 +101,15 @@ export const applyProviderConfig = async ( data: merged, }); - expect(response.status()).toBe(200); + if (response.status() !== 200) { + const body = await response.text().catch(() => ''); + throw new Error( + `applyProviderConfig PUT returned ${response.status()}: ${body.slice( + 0, + 1500 + )}` + ); + } }; export const restoreSecurityConfig = async ( @@ -110,7 +120,27 @@ export const restoreSecurityConfig = async ( data: snapshot, }); - expect(response.status()).toBe(200); + if (response.status() !== 200) { + const body = await response.text().catch(() => ''); + throw new Error( + `restoreSecurityConfig PUT returned ${response.status()}: ${body.slice( + 0, + 1500 + )}` + ); + } +}; + +export const expectPersistedSecurityConfig = async ( + apiContext: APIRequestContext, + matcher: AuthConfigMatcher +): Promise => { + const response = await apiContext.get(SECURITY_CONFIG_ENDPOINT); + expect(response.ok()).toBeTruthy(); + const persisted = (await response.json()) as { + authenticationConfiguration: Record; + }; + matcher(persisted.authenticationConfiguration); }; export const verifyLoggedInUserMatches = async ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/FailedTestCaseSampleData/FailedTestCaseSampleData.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/FailedTestCaseSampleData/FailedTestCaseSampleData.test.tsx index 7fa84ccee747..dcd93c72c1af 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/FailedTestCaseSampleData/FailedTestCaseSampleData.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/FailedTestCaseSampleData/FailedTestCaseSampleData.test.tsx @@ -18,6 +18,60 @@ import { getTestCaseFailedSampleData } from '../../../../rest/testAPI'; import observabilityRouterClassBase from '../../../../utils/ObservabilityRouterClassBase'; import FailedTestCaseSampleData from './FailedTestCaseSampleData.component'; +jest.mock('@openmetadata/ui-core-components', () => { + const Table = Object.assign( + jest + .fn() + .mockImplementation(({ children }: { children: React.ReactNode }) => ( + {children}
+ )), + { + Header: jest + .fn() + .mockImplementation( + ({ + children, + columns, + }: { + children: (col: unknown) => React.ReactNode; + columns?: unknown[]; + }) => {columns?.map(children)} + ), + Head: jest + .fn() + .mockImplementation(({ label }: { label: string }) => {label}), + Body: jest + .fn() + .mockImplementation( + ({ + children, + items, + }: { + children: (item: unknown) => React.ReactNode; + items?: unknown[]; + }) => {items?.map(children)} + ), + Row: jest + .fn() + .mockImplementation(({ children }: { children: React.ReactNode }) => ( + {children} + )), + Cell: jest + .fn() + .mockImplementation(({ children }: { children: React.ReactNode }) => ( + {children} + )), + } + ); + + return { + Table, + Typography: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }; +}); + jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), Link: jest.fn().mockImplementation(({ children, to, ...rest }) => ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/SSOConfigurationForm.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/SSOConfigurationForm.test.tsx index 128431889788..127e7aa38908 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/SSOConfigurationForm.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/SSOConfigurationForm.test.tsx @@ -37,6 +37,7 @@ import { validateSecurityConfiguration, } from '../../../rest/securityConfigAPI'; import { getAuthConfig } from '../../../utils/AuthProvider.util'; +import { requiresFreshTestLogin } from '../../../utils/SSOUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import { useAuthProvider } from '../../Auth/AuthProviders/AuthProvider'; import SSOConfigurationFormRJSF from './SSOConfigurationForm'; @@ -48,6 +49,112 @@ jest.mock('../../../utils/ToastUtils', () => ({ showSuccessToast: jest.fn(), })); +jest.mock('@openmetadata/ui-core-components', () => ({ + Accordion: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AccordionItem: ({ + children, + id, + }: { + children: React.ReactNode; + id?: string; + }) => ( +
+ {children} +
+ ), + AccordionHeader: ({ + children, + ...rest + }: React.PropsWithChildren>) => ( + + ), + AccordionPanel: ({ + children, + ...rest + }: React.PropsWithChildren>) => ( +
+ {children} +
+ ), + Button: ({ + children, + onPress, + isDisabled, + isLoading, + iconLeading: _iconLeading, + color: _color, + size: _size, + ...rest + }: React.PropsWithChildren<{ + onPress?: () => void; + isDisabled?: boolean; + isLoading?: boolean; + iconLeading?: unknown; + color?: string; + size?: string; + }> & + Record) => ( + + ), + FileTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + Input: ({ + label, + value, + onChange, + ...rest + }: { + label?: string; + value?: string; + onChange?: (value: string) => void; + } & Record) => ( + + ), + ModalOverlay: ({ + children, + isOpen, + }: { + children: React.ReactNode; + isOpen?: boolean; + }) => (isOpen ?
{children}
: null), + Modal: ({ children }: { children: React.ReactNode }) =>
{children}
, + Dialog: Object.assign( + ({ + children, + title, + ...rest + }: React.PropsWithChildren> & { + title?: string; + }) => ( +
+ {title &&

{title}

} + {children} +
+ ), + { + Header: ({ children }: { children: React.ReactNode }) => <>{children}, + Content: ({ children }: { children: React.ReactNode }) => <>{children}, + Footer: ({ children }: { children: React.ReactNode }) => <>{children}, + } + ), +})); + // Mock SSOUtils - use actual implementations where needed jest.mock('../../../utils/SSOUtils', () => { const actual = jest.requireActual('../../../utils/SSOUtils'); @@ -59,6 +166,10 @@ jest.mock('../../../utils/SSOUtils', () => { cleanupProviderSpecificFields: jest.fn( actual.cleanupProviderSpecificFields ), + // Default-bypass the lockout-risk save gate so existing tests that drive + // Save directly can still assert on validate/apply. Tests covering the + // gate itself flip this to `true` explicitly. + requiresFreshTestLogin: jest.fn(() => false), }; }); @@ -205,7 +316,6 @@ const mockGetSecurityConfiguration = getSecurityConfiguration as jest.MockedFunction< typeof getSecurityConfiguration >; - const mockFetchAuthenticationConfig = fetchAuthenticationConfig as jest.MockedFunction< typeof fetchAuthenticationConfig @@ -216,6 +326,8 @@ const mockFetchAuthorizerConfig = fetchAuthorizerConfig as jest.MockedFunction< const mockGetAuthConfig = getAuthConfig as jest.MockedFunction< typeof getAuthConfig >; +const mockRequiresFreshTestLogin = + requiresFreshTestLogin as jest.MockedFunction; const mockUseApplicationStore = useApplicationStore as jest.MockedFunction< typeof useApplicationStore @@ -269,10 +381,12 @@ describe('SSOConfigurationForm', () => { writable: true, }); - // Mock sessionStorage and localStorage Object.defineProperty(window, 'sessionStorage', { value: { clear: jest.fn(), + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn(), }, writable: true, }); @@ -280,6 +394,9 @@ describe('SSOConfigurationForm', () => { Object.defineProperty(window, 'localStorage', { value: { clear: jest.fn(), + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn(), }, writable: true, }); @@ -1955,6 +2072,154 @@ describe('SSOConfigurationForm', () => { }); }); + describe('Provider-specific Display Blocks', () => { + beforeEach(() => { + mockGetSecurityConfiguration.mockRejectedValue(new Error('No config')); + }); + + it('should render OIDC email-claim status block for Google provider', async () => { + // The OIDC-specific UI block in the form (rendered when isOidcCallbackProvider) + // surfaces the email-claim status surface; this used to be the callback URL display. + renderComponent({ selectedProvider: AuthProvider.Google }); + + await waitFor(() => { + expect(screen.getByTestId('email-claim-status')).toBeInTheDocument(); + }); + }); + + it('should render SAML ACS info banner with ACS URL and SP Entity ID', async () => { + renderComponent({ selectedProvider: AuthProvider.Saml }); + + await waitFor(() => { + expect(screen.getByTestId('saml-acs-info-banner')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('saml-acs-url')).toBeInTheDocument(); + expect(screen.getByTestId('saml-sp-entity-id')).toBeInTheDocument(); + }); + + it('should not render OIDC callback display for SAML provider', async () => { + renderComponent({ selectedProvider: AuthProvider.Saml }); + + await waitFor(() => { + expect(screen.getByTestId('saml-acs-info-banner')).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('oidc-callback-url-display') + ).not.toBeInTheDocument(); + }); + + it('should not render SAML banner for OIDC providers', async () => { + renderComponent({ selectedProvider: AuthProvider.Okta }); + + await waitFor(() => { + expect(screen.getByTestId('email-claim-status')).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('saml-acs-info-banner') + ).not.toBeInTheDocument(); + }); + + it('should not render OIDC callback display for LDAP provider', async () => { + renderComponent({ selectedProvider: AuthProvider.LDAP }); + + await waitFor(() => { + expect( + screen.getByTestId('sso-configuration-form-card') + ).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('oidc-callback-url-display') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('saml-acs-info-banner') + ).not.toBeInTheDocument(); + }); + }); + + describe('Advanced Fields Accordion', () => { + beforeEach(() => { + mockGetSecurityConfiguration.mockRejectedValue(new Error('No config')); + }); + + it('should render exactly one top-level Advanced Fields accordion when a provider is selected', async () => { + renderComponent({ selectedProvider: AuthProvider.Google }); + + await waitFor(() => { + expect( + screen.getByTestId('sso-advanced-fields-toggle') + ).toBeInTheDocument(); + }); + + expect(screen.getAllByTestId('sso-advanced-fields-toggle')).toHaveLength( + 1 + ); + expect( + screen.getByTestId('sso-advanced-fields-panel') + ).toBeInTheDocument(); + }); + + it('should render the Advanced Fields accordion for SAML provider', async () => { + renderComponent({ selectedProvider: AuthProvider.Saml }); + + await waitFor(() => { + expect( + screen.getByTestId('sso-advanced-fields-toggle') + ).toBeInTheDocument(); + }); + }); + + it('should render the Advanced Fields accordion for LDAP provider', async () => { + renderComponent({ selectedProvider: AuthProvider.LDAP }); + + await waitFor(() => { + expect( + screen.getByTestId('sso-advanced-fields-toggle') + ).toBeInTheDocument(); + }); + }); + }); + + describe('Test Login Flow', () => { + const mockSetCurrentUser = jest.fn(); + + beforeEach(() => { + mockGetSecurityConfiguration.mockRejectedValue(new Error('No config')); + mockUseApplicationStore.mockReturnValue({ + setIsAuthenticated: mockSetIsAuthenticated, + setAuthConfig: mockSetAuthConfig, + setAuthorizerConfig: mockSetAuthorizerConfig, + setCurrentUser: mockSetCurrentUser, + currentUser: { email: 'admin@example.com' }, + } as unknown as ApplicationStore); + }); + + it('should render Test Login button when a provider is selected', async () => { + renderComponent({ selectedProvider: AuthProvider.Google }); + + await waitFor(() => { + expect(screen.getByTestId('test-login-button')).toBeInTheDocument(); + }); + }); + + it('should show error toast when required OIDC fields are missing', async () => { + renderComponent({ selectedProvider: AuthProvider.Google }); + + await waitFor(() => { + expect(screen.getByTestId('test-login-button')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('test-login-button')); + + await waitFor(() => { + expect(mockShowErrorToast).toHaveBeenCalled(); + }); + }); + }); + describe('Extract Field Name Utility', () => { it('should handle field name extraction from field IDs', async () => { mockGetSecurityConfiguration.mockResolvedValue( @@ -1998,4 +2263,78 @@ describe('SSOConfigurationForm', () => { expect(docPanel).toBeInTheDocument(); }); }); + + describe('Lockout-risk Save Gate', () => { + beforeEach(() => { + mockGetSecurityConfiguration.mockRejectedValue(new Error('No config')); + }); + + it('should block save with lockout toast when fresh Test Login is required', async () => { + mockRequiresFreshTestLogin.mockReturnValue(true); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId('provider-selector')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Select Google')); + + await waitFor(() => { + expect( + screen.getByTestId('save-sso-configuration') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('save-sso-configuration')); + + await waitFor(() => { + expect(mockShowErrorToast).toHaveBeenCalledWith( + 'message.test-login-required-before-save' + ); + }); + + expect(mockValidateSecurityConfiguration).not.toHaveBeenCalled(); + expect(mockApplySecurityConfiguration).not.toHaveBeenCalled(); + }); + + it('should allow save when fresh Test Login is not required', async () => { + mockRequiresFreshTestLogin.mockReturnValue(false); + mockValidateSecurityConfiguration.mockResolvedValue( + createAxiosResponse({ + status: VALIDATION_STATUS.SUCCESS, + message: 'Validation successful', + results: [], + }) + ); + mockApplySecurityConfiguration.mockResolvedValue( + createAxiosResponse({} as SecurityConfiguration) + ); + mockOnLogoutHandler.mockResolvedValue(undefined); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId('provider-selector')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Select Google')); + + await waitFor(() => { + expect( + screen.getByTestId('save-sso-configuration') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('save-sso-configuration')); + + await waitFor(() => { + expect(mockValidateSecurityConfiguration).toHaveBeenCalled(); + }); + + expect(mockShowErrorToast).not.toHaveBeenCalledWith( + 'message.test-login-required-before-save' + ); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/SSOConfigurationForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/SSOConfigurationForm.tsx index eac1ec14a18b..1f1fdf1e8ad9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/SSOConfigurationForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/SSOConfigurationForm.tsx @@ -12,6 +12,14 @@ */ import { removeSession } from '@analytics/session-utils'; +import { + Accordion, + AccordionHeader, + AccordionItem, + AccordionPanel, + Button, + FileTrigger, +} from '@openmetadata/ui-core-components'; import Form, { IChangeEvent } from '@rjsf/core'; import { CustomValidator, @@ -19,12 +27,19 @@ import { FormValidation, RegistryFieldsType, RJSFSchema, + WidgetProps, } from '@rjsf/utils'; import validator from '@rjsf/validator-ajv8'; -import { Check, UploadCloud02, X } from '@untitledui/icons'; -import { Button, Card, Typography, Upload } from 'antd'; +import { Check, Copy01, UploadCloud02, X } from '@untitledui/icons'; import { AxiosError } from 'axios'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + DragEvent as ReactDragEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; @@ -32,15 +47,17 @@ import { compare } from 'fast-json-patch'; import { AuthenticationConfiguration, AuthorizerConfiguration, + getLockoutRiskFields, + getProviderFieldLayout, getSSOUISchema, - GOOGLE_SSO_DEFAULTS, + hasAnyAdvancedFields, MAX_XML_SIZE, NON_OIDC_SPECIFIC_FIELDS, OIDC_SPECIFIC_FIELDS, VALIDATION_STATUS, } from '../../../constants/SSO.constant'; import { User } from '../../../generated/entity/teams/user'; -import { AuthProvider, ClientType } from '../../../generated/settings/settings'; +import { AuthProvider } from '../../../generated/settings/settings'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import authenticationConfigSchema from '../../../jsons/configuration/authenticationConfiguration.json'; import authorizerConfigSchema from '../../../jsons/configuration/authorizerConfiguration.json'; @@ -56,24 +73,28 @@ import { createScrollToErrorHandler, transformErrors, } from '../../../utils/formUtils'; +import { getCallbackUrl, getServerUrl } from '../../../utils/SSOURLUtils'; import { applySamlConfiguration, - cleanupProviderSpecificFields, clearFieldError, createDOMClickHandler, createDOMFocusHandler, createFormKeyDownHandler, createFreshFormData, + fetchOidcDiscoveryDocument, findChangedFields, getProviderDisplayName, getProviderIcon, - handleClientTypeChange, hasFieldValidationErrors, isValidNonBasicProvider, + liftPublicOidcToConfidentialShape, parseSamlMetadataXml, parseValidationErrors, + prepareOidcSubmitPayload, removeRequiredFields, removeSchemaFields, + requiresFreshTestLogin, + resolveDiscoveryUri, updateLoadingState, } from '../../../utils/SSOUtils'; import { @@ -91,6 +112,14 @@ import { UnsavedChangesModal } from '../../Modals/UnsavedChangesModal/UnsavedCha import ProviderSelector from '../ProviderSelector/ProviderSelector'; import SSODocPanel from '../SSODocPanel/SSODocPanel'; import { SSOGroupedFieldTemplate } from '../SSOGroupedFieldTemplate/SSOGroupedFieldTemplate'; +import ClaimSelector from '../TestLogin/ClaimSelector.component'; +import EmailClaimRecommendation from '../TestLogin/EmailClaimRecommendation.component'; +import EmailClaimStatus from '../TestLogin/EmailClaimStatus.component'; +import { TestLoginResult } from '../TestLogin/TestLogin.interface'; +import { claimValueHasEmail } from '../TestLogin/TestLogin.utils'; +import TestLoginButton, { + TestLoginButtonHandle, +} from '../TestLogin/TestLoginButton.component'; import './sso-configuration-form.less'; import { FormData, @@ -100,6 +129,11 @@ import { import SsoConfigurationFormArrayFieldTemplate from './SsoConfigurationFormArrayFieldTemplate'; import SsoRolesSelectField from './SsoRolesSelectField'; +const preventDefaultDrag = (event: ReactDragEvent) => { + event.preventDefault(); + event.stopPropagation(); +}; + interface MetadataUploadStatusCardProps { status: 'success' | 'error'; fileName: string; @@ -131,29 +165,87 @@ const MetadataUploadStatusCard = ({ )}
- + {t( isSuccess ? 'message.metadata-xml-file-parsed-success' : 'message.metadata-xml-file-parsed-error', { fileName } )} - + ); }; +const OIDC_PROVIDERS_WITH_CALLBACK_DISPLAY: ReadonlySet = new Set( + [ + AuthProvider.Google, + AuthProvider.Auth0, + AuthProvider.Azure, + AuthProvider.Okta, + AuthProvider.AwsCognito, + AuthProvider.CustomOidc, + ] +); + +interface CopyableUrlFieldProps { + label: string; + value: string; + testId: string; +} + +const CopyableUrlField = ({ label, value, testId }: CopyableUrlFieldProps) => { + const { t } = useTranslation(); + + const handleCopy = async () => { + try { + await globalThis.navigator.clipboard.writeText(value); + showSuccessToast(t('message.copied-to-clipboard')); + } catch { + showErrorToast(t('label.copy-to-clipboard')); + } + }; + + return ( +
+ {label && {label}} +
+ + {value} + + +
+
+ ); +}; + +const CallbackUrlWidget = ({ id, value }: WidgetProps) => ( + +); + const widgets = { SelectWidget: SelectWidget, LdapRoleMappingWidget: LdapRoleMappingWidget, + CallbackUrlWidget: CallbackUrlWidget, }; const SSOConfigurationFormRJSF = ({ @@ -187,7 +279,15 @@ const SSOConfigurationFormRJSF = ({ >(null); const [metadataUploadFileName, setMetadataUploadFileName] = useState(''); + const [advancedFieldsContainer, setAdvancedFieldsContainer] = + useState(null); + const [testLoginResult, setTestLoginResult] = + useState(null); + const [claimSelectorOpen, setClaimSelectorOpen] = useState(false); + const [testLoginPassed, setTestLoginPassed] = useState(false); const fieldErrorsRef = useRef({}); + const testLoginTriggerRef = useRef(null); + const lastFetchedDiscoveryRef = useRef(null); // Helper function to setup configuration state - extracted to avoid redundancy const setupConfigurationState = useCallback( @@ -210,8 +310,11 @@ const SSOConfigurationFormRJSF = ({ applySamlConfiguration(configData); } + liftPublicOidcToConfidentialShape(configData.authenticationConfiguration); + setSavedData(configData); setInternalData(configData); + setTestLoginPassed(false); setShowForm(true); if (forceEditMode) { @@ -261,6 +364,59 @@ const SSOConfigurationFormRJSF = ({ } }, [securityConfig, selectedProvider, setupConfigurationState]); + useEffect(() => { + const authConfig = internalData?.authenticationConfiguration; + if (!authConfig) { + return; + } + const provider = authConfig.provider; + if (provider === AuthProvider.Saml || provider === AuthProvider.LDAP) { + return; + } + const discoveryUri = resolveDiscoveryUri(authConfig); + if (!discoveryUri || lastFetchedDiscoveryRef.current === discoveryUri) { + return; + } + let cancelled = false; + const handle = setTimeout(async () => { + const doc = await fetchOidcDiscoveryDocument(discoveryUri); + if (cancelled || !doc) { + return; + } + lastFetchedDiscoveryRef.current = discoveryUri; + const jwksUri = typeof doc.jwks_uri === 'string' ? doc.jwks_uri : null; + if (!jwksUri) { + return; + } + setInternalData((prev) => { + if (!prev?.authenticationConfiguration) { + return prev; + } + const existing = prev.authenticationConfiguration.publicKeyUrls; + if (existing && existing.length > 0) { + return prev; + } + + return { + ...prev, + authenticationConfiguration: { + ...prev.authenticationConfiguration, + publicKeyUrls: [jwksUri], + }, + }; + }); + }, 500); + + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [ + internalData?.authenticationConfiguration?.oidcConfiguration?.discoveryUri, + internalData?.authenticationConfiguration?.authority, + internalData?.authenticationConfiguration?.provider, + ]); + // Handle selectedProvider prop - initialize fresh form when provider is selected useEffect(() => { if (!selectedProvider) { @@ -293,6 +449,7 @@ const SSOConfigurationFormRJSF = ({ // Create fresh form data using utility function const freshFormData = createFreshFormData(selectedProvider as AuthProvider); setInternalData(freshFormData); + setTestLoginPassed(false); }, [selectedProvider]); const scrollToFirstError = useCallback( @@ -456,6 +613,21 @@ const SSOConfigurationFormRJSF = ({ removeRequiredFields(authSchema, NON_OIDC_SPECIFIC_FIELDS); } + if ( + ![AuthProvider.Saml, AuthProvider.LDAP].includes(provider as AuthProvider) + ) { + removeSchemaFields(authSchema, ['responseType']); + removeRequiredFields(authSchema, ['responseType']); + const oidcConfigSchema = ( + authSchema.properties as Record | undefined + )?.oidcConfiguration as Record | undefined; + if (oidcConfigSchema) { + const oidcFieldsToRemove = ['responseType', 'tenant', 'serverUrl']; + removeSchemaFields(oidcConfigSchema, oidcFieldsToRemove); + removeRequiredFields(oidcConfigSchema, oidcFieldsToRemove); + } + } + return createSchemaWithAuth(authSchema) as RJSFSchema; }; @@ -549,75 +721,96 @@ const SSOConfigurationFormRJSF = ({ } const baseSchema = getSSOUISchema(currentProvider, hasExistingConfig); - const currentClientType = - internalData?.authenticationConfiguration?.clientType; - const authConfig = baseSchema.authenticationConfiguration as UISchemaObject; + const authorizerConfig = + (baseSchema.authorizerConfiguration as UISchemaObject) ?? {}; - // Always hide provider field since we have separate provider selection screen authConfig.provider = { 'ui:widget': 'hidden', 'ui:hideError': true, }; - // Make clientType non-editable for existing SSO configurations - // Hide clientType for SAML/LDAP since they're always public - if ( - (hasExistingConfig && savedData) || - currentProvider === AuthProvider.Saml || - currentProvider === AuthProvider.LDAP - ) { - authConfig.clientType = { - 'ui:widget': 'hidden', - 'ui:hideError': true, - }; - } + authConfig.clientType = { + 'ui:widget': 'hidden', + 'ui:hideError': true, + }; - // Show oidcConfiguration for confidential clients, hide for public clients - if (currentClientType === ClientType.Public) { - authConfig.oidcConfiguration = { - 'ui:widget': 'hidden', - 'ui:hideError': true, - }; - // Ensure callback URL is visible for public clients - authConfig.callbackUrl = { - 'ui:title': 'Callback URL', - 'ui:placeholder': 'e.g. https://myapp.com/auth/callback', - } as UISchemaObject; - // Ensure publicKeyUrls is visible for public clients (not auto-populated) - authConfig.publicKeyUrls = { - 'ui:title': 'Public Key URLs', - 'ui:placeholder': - 'Enter value (e.g. https://www.googleapis.com/oauth2/v3/certs) and press ENTER', - } as UISchemaObject; - // Ensure authority is visible for public clients - authConfig.authority = { - 'ui:title': 'Authority', - 'ui:placeholder': 'e.g. https://accounts.google.com', + const isOidcProvider = + currentProvider !== AuthProvider.Saml && + currentProvider !== AuthProvider.LDAP; + const hidden = { 'ui:widget': 'hidden', 'ui:hideError': true } as const; + + if (isOidcProvider) { + const oidcConfigSchema = { + ...((authConfig.oidcConfiguration as UISchemaObject) ?? {}), } as UISchemaObject; - } else if (currentClientType === ClientType.Confidential) { - // The schema will be shown with OIDC prefixed labels from the constants - authConfig['oidcConfiguration'] ??= { - 'ui:title': 'OIDC Configuration', - }; - // Hide root-level clientId and callbackUrl for confidential clients since we have OIDC equivalents - authConfig.clientId = { + (oidcConfigSchema as Record)['ui:title'] = ''; + authConfig.oidcConfiguration = oidcConfigSchema; + const emailClaim = ( + internalData?.authenticationConfiguration as + | { emailClaim?: string } + | undefined + )?.emailClaim; + const principalClaimsMapping = + internalData?.authenticationConfiguration?.jwtPrincipalClaimsMapping; + const hasPrincipalClaimsMapping = + Array.isArray(principalClaimsMapping) && + principalClaimsMapping.length > 0; + const canShowClientAuthMethod = + currentProvider === AuthProvider.Okta || + currentProvider === AuthProvider.CustomOidc; + + authConfig.providerName = { ...hidden }; + authConfig.clientId = { ...hidden }; + authConfig.authority = { ...hidden }; + authConfig.discoveryUri = { ...hidden }; + authConfig.publicKeyUrls = { ...hidden }; + authConfig.forceSecureSessionCookie = { ...hidden }; + authConfig.tokenValidationAlgorithm = { ...hidden }; + authConfig.enableSelfSignup = { ...hidden }; + authConfig.emailClaim = { ...hidden }; + + if (!canShowClientAuthMethod) { + oidcConfigSchema.clientAuthenticationMethod = { ...hidden }; + } + + if (!hasExistingConfig || emailClaim) { + authConfig.jwtPrincipalClaims = { ...hidden }; + } + + if (!hasExistingConfig || !hasPrincipalClaimsMapping) { + authConfig.jwtPrincipalClaimsMapping = { ...hidden }; + } + } + + if (currentProvider === AuthProvider.LDAP) { + const sslEnabled = Boolean( + ( + internalData?.authenticationConfiguration as + | { ldapConfiguration?: { sslEnabled?: boolean } } + | undefined + )?.ldapConfiguration?.sslEnabled + ); + + if (!sslEnabled) { + const ldapConfigSchema = { + ...((authConfig.ldapConfiguration as UISchemaObject) ?? {}), + } as UISchemaObject; + ldapConfigSchema.truststoreConfigType = { ...hidden }; + ldapConfigSchema.trustStoreConfig = { ...hidden }; + authConfig.ldapConfiguration = ldapConfigSchema; + } + } + + if (!hasExistingConfig) { + authorizerConfig.adminPrincipals = { 'ui:widget': 'hidden', 'ui:hideError': true, }; - authConfig.callbackUrl = { + authorizerConfig.principalDomain = { 'ui:widget': 'hidden', 'ui:hideError': true, }; - - // For Google, show authority even in Confidential mode - const isGoogle = currentProvider === AuthProvider.Google; - if (isGoogle) { - authConfig.authority = { - 'ui:title': 'Authority', - 'ui:placeholder': GOOGLE_SSO_DEFAULTS.authority, - } as UISchemaObject; - } } const finalSchema = { @@ -629,18 +822,23 @@ const SSOConfigurationFormRJSF = ({ }, authorizerConfiguration: { ...baseSchema.authorizerConfiguration, + ...authorizerConfig, 'ui:classNames': 'hide-section-title', }, }; return finalSchema; - }, [ - currentProvider, - internalData?.authenticationConfiguration?.clientType, - hasExistingConfig, - savedData, - hideBorder, - ]); + }, [currentProvider, hasExistingConfig, hideBorder, internalData]); + + const fieldLayout = useMemo( + () => getProviderFieldLayout(currentProvider), + [currentProvider] + ); + + const showAdvancedFieldsAccordion = useMemo( + () => hasAnyAdvancedFields(fieldLayout), + [fieldLayout] + ); // Handle form data changes const clearErrorsForChangedFields = (newFormData: FormData) => { @@ -674,15 +872,22 @@ const SSOConfigurationFormRJSF = ({ return; } const newFormData = { ...e.formData }; - const authConfig = newFormData.authenticationConfiguration; clearErrorsForChangedFields(newFormData); - handleClientTypeChange( - authConfig, - internalData?.authenticationConfiguration?.clientType, - authConfig?.clientType - ); + // Invalidate Test Login freshness only when the user actually edits a + // lockout-risk field. Spurious RJSF re-renders that don't touch + // lockout-risk paths leave the flag alone. + if (testLoginPassed && internalData) { + const provider = newFormData.authenticationConfiguration?.provider as + | string + | undefined; + const lockoutRiskFields = getLockoutRiskFields(provider); + const changed = findChangedFields(internalData, newFormData); + if (changed.some((f) => lockoutRiskFields.has(f))) { + setTestLoginPassed(false); + } + } setInternalData(newFormData); handleProviderChange(newFormData); @@ -786,7 +991,15 @@ const SSOConfigurationFormRJSF = ({ return false; } - const allPatches = compare(savedData, cleanedFormData); + // Reshape both sides to the network shape so the diff carries the right + // ops on a Confidential ↔ Public flip (drops oidcConfiguration, sets + // root clientId/authority). + const cleanedSavedData = prepareOidcSubmitPayload(savedData); + if (!cleanedSavedData) { + return false; + } + + const allPatches = compare(cleanedSavedData, cleanedFormData); if (allPatches.length > 0) { await patchSecurityConfiguration(allPatches); } @@ -838,17 +1051,146 @@ const SSOConfigurationFormRJSF = ({ [hasExistingConfig, isModalSave, t, setIsAuthenticated, setCurrentUser] ); + const withAuthorizerSuggestionApplied = useCallback( + ( + data: FormData | undefined, + admin: string | null, + domain: string | null + ): FormData | undefined => { + if (!data) { + return data; + } + const trimmedAdmin = admin?.trim() ?? ''; + const trimmedDomain = domain?.trim() ?? ''; + if (!trimmedAdmin && !trimmedDomain) { + return data; + } + + const existingAdmins = + data.authorizerConfiguration?.adminPrincipals ?? []; + const adminPrincipals = + trimmedAdmin && !existingAdmins.includes(trimmedAdmin) + ? [...existingAdmins, trimmedAdmin] + : existingAdmins; + const principalDomain = + data.authorizerConfiguration?.principalDomain || trimmedDomain; + + return { + ...data, + authorizerConfiguration: { + ...data.authorizerConfiguration, + adminPrincipals, + principalDomain, + }, + }; + }, + [] + ); + + const handleTestLoginSuccess = useCallback( + (result: TestLoginResult) => { + setTestLoginResult(result); + + const existingEmailClaim = ( + internalData?.authenticationConfiguration as + | { emailClaim?: string } + | undefined + )?.emailClaim; + const claimStillResolves = + Boolean(existingEmailClaim) && + claimValueHasEmail(result.claims[existingEmailClaim as string]); + + // Auto-close path: existing config's emailClaim still resolves to a + // valid email in the returned token, so re-prompting via ClaimSelector + // would be redundant. Capture freshness snapshot and let the user save. + if (claimStillResolves) { + const next = withAuthorizerSuggestionApplied( + internalData, + result.suggestedAdminPrincipal, + result.derivedPrincipalDomain + ); + if (next && next !== internalData) { + setInternalData(next); + } + setTestLoginPassed(true); + setTestLoginResult(null); + showSuccessToast(t('message.test-login-success')); + + return; + } + + const hasClaims = Object.keys(result.claims).length > 0; + if (hasClaims) { + setClaimSelectorOpen(true); + + return; + } + + const next = withAuthorizerSuggestionApplied( + internalData, + result.suggestedAdminPrincipal, + result.derivedPrincipalDomain + ); + if (next && next !== internalData) { + setInternalData(next); + } + setTestLoginPassed(true); + showSuccessToast(t('message.test-login-success')); + }, + [internalData, withAuthorizerSuggestionApplied, t] + ); + + const handleClaimSelectorConfirm = useCallback( + ({ + adminPrincipal, + principalDomain, + emailClaim, + }: { + adminPrincipal: string; + principalDomain: string; + emailClaim: string; + }) => { + const withAdmin = withAuthorizerSuggestionApplied( + internalData, + adminPrincipal, + principalDomain + ); + const next = + withAdmin && emailClaim + ? { + ...withAdmin, + authenticationConfiguration: { + ...withAdmin.authenticationConfiguration, + emailClaim, + }, + } + : withAdmin; + + if (next && next !== internalData) { + setInternalData(next); + } + setTestLoginPassed(true); + setClaimSelectorOpen(false); + setTestLoginResult(null); + showSuccessToast(t('message.test-login-success')); + }, + [internalData, withAuthorizerSuggestionApplied, t] + ); + + const handleClaimSelectorCancel = useCallback(() => { + setClaimSelectorOpen(false); + setTestLoginResult(null); + }, []); + const handleSave = async () => { updateLoadingState(isModalSave, setIsLoading, true); fieldErrorsRef.current = {}; setErrorClearTrigger(0); try { - // Prepare payload - const cleanedFormData = cleanupProviderSpecificFields( - internalData, - internalData?.authenticationConfiguration?.provider as string - ); + // Prepare payload — derives clientType from secret presence and + // reshapes Confidential → Public when secret is blank. + const cleanedFormData = prepareOidcSubmitPayload(internalData); if (!cleanedFormData) { updateLoadingState(isModalSave, setIsLoading, false); @@ -856,6 +1198,24 @@ const SSOConfigurationFormRJSF = ({ return; } + const provider = internalData?.authenticationConfiguration?.provider as + | string + | undefined; + if ( + requiresFreshTestLogin( + hasExistingConfig, + savedData, + internalData, + provider, + testLoginPassed + ) + ) { + showErrorToast(t('message.test-login-required-before-save')); + updateLoadingState(isModalSave, setIsLoading, false); + + return; + } + const payload: SecurityConfiguration = { authenticationConfiguration: cleanedFormData.authenticationConfiguration, @@ -900,6 +1260,7 @@ const SSOConfigurationFormRJSF = ({ // For existing/configured SSO, discard changes and stay on the same page if (hasExistingConfig && savedData) { setInternalData(savedData); + setTestLoginPassed(false); return; } @@ -972,6 +1333,7 @@ const SSOConfigurationFormRJSF = ({ // Create fresh form data using utility function const freshFormData = createFreshFormData(provider); setInternalData(freshFormData); + setTestLoginPassed(false); }; if (isInitializing) { @@ -982,64 +1344,93 @@ const SSOConfigurationFormRJSF = ({ // The parent component (SettingsSso) will handle provider selection if (showProviderSelector && !onChangeProvider) { return ( - - + ); } const isSamlProvider = currentProvider === AuthProvider.Saml; + const isOidcCallbackProvider = + !!currentProvider && + OIDC_PROVIDERS_WITH_CALLBACK_DISPLAY.has(currentProvider as AuthProvider); + const callbackUrl = getCallbackUrl(); + const samlServerUrl = getServerUrl(); const formContent = ( <> + {isEditMode && showForm && isSamlProvider && ( +
+ + {t('label.register-with-identity-provider')} + + + {t('message.register-with-idp-info')} + + + +
+ )} {isEditMode && showForm && isSamlProvider && (
{metadataUploadStatus === null && ( - { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(file); - handleMetadataFileUpload(dataTransfer.files); - - return false; - }} - className="saml-metadata-upload-drop-zone" - data-testid="file-uploader" - multiple={false} - showUploadList={false}> + { + if (files) { + handleMetadataFileUpload(files); + } + }}>
+ className="saml-metadata-upload-drop-zone" + data-testid="file-uploader" + role="button" + tabIndex={0} + onDragEnter={preventDefaultDrag} + onDragLeave={preventDefaultDrag} + onDragOver={preventDefaultDrag} + onDrop={(event) => { + preventDefaultDrag(event); + if (event.dataTransfer.files?.length) { + handleMetadataFileUpload(event.dataTransfer.files); + } + }}>
- -
-
- - {t('label.click-to')}{' '} - {' '} - {t('label.or-drag-and-drop-an-xml-file-here')} - + className="flex flex-center flex-column gap-1" + data-testid="file-upload-drop-zone"> +
+ +
+
+ + {t('label.click-to')}{' '} + + {t('label.upload-lowercase')} + {' '} + {t('label.or-drag-and-drop-an-xml-file-here')} + +
+ + {t('message.upload-saml-metadata-xml-description')} +
- - {t('message.upload-saml-metadata-xml-description')} -
-
+ )} {metadataUploadStatus !== null && ( )} + {isEditMode && showForm && showAdvancedFieldsAccordion && ( + + + + {t('label.advanced-fields')} + + +
+ + + + )} + {showForm && isOidcCallbackProvider && ( +
+ testLoginTriggerRef.current?.triggerTestLogin()} + /> +
+ )} + {hasExistingConfig && + showForm && + isOidcCallbackProvider && + !( + internalData?.authenticationConfiguration as + | { emailClaim?: string } + | undefined + )?.emailClaim && ( +
+ + testLoginTriggerRef.current?.triggerTestLogin() + } + /> +
+ )} ); - // If hideBorder is true, render form with ResizablePanels but without Card wrapper and header + // If hideBorder is true, render form with ResizablePanels but without container wrapper and header if (hideBorder) { return ( <> @@ -1114,19 +1555,31 @@ const SSOConfigurationFormRJSF = ({ {isEditMode && (
+ {currentProvider && ( + + )}
@@ -1150,15 +1603,20 @@ const SSOConfigurationFormRJSF = ({ 'service-doc-panel content-resizable-panel-container m-t-xs', }} /> + ); } const wrappedFormContent = ( - - {/* SSO Provider Header */} {currentProvider && (
@@ -1172,22 +1630,23 @@ const SSOConfigurationFormRJSF = ({ /> )}
- +

{getProviderDisplayName(currentProvider)} {t('label.set-up')} - +

{hasExistingConfig && onChangeProvider && ( )}
)} {formContent} - +
); return ( @@ -1214,19 +1673,31 @@ const SSOConfigurationFormRJSF = ({ {isEditMode && (
+ {currentProvider && ( + + )}
@@ -1248,6 +1719,12 @@ const SSOConfigurationFormRJSF = ({ className: 'service-doc-panel content-resizable-panel-container', }} /> + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/sso-configuration-form.less b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/sso-configuration-form.less index e8d7e49b7daa..868d6d1ac6bd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/sso-configuration-form.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/sso-configuration-form.less @@ -16,9 +16,6 @@ border: none; padding: 0 @padding-mlg @padding-lg @padding-mlg; box-shadow: none; - .ant-card-body { - padding: 0; - } .card-title { line-height: @size-lg; @@ -32,9 +29,6 @@ line-height: @size-mlg; margin-right: @size-xs; } - .ant-divider-horizontal { - margin: @size-md 0 @size-md 0; - } .header-title-wrapper { display: none; } @@ -110,7 +104,7 @@ .sso-provider-form-header { margin-bottom: @size-sm; - .ant-typography { + .sso-provider-form-title { font-weight: 600; color: @grey-900; } @@ -121,6 +115,7 @@ bottom: 0; z-index: 10; display: flex; + align-items: center; justify-content: flex-end; gap: 12px; margin-top: @size-lg; @@ -162,6 +157,7 @@ margin-right: -@size-lg; z-index: 10; display: flex; + align-items: center; justify-content: flex-end; gap: 12px; margin-top: @size-lg; @@ -193,13 +189,6 @@ .sso-settings-page .content-resizable-panel-container.sso-configured { margin-top: @size-xs; - .ant-card { - border: none; - } - - .ant-card-body { - padding: 0px; - } // Apply same styling as non-configured for ALL form controls including array fields .rjsf .form-control { @@ -400,6 +389,7 @@ margin-right: 0; z-index: 10; display: flex; + align-items: center; justify-content: flex-end; gap: 12px; margin-top: @size-lg; @@ -864,14 +854,101 @@ } .saml-metadata-upload-drop-zone { - &.ant-upload-drag { - background-color: @grey-9; + background-color: @grey-9; + border: 1px solid @grey-15; + border-radius: @border-rad-xs; + padding: @size-md; + cursor: pointer; + outline: none; + + &:hover, + &:focus-visible { + border-color: @primary-color; + } + + .sso-upload-link { + color: @primary-6; + cursor: pointer; + } +} + +.saml-idp-info-banner, +.oidc-callback-display { + background-color: @grey-9; + border: 1px solid @grey-15; + border-radius: @border-rad-xs; + padding: @size-md; + display: flex; + flex-direction: column; + gap: @size-xs; +} + +.copyable-url-field { + display: flex; + flex-direction: column; + gap: @size-xxs; + + .copyable-url-label { + color: @grey-600; + } + + .copyable-url-value-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + gap: @size-xs; + background-color: @white; border: 1px solid @grey-15; border-radius: @border-rad-xs; + padding: @size-xxs @size-xs; + + .copyable-url-value { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: monospace; + font-size: 12px; + } + } +} + +.sso-top-advanced-accordion { + margin-top: @size-md; + + .sso-advanced-fields-toggle { + background-color: @grey-9; + padding: @size-md; + } + + .sso-advanced-fields-panel { + background-color: @grey-9; + padding: @size-md; + border: 0px; + } + + .sso-main-fields { + background-color: transparent; + padding: 0; + border-radius: 0; } - &.ant-upload.ant-upload-drag:not(.ant-upload-disabled):hover { - border-color: @grey-15; + .sso-advanced-section { + .property-wrapper { + margin-bottom: @size-md; + + .form-group { + margin-bottom: 0; + } + + &:last-child { + margin-bottom: 0; + } + } + + &:not(:last-child) { + margin-bottom: @size-md; + } } } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.tsx index fd0d06ad6dbe..5ac570c12211 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.tsx @@ -16,289 +16,117 @@ import { ObjectFieldTemplatePropertyType, ObjectFieldTemplateProps, } from '@rjsf/utils'; -import { Button, Collapse, Space } from 'antd'; +import { Button, Space } from 'antd'; import classNames from 'classnames'; -import { isEmpty, isUndefined } from 'lodash'; -import { createElement, Fragment, FunctionComponent } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ADVANCED_PROPERTIES } from '../../../constants/Services.constant'; +import { isUndefined } from 'lodash'; +import { createElement, Fragment, FunctionComponent, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { + SSOFieldLayout, + SSOSectionLayout, +} from '../../../constants/SSO.constant'; import serviceUtilClassBase from '../../../utils/ServiceUtilClassBase'; import './sso-grouped-field-template.less'; -import { FieldGroup, PropertyMap } from './SSOGroupedFieldTemplate.interface'; -export const SSOGroupedFieldTemplate: FunctionComponent< - ObjectFieldTemplateProps -> = (props: ObjectFieldTemplateProps) => { - const { t } = useTranslation(); - const { formContext, idSchema, title, onAddClick, schema, properties } = - props; - - const { advancedProperties, normalProperties } = properties.reduce( - (propertyMap, currentProperty) => { - const isAdvancedProperty = ADVANCED_PROPERTIES.includes( - currentProperty.name - ); +const ROOT_PREFIX = 'root/'; - let advancedProperties = [...propertyMap.advancedProperties]; - let normalProperties = [...propertyMap.normalProperties]; +const stripRootPrefix = (idSchemaId: string): string => + idSchemaId.startsWith(ROOT_PREFIX) + ? idSchemaId.slice(ROOT_PREFIX.length) + : idSchemaId; - if (isAdvancedProperty) { - advancedProperties = [...advancedProperties, currentProperty]; - } else { - normalProperties = [...normalProperties, currentProperty]; - } - - return { ...propertyMap, advancedProperties, normalProperties }; - }, - { - advancedProperties: [], - normalProperties: [], - } as PropertyMap - ); +const isVisibleProperty = (prop: ObjectFieldTemplatePropertyType): boolean => { + const element = prop.content; + if (!element || prop.hidden) { + return false; + } - const { - properties: updatedNormalProperties, - additionalField: AdditionalField, - additionalFieldContent, - } = serviceUtilClassBase.getProperties(normalProperties); + if ( + element.type === 'input' && + element.props && + element.props.type === 'hidden' + ) { + return false; + } - // Apply grouping only to the main SSO configuration objects - const isAuthConfigRoot = idSchema.$id === 'root/authenticationConfiguration'; - const isAuthorizerConfig = idSchema.$id === 'root/authorizerConfiguration'; - const isOIDCConfig = - idSchema.$id === 'root/authenticationConfiguration/oidcConfiguration'; - const isLDAPConfig = - idSchema.$id === 'root/authenticationConfiguration/ldapConfiguration'; - const isSAMLConfig = - idSchema.$id === 'root/authenticationConfiguration/samlConfiguration'; - - // Only apply special grouping to these specific main configuration objects - const shouldApplyGrouping = - isAuthConfigRoot || - isAuthorizerConfig || - isOIDCConfig || - isLDAPConfig || - isSAMLConfig; - - const filterVisibleProperties = ( - properties: ObjectFieldTemplatePropertyType[] - ): ObjectFieldTemplatePropertyType[] => { - return properties.filter((prop) => { - const element = prop.content; - - // No element, nothing to render - if (!element) { - return false; - } - - // If schema or UI schema marked this as hidden - if (prop.hidden) { - return false; - } - - // If it's an - if ( - element.type === 'input' && - element.props && - element.props.type === 'hidden' - ) { - return false; - } - - // Explicit style-based hiding - if (element.props?.style?.display === 'none' || element.props?.hidden) { - return false; - } - - return true; - }); - }; + return !(element.props?.style?.display === 'none' || element.props?.hidden); +}; - // Define field groups for SSO forms with logical grouping - const getFieldGroups = ( - properties: ObjectFieldTemplatePropertyType[] - ): FieldGroup[] => { - // For non-main configuration objects, use default rendering without extra background - if (!shouldApplyGrouping) { - return [ - { - properties: filterVisibleProperties(properties), - showDivider: false, - }, - ]; +const partitionByLayout = ( + properties: ObjectFieldTemplatePropertyType[], + sectionLayout: SSOSectionLayout | undefined +): { + mainProperties: ObjectFieldTemplatePropertyType[]; + advancedProperties: ObjectFieldTemplatePropertyType[]; +} => { + if (!sectionLayout) { + return { mainProperties: properties, advancedProperties: [] }; + } + + const mainProperties: ObjectFieldTemplatePropertyType[] = []; + const advancedProperties: ObjectFieldTemplatePropertyType[] = []; + + for (const prop of properties) { + if (sectionLayout[prop.name] === 'advanced') { + advancedProperties.push(prop); + } else { + mainProperties.push(prop); } + } - const groups: FieldGroup[] = []; - const visibleProperties = filterVisibleProperties(properties); - - if (isAuthConfigRoot) { - // Root authentication configuration grouping - const basicConfigFields = visibleProperties.filter((prop) => - ['provider', 'providerName'].includes(prop.name) - ); - if (basicConfigFields.length > 0) { - groups.push({ - title: 'Basic Configuration', - properties: basicConfigFields, - showDivider: false, - }); - } - - const clientFields = visibleProperties.filter((prop) => - ['clientType', 'enableSelfSignup', 'clientId', 'callbackUrl'].includes( - prop.name - ) - ); - if (clientFields.length > 0) { - groups.push({ - title: 'Client Configuration', - properties: clientFields, - showDivider: false, - }); - } + return { mainProperties, advancedProperties }; +}; - const authorityFields = visibleProperties.filter((prop) => - ['authority', 'domain'].includes(prop.name) - ); - if (authorityFields.length > 0) { - groups.push({ - title: 'Authority Settings', - properties: authorityFields, - showDivider: false, - }); - } +const renderProperty = ( + element: ObjectFieldTemplatePropertyType, + index: number, + hasAdditionalProperties?: boolean +) => ( +
+ {element.content} +
+); - const securityFields = visibleProperties.filter((prop) => - ['publicKeyUrls', 'tokenValidationAlgorithm'].includes(prop.name) - ); - if (securityFields.length > 0) { - groups.push({ - title: 'Security Configuration', - properties: securityFields, - showDivider: false, - }); - } +export const SSOGroupedFieldTemplate: FunctionComponent< + ObjectFieldTemplateProps +> = (props: ObjectFieldTemplateProps) => { + const { formContext, idSchema, title, onAddClick, schema, properties } = + props; - const credentialsFields = visibleProperties.filter((prop) => - ['secret', 'clientSecret'].includes(prop.name) - ); - if (credentialsFields.length > 0) { - groups.push({ - title: 'Credentials', - properties: credentialsFields, - showDivider: false, - }); - } + const fieldLayout = formContext?.fieldLayout as SSOFieldLayout | undefined; + const advancedFieldsContainer = formContext?.advancedFieldsContainer as + | HTMLElement + | null + | undefined; + const sectionPath = stripRootPrefix(idSchema.$id); + const sectionLayout = fieldLayout?.[sectionPath]; - const configObjectFields = visibleProperties.filter((prop) => - [ - 'oidcConfiguration', - 'ldapConfiguration', - 'samlConfiguration', - ].includes(prop.name) - ); - configObjectFields.forEach((field) => { - groups.push({ - properties: [field], - showDivider: false, - }); - }); + const { mainProperties, advancedProperties } = useMemo(() => { + const visibleProperties = properties.filter(isVisibleProperty); - // Remaining fields - const groupedFieldNames = [ - ...basicConfigFields, - ...clientFields, - ...authorityFields, - ...securityFields, - ...credentialsFields, - ...configObjectFields, - ].map((p) => p.name); - const remainingFields = visibleProperties.filter( - (prop) => !groupedFieldNames.includes(prop.name) - ); - if (remainingFields.length > 0) { - groups.push({ - title: 'Advanced Configuration', - properties: remainingFields, - showDivider: false, - }); - } - } else if (isAuthorizerConfig) { - // Authorizer configuration grouping - const principalFields = visibleProperties.filter((prop) => - [ - 'adminPrincipals', - 'botPrincipals', - 'principalDomain', - 'enforcePrincipalDomain', - ].includes(prop.name) - ); - if (principalFields.length > 0) { - groups.push({ - title: 'Principal Management', - properties: principalFields, - showDivider: false, - }); - } + const { properties: enrichedNormalProperties } = + serviceUtilClassBase.getProperties(visibleProperties); - const connectionFields = visibleProperties.filter((prop) => - [ - 'enableSecureSocketConnection', - 'className', - 'containerRequestFilter', - ].includes(prop.name) - ); - if (connectionFields.length > 0) { - groups.push({ - title: 'Connection Settings', - properties: connectionFields, - showDivider: false, - }); - } + return partitionByLayout(enrichedNormalProperties, sectionLayout); + }, [properties, sectionLayout]); - // Remaining authorizer fields - const groupedFieldNames = [...principalFields, ...connectionFields].map( - (p) => p.name - ); - const remainingFields = visibleProperties.filter( - (prop) => !groupedFieldNames.includes(prop.name) - ); - if (remainingFields.length > 0) { - groups.push({ - properties: remainingFields, - showDivider: false, - }); - } - } else if (isOIDCConfig) { - // OIDC configuration - all fields in a single group with title - groups.push({ - title: 'OIDC Configuration', - properties: visibleProperties, - showDivider: false, - }); - } else if (isLDAPConfig) { - // LDAP configuration - all fields in a single group without extra grouping - groups.push({ - properties: visibleProperties, - showDivider: false, - }); - } else if (isSAMLConfig) { - // SAML configuration - all fields in a single group without extra grouping - groups.push({ - properties: visibleProperties, - showDivider: false, - }); - } + const additionalFieldData = useMemo( + () => serviceUtilClassBase.getProperties(properties), + [properties] + ); - // Filter out only completely empty groups - return groups.filter( - (group) => group.properties && group.properties.length > 0 - ); - }; + const AdditionalField = additionalFieldData.additionalField; + const additionalFieldContent = additionalFieldData.additionalFieldContent; - const fieldGroups = getFieldGroups(updatedNormalProperties); + const hasAdvanced = advancedProperties.length > 0; + const hasAdditional = Boolean(schema.additionalProperties); + const canPortalAdvanced = hasAdvanced && Boolean(advancedFieldsContainer); - const fieldElement = ( + return ( {title && title.trim() !== '' && ( @@ -311,7 +139,7 @@ export const SSOGroupedFieldTemplate: FunctionComponent< {title} - {schema.additionalProperties && ( + {hasAdditional && ( + + + + + + ); +}; + +export default ClaimSelector; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/EmailClaimRecommendation.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/EmailClaimRecommendation.component.tsx new file mode 100644 index 000000000000..9ed62d55078c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/EmailClaimRecommendation.component.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button } from '@openmetadata/ui-core-components'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const DISMISSED_KEY = 'sso-emailClaim-banner-dismissed'; + +interface EmailClaimRecommendationProps { + onRunTestLogin: () => void; + isDisabled?: boolean; +} + +const EmailClaimRecommendation = ({ + onRunTestLogin, + isDisabled = false, +}: EmailClaimRecommendationProps) => { + const { t } = useTranslation(); + const [dismissed, setDismissed] = useState(true); + + useEffect(() => { + setDismissed(localStorage.getItem(DISMISSED_KEY) === 'true'); + }, []); + + if (dismissed) { + return null; + } + + const handleDismiss = () => { + localStorage.setItem(DISMISSED_KEY, 'true'); + setDismissed(true); + }; + + return ( +
+
+ + {t('label.set-explicit-email-claim')} + + + {t('message.email-claim-recommendation-body')} + +
+
+ + +
+
+ ); +}; + +export default EmailClaimRecommendation; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/EmailClaimStatus.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/EmailClaimStatus.component.tsx new file mode 100644 index 000000000000..4b3df0fc719f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/EmailClaimStatus.component.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button } from '@openmetadata/ui-core-components'; +import { useTranslation } from 'react-i18next'; + +interface EmailClaimStatusProps { + emailClaim?: string; + onChange: () => void; + isDisabled?: boolean; +} + +const EmailClaimStatus = ({ + emailClaim, + onChange, + isDisabled = false, +}: EmailClaimStatusProps) => { + const { t } = useTranslation(); + const isSet = Boolean(emailClaim && emailClaim.trim().length > 0); + + return ( +
+
+ + {t('label.email-claim')} + + {isSet ? ( + + {emailClaim} + {' ✓ '} + + {t('message.email-claim-verified')} + + + ) : ( + + {t('message.email-claim-not-set')} + + )} +
+ +
+ ); +}; + +export default EmailClaimStatus; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/TestLogin.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/TestLogin.interface.ts new file mode 100644 index 000000000000..262c33f57e52 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/TestLogin.interface.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type ClaimValue = string | number | boolean | string[]; + +export interface TestLoginResult { + claims: Record; + suggestedEmailClaim: string | null; + derivedPrincipalDomain: string | null; + suggestedAdminPrincipal: string | null; + hasRefreshToken: boolean; +} + +export interface ClaimSelectorConfirm { + emailClaim: string; + principalDomain: string; + adminPrincipal: string; +} + +export interface ClaimSelectorProps { + open: boolean; + result: TestLoginResult | null; + onConfirm: (selection: ClaimSelectorConfirm) => void; + onCancel: () => void; +} + +export const TEST_LOGIN_MESSAGE_TYPE = 'sso-test-login'; + +export interface TestLoginPopupPayload { + type: typeof TEST_LOGIN_MESSAGE_TYPE; + success: boolean; + error?: string; + claims?: Record; + suggestedEmailClaim?: string | null; + derivedPrincipalDomain?: string | null; + suggestedAdminPrincipal?: string | null; + hasRefreshToken?: boolean; +} + +export const isTestLoginPopupPayload = ( + value: unknown +): value is TestLoginPopupPayload => { + if (typeof value !== 'object' || value === null) { + return false; + } + const candidate = value as Partial; + + return ( + candidate.type === TEST_LOGIN_MESSAGE_TYPE && + typeof candidate.success === 'boolean' + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/TestLogin.utils.ts similarity index 59% rename from openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.interface.ts rename to openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/TestLogin.utils.ts index e163dc44c1cf..43b504668e38 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/TestLogin.utils.ts @@ -11,15 +11,17 @@ * limitations under the License. */ -import { ObjectFieldTemplatePropertyType } from '@rjsf/utils'; +import { ClaimValue } from './TestLogin.interface'; -export interface PropertyMap { - advancedProperties: ObjectFieldTemplatePropertyType[]; - normalProperties: ObjectFieldTemplatePropertyType[]; -} +export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -export interface FieldGroup { - title?: string; - properties: ObjectFieldTemplatePropertyType[]; - showDivider?: boolean; -} +export const isEmailString = (value: unknown): boolean => + typeof value === 'string' && EMAIL_REGEX.test(value.trim()); + +export const claimValueHasEmail = (value: ClaimValue): boolean => { + if (Array.isArray(value)) { + return value.some(isEmailString); + } + + return isEmailString(value); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/TestLoginButton.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/TestLoginButton.component.tsx new file mode 100644 index 000000000000..30da7bd8c37d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/TestLoginButton.component.tsx @@ -0,0 +1,500 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Button, + Dialog, + Input, + Modal, + ModalOverlay, +} from '@openmetadata/ui-core-components'; +import { + RefObject, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AuthenticationConfiguration, + OIDC_SSO_DEFAULTS, +} from '../../../constants/SSO.constant'; +import { AuthProvider } from '../../../generated/settings/settings'; +import { + SecurityConfiguration, + validateSecurityConfiguration, +} from '../../../rest/securityConfigAPI'; +import { FormData, prepareOidcSubmitPayload } from '../../../utils/SSOUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import { + buildOidcPopupFields, + buildSamlPopupFields, + CLOSE_WATCH_INTERVAL_MS, + openCenteredPopup, + POPUP_TIMEOUT_MS, + submitFormToPopup, + TEST_LOGIN_RESULT_KEY, +} from './popupLifecycle'; +import { + isTestLoginPopupPayload, + TestLoginPopupPayload, + TestLoginResult, +} from './TestLogin.interface'; + +export interface TestLoginButtonHandle { + triggerTestLogin: () => void; +} + +interface TestLoginButtonProps { + formData?: AuthenticationConfiguration; + securityConfig?: FormData; + hasExistingConfig?: boolean; + isDisabled?: boolean; + onSuccess: (result: TestLoginResult) => void; + triggerRef?: RefObject; +} + +interface LdapModalState { + open: boolean; + loading: boolean; + email: string; + password: string; +} + +const INITIAL_LDAP_MODAL_STATE: LdapModalState = { + open: false, + loading: false, + email: '', + password: '', +}; + +const TestLoginButton = ({ + formData, + securityConfig, + hasExistingConfig = false, + isDisabled = false, + onSuccess, + triggerRef, +}: TestLoginButtonProps) => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + const [ldapModal, setLdapModal] = useState( + INITIAL_LDAP_MODAL_STATE + ); + const popupRef = useRef(null); + const timerRef = useRef | null>(null); + const closeWatchRef = useRef | null>(null); + const resultReceivedRef = useRef(false); + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const clearCloseWatch = useCallback(() => { + if (closeWatchRef.current) { + clearInterval(closeWatchRef.current); + closeWatchRef.current = null; + } + }, []); + + const processResultPayload = useCallback( + (raw: string) => { + resultReceivedRef.current = true; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + showErrorToast(t('message.test-login-failed')); + setIsLoading(false); + + return; + } + + if (!isTestLoginPopupPayload(parsed)) { + return; + } + + const payload: TestLoginPopupPayload = parsed; + + clearTimer(); + clearCloseWatch(); + setIsLoading(false); + + if (payload.success) { + onSuccess({ + claims: payload.claims ?? {}, + suggestedEmailClaim: payload.suggestedEmailClaim ?? null, + derivedPrincipalDomain: payload.derivedPrincipalDomain ?? null, + suggestedAdminPrincipal: payload.suggestedAdminPrincipal ?? null, + hasRefreshToken: Boolean(payload.hasRefreshToken), + }); + } else { + showErrorToast(payload.error ?? t('message.test-login-failed')); + } + + localStorage.removeItem(TEST_LOGIN_RESULT_KEY); + }, + [onSuccess, t, clearTimer, clearCloseWatch] + ); + + const failPopupBlocked = useCallback(() => { + setIsLoading(false); + showErrorToast(t('message.popup-blocked')); + }, [t]); + + const failTimeout = useCallback(() => { + clearCloseWatch(); + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + setIsLoading(false); + showErrorToast(t('message.test-login-timeout')); + }, [clearCloseWatch, t]); + + const failPopupClosed = useCallback(() => { + clearTimer(); + clearCloseWatch(); + setIsLoading(false); + showErrorToast(t('message.test-login-popup-closed')); + }, [clearCloseWatch, clearTimer, t]); + + const startCloseWatch = useCallback(() => { + clearCloseWatch(); + closeWatchRef.current = setInterval(() => { + if (!popupRef.current?.closed) { + return; + } + if (resultReceivedRef.current) { + clearCloseWatch(); + + return; + } + const stored = localStorage.getItem(TEST_LOGIN_RESULT_KEY); + if (stored) { + clearCloseWatch(); + processResultPayload(stored); + + return; + } + failPopupClosed(); + }, CLOSE_WATCH_INTERVAL_MS); + }, [clearCloseWatch, failPopupClosed, processResultPayload]); + + const launchPopupFlow = useCallback( + (action: string, fields: Record) => { + resultReceivedRef.current = false; + localStorage.removeItem(TEST_LOGIN_RESULT_KEY); + + const popup = openCenteredPopup(); + if (!popup) { + failPopupBlocked(); + + return; + } + popupRef.current = popup; + startCloseWatch(); + submitFormToPopup(action, fields); + timerRef.current = setTimeout(failTimeout, POPUP_TIMEOUT_MS); + }, + [failPopupBlocked, failTimeout, startCloseWatch] + ); + + useEffect(() => { + const handleStorage = (event: StorageEvent) => { + if (event.key === TEST_LOGIN_RESULT_KEY && event.newValue) { + processResultPayload(event.newValue); + } + }; + + window.addEventListener('storage', handleStorage); + + return () => { + window.removeEventListener('storage', handleStorage); + clearTimer(); + clearCloseWatch(); + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + popupRef.current = null; + }; + }, [processResultPayload, clearTimer, clearCloseWatch]); + + const startSamlTestLogin = useCallback(() => { + const fields = buildSamlPopupFields(formData, hasExistingConfig); + if (!fields) { + setIsLoading(false); + showErrorToast(t('message.saml-idp-fields-required')); + + return; + } + launchPopupFlow( + `${window.location.origin}/api/v1/system/config/auth/test-login/saml-initiate`, + fields + ); + }, [formData, hasExistingConfig, launchPopupFlow, t]); + + const startOidcTestLogin = useCallback(async () => { + const discoveryUri = + formData?.oidcConfiguration?.discoveryUri || formData?.discoveryUri || ''; + const clientId = + formData?.oidcConfiguration?.id || formData?.clientId || ''; + const clientSecret = formData?.oidcConfiguration?.secret ?? ''; + const defaultScope = + formData?.provider === AuthProvider.Azure + ? OIDC_SSO_DEFAULTS.azureScope + : OIDC_SSO_DEFAULTS.scope; + const scope = formData?.oidcConfiguration?.scope ?? defaultScope; + + if (!discoveryUri || !clientId) { + setIsLoading(false); + showErrorToast( + !discoveryUri + ? t('message.discovery-uri-required') + : t('message.client-id-required') + ); + + return; + } + + if (discoveryUri.includes('{') || discoveryUri.includes('}')) { + setIsLoading(false); + showErrorToast(t('message.replace-discovery-uri-placeholders')); + + return; + } + + if (securityConfig) { + try { + // Reshape OIDC payload to derive clientType from secret presence so + // the validator sees the same shape the save endpoint will receive. + const reshapedConfig: SecurityConfiguration = + prepareOidcSubmitPayload(securityConfig) ?? securityConfig; + const response = await validateSecurityConfiguration( + reshapedConfig, + 'testLogin' + ); + const validationResult = response.data; + + if ( + validationResult && + 'errors' in validationResult && + Array.isArray(validationResult.errors) && + validationResult.errors.length > 0 + ) { + const errors = validationResult.errors as Array<{ + field: string; + error: string; + }>; + const message = errors + .map((error) => `${error.field}: ${error.error}`) + .join(', '); + setIsLoading(false); + showErrorToast(message); + + return; + } + } catch { + setIsLoading(false); + showErrorToast(t('message.test-login-failed')); + + return; + } + } + + const fields = buildOidcPopupFields({ + formData, + hasExistingConfig, + discoveryUri, + clientId, + clientSecret, + scope, + }); + launchPopupFlow( + `${window.location.origin}/api/v1/system/config/auth/test-login/initiate`, + fields + ); + }, [formData, hasExistingConfig, securityConfig, t, launchPopupFlow]); + + const submitLdapTestLogin = useCallback(async () => { + if (!ldapModal.email || !ldapModal.password) { + showErrorToast(t('message.ldap-credentials-required')); + + return; + } + + setLdapModal((s) => ({ ...s, loading: true })); + try { + const response = await fetch( + `${window.location.origin}/api/v1/system/config/auth/test-login/ldap-initiate`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + mode: hasExistingConfig ? 'existing' : 'new', + ldapConfiguration: formData?.ldapConfiguration, + email: ldapModal.email, + password: ldapModal.password, + }), + } + ); + const data = await response.json(); + + if (data?.success) { + setLdapModal(INITIAL_LDAP_MODAL_STATE); + setIsLoading(false); + onSuccess({ + claims: {}, + suggestedEmailClaim: null, + derivedPrincipalDomain: + (data.derivedPrincipalDomain as string | null) ?? null, + suggestedAdminPrincipal: + (data.suggestedAdminPrincipal as string | null) ?? null, + hasRefreshToken: false, + }); + } else { + showErrorToast( + (data?.error as string | undefined) ?? t('message.test-login-failed') + ); + } + } catch (error) { + showErrorToast( + error instanceof Error ? error.message : t('message.test-login-failed') + ); + } finally { + setLdapModal((s) => ({ ...s, loading: false })); + } + }, [ + formData, + hasExistingConfig, + ldapModal.email, + ldapModal.password, + onSuccess, + t, + ]); + + const handleTestLogin = useCallback(() => { + setIsLoading(true); + + if (formData?.provider === 'saml') { + startSamlTestLogin(); + + return; + } + + if (formData?.provider === 'ldap') { + setIsLoading(false); + setLdapModal({ open: true, loading: false, email: '', password: '' }); + + return; + } + + void startOidcTestLogin(); + }, [formData?.provider, startSamlTestLogin, startOidcTestLogin]); + + useImperativeHandle( + triggerRef, + () => ({ + triggerTestLogin: handleTestLogin, + }), + [handleTestLogin] + ); + + const closeLdapModal = useCallback(() => { + setLdapModal((s) => ({ ...s, open: false, password: '' })); + }, []); + + return ( + <> + + { + if (!isOpen) { + closeLdapModal(); + } + }}> + + + +

+ {t('message.ldap-test-login-description')} +

+ setLdapModal((s) => ({ ...s, email }))} + /> + + setLdapModal((s) => ({ ...s, password })) + } + /> +
+ + + + +
+
+
+ + ); +}; + +export default TestLoginButton; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/popupLifecycle.ts b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/popupLifecycle.ts new file mode 100644 index 000000000000..d759866d35bf --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/TestLogin/popupLifecycle.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthenticationConfiguration } from '../../../constants/SSO.constant'; + +export const TEST_LOGIN_RESULT_KEY = 'sso-test-login-result'; +export const POPUP_NAME = 'sso-test-login'; +export const POPUP_TIMEOUT_MS = 60_000; +export const CLOSE_WATCH_INTERVAL_MS = 500; +const POPUP_WIDTH = 500; +const POPUP_HEIGHT = 600; +const DEFAULT_SAML_NAME_ID = + 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'; + +export const openCenteredPopup = (): Window | null => { + const left = window.screenX + (window.outerWidth - POPUP_WIDTH) / 2; + const top = window.screenY + (window.outerHeight - POPUP_HEIGHT) / 2; + + return window.open( + '', + POPUP_NAME, + `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},scrollbars=yes` + ); +}; + +export const submitFormToPopup = ( + action: string, + fields: Record +): void => { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = action; + form.target = POPUP_NAME; + form.style.display = 'none'; + + for (const [name, value] of Object.entries(fields)) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + form.appendChild(input); + } + + document.body.appendChild(form); + try { + form.submit(); + } finally { + document.body.removeChild(form); + } +}; + +const modeFor = (hasExistingConfig: boolean): string => + hasExistingConfig ? 'existing' : 'new'; + +export const buildSamlPopupFields = ( + formData: AuthenticationConfiguration | undefined, + hasExistingConfig: boolean +): Record | null => { + const idp = formData?.samlConfiguration?.idp; + const sp = formData?.samlConfiguration?.sp; + const idpEntityId = idp?.entityId ?? ''; + const idpSsoLoginUrl = idp?.ssoLoginUrl ?? ''; + const idpX509Certificate = idp?.idpX509Certificate ?? ''; + + if (!idpEntityId || !idpSsoLoginUrl || !idpX509Certificate) { + return null; + } + + return { + mode: modeFor(hasExistingConfig), + idpEntityId, + idpSsoLoginUrl, + idpX509Certificate, + spEntityId: sp?.entityId ?? window.location.origin, + spAcsUrl: sp?.acs ?? sp?.callback ?? `${window.location.origin}/callback`, + nameIdFormat: idp?.nameId ?? DEFAULT_SAML_NAME_ID, + }; +}; + +export interface OidcPopupFieldsInput { + formData: AuthenticationConfiguration | undefined; + hasExistingConfig: boolean; + discoveryUri: string; + clientId: string; + clientSecret: string; + scope: string; +} + +export const buildOidcPopupFields = ({ + formData, + hasExistingConfig, + discoveryUri, + clientId, + clientSecret, + scope, +}: OidcPopupFieldsInput): Record => { + const oidc = formData?.oidcConfiguration; + const callbackUrl = oidc?.callbackUrl ?? `${window.location.origin}/callback`; + + return { + mode: modeFor(hasExistingConfig), + discoveryUri, + clientId, + clientSecret, + scope, + callbackUrl, + prompt: oidc?.prompt ?? '', + maxAge: String(oidc?.maxAge ?? ''), + clientAuthenticationMethod: + oidc?.clientAuthenticationMethod ?? + (clientSecret ? 'client_secret_post' : ''), + disablePkce: String(oidc?.disablePkce ?? true), + useNonce: String(oidc?.useNonce ?? true), + customParams: JSON.stringify(oidc?.customParams ?? {}), + }; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/SSO.constant.ts b/openmetadata-ui/src/main/resources/ui/src/constants/SSO.constant.ts index fee7ec59cba7..a4cdc461e938 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/SSO.constant.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/SSO.constant.ts @@ -12,6 +12,7 @@ */ import { ClientType } from '../generated/configuration/securityConfiguration'; +import { AuthProvider } from '../generated/settings/settings'; import { getAuthorityUrl, getCallbackUrl, @@ -25,12 +26,12 @@ export const DEFAULT_CALLBACK_URL = getCallbackUrl(); // OIDC-specific default values export const OIDC_SSO_DEFAULTS = { + scope: 'openid email profile', + azureScope: 'openid email profile offline_access', tokenValidity: 3600, - serverUrl: getServerUrl(), callbackUrl: getCallbackUrl(), sessionExpiry: 604800, preferredJwsAlgorithm: 'RS256', - responseType: 'code', tokenValidationAlgorithm: 'RS256', }; @@ -62,6 +63,9 @@ export const OIDC_SPECIFIC_FIELDS = [ 'clientId', 'authority', 'publicKeyUrls', + 'discoveryUri', + 'emailClaim', + 'oidcConfiguration', ]; export const NON_OIDC_SPECIFIC_FIELDS = [ @@ -80,24 +84,26 @@ export const COMMON_UI_FIELDS = { 'ui:title': 'Client Secret', 'ui:widget': 'password', 'ui:placeholder': 'Enter your client secret', + 'ui:description': 'Enter the secret from your Identity Provider.', }, oidcClientId: { - 'ui:title': 'OIDC Client ID', + 'ui:title': 'Client ID', 'ui:placeholder': 'e.g. 123456890-abcdef.apps.googleusercontent.com', }, oidcClientSecret: { - 'ui:title': 'OIDC Client Secret', + 'ui:title': 'Client Secret', 'ui:widget': 'password', - 'ui:placeholder': 'Enter your OIDC client secret', + 'ui:placeholder': 'Enter your client secret', + 'ui:description': 'Enter the secret from your Identity Provider.', }, oidcScope: { - 'ui:title': 'OIDC Request Scopes', + 'ui:title': 'Scope', 'ui:placeholder': 'Enter scope (e.g. openid, email, profile) and press ENTER', 'ui:field': 'ArrayField', }, oidcDiscoveryUri: { - 'ui:title': 'OIDC Discovery URI', + 'ui:title': 'Discovery URI', 'ui:placeholder': 'e.g. https://accounts.google.com/.well-known/openid_configuration', }, @@ -105,30 +111,21 @@ export const COMMON_UI_FIELDS = { 'ui:title': 'OIDC Callback URL', 'ui:placeholder': 'e.g. https://myapp.com/auth/callback', }, - oidcServerUrl: { - 'ui:title': 'OIDC Server URL', - 'ui:placeholder': 'e.g. https://your-domain.auth0.com', - }, - oidcTenant: { - 'ui:title': 'OIDC Tenant', - 'ui:placeholder': 'e.g. your-tenant-id', - }, // Additional common OIDC fields oidcConfiguration: { 'ui:title': 'OIDC Configuration' }, - oidcIdpType: { 'ui:title': 'OIDC IDP Type' }, - oidcUseNonce: { 'ui:title': 'OIDC Use Nonce' }, - oidcPreferredJwsAlgorithm: { 'ui:title': 'OIDC Preferred JWS Algorithm' }, - oidcResponseType: { 'ui:title': 'OIDC Response Type' }, - oidcDisablePkce: { 'ui:title': 'OIDC Disable PKCE' }, - oidcMaxClockSkew: { 'ui:title': 'OIDC Max Clock Skew' }, + oidcIdpType: { 'ui:title': 'IDP Type' }, + oidcUseNonce: { 'ui:title': 'Use Nonce' }, + oidcPreferredJwsAlgorithm: { 'ui:title': 'Preferred JWS Algorithm' }, + oidcDisablePkce: { 'ui:title': 'Disable PKCE' }, + oidcMaxClockSkew: { 'ui:title': 'Max Clock Skew' }, oidcClientAuthenticationMethod: { - 'ui:title': 'OIDC Client Authentication Method', + 'ui:title': 'Client Authentication Method', }, - oidcTokenValidity: { 'ui:title': 'OIDC Token Validity' }, - oidcCustomParameters: { 'ui:title': 'OIDC Custom Parameters' }, - oidcMaxAge: { 'ui:title': 'OIDC Max Age' }, - oidcPrompt: { 'ui:title': 'OIDC Prompt' }, - oidcSessionExpiry: { 'ui:title': 'OIDC Session Expiry' }, + oidcTokenValidity: { 'ui:title': 'Token Validity (seconds)' }, + oidcCustomParameters: { 'ui:title': 'Custom Parameters' }, + oidcMaxAge: { 'ui:title': 'Max Age' }, + oidcPrompt: { 'ui:title': 'Prompt' }, + oidcSessionExpiry: { 'ui:title': 'Session Expiry (seconds)' }, // Common non-OIDC fields authority: { 'ui:title': 'Authority', @@ -136,7 +133,10 @@ export const COMMON_UI_FIELDS = { }, callbackUrl: { 'ui:title': 'Callback URL', - 'ui:placeholder': 'e.g. https://myapp.com/auth/callback', + 'ui:readonly': true, + 'ui:widget': 'CallbackUrlWidget', + 'ui:description': + 'This field is automatically populated as {your-domain}/callback.', }, publicKeyUrls: { 'ui:title': 'Public Key URLs', @@ -147,7 +147,6 @@ export const COMMON_UI_FIELDS = { // Common hidden fields for all providers export const COMMON_HIDDEN_FIELDS = { - responseType: { 'ui:widget': 'hidden', 'ui:hideError': true }, forceSecureSessionCookie: { 'ui:widget': 'hidden', 'ui:hideError': true }, }; @@ -191,8 +190,8 @@ export const LDAP_UI_SCHEMA = { sslEnabled: { 'ui:title': 'Enable SSL' }, maxPoolSize: { 'ui:title': 'Max Pool Size' }, isFullDn: { 'ui:title': 'Full DN Required' }, - roleAdminName: { 'ui:title': 'Admin Role Name' }, - allAttributeName: { 'ui:title': 'All Attribute Name' }, + roleAdminName: { 'ui:widget': 'hidden', 'ui:hideError': true }, + allAttributeName: { 'ui:widget': 'hidden', 'ui:hideError': true }, mailAttributeName: { 'ui:title': 'Mail Attribute Name' }, usernameAttributeName: { 'ui:widget': 'hidden', 'ui:hideError': true }, groupAttributeName: { 'ui:title': 'Group Attribute Name' }, @@ -209,7 +208,9 @@ export const LDAP_UI_SCHEMA = { 'ui:field': 'RolesSelectField', 'ui:placeholder': 'Select roles to reassign to users on every login', }, - // Show truststoreConfigType when SSL is enabled + // truststoreFormat is redundant with trustStoreConfig sub-fields + truststoreFormat: { 'ui:widget': 'hidden', 'ui:hideError': true }, + // truststoreConfigType visibility is gated on sslEnabled in SSOConfigurationForm truststoreConfigType: { 'ui:title': 'Trust Store Config Type', }, @@ -283,6 +284,8 @@ export const LDAP_UI_SCHEMA = { publicKeyUrls: { 'ui:widget': 'hidden', 'ui:hideError': true }, // Hide tokenValidationAlgorithm for LDAP - global setting, default RS256 works correctly tokenValidationAlgorithm: { 'ui:widget': 'hidden', 'ui:hideError': true }, + // Hide enableAutoRedirect for LDAP - no external IdP redirect + enableAutoRedirect: { 'ui:widget': 'hidden', 'ui:hideError': true }, }; // SAML Configuration UI Schema @@ -306,18 +309,8 @@ export const SAML_UI_SCHEMA = { }, sp: { 'ui:title': 'Service Provider (SP)', - entityId: { - 'ui:title': 'SP Entity ID', - 'ui:readonly': true, - 'ui:help': - 'Auto-generated Service Provider Entity ID. Copy this value and paste it as Entity ID in your SAML Identity Provider configuration.', - }, - acs: { - 'ui:title': 'Assertion Consumer Service URL', - 'ui:readonly': true, - 'ui:help': - 'Auto-generated Assertion Consumer Service URL. Copy this value and paste it as ACS URL (or Reply URL) in your SAML Identity Provider configuration.', - }, + entityId: { 'ui:widget': 'hidden', 'ui:hideError': true }, + acs: { 'ui:widget': 'hidden', 'ui:hideError': true }, callback: { 'ui:widget': 'hidden', 'ui:hideError': true }, spX509Certificate: { 'ui:title': 'SP X.509 Certificate', @@ -364,6 +357,7 @@ export const SAML_UI_SCHEMA = { callbackUrl: { 'ui:widget': 'hidden', 'ui:hideError': true }, // Hide publicKeyUrls for SAML - uses internal LocalJwkProvider publicKeyUrls: { 'ui:widget': 'hidden', 'ui:hideError': true }, + enableAutoRedirect: { 'ui:widget': 'hidden', 'ui:hideError': true }, }; // OIDC Configuration UI Schema @@ -376,21 +370,13 @@ export const OIDC_UI_SCHEMA = { scope: COMMON_UI_FIELDS.oidcScope, discoveryUri: COMMON_UI_FIELDS.oidcDiscoveryUri, useNonce: COMMON_UI_FIELDS.oidcUseNonce, - preferredJwsAlgorithm: { 'ui:widget': 'hidden', 'ui:hideError': true }, - responseType: { 'ui:widget': 'hidden', 'ui:hideError': true }, + preferredJwsAlgorithm: { 'ui:title': 'Preferred JWS Algorithm' }, disablePkce: COMMON_UI_FIELDS.oidcDisablePkce, maxClockSkew: COMMON_UI_FIELDS.oidcMaxClockSkew, clientAuthenticationMethod: COMMON_UI_FIELDS.oidcClientAuthenticationMethod, tokenValidity: COMMON_UI_FIELDS.oidcTokenValidity, customParams: COMMON_UI_FIELDS.oidcCustomParameters, - tenant: COMMON_UI_FIELDS.oidcTenant, - serverUrl: { 'ui:widget': 'hidden', 'ui:hideError': true }, - callbackUrl: { - 'ui:title': 'OIDC Callback URL', - 'ui:readonly': true, - 'ui:help': - 'Auto-generated callback URL. Copy this and register it as Redirect URI in your OIDC provider configuration.', - }, + callbackUrl: { 'ui:widget': 'hidden', 'ui:hideError': true }, maxAge: COMMON_UI_FIELDS.oidcMaxAge, prompt: COMMON_UI_FIELDS.oidcPrompt, sessionExpiry: COMMON_UI_FIELDS.oidcSessionExpiry, @@ -403,7 +389,7 @@ export const OIDC_UI_SCHEMA = { publicKeyUrls: { 'ui:widget': 'hidden', 'ui:hideError': true }, }; -// Standard OAuth/OIDC providers (Auth0, AWS Cognito) - hides clientAuthenticationMethod and tenant +// Standard OAuth/OIDC providers (Auth0, AWS Cognito) - hides clientAuthenticationMethod export const STANDARD_OAUTH_UI_SCHEMA = { ldapConfiguration: { 'ui:widget': 'hidden', 'ui:hideError': true }, samlConfiguration: { 'ui:widget': 'hidden', 'ui:hideError': true }, @@ -415,21 +401,13 @@ export const STANDARD_OAUTH_UI_SCHEMA = { scope: COMMON_UI_FIELDS.oidcScope, discoveryUri: COMMON_UI_FIELDS.oidcDiscoveryUri, useNonce: COMMON_UI_FIELDS.oidcUseNonce, - preferredJwsAlgorithm: { 'ui:widget': 'hidden', 'ui:hideError': true }, - responseType: { 'ui:widget': 'hidden', 'ui:hideError': true }, + preferredJwsAlgorithm: { 'ui:title': 'Preferred JWS Algorithm' }, disablePkce: COMMON_UI_FIELDS.oidcDisablePkce, maxClockSkew: COMMON_UI_FIELDS.oidcMaxClockSkew, clientAuthenticationMethod: { 'ui:widget': 'hidden', 'ui:hideError': true }, tokenValidity: COMMON_UI_FIELDS.oidcTokenValidity, customParams: COMMON_UI_FIELDS.oidcCustomParameters, - tenant: { 'ui:widget': 'hidden', 'ui:hideError': true }, - serverUrl: { 'ui:widget': 'hidden', 'ui:hideError': true }, - callbackUrl: { - 'ui:title': 'OIDC Callback URL', - 'ui:readonly': true, - 'ui:help': - 'Auto-generated callback URL. Copy this and register it as Redirect URI in your OIDC provider configuration.', - }, + callbackUrl: { 'ui:widget': 'hidden', 'ui:hideError': true }, maxAge: COMMON_UI_FIELDS.oidcMaxAge, prompt: COMMON_UI_FIELDS.oidcPrompt, sessionExpiry: COMMON_UI_FIELDS.oidcSessionExpiry, @@ -440,7 +418,7 @@ export const STANDARD_OAUTH_UI_SCHEMA = { publicKeyUrls: { 'ui:widget': 'hidden', 'ui:hideError': true }, }; -// Azure-specific UI schema with required tenant for confidential client +// Azure-specific UI schema export const AZURE_OAUTH_UI_SCHEMA = { ldapConfiguration: { 'ui:widget': 'hidden', 'ui:hideError': true }, samlConfiguration: { 'ui:widget': 'hidden', 'ui:hideError': true }, @@ -452,21 +430,13 @@ export const AZURE_OAUTH_UI_SCHEMA = { scope: COMMON_UI_FIELDS.oidcScope, discoveryUri: COMMON_UI_FIELDS.oidcDiscoveryUri, useNonce: COMMON_UI_FIELDS.oidcUseNonce, - preferredJwsAlgorithm: { 'ui:widget': 'hidden', 'ui:hideError': true }, - responseType: { 'ui:widget': 'hidden', 'ui:hideError': true }, + preferredJwsAlgorithm: { 'ui:title': 'Preferred JWS Algorithm' }, disablePkce: COMMON_UI_FIELDS.oidcDisablePkce, maxClockSkew: COMMON_UI_FIELDS.oidcMaxClockSkew, clientAuthenticationMethod: { 'ui:widget': 'hidden', 'ui:hideError': true }, tokenValidity: COMMON_UI_FIELDS.oidcTokenValidity, customParams: COMMON_UI_FIELDS.oidcCustomParameters, - tenant: COMMON_UI_FIELDS.oidcTenant, - serverUrl: { 'ui:widget': 'hidden', 'ui:hideError': true }, - callbackUrl: { - 'ui:title': 'OIDC Callback URL', - 'ui:readonly': true, - 'ui:help': - 'Auto-generated callback URL. Copy this and register it as Redirect URI in your OIDC provider configuration.', - }, + callbackUrl: { 'ui:widget': 'hidden', 'ui:hideError': true }, maxAge: COMMON_UI_FIELDS.oidcMaxAge, prompt: COMMON_UI_FIELDS.oidcPrompt, sessionExpiry: COMMON_UI_FIELDS.oidcSessionExpiry, @@ -477,7 +447,7 @@ export const AZURE_OAUTH_UI_SCHEMA = { publicKeyUrls: { 'ui:widget': 'hidden', 'ui:hideError': true }, }; -// Okta-specific UI schema - keeps clientAuthenticationMethod visible, hides tenant +// Okta-specific UI schema - keeps clientAuthenticationMethod visible export const OKTA_OAUTH_UI_SCHEMA = { ldapConfiguration: { 'ui:widget': 'hidden', 'ui:hideError': true }, samlConfiguration: { 'ui:widget': 'hidden', 'ui:hideError': true }, @@ -489,21 +459,13 @@ export const OKTA_OAUTH_UI_SCHEMA = { scope: COMMON_UI_FIELDS.oidcScope, discoveryUri: COMMON_UI_FIELDS.oidcDiscoveryUri, useNonce: COMMON_UI_FIELDS.oidcUseNonce, - preferredJwsAlgorithm: { 'ui:widget': 'hidden', 'ui:hideError': true }, - responseType: { 'ui:widget': 'hidden', 'ui:hideError': true }, + preferredJwsAlgorithm: { 'ui:title': 'Preferred JWS Algorithm' }, disablePkce: COMMON_UI_FIELDS.oidcDisablePkce, maxClockSkew: COMMON_UI_FIELDS.oidcMaxClockSkew, clientAuthenticationMethod: COMMON_UI_FIELDS.oidcClientAuthenticationMethod, tokenValidity: COMMON_UI_FIELDS.oidcTokenValidity, customParams: COMMON_UI_FIELDS.oidcCustomParameters, - tenant: { 'ui:widget': 'hidden', 'ui:hideError': true }, - serverUrl: { 'ui:widget': 'hidden', 'ui:hideError': true }, - callbackUrl: { - 'ui:title': 'OIDC Callback URL', - 'ui:readonly': true, - 'ui:help': - 'Auto-generated callback URL. Copy this and register it as Redirect URI in your OIDC provider configuration.', - }, + callbackUrl: { 'ui:widget': 'hidden', 'ui:hideError': true }, maxAge: COMMON_UI_FIELDS.oidcMaxAge, prompt: COMMON_UI_FIELDS.oidcPrompt, sessionExpiry: COMMON_UI_FIELDS.oidcSessionExpiry, @@ -525,32 +487,24 @@ export const GOOGLE_OAUTH_UI_SCHEMA = { secret: COMMON_UI_FIELDS.oidcClientSecret, scope: COMMON_UI_FIELDS.oidcScope, discoveryUri: { - 'ui:title': 'OIDC Discovery URI', + 'ui:title': 'Discovery URI', 'ui:placeholder': GOOGLE_SSO_DEFAULTS.discoveryUri, }, useNonce: COMMON_UI_FIELDS.oidcUseNonce, - preferredJwsAlgorithm: { 'ui:widget': 'hidden', 'ui:hideError': true }, - responseType: { 'ui:widget': 'hidden', 'ui:hideError': true }, + preferredJwsAlgorithm: { 'ui:title': 'Preferred JWS Algorithm' }, disablePkce: COMMON_UI_FIELDS.oidcDisablePkce, maxClockSkew: COMMON_UI_FIELDS.oidcMaxClockSkew, clientAuthenticationMethod: { 'ui:widget': 'hidden', 'ui:hideError': true }, tokenValidity: { - 'ui:title': 'OIDC Token Validity', + 'ui:title': 'Token Validity (seconds)', 'ui:placeholder': `Default: ${OIDC_SSO_DEFAULTS.tokenValidity}`, }, customParams: COMMON_UI_FIELDS.oidcCustomParameters, - tenant: { 'ui:widget': 'hidden', 'ui:hideError': true }, - serverUrl: { 'ui:widget': 'hidden', 'ui:hideError': true }, - callbackUrl: { - 'ui:title': 'OIDC Callback URL', - 'ui:readonly': true, - 'ui:help': - 'Auto-generated callback URL. Copy this and register it as Redirect URI in your OIDC provider configuration.', - }, + callbackUrl: { 'ui:widget': 'hidden', 'ui:hideError': true }, maxAge: COMMON_UI_FIELDS.oidcMaxAge, prompt: COMMON_UI_FIELDS.oidcPrompt, sessionExpiry: { - 'ui:title': 'OIDC Session Expiry', + 'ui:title': 'Session Expiry (seconds)', 'ui:placeholder': `Default: ${OIDC_SSO_DEFAULTS.sessionExpiry}`, }, }, @@ -631,11 +585,20 @@ export const AUTHORIZER_FIELD_TITLES = { 'ui:title': 'Enable Secure Socket Connection', }, useRolesFromProvider: { 'ui:title': 'Use Roles From Provider' }, + defaultOAuthRole: { + 'ui:title': 'Default OAuth Role', + 'ui:placeholder': 'e.g. DataConsumer', + }, allowedEmailRegistrationDomains: { 'ui:title': 'Allowed Email Registration Domains', 'ui:placeholder': 'Enter domain (e.g. example.com) and press ENTER. Use "all" to allow all domains.', }, + allowedDomains: { + 'ui:title': 'Allowed Domains', + 'ui:placeholder': + 'Enter domain (e.g. example.com) and press ENTER. Used for CORS / referrer whitelisting.', + }, }; // Type definitions for UI Schema @@ -654,7 +617,7 @@ interface UISchemaObject { export const PROVIDER_UI_SCHEMAS: Record = { ldap: LDAP_UI_SCHEMA, saml: SAML_UI_SCHEMA, - customoidc: OIDC_UI_SCHEMA, + 'custom-oidc': OIDC_UI_SCHEMA, google: GOOGLE_OAUTH_UI_SCHEMA, auth0: STANDARD_OAUTH_UI_SCHEMA, azure: AZURE_OAUTH_UI_SCHEMA, @@ -687,7 +650,7 @@ export const ALLOWED_EMAIL_REGISTRATION_DOMAINS_VISIBILITY: Record< > = { ldap: { 'ui:widget': 'hidden', 'ui:hideError': true }, saml: { 'ui:widget': 'hidden', 'ui:hideError': true }, - customoidc: { 'ui:widget': 'hidden', 'ui:hideError': true }, + 'custom-oidc': { 'ui:widget': 'hidden', 'ui:hideError': true }, google: { 'ui:widget': 'hidden', 'ui:hideError': true }, auth0: { 'ui:widget': 'hidden', 'ui:hideError': true }, azure: { 'ui:widget': 'hidden', 'ui:hideError': true }, @@ -704,7 +667,12 @@ export const PROVIDER_FIELD_MAPPINGS: Record = { 'tokenValidationAlgorithm', 'enableSelfSignup', ], - customoidc: ['ldapConfiguration', 'samlConfiguration', 'enableSelfSignup'], + 'custom-oidc': [ + 'ldapConfiguration', + 'samlConfiguration', + 'oidcConfiguration', + 'enableSelfSignup', + ], google: [ 'ldapConfiguration', 'samlConfiguration', @@ -744,10 +712,7 @@ export const PROVIDER_FIELD_MAPPINGS: Record = { }; // Common fields to always remove from authentication configuration -export const COMMON_AUTH_FIELDS_TO_REMOVE = [ - 'responseType', - 'forceSecureSessionCookie', -]; +export const COMMON_AUTH_FIELDS_TO_REMOVE = ['forceSecureSessionCookie']; // Hardcoded authorizer values export const DEFAULT_AUTHORIZER_CLASS_NAME = @@ -784,6 +749,7 @@ export const getSSOUISchema = ( const commonSchema = { authenticationConfiguration: { 'ui:title': ' ', // Hide the title with a space to prevent rendering + 'ui:order': ['*', 'callbackUrl'], ...COMMON_HIDDEN_FIELDS, ...COMMON_FIELD_TITLES, ...providerSchema, @@ -830,6 +796,46 @@ export const VALIDATION_STATUS = { FAILED: 'failed', } as const; +export interface OidcAuthConfiguration { + id?: string; + secret?: string; + discoveryUri?: string; + scope?: string; + callbackUrl?: string; + prompt?: string; + disablePkce?: boolean; + useNonce?: boolean; + clientAuthenticationMethod?: string; + // Schema stores maxAge as an integer, but form-payload paths emit a string; + // both shapes flow through this interface. + maxAge?: string | number; + customParams?: Record; + [key: string]: unknown; +} + +export interface SamlIdpConfiguration { + entityId?: string; + ssoLoginUrl?: string; + idpX509Certificate?: string; + nameId?: string; + [key: string]: unknown; +} + +export interface SamlSpConfiguration { + entityId?: string; + acs?: string; + callback?: string; + [key: string]: unknown; +} + +export interface SamlAuthConfiguration { + idp?: SamlIdpConfiguration; + sp?: SamlSpConfiguration; + [key: string]: unknown; +} + +export type LdapAuthConfiguration = Record; + export interface AuthenticationConfiguration { provider: string; providerName: string; @@ -844,9 +850,10 @@ export interface AuthenticationConfiguration { enableAutoRedirect?: boolean; clientType?: ClientType; secret?: string; - ldapConfiguration?: Record; - samlConfiguration?: Record; - oidcConfiguration?: Record; + discoveryUri?: string; + ldapConfiguration?: LdapAuthConfiguration; + samlConfiguration?: SamlAuthConfiguration; + oidcConfiguration?: OidcAuthConfiguration; } export interface AuthorizerConfiguration { @@ -858,3 +865,207 @@ export interface AuthorizerConfiguration { enableSecureSocketConnection: boolean; botPrincipals?: string[]; } + +export type SSOFieldTier = 'main' | 'advanced'; +export type SSOSectionLayout = Record; +export type SSOFieldLayout = Record; + +const OIDC_CONFIDENTIAL_AUTH_ROOT: SSOSectionLayout = { + oidcConfiguration: 'main', + callbackUrl: 'main', + jwtPrincipalClaims: 'advanced', + jwtPrincipalClaimsMapping: 'advanced', + jwtTeamClaimMapping: 'advanced', + enableAutoRedirect: 'advanced', +}; + +const OIDC_CONFIDENTIAL_SUBSECTION: SSOSectionLayout = { + id: 'main', + discoveryUri: 'main', + secret: 'main', + scope: 'advanced', + clientAuthenticationMethod: 'advanced', + prompt: 'advanced', + useNonce: 'advanced', + disablePkce: 'advanced', + customParams: 'advanced', + tokenValidity: 'advanced', + sessionExpiry: 'advanced', + maxAge: 'advanced', + maxClockSkew: 'advanced', + preferredJwsAlgorithm: 'advanced', +}; + +const SAML_AUTH_ROOT: SSOSectionLayout = { + samlConfiguration: 'main', + responseType: 'advanced', + providerName: 'advanced', +}; + +const SAML_SUBSECTION: SSOSectionLayout = { + idp: 'main', + sp: 'advanced', + security: 'advanced', + debugMode: 'advanced', + samlDisplayNameAttributes: 'advanced', +}; + +const LDAP_AUTH_ROOT: SSOSectionLayout = { + ldapConfiguration: 'main', + responseType: 'advanced', + providerName: 'advanced', +}; + +const LDAP_SUBSECTION: SSOSectionLayout = { + host: 'main', + port: 'main', + dnAdminPrincipal: 'main', + dnAdminPassword: 'main', + userBaseDN: 'main', + mailAttributeName: 'main', + sslEnabled: 'advanced', + maxPoolSize: 'advanced', + isFullDn: 'advanced', + truststoreConfigType: 'advanced', + trustStoreConfig: 'advanced', + groupBaseDN: 'advanced', + groupAttributeName: 'advanced', + groupAttributeValue: 'advanced', + groupMemberAttributeName: 'advanced', + authRolesMapping: 'advanced', + authReassignRoles: 'advanced', +}; + +const AUTHORIZER_LAYOUT: SSOSectionLayout = { + adminPrincipals: 'main', + principalDomain: 'main', + enforcePrincipalDomain: 'advanced', + useRolesFromProvider: 'advanced', + defaultOAuthRole: 'advanced', + allowedEmailRegistrationDomains: 'advanced', + allowedDomains: 'advanced', + enableSelfSignup: 'advanced', + enableSecureSocketConnection: 'advanced', +}; + +const OIDC_CONFIDENTIAL_FIELD_LAYOUT: SSOFieldLayout = { + authenticationConfiguration: OIDC_CONFIDENTIAL_AUTH_ROOT, + 'authenticationConfiguration/oidcConfiguration': OIDC_CONFIDENTIAL_SUBSECTION, + authorizerConfiguration: AUTHORIZER_LAYOUT, +}; + +const SAML_FIELD_LAYOUT: SSOFieldLayout = { + authenticationConfiguration: SAML_AUTH_ROOT, + 'authenticationConfiguration/samlConfiguration': SAML_SUBSECTION, + authorizerConfiguration: AUTHORIZER_LAYOUT, +}; + +const LDAP_FIELD_LAYOUT: SSOFieldLayout = { + authenticationConfiguration: LDAP_AUTH_ROOT, + 'authenticationConfiguration/ldapConfiguration': LDAP_SUBSECTION, + authorizerConfiguration: AUTHORIZER_LAYOUT, +}; + +export const OIDC_PROVIDERS: ReadonlySet = new Set([ + AuthProvider.Google, + AuthProvider.Auth0, + AuthProvider.Azure, + AuthProvider.Okta, + AuthProvider.AwsCognito, + AuthProvider.CustomOidc, +]); + +export const getProviderFieldLayout = ( + provider: string | undefined +): SSOFieldLayout | undefined => { + if (!provider) { + return undefined; + } + + if (provider === AuthProvider.Saml) { + return SAML_FIELD_LAYOUT; + } + + if (provider === AuthProvider.LDAP) { + return LDAP_FIELD_LAYOUT; + } + + if (OIDC_PROVIDERS.has(provider)) { + return OIDC_CONFIDENTIAL_FIELD_LAYOUT; + } + + return undefined; +}; + +export const hasAnyAdvancedFields = ( + fieldLayout: SSOFieldLayout | undefined +): boolean => { + if (!fieldLayout) { + return false; + } + + for (const sectionLayout of Object.values(fieldLayout)) { + for (const tier of Object.values(sectionLayout)) { + if (tier === 'advanced') { + return true; + } + } + } + + return false; +}; + +const OIDC_LOCKOUT_RISK_FIELDS: ReadonlySet = new Set([ + 'root/authenticationConfiguration/discoveryUri', + 'root/authenticationConfiguration/clientId', + 'root/authenticationConfiguration/callbackUrl', + 'root/authenticationConfiguration/emailClaim', + 'root/authenticationConfiguration/jwtPrincipalClaims', + 'root/authenticationConfiguration/jwtPrincipalClaimsMapping', + 'root/authenticationConfiguration/oidcConfiguration/id', + 'root/authenticationConfiguration/oidcConfiguration/secret', + 'root/authenticationConfiguration/oidcConfiguration/scope', + 'root/authenticationConfiguration/oidcConfiguration/prompt', + 'root/authenticationConfiguration/oidcConfiguration/disablePkce', + 'root/authenticationConfiguration/oidcConfiguration/clientAuthenticationMethod', + 'root/authenticationConfiguration/oidcConfiguration/callbackUrl', + 'root/authenticationConfiguration/oidcConfiguration/discoveryUri', +]); + +const SAML_LOCKOUT_RISK_FIELDS: ReadonlySet = new Set([ + 'root/authenticationConfiguration/samlConfiguration/idp/entityId', + 'root/authenticationConfiguration/samlConfiguration/idp/ssoLoginUrl', + 'root/authenticationConfiguration/samlConfiguration/idp/idpX509Certificate', + 'root/authenticationConfiguration/samlConfiguration/idp/nameId', +]); + +const LDAP_LOCKOUT_RISK_FIELDS: ReadonlySet = new Set([ + 'root/authenticationConfiguration/ldapConfiguration/host', + 'root/authenticationConfiguration/ldapConfiguration/port', + 'root/authenticationConfiguration/ldapConfiguration/dnAdminPrincipal', + 'root/authenticationConfiguration/ldapConfiguration/dnAdminPassword', + 'root/authenticationConfiguration/ldapConfiguration/userBaseDN', + 'root/authenticationConfiguration/ldapConfiguration/mailAttributeName', +]); + +export const getLockoutRiskFields = ( + provider: string | undefined +): ReadonlySet => { + if (!provider) { + return new Set(); + } + + if (provider === AuthProvider.Saml) { + return SAML_LOCKOUT_RISK_FIELDS; + } + + if (provider === AuthProvider.LDAP) { + return LDAP_LOCKOUT_RISK_FIELDS; + } + + if (OIDC_PROVIDERS.has(provider)) { + return OIDC_LOCKOUT_RISK_FIELDS; + } + + return new Set(); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts index 5627e025bb74..4e1249dd2b9f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts @@ -30,6 +30,16 @@ export interface AuthenticationConfiguration { * Client Type */ clientType?: ClientType; + /** + * OIDC Discovery endpoint URL. Primary input for both public and confidential flows. + * Authority, publicKeyUrls, and other endpoints are auto-derived from this. + */ + discoveryUri?: string; + /** + * JWT claim name containing the user's email address. Set via Test Login. Takes priority + * over jwtPrincipalClaims fallback but not over jwtPrincipalClaimsMapping. + */ + emailClaim?: string; /** * Enable automatic redirect from the sign-in page to the configured SSO provider. */ @@ -339,7 +349,7 @@ export interface OidcClientConfig { /** * Client Secret. */ - secret: string; + secret?: string; /** * Server Url. */ diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authorizerConfiguration.ts b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authorizerConfiguration.ts index c0d7b9ea66ef..b5ff8ce39303 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authorizerConfiguration.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authorizerConfiguration.ts @@ -17,7 +17,7 @@ export interface AuthorizerConfiguration { /** * List of unique admin principals. */ - adminPrincipals: string[]; + adminPrincipals?: string[]; /** * Allowed Domains to access */ @@ -54,7 +54,7 @@ export interface AuthorizerConfiguration { /** * Principal Domain */ - principalDomain: string; + principalDomain?: string; /** * List of unique principals used as test users. **NOTE THIS IS ONLY FOR TEST SETUP AND NOT * TO BE USED IN PRODUCTION SETUP** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts index 58e6edcd13c7..ca76a9146f83 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts @@ -46,6 +46,16 @@ export interface AuthenticationConfiguration { * Client Type */ clientType?: ClientType; + /** + * OIDC Discovery endpoint URL. Primary input for both public and confidential flows. + * Authority, publicKeyUrls, and other endpoints are auto-derived from this. + */ + discoveryUri?: string; + /** + * JWT claim name containing the user's email address. Set via Test Login. Takes priority + * over jwtPrincipalClaims fallback but not over jwtPrincipalClaimsMapping. + */ + emailClaim?: string; /** * Enable automatic redirect from the sign-in page to the configured SSO provider. */ @@ -355,7 +365,7 @@ export interface OidcClientConfig { /** * Client Secret. */ - secret: string; + secret?: string; /** * Server Url. */ @@ -566,7 +576,7 @@ export interface AuthorizerConfiguration { /** * List of unique admin principals. */ - adminPrincipals: string[]; + adminPrincipals?: string[]; /** * Allowed Domains to access */ @@ -603,7 +613,7 @@ export interface AuthorizerConfiguration { /** * Principal Domain */ - principalDomain: string; + principalDomain?: string; /** * List of unique principals used as test users. **NOTE THIS IS ONLY FOR TEST SETUP AND NOT * TO BE USED IN PRODUCTION SETUP** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/security/client/oidcClientConfig.ts b/openmetadata-ui/src/main/resources/ui/src/generated/security/client/oidcClientConfig.ts index c04eb72d88ce..1297ae928cae 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/security/client/oidcClientConfig.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/security/client/oidcClientConfig.ts @@ -65,7 +65,7 @@ export interface OidcClientConfig { /** * Client Secret. */ - secret: string; + secret?: string; /** * Server Url. */ diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts b/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts index 4fca2ea25ab8..bf7ae6107f43 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts @@ -196,6 +196,16 @@ export interface PipelineServiceClientConfiguration { * Client Type */ clientType?: ClientType; + /** + * OIDC Discovery endpoint URL. Primary input for both public and confidential flows. + * Authority, publicKeyUrls, and other endpoints are auto-derived from this. + */ + discoveryUri?: string; + /** + * JWT claim name containing the user's email address. Set via Test Login. Takes priority + * over jwtPrincipalClaims fallback but not over jwtPrincipalClaimsMapping. + */ + emailClaim?: string; /** * Enable automatic redirect from the sign-in page to the configured SSO provider. */ @@ -1072,6 +1082,16 @@ export interface AuthenticationConfiguration { * Client Type */ clientType?: ClientType; + /** + * OIDC Discovery endpoint URL. Primary input for both public and confidential flows. + * Authority, publicKeyUrls, and other endpoints are auto-derived from this. + */ + discoveryUri?: string; + /** + * JWT claim name containing the user's email address. Set via Test Login. Takes priority + * over jwtPrincipalClaims fallback but not over jwtPrincipalClaimsMapping. + */ + emailClaim?: string; /** * Enable automatic redirect from the sign-in page to the configured SSO provider. */ @@ -1381,7 +1401,7 @@ export interface OidcClientConfig { /** * Client Secret. */ - secret: string; + secret?: string; /** * Server Url. */ @@ -1575,7 +1595,7 @@ export interface AuthorizerConfiguration { /** * List of unique admin principals. */ - adminPrincipals: string[]; + adminPrincipals?: string[]; /** * Allowed Domains to access */ @@ -1612,7 +1632,7 @@ export interface AuthorizerConfiguration { /** * Principal Domain */ - principalDomain: string; + principalDomain?: string; /** * List of unique principals used as test users. **NOTE THIS IS ONLY FOR TEST SETUP AND NOT * TO BE USED IN PRODUCTION SETUP** diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json index e2566ea515f5..bdef27bd5567 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json @@ -23,6 +23,7 @@ "accuracy": "الدقة", "ack": "إقرار", "acknowledged": "تم الإقرار", + "acs-url": "ACS URL", "action": "إجراء", "action-plural": "إجراءات", "action-required": "مطلوب إجراء", @@ -85,6 +86,7 @@ "address": "العنوان", "admin": "المسؤول", "admin-plural": "المسؤولون", + "admin-principal": "Admin Principal", "admin-profile": "ملف تعريف المسؤول", "admin-uppercase": "المسؤول", "advance-filter": "مرشح متقدم", @@ -92,6 +94,7 @@ "advanced-config": "تكوين متقدم", "advanced-configuration": "تكوين متقدم", "advanced-entity": "{{entity}} متقدم", + "advanced-fields": "Advanced Fields", "advanced-search": "بحث متقدم", "agent-activity": "Agent Activity", "agent-plural": "الوكلاء", @@ -196,6 +199,7 @@ "authority": "السلطة", "authorize-app": "ترخيص {{app}}", "auto-classification": "تصنيف تلقائي", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "نقاط ثقة PII التلقائية", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "وسم PII تلقائي", @@ -256,6 +260,7 @@ "by-relation-type": "حسب نوع العلاقة", "ca-certs": "شهادات المرجع المصدق (CA Certs)", "calculated-from": "محسوب من", + "callback-url": "Callback URL", "cancel": "إلغاء", "cancel-lowercase": "إلغاء", "cardinality": "عدد العناصر", @@ -269,6 +274,7 @@ "change-log-plural": "سجلات التغيير", "change-parent-entity": "تغيير {{entity}} الأصل", "change-password": "تغيير كلمة المرور", + "change-via-test-login": "Change via Test Login", "chart": "مخطط بياني", "chart-entity": "مخطط {{entity}}", "chart-plural": "مخططات بيانية", @@ -381,6 +387,7 @@ "confirm": "تأكيد", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "تأكيد", "confirm-new-password": "تأكيد كلمة المرور الجديدة", "confirm-password": "تأكيد كلمة المرور الخاصة بك", @@ -655,6 +662,7 @@ "disabled": "معطّل", "discard": "تجاهل", "discover": "اكتشاف", + "dismiss": "Dismiss", "display-name": "اسم العرض", "display-name-lowercase": "اسم العرض", "display-text": "نص العرض", @@ -714,9 +722,11 @@ "elastic-search-re-index": "إعادة فهرسة Elasticsearch", "elasticsearch": "Elasticsearch", "email": "البريد الإلكتروني", + "email-claim": "Email Claim", "email-configuration": "تكوين البريد الإلكتروني", "email-configuration-lowercase": "تكوين البريد الإلكتروني", "email-lowercase": "البريد الإلكتروني", + "email-or-username": "Email or Username", "email-plural": "رسائل البريد الإلكتروني", "emailing-entity": "إرسال {{entity}} بالبريد الإلكتروني", "embed-file-type": "تضمين {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "المفتاح الأساسي", "primary-key-plural": "المفاتيح الأساسية", "primary-shards": "Primary Shards", + "principal-domain": "Principal Domain", "privacy-policy": "سياسة الخصوصية", "private-key": "المفتاح الخاص", "private-key-id": "معرف المفتاح الخاص", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "إعادة ترتيب العقد", "reason": "السبب", "reasons-for-decision": "أسباب القرار", + "received": "Received", "receiver-plural": "المستقبلون", "recent-announcement-plural": "الإعلانات الأخيرة", "recent-event-plural": "الأحداث الأخيرة", @@ -1697,8 +1709,10 @@ "refresh-entity": "تحديث {{entity}}", "refresh-frequency": "تردد التحديث", "refresh-log": "تحديث السجل", + "refresh-token": "Refresh Token", "regenerate-registration-token": "إعادة توليد رمز التسجيل", "region-name": "اسم المنطقة", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "السجل", "regular-expression": "تعبير عادي", "reindex-failure-plural": "Reindex Failures", @@ -1818,6 +1832,7 @@ "run-agent-plural": "تشغيل الوكلاء", "run-at": "تشغيل في", "run-now": "تشغيل الآن", + "run-test-login": "Run Test Login", "run-type": "نوع التشغيل", "running": "قيد التشغيل", "running-ellipsis": "جارٍ التشغيل...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Select conflict resolution", "select-dimension": "تحديد البعد", "select-duration": "Select Duration", + "select-email-claim": "Select Email Claim", "select-entity": "تحديد {{entity}}", "select-entity-type": "تحديد نوع الكيان", "select-field": "تحديد حقل", @@ -1946,8 +1962,10 @@ "set-as-default": "تعيين كافتراضي", "set-default-entity": "تعيين {{entity}} الافتراضي", "set-default-filters": "تعيين المرشحات الافتراضية", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "إعداد", "set-up-kpi": "إعداد مؤشر الأداء الرئيسي", + "set-via-test-login": "Set via Test Login", "setting-plural": "الإعدادات", "setup-guide": "دليل الإعداد", "severity": "الخطورة", @@ -1999,6 +2017,7 @@ "source-provider": "Source Provider", "source-url": "رابط المصدر", "source-with-details": "المصدر: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "أصول بيانات محددة", "spreadsheet": "جدول بيانات", "spreadsheet-plural": "جداول بيانات", @@ -2158,6 +2177,7 @@ "test-entity": "اختبار {{entity}}", "test-level-lowercase": "مستوى الاختبار", "test-library": "مكتبة الاختبار", + "test-login": "Test Login", "test-platform-plural": "Test Platforms", "test-plural": "اختبارات", "test-plural-type": "{{type}} اختبارات", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "اختر كيفية تشغيل سير العمل", "choose-import-mode": "اختر طريقة استيراد عقد ODCS", "choose-which-assets-this-workflow-can-act-on": "اختر الأصول التي يمكن أن يعمل عليها هذا سير العمل.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(انقر لعرض الأصول المصفاة في صفحة الاستكشاف.)", "click-text-to-view-details": "انقر على <0>{{text}} لعرض التفاصيل.", + "client-id-required": "Client ID is required.", "closed-this-task": "أغلق هذه المهمة", "collaborate-with-other-user": "للتعاون مع المستخدمين الآخرين.", "collate-ai-widget-description": "نظرة عامة على البيانات التي تم إنشاؤها بواسطة Collate AI للخدمة. <0>تعلم المزيد.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "هل تريد تجاهل التغييرات؟", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "أصبحت الأمور أسهل مع جودة البيانات بدون رمز. خطوات بسيطة للاختبار والنشر وجمع النتائج، مع إشعارات فورية بفشل الاختبار. ابقَ على اطلاع دائم ببيانات موثوقة يمكنك الوثوق بها.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Percentage of distinct values in the column", "domain-change-asset-migration-warning": "Changing the domain will move {{count}} asset(s) from the current domain to {{domain}}. Do you want to proceed?", "domain-description": "نظّم وأدر نطاقات البيانات في مؤسستك.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "تأكد من أن فهارس Elasticsearch الخاصة بك محدثة عن طريق المزامنة أو إعادة إنشاء جميع الفهارس.", "elastic-search-re-index-pipeline-description": "يُستخدم سير عمل فهرس البحث لإعادة فهرسة البيانات في elasticsearch. ارجع إلى وثائقنا لمعرفة المزيد <0>{{link}}", "elasticsearch-setup": "يرجى اتباع التعليمات هنا لإعداد استيعاب البيانات الوصفية وفهرستها في Elasticsearch.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "تكوين إعدادات SMTP لإرسال رسائل البريد الإلكتروني.", "email-is-invalid": "بريد إلكتروني غير صالح.", "email-verification-token-expired": "انتهت صلاحية رمز التحقق من البريد الإلكتروني", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "ملاحظة: لم يتم تحقيق هدف مؤشر أداء الوصف بعد، ولكن لا يزال هناك وقت – بقي {{count}} يومًا لمؤسستك. للبقاء على المسار الصحيح، يرجى تفعيل تقرير رؤى البيانات. سيسمح لنا هذا بإرسال تحديثات أسبوعية إلى جميع الفرق، مما يعزز التعاون والتركيز نحو تحقيق مؤشرات الأداء الرئيسية لمؤسستنا.", "latency-sla-description": "<0>{{label}}: يجب أن تكون استجابة الاستعلام أقل من <0>{{data}}", "latest-offset-description": "أحدث إزاحة للحدث في النظام.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "مثال: cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "هذا DN لمجموعة LDAP مُعيَّن بالفعل. يمكن تعيين كل مجموعة LDAP مرة واحدة فقط.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "استكشف ميزات المنتج وتعلم كيفية عملها من خلال مواردنا", "leave-the-team-team-name": "مغادرة الفريق {{teamName}}", "length-validator-error": "مطلوب ما لا يقل عن {{length}} {{field}}", @@ -3011,6 +3039,7 @@ "no-profiler-title": "شغّل محلل البيانات لفتح رؤى الجدول", "no-recently-viewed-date": "لم تقم بعرض أي أصول بيانات مؤخرًا. استكشف للعثور على شيء مثير للاهتمام!", "no-reference-available": "لا توجد مراجع متاحة.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "لا توجد مصطلحات ذات صلة متاحة.", "no-relations-for-selected-filter": "لم يتم العثور على علاقات لأنواع العلاقة المحددة. حاول تحديد أنواع مختلفة.", "no-relations-found": "لم يتم العثور على علاقات لهذا المصطلح", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "عدد الكيانات المراد معالجتها في كل دفعة", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "متجر مركزي للبيانات الوصفية، للاكتشاف والتعاون والحصول على بياناتك بشكل صحيح.", "om-url-configuration-message": "قم بتكوين إعدادات عنوان URL لـ OpenMetadata.", "on-demand-description": "شغل الاستيعاب يدوياً.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Please select any one action below.", "please-type-text-to-confirm": "يرجى كتابة {{text}} للتأكيد.", "popup-block-message": "تم حظر النافذة المنبثقة لتسجيل الدخول بواسطة المتصفح. يرجى <0>تفعيلها والمحاولة مرة أخرى.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "تحقق من أسماء الأعمدة لوضع علامة تلقائية على أعمدة معلومات التعريف الشخصية الحساسة/غير الحساسة (PII Senstive/nonSensitive).", "process-pii-sensitive-column-message-profiler": "عند التفعيل، سيتم تحليل عينة البيانات لتحديد علامات معلومات التعريف الشخصية (PII) المناسبة لكل عمود", "processed-all-events-description": "يشير إلى ما إذا كان قد تم معالجة جميع الأحداث.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "جارٍ إعادة التوجيه إلى الصفحة الرئيسية", "refer-to-our-doc": "هل ما زلت بحاجة إلى مساعدة؟ ارجع إلى <0>{{doc}} الخاص بنا لمزيد من المعلومات.", "refresh-frequency-contract-description": "التردد المتوقع لتحديثات البيانات", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "رمز لون سداسي عشري يستخدم لتصور نوع العلاقة هذا في رسم الأنطولوجيا البياني (مثلاً، #1890ff).", "relation-type-in-use-count": "مستخدم بواسطة {{count}} علاقة مصطلح", "relation-type-not-in-use": "غير مستخدم حالياً", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "هل أنت متأكد أنك تريد إزالة الحافة بين \"{{sourceDisplayName}} و {{targetDisplayName}}\"؟.", "remove-lineage-edge": "إزالة حافة السلالة", "rename-entity": "إعادة تسمية الاسم واسم العرض لـ {{entity}}.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "طلب موافقة لـ", "request-approval-notification": "مطلوب الموافقة لـ", "request-description": "وصف الطلب", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: يجب الاحتفاظ بالبيانات لمدة <0>{{data}}", "run-sample-data-to-ingest-sample-data": "'شغل عينة البيانات لاستيعاب أصول البيانات النموذجية في OpenMetadata الخاص بك.'", "run-status-at-timestamp": "حالة التشغيل: {{status}} في {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "يحدد هذا المخطط المعلمات التي يمكن تمريرها لجمع بيانات العينة.", "schedule-description": "جدولة الاستيعاب ليتم تشغيله في وقت وتكرار محددين.", "schedule-entity-description": "سيتم تشغيل {{entity}} هذا بشكل متكرر بناءً على جدولك الزمني.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "لا يمكن التراجع عن هذا الإجراء.", "unauthorized-user": "مستخدم غير مصرح به! يرجى التحقق من البريد الإلكتروني أو كلمة المرور", "unexpected-error": "حدث خطأ غير متوقع.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index 33f50677a589..183d6bac77be 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -23,6 +23,7 @@ "accuracy": "Genauigkeit", "ack": "Bestätigung", "acknowledged": "Bestätigt", + "acs-url": "ACS URL", "action": "Aktion", "action-plural": "Weitere Aktionen", "action-required": "Aktion erforderlich", @@ -85,6 +86,7 @@ "address": "Adresse", "admin": "Administrator", "admin-plural": "Administratoren", + "admin-principal": "Admin Principal", "admin-profile": "Admin-Profil", "admin-uppercase": "ADMINISTRATOR", "advance-filter": "Erweiterter Filter", @@ -92,6 +94,7 @@ "advanced-config": "Erweiterte Konfiguration", "advanced-configuration": "Erweiterte Konfiguration", "advanced-entity": "Erweitertes {{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "Erweiterte Suche", "agent-activity": "Agentenaktivität", "agent-plural": "Agenten", @@ -196,6 +199,7 @@ "authority": "Behörde", "authorize-app": "{{app}} autorisieren", "auto-classification": "Automatische Klassifizierung", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "Auto PII-Vertrauensscore", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "Auto PII-Tag", @@ -256,6 +260,7 @@ "by-relation-type": "Nach Beziehungstyp", "ca-certs": "CA-Zertifikate", "calculated-from": "Berechnet aus", + "callback-url": "Callback URL", "cancel": "Abbrechen", "cancel-lowercase": "abbrechen", "cardinality": "Kardinalität", @@ -269,6 +274,7 @@ "change-log-plural": "Änderungsprotokolle", "change-parent-entity": "Eltern-{{entity}} ändern", "change-password": "Kommentare", + "change-via-test-login": "Change via Test Login", "chart": "Diagramm", "chart-entity": "{{entity}} Diagramm", "chart-plural": "Diagramme", @@ -381,6 +387,7 @@ "confirm": "Bestätigen", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "bestätigen", "confirm-new-password": "Neues Passwort bestätigen", "confirm-password": "Passwort bestätigen", @@ -655,6 +662,7 @@ "disabled": "Deaktiviert", "discard": "Verwerfen", "discover": "Entdecken", + "dismiss": "Dismiss", "display-name": "Anzeigename", "display-name-lowercase": "anzeigename", "display-text": "Anzeigetext", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Elasticsearch-Neuindexierung", "elasticsearch": "Elasticsearch", "email": "E-Mail", + "email-claim": "Email Claim", "email-configuration": "E-Mail-Konfiguration", "email-configuration-lowercase": "e-mail-konfiguration", "email-lowercase": "e-mail", + "email-or-username": "Email or Username", "email-plural": "E-Mails", "emailing-entity": "E-Mail-Entität", "embed-file-type": "{{fileType}} einbetten", @@ -1621,6 +1631,7 @@ "primary-key": "Primärschlüssel", "primary-key-plural": "Primärschlüssel", "primary-shards": "Primärsplitter", + "principal-domain": "Principal Domain", "privacy-policy": "Datenschutzrichtlinie", "private-key": "Privater Schlüssel", "private-key-id": "ID des privaten Schlüssels", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "Knoten neu anordnen", "reason": "Grund", "reasons-for-decision": "Entscheidungsgründe", + "received": "Received", "receiver-plural": "Empfänger", "recent-announcement-plural": "Aktuelle Ankündigungen", "recent-event-plural": "Letzte Ereignisse", @@ -1697,8 +1709,10 @@ "refresh-entity": "{{entity}} aktualisieren", "refresh-frequency": "Aktualisierungsfrequenz", "refresh-log": "Protokoll aktualisieren", + "refresh-token": "Refresh Token", "regenerate-registration-token": "Neuen Registrierungstoken generieren", "region-name": "Regionsname", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "Register", "regular-expression": "Regulärer Ausdruck", "reindex-failure-plural": "Fehler bei der Neuindizierung", @@ -1818,6 +1832,7 @@ "run-agent-plural": "Agenten ausführen", "run-at": "Ausführen um", "run-now": "Jetzt ausführen", + "run-test-login": "Run Test Login", "run-type": "Ausführart", "running": "In Ausführung", "running-ellipsis": "Wird ausgeführt...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Konfliktlösung auswählen", "select-dimension": "Dimension auswählen", "select-duration": "Wählen Sie Dauer aus", + "select-email-claim": "Select Email Claim", "select-entity": "{{entity}} auswählen", "select-entity-type": "Entitätstyp auswählen", "select-field": "{{field}}-Feld auswählen", @@ -1946,8 +1962,10 @@ "set-as-default": "Als Standard setzen", "set-default-entity": "Standard {{entity}} setzen", "set-default-filters": "Standardfilter setzen", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Aufstellen", "set-up-kpi": "KPI einrichten", + "set-via-test-login": "Set via Test Login", "setting-plural": "Einstellungen", "setup-guide": "Installationsanleitung", "severity": "Schwere", @@ -1999,6 +2017,7 @@ "source-provider": "Quellenanbieter", "source-url": "Quell-URL", "source-with-details": "Quelle: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "Spezifische Datenbestände", "spreadsheet": "Tabellenkalkulation", "spreadsheet-plural": "Tabellenkalkulationen", @@ -2158,6 +2177,7 @@ "test-entity": "{{entity}}-Test", "test-level-lowercase": "testebene", "test-library": "Testbibliothek", + "test-login": "Test Login", "test-platform-plural": "Testplattformen", "test-plural": "Tests", "test-plural-type": "{{type}}-Tests", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "Wählen Sie, wie der Workflow ausgelöst werden soll", "choose-import-mode": "Wählen Sie, wie der ODCS-Vertrag importiert werden soll", "choose-which-assets-this-workflow-can-act-on": "Wählen Sie, auf welche Assets dieser Workflow angewendet werden kann.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(Klicken, um die gefilterten Assets auf der Explore-Seite anzuzeigen.)", "click-text-to-view-details": "Klicken Sie auf <0>{{text}}, um Details anzuzeigen.", + "client-id-required": "Client ID is required.", "closed-this-task": "hat diese Aufgabe geschlossen", "collaborate-with-other-user": "um mit anderen Benutzern zusammenzuarbeiten.", "collate-ai-widget-description": "Übersicht über die von der Collate AI für den Dienst generierten Daten. <0>Mehr erfahren.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "Ihre Änderungen verwerfen?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "Die Dinge werden einfacher mit Data Quality ohne Code. Einfache Schritte zum Testen, Bereitstellen und Sammeln von Ergebnissen mit sofortigen Benachrichtigungen bei Testfehlern. Bleiben Sie auf dem Laufenden mit zuverlässigen Daten, denen Sie vertrauen können.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Prozentsatz der unterschiedlichen Werte in der Spalte", "domain-change-asset-migration-warning": "Durch das Ändern der Domäne werden _0 _ Assets von der aktuellen Domäne nach _1 __ verschoben. Möchten Sie fortfahren?", "domain-description": "Organisieren und verwalten Sie Datenbereiche in Ihrer Organisation.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "Stellen Sie sicher, dass Ihre Elasticsearch-Indizes durch Synchronisieren oder Neuerstellen aller Indizes auf dem neuesten Stand sind.", "elastic-search-re-index-pipeline-description": "Die Suchindex-Pipeline wird verwendet, um die Daten in Elasticsearch neu zu indizieren. Weitere Informationen finden Sie in unserer Dokumentation unter <0>{{link}}.", "elasticsearch-setup": "Bitte folgen Sie den Anweisungen hier, um die Metadaten-Erfassung einzurichten und in Elasticsearch zu indizieren.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "Konfigurieren Sie die SMTP-Einstellungen zum Senden von E-Mails.", "email-is-invalid": "Ungültige E-Mail-Adresse.", "email-verification-token-expired": "E-Mail-Bestätigungs-Token abgelaufen", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "Macht nichts. Es ist Zeit, Ihre Ziele neu zu strukturieren und schneller voranzukommen.", "latency-sla-description": "<0>{{label}}{{data}} liegen.", "latest-offset-description": "Der neueste Offset des Ereignisses im System.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "z.B. cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "Diese LDAP-Gruppen-DN ist bereits zugeordnet. Jede LDAP-Gruppe kann nur einmal zugeordnet werden.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "Erkunden Sie Produktfunktionen und erfahren Sie, wie sie funktionieren, durch unsere Ressourcen", "leave-the-team-team-name": "Verlassen Sie das Team {{teamName}}", "length-validator-error": "Mindestens {{length}} {{field}} erforderlich.", @@ -3011,6 +3039,7 @@ "no-profiler-title": "Profiler ausführen, um Tabelleneinblicke freizuschalten", "no-recently-viewed-date": "Keine kürzlich angesehenen Daten.", "no-reference-available": "Keine Verweise verfügbar.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "Keine verwandten Begriffe verfügbar.", "no-relations-for-selected-filter": "Keine Beziehungen für die ausgewählten Beziehungstypen gefunden. Versuchen Sie, andere Typen auszuwählen.", "no-relations-found": "Keine Beziehungen für diesen Begriff gefunden", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "Anzahl der Entitäten, die pro Stapel verarbeitet werden", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "Zentraler Metadatenspeicher, um Daten zu entdecken, zusammenzuarbeiten und die Datenqualität zu verbessern.", "om-url-configuration-message": "Konfigurieren Sie die {{brandName}}-URL-Einstellungen.", "on-demand-description": "Führen Sie die Ingestion manuell aus.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Bitte wählen Sie unten eine Aktion aus.", "please-type-text-to-confirm": "Bitte geben Sie {{text}} ein, um zu bestätigen.", "popup-block-message": "Das Anmelde-Popup wurde vom Browser blockiert. Bitte <0>aktivieren Sie es und versuchen Sie es erneut.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "Überprüfe die Spaltennamen, um PII-sensitive/nicht sensitive Spalten automatisch zu taggen.", "process-pii-sensitive-column-message-profiler": "Wenn aktiviert, wird die Beispiel-Daten analysiert, um geeignete PII-Tags für jede Spalte zu bestimmen.", "processed-all-events-description": "Gibt an, ob alle Ereignisse verarbeitet wurden.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "Umleitung zur Startseite", "refer-to-our-doc": "Brauchen Sie immer noch Hilfe? Siehe unser <0>{{doc}} für weitere Informationen.", "refresh-frequency-contract-description": "Erwartete Häufigkeit von Datenaktualisierungen", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "Hexadezimaler Farbcode zur Visualisierung dieses Beziehungstyps im Ontologie-Graphen (z.B. #1890ff).", "relation-type-in-use-count": "Verwendet von {{count}} Begriffsbeziehung(en)", "relation-type-not-in-use": "Derzeit nicht in Verwendung", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "Sind Sie sicher, dass Sie die Verbindung zwischen \"{{sourceDisplayName}} und {{targetDisplayName}}\" entfernen möchten?", "remove-lineage-edge": "Verbindungslinie entfernen", "rename-entity": "Benennen Sie den Namen und die Anzeigebezeichnung für das {{entity}} um.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "Genehmigungsanfrage für", "request-approval-notification": "Genehmigung erforderlich für", "request-description": "Beschreibung der Anfrage", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: Daten sollten für <0>{{data}} aufbewahrt werden.", "run-sample-data-to-ingest-sample-data": "Führen Sie Musterdaten aus, um Musterdatenvermögenswerte in Ihr OpenMetadata einzufügen.", "run-status-at-timestamp": "Status: {{status}} zu {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "Dieses Schema definiert die Parameter, die für die Erfassung von Beispieldaten übergeben werden können.", "schedule-description": "Planen Sie die Ingestion so, dass sie zu einer bestimmten Zeit und Häufigkeit ausgeführt wird.", "schedule-entity-description": "Diese {{entity}} wird wiederholt basierend auf Ihrem Zeitplan ausgeführt.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "Diese Aktion kann nicht rückgängig gemacht werden.", "unauthorized-user": "Unbefugter Benutzer! Bitte überprüfen Sie E-Mail oder Passwort.", "unexpected-error": "Ein unerwarteter Fehler ist aufgetreten.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 08988593691c..f91d3a00ab9a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -23,6 +23,7 @@ "accuracy": "Accuracy", "ack": "Ack", "acknowledged": "Acknowledged", + "acs-url": "ACS URL", "action": "Action", "action-plural": "Actions", "action-required": "Action Required", @@ -85,6 +86,7 @@ "address": "Address", "admin": "Admin", "admin-plural": "Admins", + "admin-principal": "Admin Principal", "admin-profile": "Admin profile", "admin-uppercase": "ADMIN", "advance-filter": "Advance Filter", @@ -92,6 +94,7 @@ "advanced-config": "Advanced Config", "advanced-configuration": "Advanced Configuration", "advanced-entity": "Advanced {{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "Advanced Search", "agent-activity": "Agent Activity", "agent-plural": "Agents", @@ -196,6 +199,7 @@ "authority": "Authority", "authorize-app": "Authorize {{app}}", "auto-classification": "Auto Classification", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "Auto PII Confidence Score", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "Auto Tag PII", @@ -256,6 +260,7 @@ "by-relation-type": "By Relation Type", "ca-certs": "CA Certs", "calculated-from": "Calculated From", + "callback-url": "Callback URL", "cancel": "Cancel", "cancel-lowercase": "cancel", "cardinality": "Cardinality", @@ -269,6 +274,7 @@ "change-log-plural": "Change Logs", "change-parent-entity": "Change Parent {{entity}}", "change-password": "Change Password", + "change-via-test-login": "Change via Test Login", "chart": "Chart", "chart-entity": "Chart {{entity}}", "chart-plural": "Charts", @@ -381,6 +387,7 @@ "confirm": "Confirm", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "confirm", "confirm-new-password": "Confirm New Password", "confirm-password": "Confirm your password", @@ -655,6 +662,7 @@ "disabled": "Disabled", "discard": "Discard", "discover": "Discover", + "dismiss": "Dismiss", "display-name": "Display Name", "display-name-lowercase": "display name", "display-text": "Display Text", @@ -714,9 +722,11 @@ "elastic-search-re-index": "ElasticsearchReindex", "elasticsearch": "Elasticsearch", "email": "Email", + "email-claim": "Email Claim", "email-configuration": "Email Configuration", "email-configuration-lowercase": "email configuration", "email-lowercase": "email", + "email-or-username": "Email or Username", "email-plural": "Emails", "emailing-entity": "Emailing Entity", "embed-file-type": "Embed {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "Primary Key", "primary-key-plural": "Primary Keys", "primary-shards": "Primary Shards", + "principal-domain": "Principal Domain", "privacy-policy": "Privacy Policy", "private-key": "PrivateKey", "private-key-id": "Private Key ID", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "Rearrange Nodes", "reason": "Reason", "reasons-for-decision": "Reasons for Decision", + "received": "Received", "receiver-plural": "Receivers", "recent-announcement-plural": "Recent Announcements", "recent-event-plural": "Recent Events", @@ -1697,8 +1709,10 @@ "refresh-entity": "Refresh {{entity}}", "refresh-frequency": "Refresh Frequency", "refresh-log": "Refresh log", + "refresh-token": "Refresh Token", "regenerate-registration-token": "Regenerate registration token", "region-name": "Region Name", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "Registry", "regular-expression": "Regular Expression", "reindex-failure-plural": "Reindex Failures", @@ -1818,6 +1832,7 @@ "run-agent-plural": "Run Agents", "run-at": "Run at", "run-now": "Run now", + "run-test-login": "Run Test Login", "run-type": "Run Type", "running": "Running", "running-ellipsis": "Running...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Select conflict resolution", "select-dimension": "Select Dimension", "select-duration": "Select Duration", + "select-email-claim": "Select Email Claim", "select-entity": "Select {{entity}}", "select-entity-type": "Select Entity Type", "select-field": "Select {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "Set as Default", "set-default-entity": "Set Default {{entity}}", "set-default-filters": "Set Default Filters", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Set Up", "set-up-kpi": "Set Up KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "Settings", "setup-guide": "Setup Guide", "severity": "Severity", @@ -1999,6 +2017,7 @@ "source-provider": "Source Provider", "source-url": "Source URL", "source-with-details": "Source: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "Specific Data Assets", "spreadsheet": "Spreadsheet", "spreadsheet-plural": "Spreadsheets", @@ -2158,6 +2177,7 @@ "test-entity": "Test {{entity}}", "test-level-lowercase": "test level", "test-library": "Test Library", + "test-login": "Test Login", "test-platform-plural": "Test Platforms", "test-plural": "Tests", "test-plural-type": "{{type}} Tests", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "Choose how the workflow should be triggered", "choose-import-mode": "Choose how to import the ODCS contract", "choose-which-assets-this-workflow-can-act-on": "Choose which assets this workflow can act on.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(Click to view the filtered assets on Explore page.)", "click-text-to-view-details": "Click <0>{{text}} to view details.", + "client-id-required": "Client ID is required.", "closed-this-task": "closed this task", "collaborate-with-other-user": "to collaborate with other users.", "collate-ai-widget-description": "Overview of the data generated by the Collate AI for the service. <0>learn more.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "Discard your changes?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "Things got easier with no-code data quality. Simple steps to test, deploy, and gather results, with instant test failure notifications. Stay up-to-date with reliable data that you can trust.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Percentage of distinct values in the column", "domain-change-asset-migration-warning": "Changing the domain will move {{count}} asset(s) from the current domain to {{domain}}. Do you want to proceed?", "domain-description": "Organize and manage data domains in your organization.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "Ensure that your Elasticsearch indexes are up-to-date by syncing, or recreating all indexes.", "elastic-search-re-index-pipeline-description": "Search index pipeline is used to re-index the data in elasticsearch. Refer to our documentation to learn more <0>{{link}}", "elasticsearch-setup": "Please follow the instructions here to set up Metadata ingestion and index them into Elasticsearch.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "Configure the SMTP Settings for sending Emails.", "email-is-invalid": "Invalid Email.", "email-verification-token-expired": "Email Verification Token Expired", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "Notice: The Description KPI target hasn't been met yet, but there's still time – your organization has {{count}} days left. To stay on track, please enable the Data Insights Report. This will allow us to send weekly updates to all teams, fostering collaboration and focus towards achieving our organization's KPIs.", "latency-sla-description": "<0>{{label}}: Query response must be under <0>{{data}}", "latest-offset-description": "The latest offset of the event in the system.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "e.g. cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "This LDAP group DN is already mapped. Each LDAP group can only be mapped once.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "Explore product features and learn how they work through our resources", "leave-the-team-team-name": "Leave the team {{teamName}}", "length-validator-error": "At least {{length}} {{field}} required", @@ -3011,6 +3039,7 @@ "no-profiler-title": "Run Profiler to Unlock Table Insights", "no-recently-viewed-date": "You haven't viewed any data assets recently. Explore to find something interesting!", "no-reference-available": "No references available.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "No related terms available.", "no-relations-for-selected-filter": "No relations found for the selected relation types. Try selecting different types.", "no-relations-found": "No relations found for this term", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "Number of entities to process in each batch", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "Centralized metadata store, to discover, collaborate and get your data right.", "om-url-configuration-message": "Configure the {{brandName}} URL Settings.", "on-demand-description": "Run the ingestion manually.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Please select any one action below.", "please-type-text-to-confirm": "Please type {{text}} to confirm.", "popup-block-message": "The sign in pop-up was blocked by the browser. Please <0>enable it and try again.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "Check column names to auto tag PII Senstive/nonSensitive columns.", "process-pii-sensitive-column-message-profiler": "When enabled, the sample data will be analysed to determine appropriate PII tags for each column", "processed-all-events-description": "Indicates whether all events have been processed.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "Redirecting to the home page", "refer-to-our-doc": "Still need help? Refer to our <0>{{doc}} for more information.", "refresh-frequency-contract-description": "Expected frequency of data updates", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "Hex color code used to visualize this relation type in the ontology graph (e.g., #1890ff).", "relation-type-in-use-count": "Used by {{count}} term relation(s)", "relation-type-not-in-use": "Not currently in use", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "Are you sure you want to remove the edge between \"{{sourceDisplayName}} and {{targetDisplayName}}\"?.", "remove-lineage-edge": "Remove lineage edge", "rename-entity": "Rename the Name and Display Name for the {{entity}}.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "Approval request for", "request-approval-notification": "Approval required for", "request-description": "Request description", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: Data should be retained for <0>{{data}}", "run-sample-data-to-ingest-sample-data": "'Run sample data to ingest sample data assets into your OpenMetadata.'", "run-status-at-timestamp": "Run status: {{status}} at {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "This schema defines the parameters that can be passed for sample data collection.", "schedule-description": "Schedule the ingestion to run at a specific time and frequency.", "schedule-entity-description": "This {{entity}} will run repeatedly based on your schedule.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "This action cannot be undone.", "unauthorized-user": "UnAuthorized user! please check email or password", "unexpected-error": "An unexpected error occurred.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index 039345923dfb..74d549838f41 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -23,6 +23,7 @@ "accuracy": "Precisión", "ack": "Confirmación", "acknowledged": "Reconocido", + "acs-url": "ACS URL", "action": "Acción", "action-plural": "Acciones", "action-required": "Acción requerida", @@ -85,6 +86,7 @@ "address": "Dirección", "admin": "Administrador", "admin-plural": "Administradores", + "admin-principal": "Admin Principal", "admin-profile": "Perfil de administrador", "admin-uppercase": "ADMINISTRADOR", "advance-filter": "Filtro Avanzado", @@ -92,6 +94,7 @@ "advanced-config": "Config avanzada", "advanced-configuration": "Configuración avanzada", "advanced-entity": "{{entity}} avanzado", + "advanced-fields": "Advanced Fields", "advanced-search": "Búsqueda avanzada", "agent-activity": "Actividad del agente", "agent-plural": "Agentes", @@ -196,6 +199,7 @@ "authority": "Autoridad", "authorize-app": "Autorizar {{app}}", "auto-classification": "Clasificación automática", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "Nivel de Confianza de Auto PII", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "Etiqueta de información personal identificable automática", @@ -256,6 +260,7 @@ "by-relation-type": "Por Tipo de Relación", "ca-certs": "Certificados CA", "calculated-from": "Calculado A Partir De", + "callback-url": "Callback URL", "cancel": "Cancelar", "cancel-lowercase": "cancelar", "cardinality": "Cardinalidad", @@ -269,6 +274,7 @@ "change-log-plural": "Registros de cambios", "change-parent-entity": "Cambiar el padre {{entity}}", "change-password": "Cambiar contraseña", + "change-via-test-login": "Change via Test Login", "chart": "Gráfico", "chart-entity": "Gráfico {{entity}}", "chart-plural": "Gráficos", @@ -381,6 +387,7 @@ "confirm": "Confirmar", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "confirmar", "confirm-new-password": "Confirmar nueva contraseña", "confirm-password": "Confirmar su contraseña", @@ -655,6 +662,7 @@ "disabled": "Deshabilitado", "discard": "Descartar", "discover": "Descubrir", + "dismiss": "Dismiss", "display-name": "Nombre de visualización", "display-name-lowercase": "nombre de visualización", "display-text": "Texto de visualización", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Reindexar Elasticsearch", "elasticsearch": "Elasticsearch", "email": "Correo electrónico", + "email-claim": "Email Claim", "email-configuration": "Configuración de correo electrónico", "email-configuration-lowercase": "configuración de correo electrónico", "email-lowercase": "correo electrónico", + "email-or-username": "Email or Username", "email-plural": "Correos electrónicos", "emailing-entity": "Entidad de correo electrónico", "embed-file-type": "{{fileType}} Embebido", @@ -1621,6 +1631,7 @@ "primary-key": "Clave primaria", "primary-key-plural": "Claves primarias", "primary-shards": "Fragmentos primarios", + "principal-domain": "Principal Domain", "privacy-policy": "Políticas de Privacidad", "private-key": "Llave privada", "private-key-id": "ID de la llave privada", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "Reorganizar nodos", "reason": "Razón", "reasons-for-decision": "Razones para la Decisión", + "received": "Received", "receiver-plural": "Destinatarios", "recent-announcement-plural": "Anuncios recientes", "recent-event-plural": "Eventos recientes", @@ -1697,8 +1709,10 @@ "refresh-entity": "Refrescar {{entity}}", "refresh-frequency": "Frecuencia de Actualización", "refresh-log": "Actualizar registro", + "refresh-token": "Refresh Token", "regenerate-registration-token": "Regenerar token de registro", "region-name": "Nombre de la región", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "Registro", "regular-expression": "Expresión Regular", "reindex-failure-plural": "Fallos de reindexación", @@ -1818,6 +1832,7 @@ "run-agent-plural": "Ejecutar Agentes", "run-at": "Ejecutar a las", "run-now": "Ejecutar ahora", + "run-test-login": "Run Test Login", "run-type": "Tipo de Ejecución", "running": "Ejecutando", "running-ellipsis": "Ejecutando...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Seleccionar resolución de conflictos", "select-dimension": "Seleccionar dimensión", "select-duration": "Seleccionar duración", + "select-email-claim": "Select Email Claim", "select-entity": "Seleccionar {{entity}}", "select-entity-type": "Seleccionar tipo de entidad", "select-field": "Seleccionar {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "Establecer como predeterminado", "set-default-entity": "Establecer {{entity}} predeterminado", "set-default-filters": "Establecer filtros predeterminados", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Configuración", "set-up-kpi": "Configurar KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "Configuraciones", "setup-guide": "Guia de preparación", "severity": "Severidad", @@ -1999,6 +2017,7 @@ "source-provider": "Proveedor de Fuente", "source-url": "Fuente URL", "source-with-details": "Fuente: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "Activos de Datos Específicos", "spreadsheet": "Hoja de cálculo", "spreadsheet-plural": "Hojas de cálculo", @@ -2158,6 +2177,7 @@ "test-entity": "Prueba {{entity}}", "test-level-lowercase": "nivel de prueba", "test-library": "Biblioteca de Pruebas", + "test-login": "Test Login", "test-platform-plural": "Plataformas de prueba", "test-plural": "Pruebas", "test-plural-type": "Pruebas de {{type}}", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "Elija cómo debe activarse el flujo de trabajo", "choose-import-mode": "Elija cómo importar el contrato ODCS", "choose-which-assets-this-workflow-can-act-on": "Elija sobre qué activos puede actuar este flujo de trabajo.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(Haz clic para ver los activos filtrados en la página Explorar.)", "click-text-to-view-details": "Haz clic en <0>{{text}} para ver detalles.", + "client-id-required": "Client ID is required.", "closed-this-task": "cerró esta tarea", "collaborate-with-other-user": "para colaborar con otros usuarios.", "collate-ai-widget-description": "Resumen de los datos generados por Collate AI para el servicio. <0>saber más.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "¿Descartar tus cambios?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "Descubra sus datos y desbloquee el valor de los activos de datos.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Porcentaje de valores distintos en la columna", "domain-change-asset-migration-warning": "Cambiar el dominio moverá _ 0 _ activos) del dominio actual a _1 __.¿Quieres continuar?", "domain-description": "Organice y gestione los dominios de datos en su organización.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "Asegúrese de que sus índices de Elasticsearch estén actualizados sincronizando o recreando todos los índices.", "elastic-search-re-index-pipeline-description": "El pipeline de reindexación de búsqueda se utiliza para volver a indexar los datos en Elasticsearch. Consulte nuestra documentación para obtener más información.", "elasticsearch-setup": "Siga las instrucciones para configurar la ingesta de metadatos e indexarlos en Elasticsearch.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "Configure la configuración SMTP para enviar correos electrónicos.", "email-is-invalid": "Correo electrónico inválido.", "email-verification-token-expired": "Token de verificación de correo electrónico expirado", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "No importa. Es hora de reestructurar tus objetivos y progresar más rápido.", "latency-sla-description": "<0>{{label}}: La respuesta a la consulta debe ser menor que <0>{{data}}.", "latest-offset-description": "El último offset del evento en el sistema.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "ej. cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "Este DN de grupo LDAP ya está mapeado. Cada grupo LDAP solo puede mapearse una vez.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "Explora las funciones del producto y aprende cómo funcionan a través de nuestros recursos", "leave-the-team-team-name": "Abandonar el equipo {{teamName}}", "length-validator-error": "Se requiere al menos {{length}} {{field}}", @@ -3011,6 +3039,7 @@ "no-profiler-title": "Ejecutar Profiler para desbloquear información de tablas", "no-recently-viewed-date": "No hay datos vistos recientemente.", "no-reference-available": "No hay referencias disponibles.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "No hay términos relacionados disponibles.", "no-relations-for-selected-filter": "No se encontraron relaciones para los tipos de relación seleccionados. Intente seleccionar tipos diferentes.", "no-relations-found": "No se encontraron relaciones para este término", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Número de aprobaciones requeridas para Aprobar", "number-of-entities-to-process-in-each-batch": "Número de entidades a procesar en cada lote", "number-of-rejections-required-to-reject": "Número de rechazos requeridos para Rechazar", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "Almacenamiento centralizado de metadatos para descubrir, colaborar y tener los datos correctos.", "om-url-configuration-message": "Configura los Ajustes de la URL de {{brandName}}.", "on-demand-description": "Ejecutar ingestión manualmente.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Seleccione una de las acciones a continuación.", "please-type-text-to-confirm": "Por favor, escribe {{text}} para confirmar.", "popup-block-message": "La ventana emergente de inicio de sesión ha sido bloqueada por el navegador. Por favor <0>actívela y vuelva a probar.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "Revisa los nombres de las columnas para etiquetar automáticamente las columnas sensibles/no sensibles de PII.", "process-pii-sensitive-column-message-profiler": "Cuando está habilitado, los datos de muestra se analizarán para determinar las etiquetas de PII apropiadas para cada columna.", "processed-all-events-description": "Indica si todos los eventos han sido procesados.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "Redirigiendo a la página principal", "refer-to-our-doc": "¿Necesitas más ayuda? Consulta nuestra <0>{{doc}} para obtener más información.", "refresh-frequency-contract-description": "Frecuencia esperada de actualizaciones de datos", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "Código de color hexadecimal utilizado para visualizar este tipo de relación en el grafo de ontología (por ejemplo, #1890ff).", "relation-type-in-use-count": "Utilizado por {{count}} relación(es) de términos", "relation-type-not-in-use": "No está en uso actualmente", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "¿Estás seguro de que quieres eliminar la relación entre \"{{sourceDisplayName}}\" y \"{{targetDisplayName}}\"?", "remove-lineage-edge": "Eliminar la relación de linaje", "rename-entity": "Renombrar el nombre y nombre visualizado para el {{entity}}.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "Solicitud de aprobación para", "request-approval-notification": "Aprobación requerida para", "request-description": "Descripción de la solicitud", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: Los datos deben conservarse durante <0>{{data}}.", "run-sample-data-to-ingest-sample-data": "'Ejecutar datos de muestra para ingresar activos de datos de muestra en tu OpenMetadata.'", "run-status-at-timestamp": "Estado de la ejecución: {{status}} a las {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "Este esquema define los parámetros que se pueden pasar para la recopilación de datos de ejemplo.", "schedule-description": "Programe la ingestión a ejecutar a una hora y frecuencia específicas.", "schedule-entity-description": "Esta {{entity}} se ejecutará repetidamente según su horario.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECCIONE * DE {tabla} DONDE {columna} < {{minValue}} O {columna} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "Esta acción no se puede deshacer.", "unauthorized-user": "¡Usuario no autorizado! Por favor revise el correo electrónico o la contraseña.", "unexpected-error": "Se produjo un error inesperado.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 496591209422..359a933e68f7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -23,6 +23,7 @@ "accuracy": "Précision", "ack": "Accusé de réception", "acknowledged": "Reconnu", + "acs-url": "ACS URL", "action": "Action", "action-plural": "Actes", "action-required": "Action requise", @@ -85,6 +86,7 @@ "address": "Adresse", "admin": "Administrateur", "admin-plural": "Administrateurs", + "admin-principal": "Admin Principal", "admin-profile": "Profil d'Administrateur", "admin-uppercase": "ADMINISTRATEUR", "advance-filter": "Filtre Avancé", @@ -92,6 +94,7 @@ "advanced-config": "Configuration Avancée", "advanced-configuration": "Configuration Avancée", "advanced-entity": "{{entity}} Avancé", + "advanced-fields": "Advanced Fields", "advanced-search": "Recherche Avancée", "agent-activity": "Activité des agents", "agent-plural": "Agentes", @@ -196,6 +199,7 @@ "authority": "Autorité", "authorize-app": "Autoriser {{app}}", "auto-classification": "Classification Automatique", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "Score de Confiance Auto PII", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "Balise Auto PII", @@ -256,6 +260,7 @@ "by-relation-type": "Par Type de Relation", "ca-certs": "Certificats CA", "calculated-from": "Calculé à partir de", + "callback-url": "Callback URL", "cancel": "Annuler", "cancel-lowercase": "annuler", "cardinality": "Cardinalité", @@ -269,6 +274,7 @@ "change-log-plural": "Journaux de Modifications", "change-parent-entity": "Changer le parent {{entity}}", "change-password": "Changer le mot de passe", + "change-via-test-login": "Change via Test Login", "chart": "Graphique", "chart-entity": "Graphique {{entity}}", "chart-plural": "Graphiques", @@ -381,6 +387,7 @@ "confirm": "Confirmer", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "confirmer", "confirm-new-password": "Confirmer le Nouveau Mot de Passe", "confirm-password": "Confirmer le Mot de Passe", @@ -655,6 +662,7 @@ "disabled": "Désactivé", "discard": "Abandonner", "discover": "Découvrir", + "dismiss": "Dismiss", "display-name": "Nom d'Affichage", "display-name-lowercase": "nom d'affichage", "display-text": "Texte d'affichage", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Ré-indexation Elasticsearch", "elasticsearch": "Elasticsearch", "email": "E-mail", + "email-claim": "Email Claim", "email-configuration": "Configuration de l'Email", "email-configuration-lowercase": "configuration de l'email", "email-lowercase": "e-mail", + "email-or-username": "Email or Username", "email-plural": "E-mails", "emailing-entity": "Entité d'Email", "embed-file-type": "Intégrer {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "Clé Primaire", "primary-key-plural": "Clés primaires", "primary-shards": "Fragments primaires", + "principal-domain": "Principal Domain", "privacy-policy": "Politique de Confidentialité", "private-key": "Clé Privée", "private-key-id": "ID de la Clé Privée", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "Réorganiser les Nœuds", "reason": "Raison", "reasons-for-decision": "Motifs de la décision", + "received": "Received", "receiver-plural": "Récepteurs", "recent-announcement-plural": "Annonces Récentes", "recent-event-plural": "Événements récents", @@ -1697,8 +1709,10 @@ "refresh-entity": "Actualiser {{entity}}", "refresh-frequency": "Fréquence d'Actualisation", "refresh-log": "Actualiser le Journal", + "refresh-token": "Refresh Token", "regenerate-registration-token": "Regénérer le Jeton d'Inscription", "region-name": "Nom de Région", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "Registre", "regular-expression": "Expression régulière", "reindex-failure-plural": "Échecs de réindexation", @@ -1818,6 +1832,7 @@ "run-agent-plural": "Exécuter les Agents", "run-at": "Exécuté à", "run-now": "Exécuter maintenant", + "run-test-login": "Run Test Login", "run-type": "Type d'Exécution", "running": "En cours d'exécution", "running-ellipsis": "En cours d'exécution...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Sélectionner la résolution de conflit", "select-dimension": "Sélectionner une dimension", "select-duration": "Sélectionnez la durée", + "select-email-claim": "Select Email Claim", "select-entity": "Sélectionner {{entity}}", "select-entity-type": "Sélectionner le type d'entité", "select-field": "Sélectionner le Champ {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "Définir par défaut", "set-default-entity": "Définir {{entity}} par défaut", "set-default-filters": "Définir les filtres par défaut", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Configurer", "set-up-kpi": "Configurer KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "Paramètres", "setup-guide": "Guide de Configuration", "severity": "Sévérité", @@ -1999,6 +2017,7 @@ "source-provider": "Fournisseur source", "source-url": "URL Source", "source-with-details": "Source : {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "Actifs de Données Spécifiques", "spreadsheet": "Feuille de calcul", "spreadsheet-plural": "Feuilles de calcul", @@ -2158,6 +2177,7 @@ "test-entity": "Test {{entity}}", "test-level-lowercase": "niveau de test", "test-library": "Bibliothèque de Tests", + "test-login": "Test Login", "test-platform-plural": "Plateformes de tests", "test-plural": "Essais", "test-plural-type": "Tests {{type}}", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "Choisissez comment le workflow doit être déclenché", "choose-import-mode": "Choisissez comment importer le contrat ODCS", "choose-which-assets-this-workflow-can-act-on": "Choisissez les actifs sur lesquels ce workflow peut agir.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(Cliquez pour voir les actifs filtrés sur la page d'exploration.)", "click-text-to-view-details": "Cliquez sur <0>{{text}} pour voir les détails.", + "client-id-required": "Client ID is required.", "closed-this-task": "fermer cette tâche", "collaborate-with-other-user": "pour collaborer avec d'autres utilisateurs.", "collate-ai-widget-description": "Aperçu des données générées par Collate AI pour le service. <0>en savoir plus.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "Abandonner vos modifications?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "Découvrez vos données et libérez la valeur de vos actifs de données", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Pourcentage de valeurs distinctes dans la colonne", "domain-change-asset-migration-warning": "La modification du domaine déplacera _ 0 _ actifs) du domaine actuel vers _1 __.Voulez-vous continuer ?", "domain-description": "Organisez et gérez les domaines de données dans votre organisation.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "Garantir qu'Elasticsearch est à jour en synchronisant ou en recréant tous les index.", "elastic-search-re-index-pipeline-description": "L'index de recherche est utilisé pour ré-indexer les données dans Elasticsearch. Référez-vous à notre documentation pour en savoir plus", "elasticsearch-setup": "Merci de suivre les instructions ici pour configurer l'ingestion des métadonnées et les indexer dans Elasticsearch.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "Configurer les paramètres SMTP pour l'envoi des emails.", "email-is-invalid": "L'email est invalide.", "email-verification-token-expired": "Email de vérification du jeton expiré", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "Dommage. Vous devriez peut-être réévaluer vos objectifs pour continuer votre progression.", "latency-sla-description": "<0>{{label}} : le temps de réponse de la requête doit être inférieur à <0>{{data}}.", "latest-offset-description": "Le dernier décalage de l'événement dans le système.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "p.ex. cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "Ce DN de groupe LDAP est déjà mappé. Chaque groupe LDAP ne peut être mappé qu'une seule fois.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "Explorez les fonctionnalités du produit et découvrez comment elles fonctionnent grâce à nos ressources", "leave-the-team-team-name": "Quitter l'équipe {{teamName}}", "length-validator-error": "Au moins {{length}} {{field}} requis", @@ -3011,6 +3039,7 @@ "no-profiler-title": "Exécuter le Profiler pour déverrouiller les informations de table", "no-recently-viewed-date": "Aucune donnée récemment consultée.", "no-reference-available": "Aucune référence disponible.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "Aucun terme associé disponible.", "no-relations-for-selected-filter": "Aucune relation trouvée pour les types de relations sélectionnés. Essayez de sélectionner d'autres types.", "no-relations-found": "Aucune relation trouvée pour ce terme", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "Nombre d'entités à traiter par lot", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "Entrepôt centralisé de métadonnées, découvrez, collaborez et assurez-vous que vos données sont correctes", "om-url-configuration-message": "Configurez les paramètres d'URL d'{{brandName}}.", "on-demand-description": "Exécutez l'ingestion manuellement.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Veuillez sélectionner l’une des actions ci-dessous.", "please-type-text-to-confirm": "Veuillez taper {{text}} pour confirmer.", "popup-block-message": "La pop-up de connexion a été bloquée par le navigateur. Merci de l' <0>enable puis ré-essayer.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "Vérifiez les noms de colonne pour marquer automatiquement les colonnes sensibles/non sensibles.", "process-pii-sensitive-column-message-profiler": "Quand activé, les données d'échantillon seront analysées pour déterminer les balises PII appropriées pour chaque colonne.", "processed-all-events-description": "Indique si tous les événements ont été traités.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "Rediriger vers la page d'accueil", "refer-to-our-doc": "Toujours besoin d'aide ? Consultez notre <0>{{doc}} pour plus d'informations.", "refresh-frequency-contract-description": "Fréquence attendue de mise à jour des données", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "Code couleur hexadécimal utilisé pour visualiser ce type de relation dans le graphe d'ontologie (par ex. #1890ff).", "relation-type-in-use-count": "Utilisé par {{count}} relation(s) de termes", "relation-type-not-in-use": "Actuellement non utilisé", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "Etes-vous sûr de vouloir supprimer le lien entre \"{{sourceDisplayName}} et {{targetDisplayName}}\" ?", "remove-lineage-edge": "Supprimer une arête de lignée", "rename-entity": "Renommer le nom et le nom d'affichage pour {{entity}}.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "Demande de validation pour", "request-approval-notification": "Approbation requise pour", "request-description": "Demander une description", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}} : les données doivent être conservées pendant <0>{{data}}.", "run-sample-data-to-ingest-sample-data": "Exécuter l'ingestion de données d'exemple dans OpenMetadata", "run-status-at-timestamp": "Etat du lancement: {{status}} à {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "Ce schéma définit les paramètres pouvant être transmis pour la collecte de données d'exemple.", "schedule-description": "Planifiez l'ingestion pour qu'elle s'exécute à une heure et une fréquence spécifiques.", "schedule-entity-description": "Cette {{entity}} s'exécutera répétitivement selon votre planification.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {colonne} < {{minValue}} OU {colonne} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "Cette action ne peut pas être annulée.", "unauthorized-user": "Utilisateur non autorisé ! Veuillez vérifier votre e-mail ou votre mot de passe", "unexpected-error": "Une erreur inattendue est survenue.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index b64adc8aeb31..9f1c57dc2c78 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -23,6 +23,7 @@ "accuracy": "Precisión", "ack": "Ack", "acknowledged": "Recoñecido", + "acs-url": "ACS URL", "action": "Acción", "action-plural": "Accións", "action-required": "Acción requirida", @@ -85,6 +86,7 @@ "address": "Enderezo", "admin": "Administrador", "admin-plural": "Administradores", + "admin-principal": "Admin Principal", "admin-profile": "Perfil de administrador", "admin-uppercase": "ADMINISTRADOR", "advance-filter": "Filtro Avanzado", @@ -92,6 +94,7 @@ "advanced-config": "Configuración avanzada", "advanced-configuration": "Configuración avanzada", "advanced-entity": "Avanzado {{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "Busca avanzada", "agent-activity": "Agent Activity", "agent-plural": "Agentes", @@ -196,6 +199,7 @@ "authority": "Autoridade", "authorize-app": "Autorizar {{app}}", "auto-classification": "auto clasificación", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "Puntuación de confianza automática de PII", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "Etiquetado automático de PII", @@ -256,6 +260,7 @@ "by-relation-type": "Por Tipo de Relación", "ca-certs": "Certificados CA", "calculated-from": "Calculado A Partir De", + "callback-url": "Callback URL", "cancel": "Cancelar", "cancel-lowercase": "cancelar", "cardinality": "Cardinality", @@ -269,6 +274,7 @@ "change-log-plural": "Rexistros de cambios", "change-parent-entity": "Cambiar {{entity}} pai", "change-password": "Cambiar o contrasinal", + "change-via-test-login": "Change via Test Login", "chart": "Gráfico", "chart-entity": "Gráfico {{entity}}", "chart-plural": "Gráficos", @@ -381,6 +387,7 @@ "confirm": "Confirmar", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "confirmar", "confirm-new-password": "Confirmar novo contrasinal", "confirm-password": "Confirmar o contrasinal", @@ -655,6 +662,7 @@ "disabled": "Desactivado", "discard": "Descartar", "discover": "Descubrir", + "dismiss": "Dismiss", "display-name": "Nome de visualización", "display-name-lowercase": "nome de visualización", "display-text": "Texto de visualización", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Reindexar Elasticsearch", "elasticsearch": "Elasticsearch", "email": "Correo electrónico", + "email-claim": "Email Claim", "email-configuration": "Configuración do correo electrónico", "email-configuration-lowercase": "configuración do correo electrónico", "email-lowercase": "correo electrónico", + "email-or-username": "Email or Username", "email-plural": "Correos electrónicos", "emailing-entity": "Enviando correo electrónico á entidade", "embed-file-type": "Embed {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "Chave primaria", "primary-key-plural": "Chaves Primarias", "primary-shards": "Primary Shards", + "principal-domain": "Principal Domain", "privacy-policy": "Política de privacidade", "private-key": "Chave privada", "private-key-id": "ID da chave privada", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "Reorganizar Nodos", "reason": "Motivo", "reasons-for-decision": "Reasons for Decision", + "received": "Received", "receiver-plural": "Receptores", "recent-announcement-plural": "Anuncios recentes", "recent-event-plural": "Eventos Recentes", @@ -1697,8 +1709,10 @@ "refresh-entity": "Refrescar {{entity}}", "refresh-frequency": "Frecuencia de actualización", "refresh-log": "Actualizar rexistro", + "refresh-token": "Refresh Token", "regenerate-registration-token": "Rexerar o token de rexistro", "region-name": "Nome da rexión", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "Rexistro", "regular-expression": "Expresión regular", "reindex-failure-plural": "Reindex Failures", @@ -1818,6 +1832,7 @@ "run-agent-plural": "Executar Agentes", "run-at": "Executar ás", "run-now": "Executar agora", + "run-test-login": "Run Test Login", "run-type": "Tipo de execución", "running": "Executando", "running-ellipsis": "En execución...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Seleccionar resolución de conflitos", "select-dimension": "Seleccionar dimensión", "select-duration": "Select Duration", + "select-email-claim": "Select Email Claim", "select-entity": "Seleccionar {{entity}}", "select-entity-type": "Seleccionar o tipo de entidade", "select-field": "Seleccionar {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "Establecer como predeterminado", "set-default-entity": "Establecer {{entity}} predeterminado", "set-default-filters": "Definir filtros por defecto", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Set Up", "set-up-kpi": "Configurar KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "Axustes", "setup-guide": "Guía de configuración", "severity": "Gravidade", @@ -1999,6 +2017,7 @@ "source-provider": "Provedor de Orixe", "source-url": "URL da fonte", "source-with-details": "Source: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "Activos de datos específicos", "spreadsheet": "Folla de cálculo", "spreadsheet-plural": "Follas de cálculo", @@ -2158,6 +2177,7 @@ "test-entity": "Proba {{entity}}", "test-level-lowercase": "nivel de proba", "test-library": "Librería de probas", + "test-login": "Test Login", "test-platform-plural": "Test Platforms", "test-plural": "Probas", "test-plural-type": "Probas de {{type}}", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "Escolla como debe activarse o fluxo de traballo", "choose-import-mode": "Escolla como importar o contrato ODCS", "choose-which-assets-this-workflow-can-act-on": "Escolla os activos sobre os que pode actuar este fluxo de traballo.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(Fai clic para ver os activos filtrados na páxina Explorar.)", "click-text-to-view-details": "Fai clic en <0>{{text}} para ver os detalles.", + "client-id-required": "Client ID is required.", "closed-this-task": "pechou esta tarefa", "collaborate-with-other-user": "para colaborar con outros usuarios.", "collate-ai-widget-description": "Visión xeral dos datos xerados por Collate AI para o servizo. <0>saber máis.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "Descartar os seus cambios?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "As cousas simplifícanse co control de calidade de datos sen código. Pasos simples para probar, despregar e reunir resultados, con notificacións instantáneas de fallos de proba. Mantente actualizado con datos fiables nos que podes confiar.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Porcentaxe de valores distintos na columna", "domain-change-asset-migration-warning": "Changing the domain will move {{count}} asset(s) from the current domain to {{domain}}. Do you want to proceed?", "domain-description": "Organize and manage data domains in your organization.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "Asegúrate de que os teus índices de Elasticsearch están actualizados sincronizándoos ou recreando todos os índices.", "elastic-search-re-index-pipeline-description": "O pipeline de índice de busca úsase para reindexar os datos en Elasticsearch. Consulta a nosa documentación para saber máis <0>{{link}}", "elasticsearch-setup": "Sigue as instrucións aquí para configurar a inxestión de Metadatos e indexalos en Elasticsearch.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "Configura os axustes SMTP para enviar correos electrónicos.", "email-is-invalid": "Correo electrónico non válido.", "email-verification-token-expired": "O token de verificación do correo electrónico caducou", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "Aviso: O obxectivo KPI de Descrición aínda non se alcanzou, pero aínda hai tempo: á túa organización quédanlle {{count}} días. Para manter o rumbo, habilita o Informe de Insights de Datos. Isto permitirá enviar actualizacións semanais a todos os equipos, fomentando a colaboración e o enfoque cara a alcanzar os KPI da nosa organización.", "latency-sla-description": "<0>{{label}}: A resposta á consulta debe ser inferior a <0>{{data}}.", "latest-offset-description": "The latest offset of the event in the system.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "p.ex. cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "Este DN de grupo LDAP xa está mapeado. Cada grupo LDAP só pode mapearse unha vez.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "Explora as funcións do produto e aprende como funcionan a través dos nosos recursos", "leave-the-team-team-name": "Abandonar o equipo {{teamName}}", "length-validator-error": "Requírese polo menos {{length}} {{field}}", @@ -3011,6 +3039,7 @@ "no-profiler-title": "Executar Profiler para desbloquear información das táboas", "no-recently-viewed-date": "Aínda non visualizaches ningún activo de datos recentemente. Explora para atopar algo interesante!", "no-reference-available": "Non hai referencias dispoñibles.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "Non hai termos relacionados dispoñibles.", "no-relations-for-selected-filter": "Non se atoparon relacións para os tipos de relación seleccionados. Tenta seleccionar tipos diferentes.", "no-relations-found": "Non se atoparon relacións para este termo", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "Número de entidades a procesar en cada lote", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "Repositorio centralizado de metadatos, para descubrir, colaborar e xestionar os teus datos.", "om-url-configuration-message": "Configure the {{brandName}} URL Settings.", "on-demand-description": "Executa a inxestión manualmente.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Por favor, selecciona algunha das accións de abaixo.", "please-type-text-to-confirm": "Escribe {{text}} para confirmar.", "popup-block-message": "O pop-up de inicio de sesión foi bloqueado polo navegador. Por favor, <0>actívao e tentalo de novo.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "Comproba os nomes das columnas para etiquetar automaticamente as columnas sensibles/non sensibles de PII.", "process-pii-sensitive-column-message-profiler": "Cando está activado, os datos de mostra serán analizados para determinar as etiquetas PII adecuadas para cada columna", "processed-all-events-description": "Indicates whether all events have been processed.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "Redirixindo á páxina de inicio", "refer-to-our-doc": "Aínda necesitas axuda? Consulta o noso <0>{{doc}} para máis información.", "refresh-frequency-contract-description": "Frecuencia esperada de actualizacións de datos", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "Código de cor hexadecimal utilizado para visualizar este tipo de relación no grafo de ontoloxía (por exemplo, #1890ff).", "relation-type-in-use-count": "Utilizado por {{count}} relación(s) de termos", "relation-type-not-in-use": "Non está en uso actualmente", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "Estás seguro de que queres eliminar a conexión entre \"{{sourceDisplayName}} e {{targetDisplayName}}\"?", "remove-lineage-edge": "Eliminar conexión de liñaxe", "rename-entity": "Renomea o Nome e o Nome de Visualización para o {{entity}}.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "Solicitude de aprobación para", "request-approval-notification": "Aprobación requirida para", "request-description": "Solicitude de descrición", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: Os datos deben conservarse por <0>{{data}}.", "run-sample-data-to-ingest-sample-data": "'Executa datos de mostra para inxerir activos de datos de mostra no teu OpenMetadata.'", "run-status-at-timestamp": "Estado de execución: {{status}} ás {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "Este esquema define os parámetros que se poden pasar para a recollida de datos de exemplo.", "schedule-description": "Programe a inxestión para que se execute a unha hora e frecuencia específicas.", "schedule-entity-description": "Esta {{entity}} executarase repetidamente baseándose no teu horario.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "This action cannot be undone.", "unauthorized-user": "Usuario non autorizado! Comproba o correo electrónico ou o contrasinal", "unexpected-error": "Produciuse un erro inesperado.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index 3a5ac1826464..d8a05624d323 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -23,6 +23,7 @@ "accuracy": "דיוק", "ack": "אישור", "acknowledged": "מאושר", + "acs-url": "ACS URL", "action": "פעולה", "action-plural": "פעולות", "action-required": "פעולה נדרשת", @@ -85,6 +86,7 @@ "address": "כתובת", "admin": "מנהל", "admin-plural": "מנהלים", + "admin-principal": "Admin Principal", "admin-profile": "פרופיל מנהל", "admin-uppercase": "מנהל", "advance-filter": "מסנן מתקדם", @@ -92,6 +94,7 @@ "advanced-config": "הגדרות מתקדמות", "advanced-configuration": "תצורה מתקדמת", "advanced-entity": "מתקדם {{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "חיפוש מתקדם", "agent-activity": "פעילות סוכן", "agent-plural": "סוכנים", @@ -196,6 +199,7 @@ "authority": "רשות", "authorize-app": "אמת את {{app}}", "auto-classification": "סיווג אוטומטי", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "ציון ביטחון PII אוטומטי", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "תיוג PII אוטומטי", @@ -256,6 +260,7 @@ "by-relation-type": "לפי סוג קשר", "ca-certs": "תעודות CA", "calculated-from": "מחושב מ", + "callback-url": "Callback URL", "cancel": "ביטול", "cancel-lowercase": "ביטול", "cardinality": "עוצמה", @@ -269,6 +274,7 @@ "change-log-plural": "יומני שינויים", "change-parent-entity": "שנה {{entity}} הורה", "change-password": "שנה סיסמה", + "change-via-test-login": "Change via Test Login", "chart": "תרשים", "chart-entity": "תרשים {{entity}}", "chart-plural": "תרשימים", @@ -381,6 +387,7 @@ "confirm": "אישור", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "אישור", "confirm-new-password": "אשר סיסמה חדשה", "confirm-password": "אשר את הסיסמה שלך", @@ -655,6 +662,7 @@ "disabled": "מושבת", "discard": "זריקה", "discover": "גלה", + "dismiss": "Dismiss", "display-name": "שם הצגה", "display-name-lowercase": "שם תצוגה", "display-text": "טקסט תצוגה", @@ -714,9 +722,11 @@ "elastic-search-re-index": "מחדש אינדקס Elasticsearch", "elasticsearch": "Elasticsearch", "email": "דוא\"ל", + "email-claim": "Email Claim", "email-configuration": "תצורת דוא\"ל", "email-configuration-lowercase": "תצורת דוא\"ל", "email-lowercase": "דוא\"ל", + "email-or-username": "Email or Username", "email-plural": "דואר אלקטרוני", "emailing-entity": "שליחת דוא\"ל לישות", "embed-file-type": "שבץ {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "מפתח ראשי", "primary-key-plural": "מפתחות ראשיים", "primary-shards": "שברי ראשי", + "principal-domain": "Principal Domain", "privacy-policy": "מדיניות פרטיות", "private-key": "מפתח פרטי", "private-key-id": "מזהה מפתח פרטי", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "סדר מחדש צמתים", "reason": "סיבה", "reasons-for-decision": "סיבות להחלטה", + "received": "Received", "receiver-plural": "מקבלים", "recent-announcement-plural": "הכרזות אחרונות", "recent-event-plural": "אירועים אחרונים", @@ -1697,8 +1709,10 @@ "refresh-entity": "רענון {{entity}}", "refresh-frequency": "תדירות ריענון", "refresh-log": "רענון לוג", + "refresh-token": "Refresh Token", "regenerate-registration-token": "הפק מחדש את אסימון הרישום", "region-name": "שם האזור", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "רשומון", "regular-expression": "ביטוי רגולרי", "reindex-failure-plural": "כשלי אינדוקס מחדש", @@ -1818,6 +1832,7 @@ "run-agent-plural": "הפעל סוכנים", "run-at": "הרץ ב", "run-now": "הרץ עכשיו", + "run-test-login": "Run Test Login", "run-type": "סוג הרצה", "running": "מריץ", "running-ellipsis": "פועל...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "בחר פתרון קונפליקט", "select-dimension": "בחר מימד", "select-duration": "בחר משך זמן", + "select-email-claim": "Select Email Claim", "select-entity": "בחר {{entity}}", "select-entity-type": "בחירת סוג האובייקט", "select-field": "בחר {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "הגדר כברירת מחדל", "set-default-entity": "הגדר ברירת מחדל {{entity}}", "set-default-filters": "קבל מסנן ברירת מחדל", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "הגדר", "set-up-kpi": "הגדר KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "הגדרות", "setup-guide": "מדריך הגדרה", "severity": "חומרה", @@ -1999,6 +2017,7 @@ "source-provider": "ספק מקור", "source-url": "כתובת מקור", "source-with-details": "מקור: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "נכסי נתונים ספציפיים", "spreadsheet": "גיליון אלקטרוני", "spreadsheet-plural": "גיליונות אלקטרוניים", @@ -2158,6 +2177,7 @@ "test-entity": "בדיקה {{entity}}", "test-level-lowercase": "רמת בדיקה", "test-library": "ספריית בדיקות", + "test-login": "Test Login", "test-platform-plural": "פלטפורמות בדיקה", "test-plural": "בדיקות", "test-plural-type": "בדיקות {{type}}", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "בחרו כיצד להפעיל את זרימת העבודה", "choose-import-mode": "בחר כיצד לייבא את חוזה ODCS", "choose-which-assets-this-workflow-can-act-on": "בחרו אילו נכסים זרימת העבודה יכולה לפעול עליהם.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(לחץ להצגת הנכסים המסוננים בדף חקירה.)", "click-text-to-view-details": "לחץ על <0>{{text}} לצפייה בפרטים.", + "client-id-required": "Client ID is required.", "closed-this-task": "סגר משימה זו", "collaborate-with-other-user": "לשתף פעולה עם משתמשים אחרים.", "collate-ai-widget-description": "סקירה של הנתונים שנוצרו על ידי Collate AI עבור השירות. <0>למד עוד.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "להשליך את השינויים שלך?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "הדברים התקלו עם איכות נתונים ללא קוד. שלבים פשוטים לבדיקה, הפעלה ואיסוף תוצאות, עם הודעות מיידיות על כשל בבדיקה. נשמר על המידע היציב שאתה יכול לסמוך עליו.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "אחוז הערכים הנבדלים בעמודה", "domain-change-asset-migration-warning": "שינוי התחום יעביר {{count}} נכס(ים) מהתחום הנוכחי ל-{{domain}}. האם ברצונך להמשיך?", "domain-description": "ארגן ונהל דומיינים של נתונים בארגון שלך.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "וודא שאינדקסי ה-Elasticsearch שלך מעודכנים על ידי הסנכרון או הייצור מחדש של כל האינדקסים.", "elastic-search-re-index-pipeline-description": "תהליך טעינת אינדקס חיפוש משמש להעברת הנתונים שוב ב-Elasticsearch. הפנה לתיעוד שלנו למידע נוסף <0>{{link}}", "elasticsearch-setup": "אנא עקוב אחר ההוראות כאן כדי להגדיר את הזיהוי של המטא-דאטה ולאינדקס אותם ל-Elasticsearch.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "הגדר את הגדרות SMTP עבור שליחת דואר אלקטרוני.", "email-is-invalid": "דוא\"ל לא תקין.", "email-verification-token-expired": "קוד אימות דוא\"ל פג תוקף", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "לא משנה. זמן לשדרג את המטרות שלך ולהתקדם במהירות יותר.", "latency-sla-description": "<0>{{label}}: זמן תגובת השאילתה חייב להיות פחות מ‑ <0>{{data}}.", "latest-offset-description": "ה-offset האחרון של האירוע במערכת.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "לדוגמה cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "DN של קבוצת LDAP זו כבר ממופה. כל קבוצת LDAP יכולה להיות ממופה רק פעם אחת.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "חקור את תכונות המוצר ולמד כיצד הן עובדות באמצעות המשאבים שלנו", "leave-the-team-team-name": "עזוב את הקבוצה {{teamName}}", "length-validator-error": "נדרשים לפחות {{length}} {{field}}", @@ -3011,6 +3039,7 @@ "no-profiler-title": "הפעל Profiler לפתיחת תובנות טבלה", "no-recently-viewed-date": "אין נתונים שנצפו לאחרונה.", "no-reference-available": "אין הפניות זמינות.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "אין מונחים קשורים זמינים.", "no-relations-for-selected-filter": "לא נמצאו קשרים עבור סוגי הקשר שנבחרו. נסה לבחור סוגים שונים.", "no-relations-found": "לא נמצאו קשרים למונח זה", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "מספר הישויות לעיבוד בכל אצווה", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "אחסון מטה מרכזי, לגלוש, לשתף פעולה ולהבין את הנתונים שלך כראוי.", "om-url-configuration-message": "הגדר את הגדרות ה-URL של {{brandName}}.", "on-demand-description": "הפעל את הבליעה באופן ידני.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "אנא בחר אחת מהפעולות שלהלן.", "please-type-text-to-confirm": "אנא הקלד {{text}} לאישור.", "popup-block-message": "חלון הכניסה הקופץ נחסם על ידי הדפדפן. אנא <0>אפשר אותו ונסה שוב.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "בדוק את שמות העמודות כדי לקבוע תגי PII Sensistive/NonSensitive באופן אוטומטי.", "process-pii-sensitive-column-message-profiler": "כאשר מופעל, המידע הדוגמא ינתח כדי לקבוע תגי PII המתאימים לכל עמודה.", "processed-all-events-description": "מציין האם כל האירועים עובדו.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "מועבר לדף הבית", "refer-to-our-doc": "זקוק.ה לעזרה נוספת? בקר.י ב-<0>{{doc}} לקבלת מידע נוסף.", "refresh-frequency-contract-description": "תדירות צפויה של עדכוני נתונים", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "קוד צבע הקסדצימלי המשמש להמחשת סוג קשר זה בגרף האונטולוגיה (למשל, #1890ff).", "relation-type-in-use-count": "בשימוש על ידי {{count}} קשר(י) מונח", "relation-type-not-in-use": "לא בשימוש כרגע", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "האם אתה בטוח שברצונך להסיר את הקשת בין \"{{sourceDisplayName}} ו-{{targetDisplayName}}\"?", "remove-lineage-edge": "הסר קישוריות", "rename-entity": "שנה את השם והשם לתצוגה של {{entity}}.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "בקשת אישור עבור", "request-approval-notification": "נדרש אישור עבור", "request-description": "תיאור הבקשה", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: הנתונים צריכים להיות נשמרים במשך <0>{{data}}.", "run-sample-data-to-ingest-sample-data": "'הרץ נתוני דוגמה כדי לשדרג נכסי נתונים דוגמה אל OpenMetadata שלך.'", "run-status-at-timestamp": "סטטוס ריצה: {{status}} ב-{{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "סכימה זו מגדירה את הפרמטרים שניתן להעביר לאיסוף נתוני דוגמה.", "schedule-description": "תזמן את הקליטה לרוץ בזמן ותדירות ספציפיים.", "schedule-entity-description": "{{entity}} זה ירוץ באופן חוזר לפי הלוח הזמנים שלכם.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "תבנית שאילתת SQL המשתמשת במציינים של פרמטרים בסוגריים מסולסלים כפולים (למשל, {{paramName}}). השתמש ב-{table} ו-{column} עבור הפניות ישות בזמן ריצה.", "test-definition-sql-query-help": "כתוב תבנית שאילתת SQL עם משתני החלפה. השתמש ב-{table} עבור שם טבלה, {column} עבור שם עמודה (נפתר בזמן ריצה). השתמש ב-{{paramName}} עבור פרמטרי משתמש המוגדרים למטה (למשל, {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "פעולה זו אינה ניתנת לביטול.", "unauthorized-user": "משתמש לא מורשה! יש לבדוק את כתובת האימייל או הסיסמה", "unexpected-error": "אירעה שגיאה לא צפויה.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 4b62292a1bbe..ef8cf7d8b26a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -23,6 +23,7 @@ "accuracy": "正確性", "ack": "アック", "acknowledged": "確認済み", + "acs-url": "ACS URL", "action": "操作", "action-plural": "アクション", "action-required": "対応が必要です", @@ -85,6 +86,7 @@ "address": "アドレス", "admin": "管理者", "admin-plural": "管理者", + "admin-principal": "Admin Principal", "admin-profile": "管理者プロフィール", "admin-uppercase": "管理者", "advance-filter": "詳細フィルター", @@ -92,6 +94,7 @@ "advanced-config": "高度な設定", "advanced-configuration": "高度な構成", "advanced-entity": "高度な{{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "詳細検索", "agent-activity": "エージェントのアクティビティ", "agent-plural": "エージェント", @@ -196,6 +199,7 @@ "authority": "認証元", "authorize-app": "{{app}} を認可", "auto-classification": "自動分類", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "自動PII信頼スコア", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "自動PIIタグ", @@ -256,6 +260,7 @@ "by-relation-type": "関係タイプ別", "ca-certs": "CA証明書", "calculated-from": "算出元", + "callback-url": "Callback URL", "cancel": "キャンセル", "cancel-lowercase": "キャンセル", "cardinality": "カーディナリティ", @@ -269,6 +274,7 @@ "change-log-plural": "変更履歴", "change-parent-entity": "親{{entity}}を変更", "change-password": "パスワードを変更", + "change-via-test-login": "Change via Test Login", "chart": "チャート", "chart-entity": "{{entity}} のチャート", "chart-plural": "チャート", @@ -381,6 +387,7 @@ "confirm": "確認", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "確認", "confirm-new-password": "新しいパスワードを確認", "confirm-password": "パスワードを確認", @@ -655,6 +662,7 @@ "disabled": "無効", "discard": "破棄", "discover": "探索", + "dismiss": "Dismiss", "display-name": "表示名", "display-name-lowercase": "表示名", "display-text": "表示テキスト", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Elasticsearch再インデックス", "elasticsearch": "Elasticsearch", "email": "メール", + "email-claim": "Email Claim", "email-configuration": "メール設定", "email-configuration-lowercase": "メール設定", "email-lowercase": "メール", + "email-or-username": "Email or Username", "email-plural": "メール", "emailing-entity": "{{entity}} をメール送信中", "embed-file-type": "{{fileType}} を埋め込む", @@ -1621,6 +1631,7 @@ "primary-key": "主キー", "primary-key-plural": "主キー", "primary-shards": "プライマリシャード", + "principal-domain": "Principal Domain", "privacy-policy": "プライバシーポリシー", "private-key": "秘密鍵", "private-key-id": "秘密鍵 ID", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "ノードを再配置", "reason": "理由", "reasons-for-decision": "判断理由", + "received": "Received", "receiver-plural": "受信者", "recent-announcement-plural": "最近のお知らせ", "recent-event-plural": "最近のイベント", @@ -1697,8 +1709,10 @@ "refresh-entity": "{{entity}} を更新", "refresh-frequency": "更新頻度", "refresh-log": "ログを更新", + "refresh-token": "Refresh Token", "regenerate-registration-token": "登録用トークンを再生成", "region-name": "リージョン名", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "レジストリ", "regular-expression": "正規表現", "reindex-failure-plural": "インデックス再作成の失敗", @@ -1818,6 +1832,7 @@ "run-agent-plural": "エージェントを実行", "run-at": "実行日時", "run-now": "今すぐ実行", + "run-test-login": "Run Test Login", "run-type": "実行タイプ", "running": "実行中", "running-ellipsis": "実行中...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "競合解決を選択", "select-dimension": "ディメンションを選択", "select-duration": "期間を選択してください", + "select-email-claim": "Select Email Claim", "select-entity": "{{entity}} を選択", "select-entity-type": "エンティティタイプを選択", "select-field": "{{field}} を選択", @@ -1946,8 +1962,10 @@ "set-as-default": "デフォルトに設定", "set-default-entity": "デフォルト{{entity}}を設定", "set-default-filters": "デフォルトフィルターを設定", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "設定", "set-up-kpi": "KPI を設定", + "set-via-test-login": "Set via Test Login", "setting-plural": "設定", "setup-guide": "セットアップガイド", "severity": "重大度", @@ -1999,6 +2017,7 @@ "source-provider": "ソースプロバイダー", "source-url": "ソース URL", "source-with-details": "ソース: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "特定のデータアセット", "spreadsheet": "スプレッドシート", "spreadsheet-plural": "スプレッドシート", @@ -2158,6 +2177,7 @@ "test-entity": "{{entity}} をテスト", "test-level-lowercase": "テストレベル", "test-library": "テストライブラリ", + "test-login": "Test Login", "test-platform-plural": "テストプラットフォーム", "test-plural": "テスト", "test-plural-type": "{{type}}テスト", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "ワークフローのトリガー方法を選択してください", "choose-import-mode": "ODCS 契約のインポート方法を選択してください", "choose-which-assets-this-workflow-can-act-on": "このワークフローが対象とするアセットを選択してください。", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(クリックして Explore ページでアセットを表示)", "click-text-to-view-details": "<0>{{text}} をクリックして詳細を表示", + "client-id-required": "Client ID is required.", "closed-this-task": "このタスクはクローズされました", "collaborate-with-other-user": "他のユーザーとコラボレーションできます。", "collate-ai-widget-description": "Collate AI によって生成されたデータの概要。<0>詳細を見る。", @@ -2626,6 +2648,7 @@ "discard-your-changes": "変更を破棄しますか?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "データを発見し、データアセットの価値を引き出しましょう。", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "列内の個別の値の割合", "domain-change-asset-migration-warning": "ドメインを変更すると、_ 0 _ 資産) が現在のドメインから _1 _ に移動されます。続行しますか?", "domain-description": "組織内のデータドメインを整理・管理します。", @@ -2651,6 +2674,9 @@ "elastic-search-message": "Elasticsearch インデックスを最新に保つには、同期または再インデックス化してください。", "elastic-search-re-index-pipeline-description": "検索インデックスパイプラインは、Elasticsearch のデータを再インデックスするために使用されます。詳細はドキュメントをご参照ください。", "elasticsearch-setup": "メタデータ取り込みと Elasticsearch へのインデックス設定については、こちらの手順をご確認ください。", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "メール送信用の SMTP 設定を行ってください。", "email-is-invalid": "無効なメールアドレスです。", "email-verification-token-expired": "メール確認トークンの有効期限が切れています", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "大丈夫。目標を再構築して、より早く前進しましょう。", "latency-sla-description": "<0>{{label}}: クエリ応答は<0>{{data}}未満である必要があります", "latest-offset-description": "システム内の最新のイベントオフセットです。", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "例:cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "このLDAPグループDNはすでにマップされています。各LDAPグループは一度のみマップできます。", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "製品の機能を探索し、リソースを通じてその仕組みを学びましょう", "leave-the-team-team-name": "チーム {{teamName}} から退出する", "length-validator-error": "{{length}} 文字以上の {{field}} が必要です", @@ -3011,6 +3039,7 @@ "no-profiler-title": "プロファイラーを実行してテーブルインサイトをアンロック", "no-recently-viewed-date": "最近表示したデータはありません。", "no-reference-available": "参照はありません。", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "関連用語はありません。", "no-relations-for-selected-filter": "選択したリレーションタイプに一致するリレーションが見つかりませんでした。別のタイプを選択してみてください。", "no-relations-found": "この用語の関係は見つかりませんでした", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "各バッチで処理するエンティティ数", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "中央集約型のメタデータストアで、データの発見・連携・管理を実現します。", "om-url-configuration-message": "{{brandName}} の URL 設定を構成してください。", "on-demand-description": "手動で取り込みを実行します。", @@ -3135,6 +3165,7 @@ "please-select-action-below": "以下のいずれかの操作を選択してください。", "please-type-text-to-confirm": "{{text}} を入力して確認してください。", "popup-block-message": "サインインのポップアップがブラウザによってブロックされました。<0>有効化してから再試行してください。", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "列名をチェックして、PII センシティブ/非センシティブ列に自動タグを付与します。", "process-pii-sensitive-column-message-profiler": "有効にすると、サンプルデータが分析され、各列に適切な PII タグが適用されます。", "processed-all-events-description": "すべてのイベントが処理されたかどうかを示します。", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "ホームページへリダイレクト中です", "refer-to-our-doc": "さらに詳しく知りたいですか?こちらの <0>{{doc}} をご覧ください。", "refresh-frequency-contract-description": "データ更新の想定頻度", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "オントロジーグラフでこの関係タイプを可視化するために使用される16進カラーコード(例: #1890ff)。", "relation-type-in-use-count": "{{count}}件の用語関係で使用中", "relation-type-not-in-use": "現在使用されていません", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "「{{sourceDisplayName}}」と「{{targetDisplayName}}」の接続を削除してもよろしいですか?", "remove-lineage-edge": "リネージ接続を削除", "rename-entity": "{{entity}} の名前と表示名を変更します。", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "承認リクエスト:", "request-approval-notification": "承認が必要です:", "request-description": "リクエストの説明", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: データは<0>{{data}}間保持する必要があります", "run-sample-data-to-ingest-sample-data": "サンプルデータを実行して OpenMetadata に取り込みます。", "run-status-at-timestamp": "実行ステータス:{{timestamp}} 時点で {{status}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "このスキーマは、サンプルデータ収集のために渡すことができるパラメータを定義します。", "schedule-description": "取り込みの実行時刻と頻度をスケジュールします。", "schedule-entity-description": "この {{entity}} はあなたのスケジュールに基づいて定期的に実行されます。", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {テーブル} WHERE {列} < {{minValue}} OR {列} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "この操作は元に戻すことができません。", "unauthorized-user": "認証されていないユーザーです!メールアドレスまたはパスワードをご確認ください。", "unexpected-error": "予期しないエラーが発生しました。", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json index fd518aca18f9..26c76f6a9a0f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json @@ -23,6 +23,7 @@ "accuracy": "정확도", "ack": "승인", "acknowledged": "확인됨", + "acs-url": "ACS URL", "action": "작업", "action-plural": "작업들", "action-required": "조치 필요", @@ -85,6 +86,7 @@ "address": "주소", "admin": "관리자", "admin-plural": "관리자들", + "admin-principal": "Admin Principal", "admin-profile": "관리자 프로필", "admin-uppercase": "관리자", "advance-filter": "고급 필터", @@ -92,6 +94,7 @@ "advanced-config": "고급 설정", "advanced-configuration": "고급 구성", "advanced-entity": "고급 {{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "고급 검색", "agent-activity": "에이전트 활동", "agent-plural": "에이전트", @@ -196,6 +199,7 @@ "authority": "권한", "authorize-app": "{{app}} 인증", "auto-classification": "자동 분류", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "자동 PII 신뢰도 점수", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "PII 자동 태그", @@ -256,6 +260,7 @@ "by-relation-type": "관계 유형별", "ca-certs": "CA 인증서", "calculated-from": "산출 기준", + "callback-url": "Callback URL", "cancel": "취소", "cancel-lowercase": "취소", "cardinality": "카디널리티", @@ -269,6 +274,7 @@ "change-log-plural": "변경 로그들", "change-parent-entity": "상위 {{entity}} 변경", "change-password": "비밀번호 변경", + "change-via-test-login": "Change via Test Login", "chart": "차트", "chart-entity": "{{entity}} 차트", "chart-plural": "차트들", @@ -381,6 +387,7 @@ "confirm": "확인", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "확인", "confirm-new-password": "새 비밀번호 확인", "confirm-password": "비밀번호 확인", @@ -655,6 +662,7 @@ "disabled": "비활성화됨", "discard": "폐기", "discover": "발견", + "dismiss": "Dismiss", "display-name": "표시 이름", "display-name-lowercase": "표시 이름", "display-text": "표시 텍스트", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Elasticsearch 재색인", "elasticsearch": "Elasticsearch", "email": "이메일", + "email-claim": "Email Claim", "email-configuration": "이메일 구성", "email-configuration-lowercase": "이메일 구성", "email-lowercase": "이메일", + "email-or-username": "Email or Username", "email-plural": "이메일들", "emailing-entity": "이메일 발송 엔티티", "embed-file-type": "{{fileType}} 삽입", @@ -1621,6 +1631,7 @@ "primary-key": "기본 키", "primary-key-plural": "기본 키", "primary-shards": "Primary Shards", + "principal-domain": "Principal Domain", "privacy-policy": "개인정보 처리방침", "private-key": "개인 키", "private-key-id": "개인 키 ID", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "노드 재배치", "reason": "이유", "reasons-for-decision": "Reasons for Decision", + "received": "Received", "receiver-plural": "수신자들", "recent-announcement-plural": "최근 공지사항들", "recent-event-plural": "최근 이벤트", @@ -1697,8 +1709,10 @@ "refresh-entity": "{entity}} 새로고침", "refresh-frequency": "새로 고침 빈도", "refresh-log": "로그 새로고침", + "refresh-token": "Refresh Token", "regenerate-registration-token": "등록 토큰 재생성", "region-name": "지역 이름", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "레지스트리", "regular-expression": "정규 표현식", "reindex-failure-plural": "Reindex Failures", @@ -1818,6 +1832,7 @@ "run-agent-plural": "에이전트 실행", "run-at": "실행 시간", "run-now": "지금 실행", + "run-test-login": "Run Test Login", "run-type": "실행 유형", "running": "실행 중", "running-ellipsis": "실행 중...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Select conflict resolution", "select-dimension": "차원 선택", "select-duration": "Select Duration", + "select-email-claim": "Select Email Claim", "select-entity": "{{entity}} 선택", "select-entity-type": "엔티티 유형 선택", "select-field": "{{field}} 선택", @@ -1946,8 +1962,10 @@ "set-as-default": "기본값으로 설정", "set-default-entity": "기본 {{entity}} 설정", "set-default-filters": "기본 필터 설정", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Set Up", "set-up-kpi": "KPI 설정", + "set-via-test-login": "Set via Test Login", "setting-plural": "설정들", "setup-guide": "설정 가이드", "severity": "심각도", @@ -1999,6 +2017,7 @@ "source-provider": "소스 제공자", "source-url": "소스 URL", "source-with-details": "소스: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "특정 데이터 자산들", "spreadsheet": "스프레드시트", "spreadsheet-plural": "스프레드시트", @@ -2158,6 +2177,7 @@ "test-entity": "{{entity}} 테스트", "test-level-lowercase": "테스트 레벨", "test-library": "테스트 라이브러리", + "test-login": "Test Login", "test-platform-plural": "Test Platforms", "test-plural": "테스트들", "test-plural-type": "{{type}} 테스트", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "워크플로 트리거 방식을 선택하세요", "choose-import-mode": "ODCS 계약 가져오기 방법을 선택하세요", "choose-which-assets-this-workflow-can-act-on": "이 워크플로가 대상으로 할 자산을 선택하세요.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(탐색 페이지에서 필터링된 자산을 보려면 클릭하세요.)", "click-text-to-view-details": "자세한 내용을 보려면 <0>{{text}}를 클릭하세요.", + "client-id-required": "Client ID is required.", "closed-this-task": "이 작업을 종료했습니다", "collaborate-with-other-user": "다른 사용자와 협업하기 위해", "collate-ai-widget-description": "해당 서비스에서 Collate AI가 생성한 데이터 개요입니다. <0>자세히 보기", @@ -2626,6 +2648,7 @@ "discard-your-changes": "변경 사항을 취소하시겠습니까?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "코드 없는 데이터 품질로 더 쉬워졌습니다. 테스트, 배포, 결과 수집을 간단한 단계로 수행하고 즉각적인 테스트 실패 알림을 받으세요. 신뢰할 수 있는 데이터로 최신 정보를 유지하세요.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Percentage of distinct values in the column", "domain-change-asset-migration-warning": "Changing the domain will move {{count}} asset(s) from the current domain to {{domain}}. Do you want to proceed?", "domain-description": "조직 내 데이터 도메인을 정리하고 관리합니다.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "동기화하거나 모든 인덱스를 재생성하여 Elasticsearch 인덱스가 최신 상태인지 확인하세요.", "elastic-search-re-index-pipeline-description": "검색 인덱스 파이프라인은 elasticsearch에서 데이터를 재색인하는 데 사용됩니다. 자세히 알아보려면 <0>{{link}}의 문서를 참조하세요.", "elasticsearch-setup": "메타데이터 수집을 설정하고 Elasticsearch에 색인화하려면 여기의 지침을 따르세요.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "이메일 전송을 위한 SMTP 설정을 구성하세요.", "email-is-invalid": "유효하지 않은 이메일입니다.", "email-verification-token-expired": "이메일 확인 토큰이 만료되었습니다", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "알림: 설명 KPI 목표가 아직 달성되지 않았지만 시간이 남아 있습니다 - 조직에 {{count}}일이 남았습니다. 진행 상황을 유지하려면 데이터 인사이트 보고서를 활성화하세요. 이를 통해 모든 팀에 주간 업데이트를 보내 조직의 KPI 달성을 위한 협업과 집중을 촉진할 수 있습니다.", "latency-sla-description": "<0>{{label}}: 쿼리 응답은 <0>{{data}} 보다 낮아야 합니다.", "latest-offset-description": "시스템 내 이벤트의 최신 오프셋입니다.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "예: cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "이 LDAP 그룹 DN은 이미 매핑되어 있습니다. 각 LDAP 그룹은 한 번만 매핑할 수 있습니다.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "제품 기능을 탐색하고 리소스를 통해 작동 방식을 알아보세요", "leave-the-team-team-name": "{{teamName}} 팀 떠나기", "length-validator-error": "최소 {{length}}개의 {{field}}이(가) 필요합니다", @@ -3011,6 +3039,7 @@ "no-profiler-title": "프로파일러를 실행하여 테이블 인사이트 잠금 해제", "no-recently-viewed-date": "최근에 본 데이터 자산이 없습니다. 흥미로운 것을 찾으려면 탐색해 보세요!", "no-reference-available": "사용 가능한 참조가 없습니다.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "사용 가능한 관련 용어가 없습니다.", "no-relations-for-selected-filter": "선택한 관계 유형에 대한 관계를 찾을 수 없습니다. 다른 유형을 선택해 보세요.", "no-relations-found": "이 용어에 대한 관계를 찾을 수 없습니다", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "각 배치에서 처리할 엔티티 수", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "데이터를 발견하고, 협업하고, 올바르게 사용하기 위한 중앙 집중식 메타데이터 저장소입니다.", "om-url-configuration-message": "사용 가능한 서비스 인사이트 데이터가 없습니다.", "on-demand-description": "수동으로 수집을 실행합니다.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "아래 작업 중 하나를 선택하세요.", "please-type-text-to-confirm": "확인을 위해 {{text}}을(를) 입력하세요.", "popup-block-message": "로그인 팝업이 브라우저에 의해 차단되었습니다. <0>활성화하고 다시 시도하세요.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "PII 민감/비민감 열을 자동 태그하기 위해 열 이름을 확인하세요.", "process-pii-sensitive-column-message-profiler": "활성화되면 샘플 데이터를 분석하여 각 열에 적절한 PII 태그를 결정합니다", "processed-all-events-description": "모든 이벤트가 처리되었는지 여부를 나타냅니다.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "홈 페이지로 리디렉션 중", "refer-to-our-doc": "더 많은 도움이 필요하신가요? 자세한 정보는 <0>{{doc}}를 참조하세요.", "refresh-frequency-contract-description": "예상 데이터 업데이트 빈도", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "온톨로지 그래프에서 이 관계 유형을 시각화하는 데 사용되는 16진수 색상 코드(예: #1890ff).", "relation-type-in-use-count": "{{count}}개의 용어 관계에서 사용 중", "relation-type-not-in-use": "현재 사용되지 않음", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "\"{{sourceDisplayName}}와(과) {{targetDisplayName}}\" 사이의 엣지를 제거하시겠습니까?", "remove-lineage-edge": "계보 엣지 제거", "rename-entity": "{{entity}}의 이름과 표시 이름을 변경합니다.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "다음에 대한 승인 요청:", "request-approval-notification": "승인 필요:", "request-description": "요청 설명", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: 데이터는 <0>{{data}} 동안 보관되어야 합니다.", "run-sample-data-to-ingest-sample-data": "'OpenMetadata에 샘플 데이터 자산을 수집하려면 샘플 데이터를 실행하세요.'", "run-status-at-timestamp": "실행 상태: {{timestamp}}에 {{status}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "이 스키마는 샘플 데이터 수집을 위해 전달할 수 있는 매개변수를 정의합니다.", "schedule-description": "특정 시간과 주기로 수집을 예약합니다.", "schedule-entity-description": "이 {{entity}} 는 스케줄에 따라 반복적으로 실행됩니다.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "This action cannot be undone.", "unauthorized-user": "인증되지 않은 사용자입니다! 이메일 또는 비밀번호를 확인해주세요", "unexpected-error": "예기치 않은 오류가 발생했습니다.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index 4e9f77771210..1928251e457a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -23,6 +23,7 @@ "accuracy": "अचूकता", "ack": "Ack", "acknowledged": "मान्य केले", + "acs-url": "ACS URL", "action": "कृती", "action-plural": "कृती", "action-required": "कृती आवश्यक आहे", @@ -85,6 +86,7 @@ "address": "पत्ता", "admin": "प्रशासक", "admin-plural": "प्रशासक", + "admin-principal": "Admin Principal", "admin-profile": "प्रशासक प्रोफाइल", "admin-uppercase": "प्रशासक", "advance-filter": "प्रगत फिल्टर", @@ -92,6 +94,7 @@ "advanced-config": "प्रगत संरचना", "advanced-configuration": "प्रगत संरचना", "advanced-entity": "प्रगत {{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "प्रगत शोध", "agent-activity": "Agent Activity", "agent-plural": "एजंट्स", @@ -196,6 +199,7 @@ "authority": "प्राधिकरण", "authorize-app": "{{app}} अधिकृत करा", "auto-classification": "Auto Classification", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "ऑटो PII आत्मविश्वास स्कोअर", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "ऑटो टॅग PII", @@ -256,6 +260,7 @@ "by-relation-type": "संबंध प्रकारानुसार", "ca-certs": "CA प्रमाणपत्रे", "calculated-from": "यावरून गणना केलेले", + "callback-url": "Callback URL", "cancel": "रद्द करा", "cancel-lowercase": "रद्द करा", "cardinality": "Cardinality", @@ -269,6 +274,7 @@ "change-log-plural": "बदल नोंदी", "change-parent-entity": "पालक {{entity}} बदला", "change-password": "पासवर्ड बदला", + "change-via-test-login": "Change via Test Login", "chart": "तक्ता", "chart-entity": "तक्ता {{entity}}", "chart-plural": "तक्ते", @@ -381,6 +387,7 @@ "confirm": "पुष्टी करा", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "पुष्टी करा", "confirm-new-password": "नवीन पासवर्ड पुष्टी करा", "confirm-password": "तुमचा पासवर्ड पुष्टी करा", @@ -655,6 +662,7 @@ "disabled": "अक्षम केले", "discard": "टाका", "discover": "शोधा", + "dismiss": "Dismiss", "display-name": "प्रदर्शन नाव", "display-name-lowercase": "प्रदर्शन नाव", "display-text": "प्रदर्शन मजकूर", @@ -714,9 +722,11 @@ "elastic-search-re-index": "एलॅस्टिकसर्च रीइंडेक्स", "elasticsearch": "एलॅस्टिकसर्च", "email": "ईमेल", + "email-claim": "Email Claim", "email-configuration": "ईमेल संरचना", "email-configuration-lowercase": "ईमेल संरचना", "email-lowercase": "ईमेल", + "email-or-username": "Email or Username", "email-plural": "ईमेल्स", "emailing-entity": "ईमेलिंग घटक", "embed-file-type": "Embed {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "प्राथमिक की", "primary-key-plural": "प्राथमिक कळा", "primary-shards": "Primary Shards", + "principal-domain": "Principal Domain", "privacy-policy": "गोपनीयता धोरण", "private-key": "खाजगी की", "private-key-id": "खाजगी की आयडी", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "नोड्स पुनर्रचना करा", "reason": "कारण", "reasons-for-decision": "Reasons for Decision", + "received": "Received", "receiver-plural": "प्राप्तकर्ते", "recent-announcement-plural": "अलीकडील घोषणा", "recent-event-plural": "Recent Events", @@ -1697,8 +1709,10 @@ "refresh-entity": "{{entity}} चे पुनरावलोकन करा", "refresh-frequency": "रीफ्रेश वारंवारता", "refresh-log": "लॉग ताजेतवाने करा", + "refresh-token": "Refresh Token", "regenerate-registration-token": "नोंदणी टोकन पुन्हा व्युत्पन्न करा", "region-name": "प्रदेशाचे नाव", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "नोंदणी", "regular-expression": "नियमित अभिव्यक्ती", "reindex-failure-plural": "Reindex Failures", @@ -1818,6 +1832,7 @@ "run-agent-plural": "एजेंट्स चलवा", "run-at": "येथे चालवा", "run-now": "आता चालवा", + "run-test-login": "Run Test Login", "run-type": "चालवा प्रकार", "running": "चालू आहे", "running-ellipsis": "चालू आहे...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Select conflict resolution", "select-dimension": "परिमाण निवडा", "select-duration": "Select Duration", + "select-email-claim": "Select Email Claim", "select-entity": "{{entity}} निवडा", "select-entity-type": "एन्टिटी टाइप निवडा", "select-field": "{{field}} निवडा", @@ -1946,8 +1962,10 @@ "set-as-default": "डीफॉल्ट म्हणून सेट करा", "set-default-entity": "डीफॉल्ट {{entity}} सेट करा", "set-default-filters": "मूलभूत फ़िल्टर्स सेट करा", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Set Up", "set-up-kpi": "KPI सेट करा", + "set-via-test-login": "Set via Test Login", "setting-plural": "सेटिंग्ज", "setup-guide": "सेटअप मार्गदर्शक", "severity": "तीव्रता", @@ -1999,6 +2017,7 @@ "source-provider": "स्त्रोत प्रदाता", "source-url": "स्रोत URL", "source-with-details": "Source: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "विशिष्ट डेटा ॲसेट", "spreadsheet": "स्प्रेडशीट", "spreadsheet-plural": "स्प्रेडशीट्स", @@ -2158,6 +2177,7 @@ "test-entity": "चाचणी {{entity}}", "test-level-lowercase": "चाचणी पातळी", "test-library": "चाचणी लायब्ररी", + "test-login": "Test Login", "test-platform-plural": "Test Platforms", "test-plural": "चाचण्या", "test-plural-type": "{{type}} चाचण्या", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "कार्यप्रवाह कसा सुरू होईल ते निवडा", "choose-import-mode": "ODCS करार कसा आयात करायचा ते निवडा", "choose-which-assets-this-workflow-can-act-on": "हा कार्यप्रवाह कोणत्या अॅसेटवर कार्य करू शकतो ते निवडा.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(फिल्टर केलेल्या ॲसेट्स एक्सप्लोर पृष्ठावर पाहण्यासाठी क्लिक करा.)", "click-text-to-view-details": "तपशील पाहण्यासाठी <0>{{text}} क्लिक करा.", + "client-id-required": "Client ID is required.", "closed-this-task": "हे कार्य बंद केले", "collaborate-with-other-user": "इतर वापरकर्त्यांसह सहयोग करण्यासाठी.", "collate-ai-widget-description": "सेवेसाठी कोलेट एआय द्वारे तयार केलेल्या डेटाचे विहंगावलोकन. <0>अधिक जाणून घ्या.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "आपले बदल टाकून द्यायचे?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "कोडलेस डेटा गुणवत्ता सोप्या पायऱ्यांसह सोपी झाली. चाचणी, उपयोजन आणि निकाल गोळा करा, त्वरित चाचणी अयशस्वी सूचना मिळवा. विश्वासार्ह डेटासह अद्ययावत रहा.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Percentage of distinct values in the column", "domain-change-asset-migration-warning": "Changing the domain will move {{count}} asset(s) from the current domain to {{domain}}. Do you want to proceed?", "domain-description": "Organize and manage data domains in your organization.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "तुमच्या Elasticsearch अनुक्रमणिका अद्ययावत आहेत याची खात्री करण्यासाठी, सर्व अनुक्रमणिका समक्रमित करा किंवा पुन्हा तयार करा.", "elastic-search-re-index-pipeline-description": "शोध अनुक्रमणिका पाइपलाइन Elasticsearch मध्ये डेटा पुन्हा अनुक्रमित करण्यासाठी वापरली जाते. अधिक जाणून घेण्यासाठी आमच्या दस्तऐवजांचा संदर्भ घ्या <0>{{link}}", "elasticsearch-setup": "मेटाडेटा अंतर्ग्रहण सेट अप करण्यासाठी आणि Elasticsearch मध्ये अनुक्रमित करण्यासाठी कृपया येथे दिलेल्या सूचनांचे अनुसरण करा.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "ईमेल पाठवण्यासाठी SMTP सेटिंग्ज कॉन्फिगर करा.", "email-is-invalid": "अवैध ईमेल.", "email-verification-token-expired": "ईमेल सत्यापन टोकन कालबाह्य झाले", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "सूचना: वर्णन KPI लक्ष्य अद्याप पूर्ण झालेले नाही, परंतु अजूनही वेळ आहे – तुमच्या संस्थेकडे {{count}} दिवस शिल्लक आहेत. ट्रॅकवर राहण्यासाठी, कृपया डेटा अंतर्दृष्टी अहवाल सक्षम करा. हे आम्हाला सर्व टीम्सना साप्ताहिक अद्यतने पाठविण्याची परवानगी देईल, सहकार्य आणि आमच्या संस्थेच्या KPI साध्य करण्याच्या दिशेने लक्ष केंद्रित करेल.", "latency-sla-description": "<0>{{label}}: Query response must be under <0>{{data}}", "latest-offset-description": "The latest offset of the event in the system.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "उदा. cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "हा LDAP ग्रुप DN आधीच मॅप केला आहे. प्रत्येक LDAP ग्रुप फक्त एकदाच मॅप केला जाऊ शकतो.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "उत्पादन वैशिष्ट्ये एक्सप्लोर करा आणि आमच्या संसाधनांद्वारे ते कसे कार्य करतात ते शिका", "leave-the-team-team-name": "टीम {{teamName}} सोडा", "length-validator-error": "किमान {{length}} {{field}} आवश्यक", @@ -3011,6 +3039,7 @@ "no-profiler-title": "टेबल अंतर्दृष्टी अनलॉक करण्यासाठी प्रोफाइलर चालवा", "no-recently-viewed-date": "तुम्ही अलीकडे कोणतेही डेटा ॲसेट पाहिलेले नाहीत. काहीतरी मनोरंजक शोधण्यासाठी एक्सप्लोर करा!", "no-reference-available": "कोणतेही संदर्भ उपलब्ध नाहीत.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "संबंधित संज्ञा उपलब्ध नाहीत.", "no-relations-for-selected-filter": "निवडलेल्या संबंध प्रकारांसाठी कोणतेही संबंध आढळले नाहीत. वेगळे प्रकार निवडण्याचा प्रयत्न करा.", "no-relations-found": "या संज्ञेसाठी संबंध आढळले नाहीत", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "प्रत्येक बॅचमध्ये प्रक्रिया करायच्या एंटिटीची संख्या", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "केंद्रीकृत मेटाडेटा स्टोअर, शोधण्यासाठी, सहयोग करण्यासाठी आणि तुमचा डेटा योग्य मिळवण्यासाठी.", "om-url-configuration-message": "Configure the {{brandName}} URL Settings.", "on-demand-description": "Run the ingestion manually.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "खालीलपैकी एक कृती निवडा.", "please-type-text-to-confirm": "कृपया पुष्टी करण्यासाठी {{text}} टाइप करा.", "popup-block-message": "साइन इन पॉप-अप ब्राउझरद्वारे अवरोधित केले गेले. कृपया <0>सक्षम करा आणि पुन्हा प्रयत्न करा.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "PII संवेदनशील/असंवेदनशील स्तंभ स्वयंचलितपणे टॅग करण्यासाठी स्तंभ नावे तपासा.", "process-pii-sensitive-column-message-profiler": "सक्षम केल्यावर, नमुना डेटा विश्लेषित केला जाईल आणि प्रत्येक स्तंभासाठी योग्य PII टॅग ठरवले जातील", "processed-all-events-description": "Indicates whether all events have been processed.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "मुख्यपृष्ठावर पुनर्निर्देशित करत आहे", "refer-to-our-doc": "अजूनही मदत हवी आहे? अधिक माहितीसाठी आमच्या <0>{{doc}} चा संदर्भ घ्या.", "refresh-frequency-contract-description": "डेटा अद्ययावत करण्याची अपेक्षित वारंवारता", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "ऑन्टोलॉजी आलेखात या संबंध प्रकाराचे दृश्यमान करण्यासाठी वापरला जाणारा Hex रंग कोड (उदा., #1890ff).", "relation-type-in-use-count": "{{count}} संज्ञा संबंधांमध्ये वापरात आहे", "relation-type-not-in-use": "सध्या वापरात नाही", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "\"{{sourceDisplayName}} आणि {{targetDisplayName}}\" यांच्यातील काठ काढायचे आहे का?.", "remove-lineage-edge": "वंशावळ काठ काढा", "rename-entity": "{{entity}} साठी नाव आणि प्रदर्शन नाव बदला.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "मंजुरी विनंती", "request-approval-notification": "मान्यता आवश्यक आहे", "request-description": "वर्णन विनंती", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}{{data}} {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "This action cannot be undone.", "unauthorized-user": "अनधिकृत वापरकर्ता! कृपया ईमेल किंवा पासवर्ड तपासा", "unexpected-error": "अप्रत्याशित त्रुटी आली.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index 4de58d3599dc..e8be6878ca91 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -23,6 +23,7 @@ "accuracy": "Nauwkeurigheid", "ack": "Bevestiging", "acknowledged": "Bevestigd", + "acs-url": "ACS URL", "action": "Actie", "action-plural": "Acties", "action-required": "Actie vereist", @@ -85,6 +86,7 @@ "address": "Adres", "admin": "Beheerder", "admin-plural": "Beheerders", + "admin-principal": "Admin Principal", "admin-profile": "Beheerdersprofiel", "admin-uppercase": "BEHEERDER", "advance-filter": "Geavanceerd filter", @@ -92,6 +94,7 @@ "advanced-config": "Geavanceerde configuratie", "advanced-configuration": "Geavanceerde configuratie", "advanced-entity": "Geavanceerd {{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "Geavanceerd zoeken", "agent-activity": "Agentactiviteit", "agent-plural": "Agenten", @@ -196,6 +199,7 @@ "authority": "Autoriteit", "authorize-app": "Applicatie autoriseren {{app}}", "auto-classification": "Automatische classificatie", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "Automatische PII-vertrouwensscore", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "Automatisch taggen van PII", @@ -256,6 +260,7 @@ "by-relation-type": "Op Relatietype", "ca-certs": "CA-certificaten", "calculated-from": "Berekend uit", + "callback-url": "Callback URL", "cancel": "Annuleren", "cancel-lowercase": "annuleren", "cardinality": "Cardinality", @@ -269,6 +274,7 @@ "change-log-plural": "Wijzigingslogboeken", "change-parent-entity": "Change Parent {{entity}}", "change-password": "Wachtwoord wijzigen", + "change-via-test-login": "Change via Test Login", "chart": "Grafiek", "chart-entity": "Chart {{entity}}", "chart-plural": "Grafieken", @@ -381,6 +387,7 @@ "confirm": "Bevestigen", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "bevestigen", "confirm-new-password": "Nieuw wachtwoord bevestigen", "confirm-password": "Bevestig je wachtwoord", @@ -655,6 +662,7 @@ "disabled": "Uitgeschakeld", "discard": "Weggooien", "discover": "Ontdekken", + "dismiss": "Dismiss", "display-name": "Weergavenaam", "display-name-lowercase": "weergavenaam", "display-text": "Weergavetekst", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Elasticsearch opnieuw indexeren", "elasticsearch": "Elasticsearch", "email": "E-mail", + "email-claim": "Email Claim", "email-configuration": "E-mailconfiguratie", "email-configuration-lowercase": "e-mailconfiguratie", "email-lowercase": "e-mail", + "email-or-username": "Email or Username", "email-plural": "E-mails", "emailing-entity": "E-mailende entiteit", "embed-file-type": "Embed {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "Primaire sleutel", "primary-key-plural": "Primaire sleutels", "primary-shards": "Primary Shards", + "principal-domain": "Principal Domain", "privacy-policy": "Privacybeleid", "private-key": "Privésleutel", "private-key-id": "Privésleutel-ID", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "Nodes herschikken", "reason": "Reden", "reasons-for-decision": "Reasons for Decision", + "received": "Received", "receiver-plural": "Ontvangers", "recent-announcement-plural": "Recente aankondigingen", "recent-event-plural": "Recente gebeurtenissen", @@ -1697,8 +1709,10 @@ "refresh-entity": "{{entity}} vernieuwen", "refresh-frequency": "Vernieuwfrequentie", "refresh-log": "Vernieuw logboek", + "refresh-token": "Refresh Token", "regenerate-registration-token": "Opnieuw genereren registratietoken", "region-name": "Regionaam", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "Register", "regular-expression": "Regular Expression", "reindex-failure-plural": "Reindex Failures", @@ -1818,6 +1832,7 @@ "run-agent-plural": "Agents Uitvoeren", "run-at": "Uitvoeren op", "run-now": "Nu uitvoeren", + "run-test-login": "Run Test Login", "run-type": "Uitvoertype", "running": "Bezig met uitvoeren", "running-ellipsis": "Bezig...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Selecteer conflictoplossing", "select-dimension": "Selecteer dimensie", "select-duration": "Select Duration", + "select-email-claim": "Select Email Claim", "select-entity": "Select {{entity}}", "select-entity-type": "Entiteitstype selecteren", "select-field": "Selecteer {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "Als standaard instellen", "set-default-entity": "Standaard {{entity}} instellen", "set-default-filters": "Standaardfilters instellen", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Set Up", "set-up-kpi": "KPI instellen", + "set-via-test-login": "Set via Test Login", "setting-plural": "Instellingen", "setup-guide": "Installatiehandleiding", "severity": "Impact", @@ -1999,6 +2017,7 @@ "source-provider": "Bronprovider", "source-url": "Bron-URL", "source-with-details": "Bron: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "Specifieke data-assets", "spreadsheet": "Spreadsheet", "spreadsheet-plural": "Spreadsheets", @@ -2158,6 +2177,7 @@ "test-entity": "Test {{entity}}", "test-level-lowercase": "testniveau", "test-library": "Testbibliotheek", + "test-login": "Test Login", "test-platform-plural": "Testplatforms", "test-plural": "Testen", "test-plural-type": "{{type}}-testen", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "Kies hoe de workflow moet worden geactiveerd", "choose-import-mode": "Kies hoe het ODCS-contract moet worden geïmporteerd", "choose-which-assets-this-workflow-can-act-on": "Kies op welke assets deze workflow mag inwerken.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(Klik om de gefilterde assets op de Verkennen-pagina te bekijken.)", "click-text-to-view-details": "Klik op <0>{{text}} om details te bekijken.", + "client-id-required": "Client ID is required.", "closed-this-task": "heeft deze taak gesloten", "collaborate-with-other-user": "om samen te werken met andere gebruikers.", "collate-ai-widget-description": "Overzicht van de data gegenereerd door de Collate AI voor de service. <0>meer informatie.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "Uw wijzigingen weggooien?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "Dingen zijn eenvoudiger geworden met no-code datakwaliteit. Eenvoudige stappen om te testen, deployen en resultaten te verzamelen, met onmiddellijke meldingen van testfouten. Blijf op de hoogte van betrouwbare data die je kunt vertrouwen.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Percentage verschillende waarden in de kolom", "domain-change-asset-migration-warning": "Als u het domein wijzigt, worden _ 0 _ assets) van het huidige domein naar _1 __ verplaatst.Wilt u doorgaan?", "domain-description": "Organiseer en beheer datadomeinen in uw organisatie.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "Zorg ervoor dat je Elasticsearch-indexen up-to-date zijn door te synchroniseren of alle indexen opnieuw te maken.", "elastic-search-re-index-pipeline-description": "De zoekindexpipeline wordt gebruikt om de data opnieuw te indexeren in Elasticsearch. Raadpleeg onze documentatie voor meer informatie <0>{{link}}", "elasticsearch-setup": "Volg de instructies hier om Metadata in te stellen en te indexeren in Elasticsearch.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "Configureer de SMTP-instellingen voor het verzenden van e-mails.", "email-is-invalid": "Ongeldig e-mailadres.", "email-verification-token-expired": "E-mailverificatietoken is verlopen", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "Maakt niet uit. Het is tijd om je doelen te herstructureren en sneller vooruitgang te boeken.", "latency-sla-description": "<0>{{label}}: Reactietijd van de query moet onder <0>{{data}} liggen.", "latest-offset-description": "De laatste offset van de gebeurtenis in het systeem.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "bijv. cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "Deze LDAP-groep DN is al toegewezen. Elke LDAP-groep kan slechts één keer worden toegewezen.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "Ontdek productfuncties en leer hoe ze werken via onze bronnen", "leave-the-team-team-name": "Verlaat het team {{teamName}}", "length-validator-error": "Minimaal {{length}} {{field}} vereist", @@ -3011,6 +3039,7 @@ "no-profiler-title": "Voer Profiler uit om tabelinzichten te ontgrendelen", "no-recently-viewed-date": "Geen recent bekeken data.", "no-reference-available": "Geen referenties beschikbaar.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "Geen gerelateerde termen beschikbaar.", "no-relations-for-selected-filter": "Geen relaties gevonden voor de geselecteerde relatietypes. Probeer andere types te selecteren.", "no-relations-found": "Geen relaties gevonden voor deze term", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "Aantal entiteiten om per batch te verwerken", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "Gecentraliseerde metadatastore, om data te ontdekken, samen te werken en op orde te brengen.", "om-url-configuration-message": "Configureer de {{brandName}} URL-instellingen.", "on-demand-description": "Voer de opname handmatig uit.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Selecteer hieronder één actie.", "please-type-text-to-confirm": "Typ alstublieft {{text}} om te bevestigen.", "popup-block-message": "De aanmeld-pop-up werd geblokkeerd door de browser. <0>Schakel deze in en probeer opnieuw.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "Controleer kolomnamen om PII-gevoelige/niet-gevoelige kolommen automatisch te taggen.", "process-pii-sensitive-column-message-profiler": "Indien ingeschakeld, wordt de voorbeelddata geanalyseerd om geschikte PII-tags voor elke kolom te bepalen", "processed-all-events-description": "Geeft aan of alle gebeurtenissen zijn verwerkt.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "Doorgaan naar de startpagina", "refer-to-our-doc": "Hulp nodig? Raadpleeg onze <0>{{doc}} voor meer informatie.", "refresh-frequency-contract-description": "Verwachte frequentie van gegevensupdates", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "Hexadecimale kleurcode voor de visualisatie van dit relatietype in de ontologiegraf (bijv. #1890ff).", "relation-type-in-use-count": "Gebruikt door {{count}} termrelatie(s)", "relation-type-not-in-use": "Momenteel niet in gebruik", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "Weet je zeker dat je de verbinding tussen \"{{sourceDisplayName}} en {{targetDisplayName}}\" wilt verwijderen?", "remove-lineage-edge": "Verwijder lineageverbinding", "rename-entity": "Hernoem de naam en weergavenaam voor de {{entity}}.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "Goedkeuringsverzoek voor", "request-approval-notification": "Goedkeuring vereist voor", "request-description": "Verzoekomschrijving", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: Gegevens moeten bewaard worden voor <0>{{data}}.", "run-sample-data-to-ingest-sample-data": "'Voer voorbeelddata uit om voorbeeld-data-assets te ingesten in je OpenMetadata.'", "run-status-at-timestamp": "Uitvoerstatus: {{status}} op {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "Dit schema definieert de parameters die kunnen worden doorgegeven voor het verzamelen van voorbeeldgegevens.", "schedule-description": "Plan de ingestie om op een specifiek tijdstip en frequentie te draaien.", "schedule-entity-description": "Deze {{entity}} wordt herhaaldelijk uitgevoerd op basis van uw planning.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "This action cannot be undone.", "unauthorized-user": "Ongeautoriseerde gebruiker! controleer e-mail of wachtwoord", "unexpected-error": "Er is een onverwachte fout opgetreden.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index 90326da2a079..50365f92b922 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -23,6 +23,7 @@ "accuracy": "دقت", "ack": "Ack", "acknowledged": "تأیید شد", + "acs-url": "ACS URL", "action": "عمل", "action-plural": "اعمال", "action-required": "Acción requerida", @@ -85,6 +86,7 @@ "address": "آدرس", "admin": "مدیر", "admin-plural": "مدیران", + "admin-principal": "Admin Principal", "admin-profile": "پروفایل مدیر", "admin-uppercase": "مدیر", "advance-filter": "فیلتر پیشرفته", @@ -92,6 +94,7 @@ "advanced-config": "تنظیمات پیشرفته", "advanced-configuration": "پیکربندی پیشرفته", "advanced-entity": "{{entity}} پیشرفته", + "advanced-fields": "Advanced Fields", "advanced-search": "جستجوی پیشرفته", "agent-activity": "Agent Activity", "agent-plural": "Agentes", @@ -196,6 +199,7 @@ "authority": "مرجع", "authorize-app": "مجوز دادن به {{app}}", "auto-classification": "طبقه‌بندی خودکار", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "امتیاز اعتماد PII خودکار", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "برچسب PII خودکار", @@ -256,6 +260,7 @@ "by-relation-type": "بر اساس نوع رابطه", "ca-certs": "گواهی‌های CA", "calculated-from": "محاسبه شده از", + "callback-url": "Callback URL", "cancel": "لغو", "cancel-lowercase": "لغو", "cardinality": "Cardinality", @@ -269,6 +274,7 @@ "change-log-plural": "لاگ‌های تغییر", "change-parent-entity": "تغییر والد {{entity}}", "change-password": "Cambiar contraseña", + "change-via-test-login": "Change via Test Login", "chart": "نمودار", "chart-entity": "نمودار {{entity}}", "chart-plural": "نمودارها", @@ -381,6 +387,7 @@ "confirm": "تأیید", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "تأیید", "confirm-new-password": "تأیید رمز عبور جدید", "confirm-password": "رمز عبور خود را تأیید کنید", @@ -655,6 +662,7 @@ "disabled": "غیرفعال شد", "discard": "رد کرنا", "discover": "کشف", + "dismiss": "Dismiss", "display-name": "نام نمایشی", "display-name-lowercase": "نام نمایشی", "display-text": "متن نمایشی", @@ -714,9 +722,11 @@ "elastic-search-re-index": "بازیابی مجدد ایندکس Elasticsearch", "elasticsearch": "Elasticsearch", "email": "ایمیل", + "email-claim": "Email Claim", "email-configuration": "پیکربندی ایمیل", "email-configuration-lowercase": "پیکربندی ایمیل", "email-lowercase": "ایمیل", + "email-or-username": "Email or Username", "email-plural": "ایمیل‌ها", "emailing-entity": "ارسال ایمیل به نهاد", "embed-file-type": "Embed {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "کلید اصلی", "primary-key-plural": "کلیدهای اصلی", "primary-shards": "Primary Shards", + "principal-domain": "Principal Domain", "privacy-policy": "سیاست حفظ حریم خصوصی", "private-key": "کلید خصوصی", "private-key-id": "شناسه کلید خصوصی", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "مرتب‌سازی گره‌ها", "reason": "دلیل", "reasons-for-decision": "Reasons for Decision", + "received": "Received", "receiver-plural": "گیرندگان", "recent-announcement-plural": "اعلانات اخیر", "recent-event-plural": "Recent Events", @@ -1697,8 +1709,10 @@ "refresh-entity": "تازه‌سازی {{entity}}", "refresh-frequency": "فرکانس تازه‌سازی", "refresh-log": "تازه‌سازی لاگ", + "refresh-token": "Refresh Token", "regenerate-registration-token": "بازسازی توکن ثبت‌نام", "region-name": "نام منطقه", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "رجیستری", "regular-expression": "Regular Expression", "reindex-failure-plural": "Reindex Failures", @@ -1818,6 +1832,7 @@ "run-agent-plural": "اجرای عامل‌ها", "run-at": "اجرا در", "run-now": "الان اجرا کنید", + "run-test-login": "Run Test Login", "run-type": "نوع اجرا", "running": "در حال اجرا", "running-ellipsis": "در حال اجرا...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "انتخاب حل تعارض", "select-dimension": "انتخاب بُعد", "select-duration": "Select Duration", + "select-email-claim": "Select Email Claim", "select-entity": "انتخاب {{entity}}", "select-entity-type": "نوع جسم را انتخاب کنید", "select-field": "انتخاب {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "ਡਿਫਾਲਟ ਵਜੋਂ ਸੈੱਟ ਕਰੋ", "set-default-entity": "ਡਿਫਾਲਟ {{entity}} ਸੈੱਟ ਕਰੋ", "set-default-filters": "تعیین فیلترهای پیشفرض", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Set Up", "set-up-kpi": "راه‌اندازی KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "تنظیمات", "setup-guide": "راهنمای راه‌اندازی", "severity": "شدت", @@ -1999,6 +2017,7 @@ "source-provider": "Source Provider", "source-url": "آدرس منبع", "source-with-details": "Source: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "دارایی‌های داده خاص", "spreadsheet": "صفحه گسترده", "spreadsheet-plural": "صفحات گسترده", @@ -2158,6 +2177,7 @@ "test-entity": "تست {{entity}}", "test-level-lowercase": "سطح تست", "test-library": "کتابخانه تست", + "test-login": "Test Login", "test-platform-plural": "Test Platforms", "test-plural": "تست‌ها", "test-plural-type": "Testy {{type}}", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "نحوه راه‌اندازی گردش کار را انتخاب کنید", "choose-import-mode": "Choose how to import the ODCS contract", "choose-which-assets-this-workflow-can-act-on": "دارایی‌هایی را که این گردش کار روی آنها اثر می‌گذارد انتخاب کنید.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(Click to view the filtered assets on Explore page.)", "click-text-to-view-details": "برای مشاهده جزئیات، روی <0>{{text}} کلیک کنید.", + "client-id-required": "Client ID is required.", "closed-this-task": "این وظیفه را بست.", "collaborate-with-other-user": "برای همکاری با کاربران دیگر.", "collate-ai-widget-description": "نمای کلی داده‌های تولید شده توسط Collate AI برای سرویس. <0>بیشتر بدانید.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "تغییرات خود را دور بریزید؟", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "کارها با کیفیت داده بدون نیاز به کدنویسی آسان‌تر شده است. با چند قدم ساده تست کنید، استقرار دهید و نتایج را جمع‌آوری کنید، همراه با اطلاعیه‌های فوری از شکست تست‌ها. با داده‌های قابل اعتماد به‌روز باشید.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Percentage of distinct values in the column", "domain-change-asset-migration-warning": "Changing the domain will move {{count}} asset(s) from the current domain to {{domain}}. Do you want to proceed?", "domain-description": "Organize and manage data domains in your organization.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "اطمینان حاصل کنید که ایندکس‌های Elasticsearch شما به‌روز هستند، با همگام‌سازی یا بازسازی تمامی ایندکس‌ها.", "elastic-search-re-index-pipeline-description": "پایپ‌لاین ایندکس جستجو برای ایندکس مجدد داده‌ها در Elasticsearch استفاده می‌شود. برای یادگیری بیشتر به مستندات ما مراجعه کنید <0>{{link}}.", "elasticsearch-setup": "لطفاً دستورالعمل‌های موجود را دنبال کنید تا راه‌اندازی ورود متادیتا و ایندکس آن‌ها به Elasticsearch انجام شود.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "تنظیمات SMTP را برای ارسال ایمیل پیکربندی کنید.", "email-is-invalid": "ایمیل نامعتبر است.", "email-verification-token-expired": "توکن تأیید ایمیل منقضی شده است.", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "توجه: هدف KPI هنوز محقق نشده است، اما هنوز زمان باقیست – سازمان شما {{count}} روز فرصت دارد. برای حفظ مسیر، لطفاً گزارش بینش داده را فعال کنید. این امکان را برای ما فراهم می‌کند که به‌روزرسانی‌های هفتگی را به تمامی تیم‌ها ارسال کنیم تا همکاری و تمرکز به سمت دستیابی به KPI‌های سازمانی حفظ شود.", "latency-sla-description": "<0>{{label}}: پاسخ پرس‌وجو باید کمتر از <0>{{data}} باشد.", "latest-offset-description": "The latest offset of the event in the system.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "e.g. cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "این DN گروه LDAP قبلاً نگاشت شده است. هر گروه LDAP فقط یک بار قابل نگاشت است.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "ویژگی‌های محصول را کشف کنید و از طریق منابع ما یاد بگیرید که چگونه کار می‌کنند", "leave-the-team-team-name": "تیم {{teamName}} را ترک کنید.", "length-validator-error": "حداقل {{length}} {{field}} لازم است.", @@ -3011,6 +3039,7 @@ "no-profiler-title": "Uruchom Profiler, aby odblokować wgląd w tabele", "no-recently-viewed-date": "شما اخیراً هیچ دارایی داده‌ای را مشاهده نکرده‌اید. کاوش کنید تا چیزی جالب پیدا کنید!", "no-reference-available": "هیچ مرجعی در دسترس نیست.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "هیچ واژه مرتبطی موجود نیست.", "no-relations-for-selected-filter": "No relations found for the selected relation types. Try selecting different types.", "no-relations-found": "رابطه‌ای برای این اصطلاح یافت نشد", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "تعداد موجودیت‌ها برای پردازش در هر دسته", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "ذخیره متمرکز متادیتا، برای کشف، همکاری و درست کردن داده‌های شما.", "om-url-configuration-message": "Configure the {{brandName}} URL Settings.", "on-demand-description": "Run the ingestion manually.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Selecione uma das ações abaixo.", "please-type-text-to-confirm": "لطفاً {{text}} را برای تأیید تایپ کنید.", "popup-block-message": "پنجره ورود توسط مرورگر مسدود شد. لطفاً <0>فعال کنید و دوباره تلاش کنید.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "نام ستون‌ها را بررسی کنید تا به صورت خودکار ستون‌های حساس/غیرحساس PII برچسب‌گذاری شوند.", "process-pii-sensitive-column-message-profiler": "در صورت فعال شدن، داده‌های نمونه برای تعیین برچسب‌های مناسب PII برای هر ستون تجزیه و تحلیل خواهند شد.", "processed-all-events-description": "Indicates whether all events have been processed.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "در حال انتقال به صفحه اصلی.", "refer-to-our-doc": "هنوز نیاز به کمک دارید؟ به <0>{{doc}} ما مراجعه کنید برای اطلاعات بیشتر.", "refresh-frequency-contract-description": "فرکانس مورد انتظار به‌روزرسانی داده‌ها", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "کد رنگ Hex برای نمایش این نوع رابطه در نمودار هستی‌شناسی (مثلاً #1890ff).", "relation-type-in-use-count": "توسط {{count}} رابطه اصطلاح استفاده می‌شود", "relation-type-not-in-use": "در حال حاضر استفاده نمی‌شود", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "آیا مطمئن هستید که می‌خواهید لبه بین \"{{sourceDisplayName}} و {{targetDisplayName}}\" را حذف کنید؟", "remove-lineage-edge": "حذف لبه نسبت.", "rename-entity": "نام و نمایش نام برای {{entity}} را تغییر دهید.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "درخواست تأیید برای", "request-approval-notification": "تأیید مورد نیاز برای", "request-description": "توضیحات درخواست.", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: داده‌ها باید به مدت <0>{{data}} نگه داشته شوند.", "run-sample-data-to-ingest-sample-data": "'اجرای داده نمونه برای ورود داده‌های نمونه به OpenMetadata.'", "run-status-at-timestamp": "وضعیت اجرا: {{status}} در {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "This schema defines the parameters that can be passed for sample data collection.", "schedule-description": "Schedule the ingestion to run at a specific time and frequency.", "schedule-entity-description": "این {{entity}} بر اساس زمان‌بندی شما به طور مکرر اجرا خواهد شد.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "This action cannot be undone.", "unauthorized-user": "کاربر غیرمجاز! لطفاً ایمیل یا رمز عبور را بررسی کنید.", "unexpected-error": "یک خطای غیرمنتظره رخ داده است.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index ce229a5a714a..7408564a5123 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -23,6 +23,7 @@ "accuracy": "Precisão", "ack": "Confirmar", "acknowledged": "Visualizado", + "acs-url": "ACS URL", "action": "Ação", "action-plural": "Ações", "action-required": "Ação necessária", @@ -85,6 +86,7 @@ "address": "Endereço", "admin": "Administrador", "admin-plural": "Administradores", + "admin-principal": "Admin Principal", "admin-profile": "Perfil do Admin", "admin-uppercase": "ADMINISTRADOR", "advance-filter": "Filtro avançado", @@ -92,6 +94,7 @@ "advanced-config": "Configuração Avançada", "advanced-configuration": "Configuração Avançada", "advanced-entity": "{{entity}} Avançado", + "advanced-fields": "Advanced Fields", "advanced-search": "Busca Avançada", "agent-activity": "Atividade do agente", "agent-plural": "Agentes", @@ -196,6 +199,7 @@ "authority": "Autoridade", "authorize-app": "Autorizar {{app}}", "auto-classification": "Classificação Automática", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "Pontuação de Confiança Automática PII", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "Tag automática de PII", @@ -256,6 +260,7 @@ "by-relation-type": "Por Tipo de Relação", "ca-certs": "Certificados CA", "calculated-from": "Calculado A Partir De", + "callback-url": "Callback URL", "cancel": "Cancelar", "cancel-lowercase": "cancelar", "cardinality": "Cardinalidade", @@ -269,6 +274,7 @@ "change-log-plural": "Logs de Mudança", "change-parent-entity": "Alterar {{entity}} pai", "change-password": "Alterar senha", + "change-via-test-login": "Change via Test Login", "chart": "Gráfico", "chart-entity": "Gráfico {{entity}}", "chart-plural": "Gráficos", @@ -381,6 +387,7 @@ "confirm": "Confirmar", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "confirmar", "confirm-new-password": "Confirmar Nova Senha", "confirm-password": "Confirme sua senha", @@ -655,6 +662,7 @@ "disabled": "Desativado", "discard": "Descartar", "discover": "Descobrir", + "dismiss": "Dismiss", "display-name": "Nome de Exibição", "display-name-lowercase": "nome de exibição", "display-text": "Texto de exibição", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Reindexação do Elasticsearch", "elasticsearch": "Elasticsearch", "email": "E-mail", + "email-claim": "Email Claim", "email-configuration": "Configuração de E-mail", "email-configuration-lowercase": "configuração de e-mail", "email-lowercase": "e-mail", + "email-or-username": "Email or Username", "email-plural": "E-mails", "emailing-entity": "Entidade de Mailing", "embed-file-type": "Incorporar {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "Chave Primária", "primary-key-plural": "Chaves primárias", "primary-shards": "Fragmentos Primários", + "principal-domain": "Principal Domain", "privacy-policy": "Política de Privacidade", "private-key": "Chave Privada", "private-key-id": "ID da Chave Privada", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "Reorganizar Nós", "reason": "Razão", "reasons-for-decision": "Razões para decisão", + "received": "Received", "receiver-plural": "Receptores", "recent-announcement-plural": "Anúncios Recentes", "recent-event-plural": "Eventos recentes", @@ -1697,8 +1709,10 @@ "refresh-entity": "Atualizar {{entity}}", "refresh-frequency": "Frequência de Atualização", "refresh-log": "Atualizar log", + "refresh-token": "Refresh Token", "regenerate-registration-token": "Regenerar token de registro", "region-name": "Nome da Região", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "Registro", "regular-expression": "Expressão regular", "reindex-failure-plural": "Falhas de reindexação", @@ -1818,6 +1832,7 @@ "run-agent-plural": "Executar Agentes", "run-at": "Executar em", "run-now": "Executar agora", + "run-test-login": "Run Test Login", "run-type": "Tipo de Execução", "running": "Em execução", "running-ellipsis": "Executando...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Selecionar resolução de conflito", "select-dimension": "Selecionar dimensão", "select-duration": "Selecione Duração", + "select-email-claim": "Select Email Claim", "select-entity": "Selecione {{entity}}", "select-entity-type": "Selecionar tipo de entidade", "select-field": "Selecionar {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "Definir como padrão", "set-default-entity": "Definir {{entity}} padrão", "set-default-filters": "Definir Filtros Padrão", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Configurar", "set-up-kpi": "Configurar KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "Configurações", "setup-guide": "Guia de Configuração", "severity": "Severidade", @@ -1999,6 +2017,7 @@ "source-provider": "Provedor de Origem", "source-url": "URL de origem", "source-with-details": "Fonte: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "Ativos de Dados Específicos", "spreadsheet": "Planilha", "spreadsheet-plural": "Planilhas", @@ -2158,6 +2177,7 @@ "test-entity": "Teste {{entity}}", "test-level-lowercase": "nível de teste", "test-library": "Biblioteca de Testes", + "test-login": "Test Login", "test-platform-plural": "Plataformas de teste", "test-plural": "Testes", "test-plural-type": "Testes de {{type}}", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "Escolha como o fluxo de trabalho deve ser acionado", "choose-import-mode": "Escolha como importar o contrato ODCS", "choose-which-assets-this-workflow-can-act-on": "Escolha em quais ativos este fluxo de trabalho pode atuar.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(Clique para ver os ativos filtrados na página Explorar.)", "click-text-to-view-details": "Clique em <0>{{text}} para ver detalhes.", + "client-id-required": "Client ID is required.", "closed-this-task": "fechou esta tarefa", "collaborate-with-other-user": "para colaborar com outros usuários.", "collate-ai-widget-description": "Visão geral dos dados gerados pela IA Collate para o serviço. <0>saiba mais.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "Descartar suas alterações?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "As coisas ficaram mais fáceis com a qualidade de dados sem código. Passos simples para testar, implantar e obter resultados, com notificações instantâneas de falha nos testes. Mantenha-se atualizado com dados confiáveis nos quais você pode confiar.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Porcentagem de valores distintos na coluna", "domain-change-asset-migration-warning": "A alteração do domínio moverá _0 _ ativos) do domínio atual para _1 __.Você quer prosseguir?", "domain-description": "Organize e gerencie domínios de dados em sua organização.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "Certifique-se de que seus índices do Elasticsearch estão atualizados sincronizando ou recriando todos os índices.", "elastic-search-re-index-pipeline-description": "Pipeline de índice de busca é usado para reindexar os dados no elasticsearch. Consulte nossa documentação para saber mais <0>{{link}}", "elasticsearch-setup": "Siga as instruções aqui para configurar a ingestão de Metadados e indexá-los no Elasticsearch.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "Configure as Configurações SMTP para enviar E-mails.", "email-is-invalid": "E-mail inválido.", "email-verification-token-expired": "Token de Verificação de E-mail Expirou", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "Não se preocupe. É hora de reestruturar seus objetivos e progredir mais rapidamente.", "latency-sla-description": "<0>{{label}}: A resposta da consulta deve estar abaixo de <0>{{data}}", "latest-offset-description": "O deslocamento mais recente do evento no sistema.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "ex. cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "Este DN do grupo LDAP já está mapeado. Cada grupo LDAP pode ser mapeado apenas uma vez.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "Explore os recursos do produto e aprenda como eles funcionam através dos nossos recursos", "leave-the-team-team-name": "Sair da equipe {{teamName}}", "length-validator-error": "Pelo menos {{length}} {{field}} necessário", @@ -3011,6 +3039,7 @@ "no-profiler-title": "Execute o Profiler para desbloquear insights da tabela", "no-recently-viewed-date": "Nenhum dado visualizado recentemente.", "no-reference-available": "Nenhuma referência disponível.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "Nenhum termo relacionado disponível.", "no-relations-for-selected-filter": "Nenhuma relação encontrada para os tipos de relação selecionados. Tente selecionar tipos diferentes.", "no-relations-found": "Nenhuma relação encontrada para este termo", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "Número de entidades a processar em cada lote", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "Armazenamento centralizado de metadados, para descobrir, colaborar e obter seus dados corretamente.", "om-url-configuration-message": "Defina as configurações de URL do {{brandName}}.", "on-demand-description": "Execute a ingestão manualmente.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Selecione uma das ações abaixo.", "please-type-text-to-confirm": "Digite {{text}} para confirmar.", "popup-block-message": "O navegador bloqueou o pop-up de login. <0>Habilite o pop-up e tente novamente.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "Verifique os nomes das colunas para marcar automaticamente as colunas PII Sensíveis/Não Sensíveis.", "process-pii-sensitive-column-message-profiler": "Quando ativado, os dados de amostra serão analisados para determinar as tags PII apropriadas para cada coluna.", "processed-all-events-description": "Indica se todos os eventos foram processados.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "Redirecionando para a página inicial", "refer-to-our-doc": "Ainda precisa de ajuda? Consulte nossa <0>{{doc}} para mais informações.", "refresh-frequency-contract-description": "Frequência esperada de atualizações de dados", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "Código de cor hexadecimal usado para visualizar este tipo de relação no grafo de ontologia (por exemplo, #1890ff).", "relation-type-in-use-count": "Utilizado por {{count}} relação(ões) de termos", "relation-type-not-in-use": "Não está em uso atualmente", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "Tem certeza de que deseja remover a aresta entre \"{{sourceDisplayName}} e {{targetDisplayName}}\"?", "remove-lineage-edge": "Remover aresta de linhagem", "rename-entity": "Renomear o Nome e o Nome de Exibição para {{entity}}.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "Solicitação de aprovação para", "request-approval-notification": "Aprovação necessária para", "request-description": "Descrição da solicitação", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: Os dados devem ser retidos por <0>{{data}}", "run-sample-data-to-ingest-sample-data": "Executar dados de exemplo para ingerir ativos de dados de exemplo no OpenMetadata.", "run-status-at-timestamp": "Status de execução: {{status}} em {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "Este esquema define os parâmetros que podem ser passados para a coleta de dados de exemplo.", "schedule-description": "Programe a ingestão para ser executada em um horário e frequência específicos.", "schedule-entity-description": "Esta {{entity}} será executada repetidamente baseada no seu agendamento.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECIONE * DE {tabela} ONDE {coluna} < {{minValue}} OU {coluna} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "Esta ação não pode ser desfeita.", "unauthorized-user": "Usuário não autorizado! Por favor, verifique o e-mail ou a senha", "unexpected-error": "Ocorreu um erro inesperado.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index 8280b7673726..c7e72ae00bac 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -23,6 +23,7 @@ "accuracy": "Precisão", "ack": "Confirmação", "acknowledged": "Reconhecido", + "acs-url": "ACS URL", "action": "Ação", "action-plural": "Ações", "action-required": "Ação necessária", @@ -85,6 +86,7 @@ "address": "Endereço", "admin": "Administrador", "admin-plural": "Admins", + "admin-principal": "Admin Principal", "admin-profile": "Perfil do Admin", "admin-uppercase": "ADMINISTRADOR", "advance-filter": "Filtro avançado", @@ -92,6 +94,7 @@ "advanced-config": "Configuração Avançada", "advanced-configuration": "Configuração Avançada", "advanced-entity": "{{entity}} Avançado", + "advanced-fields": "Advanced Fields", "advanced-search": "Busca Avançada", "agent-activity": "Atividade do Agente", "agent-plural": "Agentes", @@ -196,6 +199,7 @@ "authority": "Autoridade", "authorize-app": "Autorizar {{app}}", "auto-classification": "Classificação Automática", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "Pontuação de Confiança Automática PII", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "Etiqueta Automática PII", @@ -256,6 +260,7 @@ "by-relation-type": "Por Tipo de Relação", "ca-certs": "Certificados CA", "calculated-from": "Calculado A Partir De", + "callback-url": "Callback URL", "cancel": "Cancelar", "cancel-lowercase": "cancelar", "cardinality": "Cardinalidade", @@ -269,6 +274,7 @@ "change-log-plural": "Registros de Alteração", "change-parent-entity": "Mudar pai {{entity}}", "change-password": "Alterar palavra-passe", + "change-via-test-login": "Change via Test Login", "chart": "Gráfico", "chart-entity": "Gráfico {{entity}}", "chart-plural": "Gráficos", @@ -381,6 +387,7 @@ "confirm": "Confirmar", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "confirmar", "confirm-new-password": "Confirmar Nova Senha", "confirm-password": "Confirme sua senha", @@ -655,6 +662,7 @@ "disabled": "Desativado", "discard": "Descartar", "discover": "Descobrir", + "dismiss": "Dismiss", "display-name": "Nome de Exibição", "display-name-lowercase": "nome de exibição", "display-text": "Texto de apresentação", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Reindexação do Elasticsearch", "elasticsearch": "Elasticsearch", "email": "E-mail", + "email-claim": "Email Claim", "email-configuration": "Configuração de E-mail", "email-configuration-lowercase": "configuração de e-mail", "email-lowercase": "e-mail", + "email-or-username": "Email or Username", "email-plural": "E-mails", "emailing-entity": "Entidade de Mailing", "embed-file-type": "Incorporar {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "Chave Primária", "primary-key-plural": "Chaves primárias", "primary-shards": "Primary Shards", + "principal-domain": "Principal Domain", "privacy-policy": "Política de Privacidade", "private-key": "Chave Privada", "private-key-id": "ID da Chave Privada", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "Reorganizar Nós", "reason": "Razão", "reasons-for-decision": "Razões para a Decisão", + "received": "Received", "receiver-plural": "Receptores", "recent-announcement-plural": "Anúncios Recentes", "recent-event-plural": "Eventos recentes", @@ -1697,8 +1709,10 @@ "refresh-entity": "Atualizar {{entity}}", "refresh-frequency": "Frequência de atualização", "refresh-log": "Atualizar log", + "refresh-token": "Refresh Token", "regenerate-registration-token": "Regenerar token de registo", "region-name": "Nome da Região", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "Registo", "regular-expression": "Expressão Regular", "reindex-failure-plural": "Reindex Failures", @@ -1818,6 +1832,7 @@ "run-agent-plural": "Executar Agentes", "run-at": "Executar em", "run-now": "Executar agora", + "run-test-login": "Run Test Login", "run-type": "Tipo de Execução", "running": "Em execução", "running-ellipsis": "A executar...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Selecionar resolução de conflito", "select-dimension": "Seleccionar dimensão", "select-duration": "Select Duration", + "select-email-claim": "Select Email Claim", "select-entity": "Selecionar {{entity}}", "select-entity-type": "Selecionar tipo de entidade", "select-field": "Selecionar {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "Definir como predefinição", "set-default-entity": "Definir {{entity}} predefinida", "set-default-filters": "Definir Filtros Padrão", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Configurar", "set-up-kpi": "Configurar KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "Configurações", "setup-guide": "Guia de Configuração", "severity": "Gravidade", @@ -1999,6 +2017,7 @@ "source-provider": "Fornecedor de Origem", "source-url": "URL de origem", "source-with-details": "Fonte: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "Ativos de Dados Específicos", "spreadsheet": "Folha de cálculo", "spreadsheet-plural": "Folhas de cálculo", @@ -2158,6 +2177,7 @@ "test-entity": "Teste {{entity}}", "test-level-lowercase": "nível de teste", "test-library": "Biblioteca de Testes", + "test-login": "Test Login", "test-platform-plural": "Test Platforms", "test-plural": "Testes", "test-plural-type": "Testes de {{type}}", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "Escolha como o fluxo de trabalho deve ser desencadeado", "choose-import-mode": "Escolha como importar o contrato ODCS", "choose-which-assets-this-workflow-can-act-on": "Escolha em que ativos este fluxo de trabalho pode atuar.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(Clique para ver os ativos filtrados na página Explorar.)", "click-text-to-view-details": "Clique em <0>{{text}} para ver detalhes.", + "client-id-required": "Client ID is required.", "closed-this-task": "fechou esta tarefa", "collaborate-with-other-user": "para colaborar com outros Utilizadores.", "collate-ai-widget-description": "Visão geral dos dados gerados pela IA Collate para o serviço. <0>saber mais.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "Descartar as suas alterações?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "As coisas ficaram mais fáceis com a qualidade de dados sem código. Passos simples para testar, implantar e obter resultados, com notificações instantâneas de falha nos testes. Mantenha-se atualizado com dados confiáveis nos quais você pode confiar.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Percentagem de valores distintos na coluna", "domain-change-asset-migration-warning": "Alterar o domínio moverá {{count}} ativo(s) do domínio atual para {{domain}}. Deseja prosseguir?", "domain-description": "Organizar e gerir domínios de dados na sua organização.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "Certifique-se de que seus índices do Elasticsearch estão atualizados sincronizando ou recriando todos os índices.", "elastic-search-re-index-pipeline-description": "Pipeline de índice de busca é usado para reindexar os dados no elasticsearch. Consulte nossa documentação para saber mais <0>{{link}}", "elasticsearch-setup": "Siga as instruções aqui para configurar a ingestão de Metadados e indexá-los no Elasticsearch.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "Configure as Configurações SMTP para enviar E-mails.", "email-is-invalid": "E-mail inválido.", "email-verification-token-expired": "Token de Verificação de E-mail Expirou", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "Aviso: O objetivo do KPI de Descrição ainda não foi atingido, mas ainda há tempo – a sua organização tem {{count}} dias restantes. Para manter o rumo, ative o Relatório de Insights de Dados. Isso permitir-nos-á enviar atualizações semanais a todas as equipas, promovendo a colaboração e o foco no cumprimento dos KPIs da nossa organização.", "latency-sla-description": "<0>{{label}}: A resposta da consulta deve estar abaixo de <0>{{data}}", "latest-offset-description": "O offset mais recente do evento no sistema.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "p.ex. cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "Este DN do grupo LDAP já está mapeado. Cada grupo LDAP pode ser mapeado apenas uma vez.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "Explore as funcionalidades do produto e aprenda como funcionam através dos nossos recursos", "leave-the-team-team-name": "Sair da equipa {{teamName}}", "length-validator-error": "Pelo menos {{length}} {{field}} necessário", @@ -3011,6 +3039,7 @@ "no-profiler-title": "Executar Profiler para desbloquear informações da tabela", "no-recently-viewed-date": "Ainda não visualizou nenhum ativo de dados recentemente. Explore para encontrar algo interessante!", "no-reference-available": "Nenhuma referência disponível.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "Nenhum termo relacionado disponível.", "no-relations-for-selected-filter": "Não foram encontradas relações para os tipos de relação selecionados. Tente selecionar tipos diferentes.", "no-relations-found": "Não foram encontradas relações para este termo", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "Número de entidades a processar em cada lote", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "Armazenamento centralizado de metadados, para descobrir, colaborar e obter seus dados corretamente.", "om-url-configuration-message": "Configure as Definições de URL do {{brandName}}.", "on-demand-description": "Execute a ingestão manualmente.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Por favor, selecione qualquer uma das ações abaixo.", "please-type-text-to-confirm": "Digite {{text}} para confirmar.", "popup-block-message": "O pop-up de início de sessão foi bloqueado pelo navegador. Por favor <0>ative-o e tente novamente.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "Verifique os nomes das colunas para marcar automaticamente as colunas PII Sensíveis/Não Sensíveis.", "process-pii-sensitive-column-message-profiler": "Quando ativado, os dados de amostra serão analisados para determinar as tags PII apropriadas para cada coluna.", "processed-all-events-description": "Indica se todos os eventos foram processados.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "Redirecionando para a página inicial", "refer-to-our-doc": "Ainda precisa de ajuda? Consulte nossa <0>{{doc}} para mais informações.", "refresh-frequency-contract-description": "Frequência esperada de atualizações de dados", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "Código de cor hexadecimal utilizado para visualizar este tipo de relação no grafo de ontologia (por exemplo, #1890ff).", "relation-type-in-use-count": "Utilizado por {{count}} relação(ões) de termos", "relation-type-not-in-use": "Não está em utilização atualmente", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "Tem certeza de que deseja remover a aresta entre \"{{sourceDisplayName}} e {{targetDisplayName}}\"?", "remove-lineage-edge": "Remover aresta de linhagem", "rename-entity": "Renomear o Nome e o Nome de Exibição para {{entity}}.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "Pedido de aprovação para", "request-approval-notification": "Aprovação necessária para", "request-description": "Descrição da solicitação", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: Os dados devem ser retidos por <0>{{data}}", "run-sample-data-to-ingest-sample-data": "Executar dados de exemplo para ingerir ativos de dados de exemplo no OpenMetadata.", "run-status-at-timestamp": "Estado da execução: {{status}} às {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "Este esquema define os parâmetros que podem ser passados para a recolha de dados de exemplo.", "schedule-description": "Agendar a ingestão para ser executada numa hora e frequência específicas.", "schedule-entity-description": "Esta {{entity}} será executada repetidamente baseada no seu agendamento.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "Esta ação não pode ser desfeita.", "unauthorized-user": "Utilizador não autorizado! Por favor, verifique o e-mail ou a senha", "unexpected-error": "Ocorreu um erro inesperado.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index ec4d857f70da..ce1d130c7ca1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -23,6 +23,7 @@ "accuracy": "Точность", "ack": "Подтверждение", "acknowledged": "Подтверждено", + "acs-url": "ACS URL", "action": "Действие", "action-plural": "Действия", "action-required": "Требуется действие", @@ -85,6 +86,7 @@ "address": "Адрес", "admin": "Админ", "admin-plural": "Админы", + "admin-principal": "Admin Principal", "admin-profile": "Профиль админа", "admin-uppercase": "АДМИН", "advance-filter": "Расширенный фильтр", @@ -92,6 +94,7 @@ "advanced-config": "Расширенная конфигурация", "advanced-configuration": "Расширенная конфигурация", "advanced-entity": "Расширенный {{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "Расширенный поиск", "agent-activity": "Активность агента", "agent-plural": "Агенты", @@ -196,6 +199,7 @@ "authority": "Власть", "authorize-app": "Авторизация {{app}}", "auto-classification": "Автоклассификация", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "Оценка достоверности Auto PII", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "Автотег PII", @@ -256,6 +260,7 @@ "by-relation-type": "По типу отношения", "ca-certs": "Сертификаты CA", "calculated-from": "Вычислено из", + "callback-url": "Callback URL", "cancel": "Отмена", "cancel-lowercase": "отмена", "cardinality": "Кардинальность", @@ -269,6 +274,7 @@ "change-log-plural": "Журналы изменений", "change-parent-entity": "Изменить родительский объект «{{entity}}»", "change-password": "Изменить пароль", + "change-via-test-login": "Change via Test Login", "chart": "Диаграмма", "chart-entity": "Диаграмма объекта «{{entity}}»", "chart-plural": "Диаграммы", @@ -381,6 +387,7 @@ "confirm": "Подтвердить", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "подтвердить", "confirm-new-password": "Подтвердить новый пароль", "confirm-password": "Подтвердить пароль", @@ -655,6 +662,7 @@ "disabled": "Отключено", "discard": "Отклонить", "discover": "Обнаружить", + "dismiss": "Dismiss", "display-name": "Отображаемое имя", "display-name-lowercase": "отображаемое имя", "display-text": "Отображаемый текст", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Переиндексация Elasticsearch", "elasticsearch": "Elasticsearch", "email": "Электронная почта", + "email-claim": "Email Claim", "email-configuration": "Конфигурация электронной почты", "email-configuration-lowercase": "конфигурация электронной почты", "email-lowercase": "электронная почта", + "email-or-username": "Email or Username", "email-plural": "электронные почты", "emailing-entity": "Отправка сообщения по электронной почте", "embed-file-type": "Встроить {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "Первичный ключ", "primary-key-plural": "Первичные ключи", "primary-shards": "Первичные осколки", + "principal-domain": "Principal Domain", "privacy-policy": "Политика доступа", "private-key": "Закрытый ключ", "private-key-id": "ID закрытого ключа", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "Перегруппировать узлы", "reason": "Причина", "reasons-for-decision": "Причины решения", + "received": "Received", "receiver-plural": "Приемники", "recent-announcement-plural": "Последние объявления", "recent-event-plural": "Недавние события", @@ -1697,8 +1709,10 @@ "refresh-entity": "Обновить объект «{{entity}}»", "refresh-frequency": "Частота обновления", "refresh-log": "Обновить логи", + "refresh-token": "Refresh Token", "regenerate-registration-token": "Восстановить регистрационный токен", "region-name": "Наименование региона", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "Реестр", "regular-expression": "Регулярное выражение", "reindex-failure-plural": "Сбои переиндексации", @@ -1818,6 +1832,7 @@ "run-agent-plural": "Запустить агенты", "run-at": "Запустить в ", "run-now": "Запустить сейчас", + "run-test-login": "Run Test Login", "run-type": "Тип запуска", "running": "Запущено", "running-ellipsis": "Выполняется...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Выберите разрешение конфликта", "select-dimension": "Выбрать измерение", "select-duration": "Выберите продолжительность", + "select-email-claim": "Select Email Claim", "select-entity": "Выбрать объект «{{entity}}»", "select-entity-type": "Выберите тип сущности", "select-field": "Выбрать {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "Установить по умолчанию", "set-default-entity": "Установить {{entity}} по умолчанию", "set-default-filters": "Установить фильтры по умолчанию", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Настроить", "set-up-kpi": "Настроить KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "Настройки", "setup-guide": "Руководство по установке", "severity": "Критичность инцидента", @@ -1999,6 +2017,7 @@ "source-provider": "Поставщик источника", "source-url": "URL источника", "source-with-details": "Источник: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "Специфические объекты данных", "spreadsheet": "Электронная таблица", "spreadsheet-plural": "Электронные таблицы", @@ -2158,6 +2177,7 @@ "test-entity": "Проверка объекта «{{entity}}»", "test-level-lowercase": "уровень теста", "test-library": "Библиотека Тестов", + "test-login": "Test Login", "test-platform-plural": "Тестовые платформы", "test-plural": "Проверки", "test-plural-type": "Тесты {{type}}", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "Выберите, как должен запускаться рабочий процесс", "choose-import-mode": "Выберите способ импорта контракта ODCS", "choose-which-assets-this-workflow-can-act-on": "Выберите, к каким активам может применяться этот рабочий процесс.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(Нажмите, чтобы просмотреть отфильтрованные объекты в каталоге.)", "click-text-to-view-details": "Нажмите <0>{{text}}, чтобы просмотреть подробности.", + "client-id-required": "Client ID is required.", "closed-this-task": "задача закрыта", "collaborate-with-other-user": "для совместной работы с другими пользователями.", "collate-ai-widget-description": "Обзор данных, сгенерированных Collate ИИ для сервиса. <0>Узнать больше.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "Отменить ваши изменения?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "Откройте для себя ваши данные и раскройте ценность информационных ресурсов.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Процент различных значений в столбце", "domain-change-asset-migration-warning": "Изменение домена приведет к перемещению _ 0 _ активов) из текущего домена в _1 __.Хотите продолжить?", "domain-description": "Организуйте и управляйте доменами данных в вашей организации.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "Убедитесь, что ваши индексы Elasticsearch обновлены, путем синхронизации или повторного создания всех индексов.", "elastic-search-re-index-pipeline-description": "Конвейер поискового индекса используется для переиндексации данных в elasticsearch. Дополнительные сведения см. в нашей документации <0>{{link}}", "elasticsearch-setup": "Следуйте приведенным здесь инструкциям, чтобы настроить прием метаданных и индексировать их в Elasticsearch.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "Настройте параметры SMTP для отправки электронной почты.", "email-is-invalid": "Неверный адрес электронной почты.", "email-verification-token-expired": "Срок действия токена подтверждения электронной почты истек", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "Не беспокойтесь. Пришло время перестроить свои цели и двигаться быстрее.", "latency-sla-description": "<0>{{label}}: Время ответа на запрос должно быть меньше <0>{{data}}.", "latest-offset-description": "Последнее смещение события в системе.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "например cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "Этот DN группы LDAP уже сопоставлен. Каждая группа LDAP может быть сопоставлена только один раз.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "Изучайте возможности продукта и узнавайте, как они работают, с помощью наших ресурсов", "leave-the-team-team-name": "Покинуть команду «{{teamName}}»", "length-validator-error": "Требуется не менее {{length}} {{field}}", @@ -3011,6 +3039,7 @@ "no-profiler-title": "Запустите профайлер, чтобы разблокировать статистику таблицы", "no-recently-viewed-date": "Нет недавно просмотренных данных.", "no-reference-available": "Нет доступных ссылок.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "Нет доступных связанных терминов.", "no-relations-for-selected-filter": "Отношения для выбранных типов не найдены. Попробуйте выбрать другие типы.", "no-relations-found": "Связи для данного термина не найдены", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "Количество сущностей для обработки в каждой пакетной порции", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "Централизованное хранилище метаданных для обнаружения, совместной работы и правильной обработки ваших данных.", "om-url-configuration-message": "Настройте параметры URL {{brandName}}.", "on-demand-description": "Запустите извлечение вручную.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Пожалуйста, выберите одно из действий ниже.", "please-type-text-to-confirm": "Введите {{text}} для подтверждения.", "popup-block-message": "Всплывающее окно входа заблокировано браузером. Пожалуйста, <0>enable его и повторите попытку.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "Проверьте имена столбцов, чтобы автоматически пометить столбцы с конфиденциальной/неконфиденциальной информацией PII.", "process-pii-sensitive-column-message-profiler": "Если эта функция включена, образцы данных будут проанализированы для определения соответствующих тегов PII для каждого столбца.", "processed-all-events-description": "Указывает, были ли обработаны все события.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "Перенаправление на главную страницу", "refer-to-our-doc": "Все еще нужна помощь? Дополнительную информацию см. по ссылке — <0>{{doc}}.", "refresh-frequency-contract-description": "Ожидаемая частота обновления данных", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "Шестнадцатеричный код цвета для визуализации этого типа отношения в графе онтологии (например, #1890ff).", "relation-type-in-use-count": "Используется в {{count}} связях терминов", "relation-type-not-in-use": "В настоящее время не используется", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "Вы уверены, что хотите удалить связь между \"{{sourceDisplayName}} и {{targetDisplayName}}\"?", "remove-lineage-edge": "Удалить связь между объектами", "rename-entity": "Измените имя и отображаемое имя для объекта «{{entity}}».", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "Задача на согласование для", "request-approval-notification": "Требуется одобрение для", "request-description": "Запросить описание", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: Данные должны храниться в течение <0>{{data}}.", "run-sample-data-to-ingest-sample-data": "Запустите образцы данных, чтобы добавить образцы данных в свои OpenMetadata.", "run-status-at-timestamp": "Статус запуска: {{status}} в {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "Эта схема определяет параметры, которые могут быть переданы для сбора примеров данных.", "schedule-description": "Запланируйте запуск извлечения в определенное время и с определенной частотой.", "schedule-entity-description": "Эта {{entity}} будет выполняться повторно в соответствии с вашим расписанием.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {столбец} < {{minValue}} ИЛИ {столбец} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "Это действие нельзя отменить.", "unauthorized-user": "Неавторизованный пользователь! пожалуйста, проверьте электронную почту или пароль", "unexpected-error": "Произошла непредвиденная ошибка.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index cc877e89cea5..a48a1f304d8a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -23,6 +23,7 @@ "accuracy": "ความแม่นยำ", "ack": "Ack", "acknowledged": "ได้รับการยอมรับ", + "acs-url": "ACS URL", "action": "การดำเนินการ", "action-plural": "การดำเนินการหลายอย่าง", "action-required": "ต้องดำเนินการ", @@ -85,6 +86,7 @@ "address": "ที่อยู่", "admin": "ผู้ดูแลระบบ", "admin-plural": "ผู้ดูแลระบบหลายคน", + "admin-principal": "Admin Principal", "admin-profile": "โปรไฟล์ผู้ดูแลระบบ", "admin-uppercase": "ผู้ดูแลระบบ", "advance-filter": "ตัวกรองขั้นสูง", @@ -92,6 +94,7 @@ "advanced-config": "การตั้งค่าขั้นสูง", "advanced-configuration": "การกำหนดค่าขั้นสูง", "advanced-entity": "ขั้นสูง {{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "การค้นหาขั้นสูง", "agent-activity": "Agent Activity", "agent-plural": "เอเจนต์", @@ -196,6 +199,7 @@ "authority": "อำนาจ", "authorize-app": "อนุญาต {{app}}", "auto-classification": "การจำแนกประเภทอัตโนมัติ", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "คะแนนความมั่นใจ PII อัตโนมัติ", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "แท็ก PII อัตโนมัติ", @@ -256,6 +260,7 @@ "by-relation-type": "ตามประเภทความสัมพันธ์", "ca-certs": "ใบรับรอง CA", "calculated-from": "คำนวณจาก", + "callback-url": "Callback URL", "cancel": "ยกเลิก", "cancel-lowercase": "ยกเลิก", "cardinality": "Cardinality", @@ -269,6 +274,7 @@ "change-log-plural": "บันทึกการเปลี่ยนแปลง", "change-parent-entity": "เปลี่ยนผู้ปกครอง {{entity}}", "change-password": "เปลี่ยนรหัสผ่าน", + "change-via-test-login": "Change via Test Login", "chart": "กราฟ", "chart-entity": "กราฟ {{entity}}", "chart-plural": "กราฟหลายรายการ", @@ -381,6 +387,7 @@ "confirm": "ยืนยัน", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "ยืนยัน", "confirm-new-password": "ยืนยันรหัสผ่านใหม่", "confirm-password": "ยืนยันรหัสผ่านของคุณ", @@ -655,6 +662,7 @@ "disabled": "ถูกปิดการใช้งาน", "discard": "ทิ้ง", "discover": "ค้นพบ", + "dismiss": "Dismiss", "display-name": "ชื่อแสดง", "display-name-lowercase": "ชื่อแสดง", "display-text": "ข้อความที่แสดง", @@ -714,9 +722,11 @@ "elastic-search-re-index": "ElasticsearchReindex", "elasticsearch": "Elasticsearch", "email": "อีเมล", + "email-claim": "Email Claim", "email-configuration": "การตั้งค่าอีเมล", "email-configuration-lowercase": "การตั้งค่าอีเมล", "email-lowercase": "อีเมล", + "email-or-username": "Email or Username", "email-plural": "อีเมลหลายรายการ", "emailing-entity": "เอนทิตีการส่งอีเมล", "embed-file-type": "Embed {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "คีย์หลัก", "primary-key-plural": "คีย์หลัก", "primary-shards": "Primary Shards", + "principal-domain": "Principal Domain", "privacy-policy": "นโยบายความเป็นส่วนตัว", "private-key": "คีย์ส่วนตัว", "private-key-id": "รหัสคีย์ส่วนตัว", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "จัดเรียงโหนดใหม่", "reason": "เหตุผล", "reasons-for-decision": "Reasons for Decision", + "received": "Received", "receiver-plural": "ผู้รับ", "recent-announcement-plural": "ประกาศล่าสุด", "recent-event-plural": "Recent Events", @@ -1697,8 +1709,10 @@ "refresh-entity": "รีเฟรช {{entity}}", "refresh-frequency": "ความถี่ในการรีเฟรช", "refresh-log": "รีเฟรชบันทึก", + "refresh-token": "Refresh Token", "regenerate-registration-token": "สร้างโทเค็นการลงทะเบียนใหม่", "region-name": "ชื่อภูมิภาค", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "ทะเบียน", "regular-expression": "นิพจน์ปกติ", "reindex-failure-plural": "Reindex Failures", @@ -1818,6 +1832,7 @@ "run-agent-plural": "รันเอเจนต์", "run-at": "รันที่", "run-now": "รันตอนนี้", + "run-test-login": "Run Test Login", "run-type": "ประเภทการรัน", "running": "กำลังรัน", "running-ellipsis": "กำลังดำเนินการ...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "เลือกการแก้ไขความขัดแย้ง", "select-dimension": "เลือกมิติ", "select-duration": "Select Duration", + "select-email-claim": "Select Email Claim", "select-entity": "เลือก {{entity}}", "select-entity-type": "เลือกประเภทเอนทิตี", "select-field": "เลือก {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "ตั้งเป็นค่าเริ่มต้น", "set-default-entity": "ตั้งค่า {{entity}} เริ่มต้น", "set-default-filters": "ตั้งค่าตัวกรองตัวค่าเริ่มต้น", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Set Up", "set-up-kpi": "ตั้งค่า KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "การตั้งค่าหลายรายการ", "setup-guide": "คู่มือการติดตั้ง", "severity": "ความรุนแรง", @@ -1999,6 +2017,7 @@ "source-provider": "ผู้ให้บริการแหล่งที่มา", "source-url": "URL แหล่งที่มา", "source-with-details": "แหล่งที่มา: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "สินทรัพย์ข้อมูลเฉพาะ", "spreadsheet": "สเปรดชีต", "spreadsheet-plural": "สเปรดชีต", @@ -2158,6 +2177,7 @@ "test-entity": "ทดสอบ {{entity}}", "test-level-lowercase": "ระดับการทดสอบ", "test-library": "ไลบรารีการทดสอบ", + "test-login": "Test Login", "test-platform-plural": "Test Platforms", "test-plural": "การทดสอบหลายรายการ", "test-plural-type": "การทดสอบ {{type}}", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "เลือกวิธีที่เวิร์กโฟลว์จะถูกทริกเกอร์", "choose-import-mode": "เลือกวิธีการนำเข้าสัญญา ODCS", "choose-which-assets-this-workflow-can-act-on": "เลือกสินทรัพย์ที่เวิร์กโฟลว์นี้สามารถดำเนินการได้", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(คลิกเพื่อดูสินทรัพย์ที่กรองในหน้าสำรวจ)", "click-text-to-view-details": "คลิก <0>{{text}} เพื่อดูรายละเอียด", + "client-id-required": "Client ID is required.", "closed-this-task": "ปิดงานนี้แล้ว", "collaborate-with-other-user": "เพื่อร่วมมือกับผู้ใช้คนอื่น", "collate-ai-widget-description": "ภาพรวมของข้อมูลที่สร้างโดย Collate AI สำหรับบริการ <0>เรียนรู้เพิ่มเติม", @@ -2626,6 +2648,7 @@ "discard-your-changes": "ละทิ้งการเปลี่ยนแปลงของคุณ?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "ทุกอย่างง่ายขึ้นด้วยคุณภาพข้อมูลแบบไม่มีโค้ด ขั้นตอนง่ายๆในการทดสอบ, เรียกใช้, และรวบรวมผลลัพธ์ พร้อมการแจ้งเตือนการล้มเหลวในการทดสอบทันที อัปเดตด้วยข้อมูลที่เชื่อถือได้และคุณสามารถไว้วางใจได้", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Percentage of distinct values in the column", "domain-change-asset-migration-warning": "Changing the domain will move {{count}} asset(s) from the current domain to {{domain}}. Do you want to proceed?", "domain-description": "จัดระเบียบและจัดการโดเมนข้อมูลในองค์กรของคุณ", @@ -2651,6 +2674,9 @@ "elastic-search-message": "ตรวจสอบให้แน่ใจว่าดัชนี Elasticsearch ของคุณเป็นปัจจุบันโดยการซิงค์หรือสร้างดัชนีทั้งหมดใหม่", "elastic-search-re-index-pipeline-description": "ท่อดัชนีการค้นหาใช้ในการสร้างดัชนีข้อมูลใน elasticsearch โปรดดูเอกสารของเราเพื่อเรียนรู้เพิ่มเติม <0>{{link}}", "elasticsearch-setup": "โปรดปฏิบัติตามคำแนะนำที่นี่เพื่อตั้งค่าการนำเข้าข้อมูลเมตาและสร้างดัชนีใน Elasticsearch", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "กำหนดค่าการตั้งค่า SMTP สำหรับการส่งอีเมล", "email-is-invalid": "อีเมลไม่ถูกต้อง", "email-verification-token-expired": "โทเค็นการตรวจสอบอีเมลหมดอายุ", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "หมายเหตุ: เป้าหมาย KPI คำอธิบายยังไม่ได้รับการบรรลุ แต่ยังมีเวลา – องค์กรของคุณมีเวลาที่เหลือ {{count}} วัน เพื่อให้มีเวลาติดตาม กรุณาเปิดใช้รายงานข้อมูลเชิงลึก ซึ่งจะทำให้เราสามารถส่งอัปเดตประจำสัปดาห์ไปยังทีมทั้งหมด เพื่อส่งเสริมการร่วมมือและมุ่งเน้นไปที่การบรรลุ KPIs ขององค์กรของเรา", "latency-sla-description": "<0>{{label}}: เวลาตอบสนองของคำค้นต้องต่ำกว่า <0>{{data}}", "latest-offset-description": "The latest offset of the event in the system.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "เช่น cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "LDAP group DN นี้ได้ถูกแมปแล้ว แต่ละกลุ่ม LDAP สามารถแมปได้เพียงครั้งเดียวเท่านั้น", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "สำรวจคุณสมบัติของผลิตภัณฑ์และเรียนรู้วิธีการทำงานผ่านทรัพยากรของเรา", "leave-the-team-team-name": "ออกจากทีม {{teamName}}", "length-validator-error": "จำเป็นต้องมีอย่างน้อย {{length}} {{field}}", @@ -3011,6 +3039,7 @@ "no-profiler-title": "เรียกใช้ Profiler เพื่อปลดล็อกข้อมูลเชิงลึกของตาราง", "no-recently-viewed-date": "คุณยังไม่ได้ดูสินทรัพย์ข้อมูลใด ๆ เมื่อเร็ว ๆ นี้ สำรวจเพื่อค้นหาสิ่งที่น่าสนใจ!", "no-reference-available": "ไม่มีการอ้างอิงที่ใช้งานได้", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "ไม่มีคำที่เกี่ยวข้องที่สามารถใช้งานได้", "no-relations-for-selected-filter": "ไม่พบความสัมพันธ์สำหรับประเภทความสัมพันธ์ที่เลือก ลองเลือกประเภทอื่น", "no-relations-found": "ไม่พบความสัมพันธ์สำหรับคำศัพท์นี้", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "จำนวนเอนทิตีที่จะประมวลผลในแต่ละแบตช์", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "ที่จัดเก็บข้อมูลเมตาที่รวมศูนย์ เพื่อค้นหา, ร่วมมือ และรับข้อมูลที่ถูกต้อง", "om-url-configuration-message": "Configure the {{brandName}} URL Settings.", "on-demand-description": "เรียกใช้งานการนำเข้าด้วยตนเอง", @@ -3135,6 +3165,7 @@ "please-select-action-below": "โปรดเลือกหนึ่งการดำเนินการด้านล่าง", "please-type-text-to-confirm": "โปรดพิมพ์ {{text}} เพื่อยืนยัน", "popup-block-message": "หน้าต่างเข้าสู่ระบบถูกบล็อกโดยเบราว์เซอร์ โปรด <0>เปิดใช้งาน และลองใหม่อีกครั้ง", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "ตรวจสอบชื่อคอลัมน์เพื่อติดแท็ก PII ที่อ่อนไหว/ไม่อ่อนไหวโดยอัตโนมัติ", "process-pii-sensitive-column-message-profiler": "เมื่อเปิดใช้งาน ข้อมูลตัวอย่างจะถูกวิเคราะห์เพื่อตรวจหาติดแท็ก PII ที่เหมาะสมสำหรับแต่ละคอลัมน์", "processed-all-events-description": "Indicates whether all events have been processed.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "กำลังเปลี่ยนเส้นทางไปยังหน้าแรก", "refer-to-our-doc": "ยังต้องการความช่วยเหลืออยู่ใช่ไหม? โปรดดูเอกสารของเรา <0>{{doc}} สำหรับข้อมูลเพิ่มเติม", "refresh-frequency-contract-description": "ความถี่ที่คาดว่าจะมีการอัปเดตข้อมูล", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "รหัสสี Hex ที่ใช้แสดงประเภทความสัมพันธ์นี้ในกราฟออนโทโลยี (เช่น #1890ff)", "relation-type-in-use-count": "ใช้อยู่ใน {{count}} ความสัมพันธ์ของคำศัพท์", "relation-type-not-in-use": "ยังไม่ได้ใช้งาน", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "คุณแน่ใจหรือไม่ว่าต้องการลบขอบระหว่าง \"{{sourceDisplayName}}\" และ \"{{targetDisplayName}}\"?", "remove-lineage-edge": "ลบขอบลำดับชั้น", "rename-entity": "เปลี่ยนชื่อและชื่อแสดงสำหรับ {{entity}}", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "คำขออนุมัติสำหรับ", "request-approval-notification": "ต้องการการอนุมัติสำหรับ", "request-description": "คำอธิบายคำขอ", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: ข้อมูลควรถูกเก็บไว้เป็นเวลา <0>{{data}}", "run-sample-data-to-ingest-sample-data": "'รันข้อมูลตัวอย่างเพื่อนำเข้าทรัพย์สินข้อมูลตัวอย่างเข้าสู่ OpenMetadata ของคุณ.'", "run-status-at-timestamp": "สถานะการรัน: {{status}} ที่ {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "สคีมานี้กำหนดพารามิเตอร์ที่สามารถส่งผ่านสำหรับการเก็บรวบรวมข้อมูลตัวอย่าง", "schedule-description": "กำหนดเวลาการนำเข้าให้ดำเนินการในเวลาที่เฉพาะและความถี่ที่กำหนด", "schedule-entity-description": "{{entity}} นี้จะทำงานซ้ำตามตารางเวลาของคุณ", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "This action cannot be undone.", "unauthorized-user": "ผู้ใช้ที่ไม่ได้รับอนุญาต! โปรดตรวจสอบอีเมลหรือรหัสผ่าน", "unexpected-error": "เกิดข้อผิดพลาดที่ไม่คาดคิดขึ้น", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json index 8a13529de8a2..42ee94bf88a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json @@ -23,6 +23,7 @@ "accuracy": "Doğruluk", "ack": "Ack", "acknowledged": "Onaylandı", + "acs-url": "ACS URL", "action": "Eylem", "action-plural": "Eylemler", "action-required": "Eylem Gerekli", @@ -85,6 +86,7 @@ "address": "Adres", "admin": "Yönetici", "admin-plural": "Yöneticiler", + "admin-principal": "Admin Principal", "admin-profile": "Yönetici profili", "admin-uppercase": "YÖNETİCİ", "advance-filter": "Gelişmiş filtre", @@ -92,6 +94,7 @@ "advanced-config": "Gelişmiş Yapılandırma", "advanced-configuration": "Gelişmiş Yapılandırma", "advanced-entity": "Gelişmiş {{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "Gelişmiş Arama", "agent-activity": "Agent Activity", "agent-plural": "Agent'lar", @@ -196,6 +199,7 @@ "authority": "Yetki", "authorize-app": "{{app}} Yetkilendir", "auto-classification": "Otomatik Sınıflandırma", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "Otomatik KVT Güven Skoru", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "KVT'yi Otomatik Etiketle", @@ -256,6 +260,7 @@ "by-relation-type": "İlişki Türüne Göre", "ca-certs": "CA Sertifikaları", "calculated-from": "Hesaplanan Kaynak", + "callback-url": "Callback URL", "cancel": "İptal", "cancel-lowercase": "iptal", "cardinality": "Cardinality", @@ -269,6 +274,7 @@ "change-log-plural": "Değişiklik Günlükleri", "change-parent-entity": "Üst {{entity}} Değiştir", "change-password": "Şifre Değiştir", + "change-via-test-login": "Change via Test Login", "chart": "Grafik", "chart-entity": "Grafik {{entity}}", "chart-plural": "Grafikler", @@ -381,6 +387,7 @@ "confirm": "Onayla", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "onayla", "confirm-new-password": "Yeni Şifreyi Onayla", "confirm-password": "Şifrenizi onaylayın", @@ -655,6 +662,7 @@ "disabled": "Devre Dışı", "discard": "At", "discover": "Keşfet", + "dismiss": "Dismiss", "display-name": "Görünen Ad", "display-name-lowercase": "görünen ad", "display-text": "Görüntüleme metni", @@ -714,9 +722,11 @@ "elastic-search-re-index": "ElasticsearchYenidenİndeksle", "elasticsearch": "Elasticsearch", "email": "E-posta", + "email-claim": "Email Claim", "email-configuration": "E-posta Yapılandırması", "email-configuration-lowercase": "e-posta yapılandırması", "email-lowercase": "e-posta", + "email-or-username": "Email or Username", "email-plural": "E-postalar", "emailing-entity": "Varlık E-postalanıyor", "embed-file-type": "{{fileType}} Göm", @@ -1621,6 +1631,7 @@ "primary-key": "Birincil Anahtar", "primary-key-plural": "Birincil Anahtarlar", "primary-shards": "Primary Shards", + "principal-domain": "Principal Domain", "privacy-policy": "Gizlilik Politikası", "private-key": "ÖzelAnahtar", "private-key-id": "Özel Anahtar Kimliği", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "Düğümleri Yeniden Düzenle", "reason": "Neden", "reasons-for-decision": "Reasons for Decision", + "received": "Received", "receiver-plural": "Alıcılar", "recent-announcement-plural": "Son Duyurular", "recent-event-plural": "Son Olaylar", @@ -1697,8 +1709,10 @@ "refresh-entity": "{{entity}} Yenile", "refresh-frequency": "Yenileme Sıklığı", "refresh-log": "Günlüğü yenile", + "refresh-token": "Refresh Token", "regenerate-registration-token": "Kayıt anahtarını yeniden oluştur", "region-name": "Bölge Adı", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "Kayıt Defteri", "regular-expression": "Düzenli İfade", "reindex-failure-plural": "Reindex Failures", @@ -1818,6 +1832,7 @@ "run-agent-plural": "Agent'ları Çalıştır", "run-at": "Şu zamanda çalıştır", "run-now": "Şimdi çalıştır", + "run-test-login": "Run Test Login", "run-type": "Çalıştırma Türü", "running": "Çalışıyor", "running-ellipsis": "Çalışıyor...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "Select conflict resolution", "select-dimension": "Boyut Seç", "select-duration": "Select Duration", + "select-email-claim": "Select Email Claim", "select-entity": "{{entity}} Seç", "select-entity-type": "Varlık Türü Seçin", "select-field": "{{field}} Seç", @@ -1946,8 +1962,10 @@ "set-as-default": "Varsayılan Olarak Ayarla", "set-default-entity": "Varsayılan {{entity}} Ayarla", "set-default-filters": "Varsayılan Filtreleri Ayarla", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Set Up", "set-up-kpi": "KPI Kur", + "set-via-test-login": "Set via Test Login", "setting-plural": "Ayarlar", "setup-guide": "Kurulum Kılavuzu", "severity": "Önem Derecesi", @@ -1999,6 +2017,7 @@ "source-provider": "Kaynak Sağlayıcı", "source-url": "Kaynak URL'si", "source-with-details": "Kaynak: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "Belirli Veri Varlıkları", "spreadsheet": "Hesap tablosu", "spreadsheet-plural": "Hesap tabloları", @@ -2158,6 +2177,7 @@ "test-entity": "Test {{entity}}", "test-level-lowercase": "test seviyesi", "test-library": "Test Kütüphanesi", + "test-login": "Test Login", "test-platform-plural": "Test Platforms", "test-plural": "Testler", "test-plural-type": "{{type}} Testleri", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "İş akışının nasıl tetikleneceğini seçin", "choose-import-mode": "ODCS sözleşmesini nasıl içe aktaracağınızı seçin", "choose-which-assets-this-workflow-can-act-on": "Bu iş akışının hangi varlıklara uygulanabileceğini seçin.", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(Filtrelenmiş varlıkları Keşfet sayfasında görüntülemek için tıklayın.)", "click-text-to-view-details": "Detayları görüntülemek için <0>{{text}} tıklayın.", + "client-id-required": "Client ID is required.", "closed-this-task": "bu görevi kapattı", "collaborate-with-other-user": "diğer kullanıcılarla işbirliği yapmak için.", "collate-ai-widget-description": "Collate AI tarafından servis için oluşturulan verilere genel bakış. <0>daha fazla bilgi edinin.", @@ -2626,6 +2648,7 @@ "discard-your-changes": "Değişikliklerinizi iptal etmek istiyor musunuz?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "Kodsuz veri kalitesiyle işler kolaylaştı. Test etmek, dağıtmak ve sonuçları toplamak için basit adımlar, anında test hatası bildirimleriyle. Güvenebileceğiniz güvenilir verilerle güncel kalın.", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Percentage of distinct values in the column", "domain-change-asset-migration-warning": "Changing the domain will move {{count}} asset(s) from the current domain to {{domain}}. Do you want to proceed?", "domain-description": "Kuruluşunuzdaki veri alanlarını düzenleyin ve yönetin.", @@ -2651,6 +2674,9 @@ "elastic-search-message": "Elasticsearch dizinlerinizin senkronize ederek veya tüm dizinleri yeniden oluşturarak güncel olduğundan emin olun.", "elastic-search-re-index-pipeline-description": "Arama dizini iş akışı, elasticsearch'teki verileri yeniden indekslemek için kullanılır. Daha fazla bilgi edinmek için belgelerimize bakın <0>{{link}}", "elasticsearch-setup": "Metadata alımını kurmak ve Elasticsearch'e indekslemek için lütfen buradaki talimatları izleyin.", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "E-posta göndermek için SMTP Ayarlarını yapılandırın.", "email-is-invalid": "Geçersiz E-posta.", "email-verification-token-expired": "E-posta Doğrulama Anahtarı Süresi Doldu", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "Uyarı: Açıklama KPI hedefi henüz karşılanmadı, ancak hala zaman var – kuruluşunuzun {{count}} günü kaldı. Takipte kalmak için lütfen Veri Analizleri Raporunu etkinleştirin. Bu, tüm ekiplere haftalık güncellemeler göndermemizi sağlayacak, böylece kuruluşumuzun KPI'larına ulaşma yönünde işbirliğini ve odaklanmayı teşvik edecektir.", "latency-sla-description": "<0>{{label}}: Sorgu yanıtı <0>{{data}}'den az olmalıdır.", "latest-offset-description": "Sistemdeki olayın en son ofseti.", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "örn. cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "Bu LDAP grup DN'i zaten eşlenmiştir. Her LDAP grubu sadece bir kez eşlenebilir.", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "Ürün özelliklerini keşfedin ve kaynaklarımız aracılığıyla nasıl çalıştıklarını öğrenin", "leave-the-team-team-name": "{{teamName}} takımından ayrıl", "length-validator-error": "En az {{length}} {{field}} gerekli", @@ -3011,6 +3039,7 @@ "no-profiler-title": "Tablo İçgörülerinin Kilidini Açmak İçin Profiler'ı Çalıştırın", "no-recently-viewed-date": "Son zamanlarda herhangi bir veri varlığı görüntülemediniz. İlginç bir şey bulmak için keşfedin!", "no-reference-available": "Kullanılabilir referans yok.", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "Kullanılabilir ilgili terim yok.", "no-relations-for-selected-filter": "Seçilen ilişki türleri için ilişki bulunamadı. Farklı türler seçmeyi deneyin.", "no-relations-found": "Bu terim için ilişki bulunamadı", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "Her toplu işlemde işlenecek varlık sayısı", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "Merkezi metadata deposu, keşfetmek, işbirliği yapmak ve verilerinizi doğru almak için.", "om-url-configuration-message": "OpenMetadata URL Ayarlarını yapılandırın.", "on-demand-description": "Alımı manuel olarak çalıştırın.", @@ -3135,6 +3165,7 @@ "please-select-action-below": "Lütfen aşağıdan bir işlem seçin.", "please-type-text-to-confirm": "Onaylamak için lütfen {{text}} yazın.", "popup-block-message": "Giriş açılır penceresi tarayıcı tarafından engellendi. Lütfen <0>etkinleştirin ve tekrar deneyin.", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "KVT Hassas/Hassas olmayan sütunları otomatik olarak etiketlemek için sütun adlarını kontrol edin.", "process-pii-sensitive-column-message-profiler": "Etkinleştirildiğinde, örnek veriler her sütun için uygun KVT etiketlerini belirlemek üzere analiz edilecektir", "processed-all-events-description": "Tüm olayların işlenip işlenmediğini gösterir.", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "Ana sayfaya yönlendiriliyor", "refer-to-our-doc": "Hala yardıma mı ihtiyacınız var? Daha fazla bilgi için <0>{{doc}} bölümümüze bakın.", "refresh-frequency-contract-description": "Beklenen veri güncelleme sıklığı", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "Ontoloji grafiklerinde bu ilişki türünü görselleştirmek için kullanılan on altılık renk kodu (örneğin, #1890ff).", "relation-type-in-use-count": "{{count}} terim ilişkisi tarafından kullanılıyor", "relation-type-not-in-use": "Şu anda kullanılmıyor", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "\"{{sourceDisplayName}} ve {{targetDisplayName}}\" arasındaki kenarı kaldırmak istediğinizden emin misiniz?.", "remove-lineage-edge": "Veri soyu kenarını kaldır", "rename-entity": "{{entity}} için Adı ve Görünen Adı yeniden adlandırın.", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "için onay talebi", "request-approval-notification": "Onay gerekli:", "request-description": "Açıklama iste", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}: Veriler <0>{{data}} süreyle saklanmalıdır.", "run-sample-data-to-ingest-sample-data": "'OpenMetadata'nıza örnek veri varlıkları almak için örnek verileri çalıştırın.'", "run-status-at-timestamp": "Çalışma durumu: {{status}} , zaman damgası: {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "Bu şema, örnek veri toplama için iletilebilecek parametreleri tanımlar.", "schedule-description": "Alımı belirli bir zamanda ve sıklıkta çalışacak şekilde zamanlayın.", "schedule-entity-description": "Bu {{entity}} programınıza göre tekrar tekrar çalışacak.", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "This action cannot be undone.", "unauthorized-user": "Yetkisiz kullanıcı! lütfen e-postayı veya şifreyi kontrol edin", "unexpected-error": "Beklenmedik bir hata oluştu.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index e1ccb85ddfff..ded28da50257 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -23,6 +23,7 @@ "accuracy": "准确性", "ack": "确认", "acknowledged": "已确认", + "acs-url": "ACS URL", "action": "操作", "action-plural": "操作", "action-required": "需要操作", @@ -85,6 +86,7 @@ "address": "地址", "admin": "管理员", "admin-plural": "管理员", + "admin-principal": "Admin Principal", "admin-profile": "管理员资料", "admin-uppercase": "管理员", "advance-filter": "高级筛选", @@ -92,6 +94,7 @@ "advanced-config": "高级配置", "advanced-configuration": "高级配置", "advanced-entity": "高级{{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "高级搜索", "agent-activity": "代理活动", "agent-plural": "代理", @@ -196,6 +199,7 @@ "authority": "授权", "authorize-app": "授权{{app}}", "auto-classification": "自动分类", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "自动计算 PII 信任值", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "自动标记 PII", @@ -256,6 +260,7 @@ "by-relation-type": "按关系类型", "ca-certs": "CA 证书", "calculated-from": "计算来源", + "callback-url": "Callback URL", "cancel": "取消", "cancel-lowercase": "取消", "cardinality": "基数", @@ -269,6 +274,7 @@ "change-log-plural": "变更日志", "change-parent-entity": "更改父{{entity}}", "change-password": "更改密码", + "change-via-test-login": "Change via Test Login", "chart": "图表", "chart-entity": "图表{{entity}}", "chart-plural": "图表", @@ -381,6 +387,7 @@ "confirm": "确认", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "确认", "confirm-new-password": "确认新密码", "confirm-password": "确认密码", @@ -655,6 +662,7 @@ "disabled": "已禁用", "discard": "丢弃", "discover": "发现", + "dismiss": "Dismiss", "display-name": "显示名称", "display-name-lowercase": "显示名称", "display-text": "显示文本", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Elasticsearch 重新索引", "elasticsearch": "Elasticsearch", "email": "电子邮箱", + "email-claim": "Email Claim", "email-configuration": "邮箱配置", "email-configuration-lowercase": "邮箱配置", "email-lowercase": "电子邮箱", + "email-or-username": "Email or Username", "email-plural": "电子邮箱", "emailing-entity": "发送邮件实体", "embed-file-type": "嵌入 {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "主键", "primary-key-plural": "主键集合", "primary-shards": "主碎片", + "principal-domain": "Principal Domain", "privacy-policy": "隐私条款", "private-key": "私钥", "private-key-id": "私钥 ID", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "重新排列节点", "reason": "理由", "reasons-for-decision": "决定理由", + "received": "Received", "receiver-plural": "接收者", "recent-announcement-plural": "近期公告", "recent-event-plural": "最近事件", @@ -1697,8 +1709,10 @@ "refresh-entity": "刷新 {{entity}}", "refresh-frequency": "刷新频率", "refresh-log": "刷新日志", + "refresh-token": "Refresh Token", "regenerate-registration-token": "重新产生注册令牌", "region-name": "区域名称", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "仓库", "regular-expression": "正则表达式", "reindex-failure-plural": "重建索引失败", @@ -1818,6 +1832,7 @@ "run-agent-plural": "运行代理", "run-at": "运行于", "run-now": "立即运行", + "run-test-login": "Run Test Login", "run-type": "运行类型", "running": "正在运行", "running-ellipsis": "运行中...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "选择冲突解决方式", "select-dimension": "选择维度", "select-duration": "选择持续时间", + "select-email-claim": "Select Email Claim", "select-entity": "选择{{entity}}", "select-entity-type": "选择实体类型", "select-field": "选择{{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "设为默认", "set-default-entity": "设置默认{{entity}}", "set-default-filters": "设置默认过滤器", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "设置", "set-up-kpi": "设置KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "设置", "setup-guide": "安装引导", "severity": "严重性", @@ -1999,6 +2017,7 @@ "source-provider": "来源提供者", "source-url": "源 URL", "source-with-details": "来源: {{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "特定数据资产", "spreadsheet": "电子表格", "spreadsheet-plural": "电子表格", @@ -2158,6 +2177,7 @@ "test-entity": "测试{{entity}}", "test-level-lowercase": "测试级别", "test-library": "测试库", + "test-login": "Test Login", "test-platform-plural": "测试平台", "test-plural": "测试", "test-plural-type": "{{type}}测试", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "选择工作流的触发方式", "choose-import-mode": "选择如何导入 ODCS 合同", "choose-which-assets-this-workflow-can-act-on": "选择此工作流可以作用的数据资产。", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(点击在探索页面查看筛选后的资产。)", "click-text-to-view-details": "点击<0>{{text}}查看详情", + "client-id-required": "Client ID is required.", "closed-this-task": "关闭了此任务", "collaborate-with-other-user": "与其他用户协作", "collate-ai-widget-description": "服务中由 Collate AI 生成的数据概览。<0>了解更多。", @@ -2626,6 +2648,7 @@ "discard-your-changes": "放弃您的更改?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "深入探索数据, 释放数据资产的价值", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "列中不同值的百分比", "domain-change-asset-migration-warning": "更改域会将 _ 0 _ 资产从当前域移动到 _1 __。您想继续吗?", "domain-description": "在您的组织中组织和管理数据域。", @@ -2651,6 +2674,9 @@ "elastic-search-message": "同步或重新创建所有索引以确保您的 Elasticsearch 索引是最新的", "elastic-search-re-index-pipeline-description": "搜索索引工作流被用于重新索引 Elasticsearch 的数据, 请参阅我们的文档以了解更多信息", "elasticsearch-setup": "请按照此处的说明设置元数据提取, 并将其索引到 Elasticsearch 中", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "请配置 SMTP 信息, 以发送邮件", "email-is-invalid": "电子邮箱无效", "email-verification-token-expired": "电子邮箱验证令牌已过期", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "没关系, 现在是重新制定目标并更快进展的时候了。", "latency-sla-description": "<0>{{label}}:查询响应时间必须小于 <0>{{data}}。", "latest-offset-description": "系统中事件的最新偏移量。", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "例如 cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "此LDAP组DN已被映射。每个LDAP组只能映射一次。", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "探索产品功能,通过我们的资源了解它们的工作原理", "leave-the-team-team-name": "离开团队{{teamName}}", "length-validator-error": "至少需要{{length}}个{{field}}", @@ -3011,6 +3039,7 @@ "no-profiler-title": "运行分析器以解锁表洞察", "no-recently-viewed-date": "无最近查看过的数据", "no-reference-available": "无可用参考", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "无相关术语可用", "no-relations-for-selected-filter": "未找到所选关系类型的关系。请尝试选择其他类型。", "no-relations-found": "未找到该术语的关系", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "每批处理的实体数量", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "统一的元数据存储平台, 更好地探索、协作和处理数据", "om-url-configuration-message": "配置{{brandName}} URL设置。", "on-demand-description": "手动运行摄取。", @@ -3135,6 +3165,7 @@ "please-select-action-below": "请选择下面的一项操作。", "please-type-text-to-confirm": "请键入{{text}}确认", "popup-block-message": "浏览器阻止了登录弹出窗口, 请<0>enable后再试", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "检查列名以自动标记PII敏感/非敏感列", "process-pii-sensitive-column-message-profiler": "启用后, 将分析样本数据以确定每个列的适当PII标记", "processed-all-events-description": "指示是否所有事件都已处理。", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "正在重定向到主页", "refer-to-our-doc": "仍需要帮助?请参考我们的相关<0>{{doc}}以获取更多信息。", "refresh-frequency-contract-description": "预期的数据更新频率", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "用于在本体图中可视化此关系类型的十六进制颜色代码(例如:#1890ff)。", "relation-type-in-use-count": "被{{count}}个术语关系使用", "relation-type-not-in-use": "当前未使用", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "是否确定要删除“{{sourceDisplayName}}和{{targetDisplayName}}”之间的连线?", "remove-lineage-edge": "删除血缘连线", "rename-entity": "修改{{entity}}的名称和显示名", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "批准请求", "request-approval-notification": "需要批准:", "request-description": "请求详细描述", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}:数据应保留 <0>{{data}}。", "run-sample-data-to-ingest-sample-data": "'运行样本数据以提取样本数据资产到 OpenMetadata'", "run-status-at-timestamp": "运行状态: {{status}} 在 {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "此架构定义了可用于示例数据收集的参数。", "schedule-description": "安排摄取在特定时间和频率运行。", "schedule-entity-description": "这个 {{entity}} 将根据您的计划重复运行。", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} 或 {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "此操作无法撤消。", "unauthorized-user": "未经授权的用户! 请检查电子邮箱或密码", "unexpected-error": "发生意外错误", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json index 1628531284b1..2bd7aa53ae29 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json @@ -23,6 +23,7 @@ "accuracy": "準確性", "ack": "Ack", "acknowledged": "已確認", + "acs-url": "ACS URL", "action": "操作", "action-plural": "操作", "action-required": "需要操作", @@ -85,6 +86,7 @@ "address": "地址", "admin": "管理員", "admin-plural": "管理員", + "admin-principal": "Admin Principal", "admin-profile": "管理員設定檔", "admin-uppercase": "管理員", "advance-filter": "進階篩選", @@ -92,6 +94,7 @@ "advanced-config": "進階組態", "advanced-configuration": "進階組態", "advanced-entity": "進階 {{entity}}", + "advanced-fields": "Advanced Fields", "advanced-search": "進階搜尋", "agent-activity": "Agent Activity", "agent-plural": "代理程式", @@ -196,6 +199,7 @@ "authority": "授權單位", "authorize-app": "授權 {{app}}", "auto-classification": "自動分類", + "auto-derived-from-selection": "Auto-derived from selection", "auto-pii-confidence-score": "自動 PII 信賴分數", "auto-pilot": "AutoPilot", "auto-tag-pii-uppercase": "自動標記 PII", @@ -256,6 +260,7 @@ "by-relation-type": "依關係類型", "ca-certs": "CA 憑證", "calculated-from": "計算來源", + "callback-url": "Callback URL", "cancel": "取消", "cancel-lowercase": "取消", "cardinality": "Cardinality", @@ -269,6 +274,7 @@ "change-log-plural": "變更日誌", "change-parent-entity": "變更父項 {{entity}}", "change-password": "變更密碼", + "change-via-test-login": "Change via Test Login", "chart": "圖表", "chart-entity": "圖表 {{entity}}", "chart-plural": "圖表", @@ -381,6 +387,7 @@ "confirm": "確認", "confirm-asset-move": "Confirm Asset Move", "confirm-asset-remove": "Confirm Asset Remove", + "confirm-email-claim": "Confirm Email Claim", "confirm-lowercase": "確認", "confirm-new-password": "確認新密碼", "confirm-password": "確認您的密碼", @@ -655,6 +662,7 @@ "disabled": "已停用", "discard": "放棄", "discover": "探索", + "dismiss": "Dismiss", "display-name": "顯示名稱", "display-name-lowercase": "顯示名稱", "display-text": "顯示文字", @@ -714,9 +722,11 @@ "elastic-search-re-index": "Elasticsearch 重建索引", "elasticsearch": "Elasticsearch", "email": "電子郵件", + "email-claim": "Email Claim", "email-configuration": "電子郵件組態", "email-configuration-lowercase": "電子郵件組態", "email-lowercase": "電子郵件", + "email-or-username": "Email or Username", "email-plural": "電子郵件", "emailing-entity": "正在寄送實體郵件", "embed-file-type": "嵌入 {{fileType}}", @@ -1621,6 +1631,7 @@ "primary-key": "主鍵", "primary-key-plural": "主鍵", "primary-shards": "Primary Shards", + "principal-domain": "Principal Domain", "privacy-policy": "隱私權政策", "private-key": "私密金鑰", "private-key-id": "私密金鑰 ID", @@ -1680,6 +1691,7 @@ "rearrange-nodes": "重新排列節點", "reason": "原因", "reasons-for-decision": "決策原因", + "received": "Received", "receiver-plural": "接收者", "recent-announcement-plural": "最近的公告", "recent-event-plural": "最近的事件", @@ -1697,8 +1709,10 @@ "refresh-entity": "重新整理 {{entity}}", "refresh-frequency": "重新整理頻率", "refresh-log": "重新整理日誌", + "refresh-token": "Refresh Token", "regenerate-registration-token": "重新產生註冊權杖", "region-name": "區域名稱", + "register-with-identity-provider": "Register with your Identity Provider", "registry": "登錄", "regular-expression": "規則運算式", "reindex-failure-plural": "Reindex Failures", @@ -1818,6 +1832,7 @@ "run-agent-plural": "執行代理程式", "run-at": "執行於", "run-now": "立即執行", + "run-test-login": "Run Test Login", "run-type": "執行類型", "running": "執行中", "running-ellipsis": "執行中...", @@ -1899,6 +1914,7 @@ "select-conflict-resolution": "选择冲突解决方式", "select-dimension": "選擇維度", "select-duration": "Select Duration", + "select-email-claim": "Select Email Claim", "select-entity": "選取 {{entity}}", "select-entity-type": "選取實體類型", "select-field": "選取 {{field}}", @@ -1946,8 +1962,10 @@ "set-as-default": "設為預設", "set-default-entity": "設定預設 {{entity}}", "set-default-filters": "設定預設篩選器", + "set-explicit-email-claim": "Set an explicit email claim for better reliability", "set-up": "Set Up", "set-up-kpi": "設定 KPI", + "set-via-test-login": "Set via Test Login", "setting-plural": "設定", "setup-guide": "設定指南", "severity": "嚴重性", @@ -1999,6 +2017,7 @@ "source-provider": "來源提供者", "source-url": "來源 URL", "source-with-details": "來源:{{source}} ({{entityName}})", + "sp-entity-id": "SP Entity ID", "specific-data-asset-plural": "特定資料資產", "spreadsheet": "試算表", "spreadsheet-plural": "試算表", @@ -2158,6 +2177,7 @@ "test-entity": "測試 {{entity}}", "test-level-lowercase": "測試層級", "test-library": "測試庫", + "test-login": "Test Login", "test-platform-plural": "Test Platforms", "test-plural": "測試", "test-plural-type": "{{type}}測試", @@ -2499,8 +2519,10 @@ "choose-how-the-workflow-should-be-triggered": "選擇工作流程的觸發方式", "choose-import-mode": "選擇如何匯入 ODCS 合約", "choose-which-assets-this-workflow-can-act-on": "選擇此工作流程可作用的資產。", + "claims-received-from-idp": "Claims received from your Identity Provider. Pick the claim that holds the user's email — it will be used to derive the admin principal and principal domain.", "click-here-to-view-assets-on-explore": "(點擊以在探索頁面上檢視篩選後的資產。)", "click-text-to-view-details": "點擊 <0>{{text}} 查看詳情。", + "client-id-required": "Client ID is required.", "closed-this-task": "已關閉此任務", "collaborate-with-other-user": "與其他使用者協作。", "collate-ai-widget-description": "Collate AI 為服務產生的資料概觀。<0>了解更多。", @@ -2626,6 +2648,7 @@ "discard-your-changes": "要捨棄變更嗎?", "discover-data-products-subtitle": "Discover trusted data products and request data access", "discover-your-data-and-unlock-the-value-of-data-assets": "無程式碼的資料品質讓事情變得更簡單。透過簡單的步驟來測試、部署和收集結果,並即時收到測試失敗通知。隨時掌握您可以信賴的可靠資料。", + "discovery-uri-required": "Discovery URI is required.", "distinct-profile-metric-description": "Percentage of distinct values in the column", "domain-change-asset-migration-warning": "Changing the domain will move {{count}} asset(s) from the current domain to {{domain}}. Do you want to proceed?", "domain-description": "在您的組織中組織和管理資料域。", @@ -2651,6 +2674,9 @@ "elastic-search-message": "透過同步或重建所有索引,確保您的 Elasticsearch 索引是最新的。", "elastic-search-re-index-pipeline-description": "搜尋索引管線用於在 Elasticsearch 中重建資料索引。請參考我們的文件以了解更多 <0>{{link}}", "elasticsearch-setup": "請依照此處的說明設定元資料擷取並將其索引到 Elasticsearch。", + "email-claim-not-set": "not set — using legacy claim detection", + "email-claim-recommendation-body": "Run Test Login to verify which claim contains the email from your Identity Provider.", + "email-claim-verified": "verified via Test Login", "email-configuration-message": "設定用於傳送電子郵件的 SMTP 設定。", "email-is-invalid": "電子郵件無效。", "email-verification-token-expired": "電子郵件驗證權杖已過期", @@ -2849,8 +2875,10 @@ "kpi-target-overdue": "注意:描述 KPI 目標尚未達成,但還有時間——您的組織還有 {{count}} 天。為保持進度,請啟用資料洞察報告。這將使我們能夠向所有團隊發送每週更新,促進協作並專注於實現我們組織的 KPI。", "latency-sla-description": "<0>{{label}}:查詢回應時間必須低於 <0>{{data}}。", "latest-offset-description": "系統中事件的最新偏移量。", + "ldap-credentials-required": "Email and password are required.", "ldap-group-dn-placeholder": "例如 cn=admins,ou=groups,dc=example,dc=com", "ldap-group-duplicate-error": "此LDAP群組DN已被對應。每個LDAP群組只能對應一次。", + "ldap-test-login-description": "Enter your LDAP credentials. The backend binds as the admin user, looks up your account, then binds as you to verify the password.", "learning-resources-management-description": "探索產品功能,透過我們的資源了解它們的運作方式", "leave-the-team-team-name": "離開團隊 {{teamName}}", "length-validator-error": "至少需要 {{length}} 個 {{field}}", @@ -3011,6 +3039,7 @@ "no-profiler-title": "執行分析器以解鎖表洞察", "no-recently-viewed-date": "您最近沒有檢視任何資料資產。探索以尋找有趣的東西!", "no-reference-available": "沒有可用的參考。", + "no-refresh-token": "No refresh token returned by the IdP", "no-related-terms-available": "沒有可用的相關術語。", "no-relations-for-selected-filter": "未找到所選關係類型的關係。請嘗試選擇其他類型。", "no-relations-found": "找不到此術語的關係", @@ -3052,6 +3081,7 @@ "number-of-approvals-required-to-approve": "Number of approvals required to Approve", "number-of-entities-to-process-in-each-batch": "每批次處理的實體數量", "number-of-rejections-required-to-reject": "Number of rejections required to Reject", + "oidc-callback-info": "Register this URL as a Redirect URI in your OIDC provider configuration.", "om-description": "集中的元資料儲存庫,用於探索、協作並確保您的資料正確。", "om-url-configuration-message": "設定 {{brandName}} URL 設定。", "on-demand-description": "手動執行擷取。", @@ -3135,6 +3165,7 @@ "please-select-action-below": "請選擇以下其中一個操作。", "please-type-text-to-confirm": "請輸入 {{text}} 以確認。", "popup-block-message": "登入彈出視窗被瀏覽器封鎖。請 <0>啟用 它並再試一次。", + "popup-blocked": "The browser blocked the test-login popup. Please allow popups for this site and try again.", "process-pii-sensitive-column-message": "檢查欄位名稱以自動標記 PII 敏感/非敏感欄位。", "process-pii-sensitive-column-message-profiler": "啟用後,將分析範例資料以確定每個欄位的適當 PII 標籤", "processed-all-events-description": "指示是否已處理所有事件。", @@ -3157,6 +3188,7 @@ "redirecting-to-home-page": "正在重新導向到首頁", "refer-to-our-doc": "仍需要協助嗎?請參考我們的 <0>{{doc}} 以獲取更多資訊。", "refresh-frequency-contract-description": "預期資料更新頻率", + "register-with-idp-info": "Add the values below to your Identity Provider configuration before saving.", "relation-color-tooltip": "用於在本體圖中視覺化此關係類型的十六進位色碼(例如:#1890ff)。", "relation-type-in-use-count": "被{{count}}個術語關係使用", "relation-type-not-in-use": "目前未使用", @@ -3167,6 +3199,7 @@ "remove-edge-between-source-and-target": "您確定要移除 \"{{sourceDisplayName}} 和 {{targetDisplayName}}\" 之間的邊緣嗎?", "remove-lineage-edge": "移除血緣邊緣", "rename-entity": "重新命名 {{entity}} 的名稱和顯示名稱。", + "replace-discovery-uri-placeholders": "Replace placeholder values (e.g. {tenant-id}, {your-domain}) in the Discovery URI with real values before testing.", "request-approval-message": "請求核准", "request-approval-notification": "需要批准:", "request-description": "請求描述", @@ -3185,6 +3218,7 @@ "retention-sla-description": "<0>{{label}}:資料應保留 <0>{{data}}。", "run-sample-data-to-ingest-sample-data": "「執行範例資料以將範例資料資產擷取到您的 OpenMetadata 中。」", "run-status-at-timestamp": "執行狀態:{{status}} 於 {{timestamp}}", + "saml-idp-fields-required": "IdP Entity ID, SSO Login URL, and X.509 Certificate are required for SAML test login.", "sample-data-ingestion-config-description": "此結構描述定義了可用於範例資料收集的參數。", "schedule-description": "排程擷取在特定時間和頻率下執行。", "schedule-entity-description": "此 {{entity}} 將根據您的排程重複執行。", @@ -3466,6 +3500,11 @@ "test-definition-sql-expression-placeholder": "SELECT * FROM {table} WHERE {column} < {{minValue}} OR {column} > {{maxValue}}", "test-definition-sql-expression-tooltip": "SQL query template using parameter placeholders in double curly braces (e.g., {{paramName}}). Use {table} and {column} for runtime entity references.", "test-definition-sql-query-help": "Write SQL query template with substitution variables. Use {table} for table name, {column} for column name (resolved at runtime). Use {{paramName}} for user parameters defined below (e.g., {{minValue}}, {{maxValue}}).", + "test-login-failed": "Test login failed. Please verify your configuration.", + "test-login-popup-closed": "Test Login was not completed. The window was closed before authentication finished. Please try again.", + "test-login-required-before-save": "Run Test Login to verify this configuration before saving.", + "test-login-success": "Test login succeeded. Authorizer fields have been pre-filled.", + "test-login-timeout": "The test login flow timed out. Please try again.", "this-action-cannot-be-undone": "This action cannot be undone.", "unauthorized-user": "未經授權的使用者!請檢查電子郵件或密碼", "unexpected-error": "發生未預期的錯誤。", diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/securityConfigAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/securityConfigAPI.ts index b73f331c7849..b54bf46af2f2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/securityConfigAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/securityConfigAPI.ts @@ -39,15 +39,21 @@ export interface SecurityValidationResponse { /** * Validate security configuration * @param data - Security configuration data + * @param context - Optional validation context (e.g. "testLogin") forwarded to backend * @returns Promise with validation result */ export const validateSecurityConfiguration = async ( - data: SecurityConfiguration + data: SecurityConfiguration, + context?: string ): Promise> => { + const url = context + ? `/system/security/validate?context=${encodeURIComponent(context)}` + : '/system/security/validate'; + return APIClient.post< SecurityConfiguration, AxiosResponse - >('/system/security/validate', data); + >(url, data); }; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.test.ts index fe02947ec751..d9de97dc8069 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.test.ts @@ -22,6 +22,7 @@ import { createDOMFocusHandler, createFormKeyDownHandler, createFreshFormData, + deriveOidcClientType, extractFieldName, findChangedFields, FormData, @@ -35,10 +36,12 @@ import { hasFieldValidationErrors, isValidNonBasicProvider, isValidUrl, + liftPublicOidcToConfidentialShape, parseSamlMetadataXml, parseValidationErrors, populateSamlIdpAuthority, populateSamlSpCallback, + prepareOidcSubmitPayload, removeRequiredFields, removeSchemaFields, updateLoadingState, @@ -46,7 +49,7 @@ import { // Mock the constants module jest.mock('../constants/SSO.constant', () => ({ - COMMON_AUTH_FIELDS_TO_REMOVE: ['responseType'], + COMMON_AUTH_FIELDS_TO_REMOVE: ['forceSecureSessionCookie'], COMMON_AUTHORIZER_FIELDS_TO_REMOVE: [ 'testPrincipals', 'allowedEmailRegistrationDomains', @@ -58,10 +61,20 @@ jest.mock('../constants/SSO.constant', () => ({ DEFAULT_CONTAINER_REQUEST_FILTER: 'org.openmetadata.service.security.JwtFilter', DEFAULT_CALLBACK_URL: 'http://localhost:8585/callback', + OIDC_PROVIDERS: new Set([ + 'google', + 'auth0', + 'azure', + 'okta', + 'aws-cognito', + 'custom-oidc', + ]), OIDC_SSO_DEFAULTS: { + scope: 'openid email profile', + azureScope: 'openid email profile offline_access', tokenValidity: 3600, sessionExpiry: 604800, - serverUrl: 'http://localhost:8585', + preferredJwsAlgorithm: 'RS256', }, GOOGLE_SSO_DEFAULTS: { authority: 'https://accounts.google.com', @@ -100,6 +113,12 @@ jest.mock('../constants/SSO.constant', () => ({ 'oidcConfiguration', 'enableSelfSignup', ], + 'custom-oidc': [ + 'ldapConfiguration', + 'samlConfiguration', + 'oidcConfiguration', + 'enableSelfSignup', + ], }, PROVIDERS_WITHOUT_BOT_PRINCIPALS: ['google', 'auth0', 'basic', 'aws-cognito'], })); @@ -433,7 +452,7 @@ describe('SSOUtils', () => { result.authenticationConfiguration.oidcConfiguration ).toBeDefined(); expect(result.authenticationConfiguration.oidcConfiguration?.scope).toBe( - 'openid email profile' + 'openid email profile offline_access' ); }); @@ -579,7 +598,6 @@ describe('SSOUtils', () => { id: 'test-id', secret: 'test-secret', callbackUrl: 'http://localhost:8585/callback', - serverUrl: 'http://localhost:8585/callback', }, }, authorizerConfiguration: { @@ -876,44 +894,6 @@ describe('SSOUtils', () => { ClientType.Public ); }); - - it('should clean up serverUrl to remove /callback suffix', () => { - const data: FormData = { - authenticationConfiguration: { - provider: 'google', - providerName: 'Google', - clientType: ClientType.Confidential, - authority: 'https://accounts.google.com', - clientId: 'test-client-id', - callbackUrl: 'http://localhost:8585/callback', - publicKeyUrls: ['https://www.googleapis.com/oauth2/v3/certs'], - tokenValidationAlgorithm: 'RS256', - jwtPrincipalClaims: ['email'], - jwtPrincipalClaimsMapping: [], - enableSelfSignup: true, - oidcConfiguration: { - id: 'test-id', - secret: 'test-secret', - callbackUrl: 'http://localhost:8585/callback', - serverUrl: 'http://localhost:8585/callback/', - }, - }, - authorizerConfiguration: { - className: 'org.openmetadata.service.security.DefaultAuthorizer', - containerRequestFilter: 'org.openmetadata.service.security.JwtFilter', - adminPrincipals: [], - principalDomain: '', - enforcePrincipalDomain: false, - enableSecureSocketConnection: false, - }, - }; - - const result = cleanupProviderSpecificFields(data, 'google'); - - expect( - result?.authenticationConfiguration.oidcConfiguration?.serverUrl - ).toBe('http://localhost:8585'); - }); }); /** @@ -2138,12 +2118,10 @@ describe('SSOUtils', () => { ); expect(oidcConfig.tokenValidity).toBe(3600); expect(oidcConfig.sessionExpiry).toBe(604800); - expect(oidcConfig.serverUrl).toBe('http://localhost:8585'); expect(oidcConfig.scope).toBe('openid email profile'); - expect(oidcConfig.useNonce).toBe(false); + expect(oidcConfig.useNonce).toBe(true); expect(oidcConfig.preferredJwsAlgorithm).toBe('RS256'); - expect(oidcConfig.responseType).toBe('code'); - expect(oidcConfig.disablePkce).toBe(false); + expect(oidcConfig.disablePkce).toBe(true); expect(oidcConfig.maxClockSkew).toBe(0); expect(oidcConfig.clientAuthenticationMethod).toBe('client_secret_post'); }); @@ -2156,7 +2134,6 @@ describe('SSOUtils', () => { scope: 'custom scope', useNonce: true, preferredJwsAlgorithm: 'ES256', - responseType: 'id_token', disablePkce: true, maxClockSkew: 10, clientAuthenticationMethod: 'client_secret_basic', @@ -2173,7 +2150,6 @@ describe('SSOUtils', () => { expect(oidcConfig.scope).toBe('custom scope'); expect(oidcConfig.useNonce).toBe(true); expect(oidcConfig.preferredJwsAlgorithm).toBe('ES256'); - expect(oidcConfig.responseType).toBe('id_token'); expect(oidcConfig.disablePkce).toBe(true); expect(oidcConfig.maxClockSkew).toBe(10); expect(oidcConfig.clientAuthenticationMethod).toBe('client_secret_basic'); @@ -3341,3 +3317,340 @@ describe('parseSamlMetadataXml', () => { ); }); }); + +const buildOidcAuthConfig = ( + overrides: Partial = {}, + oidcOverrides: Record = {} +): FormData['authenticationConfiguration'] => ({ + provider: AuthProvider.CustomOidc, + providerName: 'Custom OIDC', + authority: '', + clientId: '', + callbackUrl: 'http://localhost:8585/callback', + publicKeyUrls: [], + tokenValidationAlgorithm: 'RS256', + jwtPrincipalClaims: ['email', 'preferred_username', 'sub'], + jwtPrincipalClaimsMapping: [], + enableSelfSignup: true, + clientType: ClientType.Confidential, + oidcConfiguration: { + type: AuthProvider.CustomOidc, + id: 'client-abc', + secret: 'super-secret', + discoveryUri: 'https://idp.example.com/.well-known/openid-configuration', + callbackUrl: 'http://localhost:8585/callback', + ...oidcOverrides, + }, + ...overrides, +}); + +const buildAuthorizerConfig = (): FormData['authorizerConfiguration'] => ({ + className: 'org.openmetadata.service.security.DefaultAuthorizer', + containerRequestFilter: 'org.openmetadata.service.security.JwtFilter', + adminPrincipals: [], + principalDomain: '', + enforcePrincipalDomain: false, + enableSecureSocketConnection: false, +}); + +describe('deriveOidcClientType', () => { + it('returns Confidential when oidcConfiguration.secret is non-empty', () => { + const authConfig = buildOidcAuthConfig(); + + expect(deriveOidcClientType(authConfig)).toBe(ClientType.Confidential); + }); + + it('returns Public when oidcConfiguration.secret is an empty string', () => { + const authConfig = buildOidcAuthConfig({}, { secret: '' }); + + expect(deriveOidcClientType(authConfig)).toBe(ClientType.Public); + }); + + it('returns Public when secret is whitespace-only', () => { + const authConfig = buildOidcAuthConfig({}, { secret: ' ' }); + + expect(deriveOidcClientType(authConfig)).toBe(ClientType.Public); + }); + + it('returns Public when oidcConfiguration is missing', () => { + const authConfig = buildOidcAuthConfig(); + delete authConfig.oidcConfiguration; + + expect(deriveOidcClientType(authConfig)).toBe(ClientType.Public); + }); + + it('returns Public when authConfig is undefined', () => { + expect(deriveOidcClientType(undefined)).toBe(ClientType.Public); + }); +}); + +describe('liftPublicOidcToConfidentialShape', () => { + it('promotes root clientId/authority into oidcConfiguration', () => { + const authConfig: FormData['authenticationConfiguration'] = { + provider: AuthProvider.CustomOidc, + providerName: 'Custom OIDC', + authority: 'https://idp.example.com', + clientId: 'public-client-id', + callbackUrl: 'http://localhost:8585/callback', + publicKeyUrls: ['https://idp.example.com/jwks'], + tokenValidationAlgorithm: 'RS256', + jwtPrincipalClaims: ['email'], + jwtPrincipalClaimsMapping: [], + enableSelfSignup: true, + clientType: ClientType.Public, + }; + + liftPublicOidcToConfidentialShape(authConfig); + + expect(authConfig.oidcConfiguration?.id).toBe('public-client-id'); + expect(authConfig.oidcConfiguration?.discoveryUri).toBe( + 'https://idp.example.com/.well-known/openid-configuration' + ); + expect(authConfig.oidcConfiguration?.secret).toBe(''); + expect(authConfig.oidcConfiguration?.callbackUrl).toBe( + 'http://localhost:8585/callback' + ); + expect(authConfig.clientId).toBe(''); + expect(authConfig.authority).toBe(''); + expect(authConfig.clientType).toBe(ClientType.Confidential); + }); + + it('strips trailing slash from authority before constructing discoveryUri', () => { + const authConfig: FormData['authenticationConfiguration'] = { + provider: AuthProvider.Google, + providerName: 'Google', + authority: 'https://accounts.google.com/', + clientId: 'gid', + callbackUrl: 'http://localhost:8585/callback', + publicKeyUrls: [], + tokenValidationAlgorithm: 'RS256', + jwtPrincipalClaims: ['email'], + jwtPrincipalClaimsMapping: [], + enableSelfSignup: true, + clientType: ClientType.Public, + }; + + liftPublicOidcToConfidentialShape(authConfig); + + expect(authConfig.oidcConfiguration?.discoveryUri).toBe( + 'https://accounts.google.com/.well-known/openid-configuration' + ); + }); + + it('uses Azure-specific scope when provider is Azure', () => { + const authConfig: FormData['authenticationConfiguration'] = { + provider: AuthProvider.Azure, + providerName: 'Azure AD', + authority: 'https://login.microsoftonline.com/tenant-id', + clientId: 'azure-client', + callbackUrl: 'http://localhost:8585/callback', + publicKeyUrls: [], + tokenValidationAlgorithm: 'RS256', + jwtPrincipalClaims: ['email'], + jwtPrincipalClaimsMapping: [], + enableSelfSignup: true, + clientType: ClientType.Public, + }; + + liftPublicOidcToConfidentialShape(authConfig); + + expect(authConfig.oidcConfiguration?.scope).toBe( + 'openid email profile offline_access' + ); + }); + + it('is a no-op when clientType is already Confidential', () => { + const authConfig = buildOidcAuthConfig(); + const before = JSON.parse(JSON.stringify(authConfig)); + + liftPublicOidcToConfidentialShape(authConfig); + + expect(authConfig).toEqual(before); + }); + + it('is a no-op for non-OIDC providers', () => { + const authConfig: FormData['authenticationConfiguration'] = { + provider: AuthProvider.Saml, + providerName: 'SAML', + authority: 'https://saml.idp/login', + clientId: 'sp-id', + callbackUrl: 'http://localhost:8585/callback', + publicKeyUrls: [], + tokenValidationAlgorithm: 'RS256', + jwtPrincipalClaims: ['email'], + jwtPrincipalClaimsMapping: [], + enableSelfSignup: true, + clientType: ClientType.Public, + }; + const before = JSON.parse(JSON.stringify(authConfig)); + + liftPublicOidcToConfidentialShape(authConfig); + + expect(authConfig).toEqual(before); + }); + + it('preserves existing oidcConfiguration values via spread', () => { + const authConfig: FormData['authenticationConfiguration'] = { + provider: AuthProvider.CustomOidc, + providerName: 'Custom OIDC', + authority: 'https://idp.example.com', + clientId: 'public-client-id', + callbackUrl: 'http://localhost:8585/callback', + publicKeyUrls: [], + tokenValidationAlgorithm: 'RS256', + jwtPrincipalClaims: ['email'], + jwtPrincipalClaimsMapping: [], + enableSelfSignup: true, + clientType: ClientType.Public, + oidcConfiguration: { + prompt: 'login', + }, + }; + + liftPublicOidcToConfidentialShape(authConfig); + + expect(authConfig.oidcConfiguration?.prompt).toBe('login'); + expect(authConfig.oidcConfiguration?.id).toBe('public-client-id'); + }); +}); + +describe('prepareOidcSubmitPayload', () => { + it('returns undefined for undefined input', () => { + expect(prepareOidcSubmitPayload(undefined)).toBeUndefined(); + }); + + it('keeps Confidential shape when secret is filled', () => { + const data: FormData = { + authenticationConfiguration: buildOidcAuthConfig(), + authorizerConfiguration: buildAuthorizerConfig(), + }; + + const result = prepareOidcSubmitPayload(data); + const auth = result?.authenticationConfiguration; + + expect(auth?.clientType).toBe(ClientType.Confidential); + expect(auth?.oidcConfiguration).toBeDefined(); + expect(auth?.oidcConfiguration?.secret).toBe('super-secret'); + }); + + it('mirrors nested discoveryUri to root on Confidential so backend can derive authority and Azure tenant', () => { + const data: FormData = { + authenticationConfiguration: buildOidcAuthConfig({ + provider: AuthProvider.Azure, + providerName: 'azure', + }), + authorizerConfiguration: buildAuthorizerConfig(), + }; + + const result = prepareOidcSubmitPayload(data); + const auth = result?.authenticationConfiguration as + | (FormData['authenticationConfiguration'] & { discoveryUri?: string }) + | undefined; + + expect(auth?.clientType).toBe(ClientType.Confidential); + expect(auth?.discoveryUri).toBe( + 'https://idp.example.com/.well-known/openid-configuration' + ); + expect(auth?.oidcConfiguration?.discoveryUri).toBe( + 'https://idp.example.com/.well-known/openid-configuration' + ); + }); + + it('does not override an explicit root discoveryUri on the Confidential path', () => { + const data: FormData = { + authenticationConfiguration: { + ...buildOidcAuthConfig({ + provider: AuthProvider.Azure, + providerName: 'azure', + }), + discoveryUri: + 'https://explicit.example.com/.well-known/openid-configuration', + } as FormData['authenticationConfiguration'] & { discoveryUri?: string }, + authorizerConfiguration: buildAuthorizerConfig(), + }; + + const result = prepareOidcSubmitPayload(data); + const auth = result?.authenticationConfiguration as + | (FormData['authenticationConfiguration'] & { discoveryUri?: string }) + | undefined; + + expect(auth?.discoveryUri).toBe( + 'https://explicit.example.com/.well-known/openid-configuration' + ); + }); + + it('reshapes to Public when secret is blank', () => { + const data: FormData = { + authenticationConfiguration: buildOidcAuthConfig({}, { secret: '' }), + authorizerConfiguration: buildAuthorizerConfig(), + }; + + const result = prepareOidcSubmitPayload(data); + const auth = result?.authenticationConfiguration; + + expect(auth?.clientType).toBe(ClientType.Public); + expect(auth?.clientId).toBe('client-abc'); + expect(auth?.authority).toBe('https://idp.example.com'); + expect(auth?.callbackUrl).toBe('http://localhost:8585/callback'); + }); + + it('drops oidcConfiguration on Public — canonical shape is root-level only', () => { + const data: FormData = { + authenticationConfiguration: buildOidcAuthConfig({}, { secret: '' }), + authorizerConfiguration: buildAuthorizerConfig(), + }; + + const result = prepareOidcSubmitPayload(data); + const auth = result?.authenticationConfiguration as + | (FormData['authenticationConfiguration'] & { discoveryUri?: string }) + | undefined; + + expect(auth?.clientType).toBe(ClientType.Public); + expect(auth?.oidcConfiguration).toBeUndefined(); + expect(auth?.discoveryUri).toBe( + 'https://idp.example.com/.well-known/openid-configuration' + ); + expect(auth?.authority).toBe('https://idp.example.com'); + expect(auth?.clientId).toBe('client-abc'); + expect(auth?.callbackUrl).toBe('http://localhost:8585/callback'); + }); + + it('does not mutate the input on the Public path', () => { + const data: FormData = { + authenticationConfiguration: buildOidcAuthConfig({}, { secret: '' }), + authorizerConfiguration: buildAuthorizerConfig(), + }; + const before = JSON.parse(JSON.stringify(data)); + + prepareOidcSubmitPayload(data); + + expect(data).toEqual(before); + }); + + it('skips the OIDC reshape for SAML providers', () => { + const data: FormData = { + authenticationConfiguration: { + provider: AuthProvider.Saml, + providerName: 'SAML', + authority: 'https://saml.idp/login', + clientId: 'sp-id', + callbackUrl: 'http://localhost:8585/callback', + publicKeyUrls: [], + tokenValidationAlgorithm: 'RS256', + jwtPrincipalClaims: ['email'], + jwtPrincipalClaimsMapping: [], + enableSelfSignup: true, + clientType: ClientType.Public, + samlConfiguration: { debugMode: false }, + }, + authorizerConfiguration: buildAuthorizerConfig(), + }; + + const result = prepareOidcSubmitPayload(data); + + expect(result?.authenticationConfiguration.clientType).toBe( + ClientType.Public + ); + expect(result?.authenticationConfiguration.samlConfiguration).toBeDefined(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.ts index 490ee0ef0db1..dc588c865cf4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.ts @@ -29,7 +29,9 @@ import { DEFAULT_AUTHORIZER_CLASS_NAME, DEFAULT_CALLBACK_URL, DEFAULT_CONTAINER_REQUEST_FILTER, + getLockoutRiskFields, GOOGLE_SSO_DEFAULTS, + OIDC_PROVIDERS, OIDC_SSO_DEFAULTS, PROVIDERS_WITHOUT_BOT_PRINCIPALS, PROVIDER_FIELD_MAPPINGS, @@ -343,7 +345,13 @@ export const getDefaultsForProvider = ( publicKeyUrls = [], } = isGoogle ? GOOGLE_SSO_DEFAULTS : {}; - const { tokenValidity, serverUrl, sessionExpiry } = OIDC_SSO_DEFAULTS; + const { + azureScope, + preferredJwsAlgorithm, + scope, + tokenValidity, + sessionExpiry, + } = OIDC_SSO_DEFAULTS; const authConfig: AuthenticationConfiguration = { provider: provider, @@ -357,6 +365,7 @@ export const getDefaultsForProvider = ( tokenValidationAlgorithm: 'RS256', jwtPrincipalClaims: getProviderJwtClaims(provider), jwtPrincipalClaimsMapping: [], + enableAutoRedirect: false, // Always include authority and publicKeyUrls for Google (required by backend) ...(isGoogle ? { @@ -402,22 +411,20 @@ export const getDefaultsForProvider = ( // For confidential clients, fields go in oidcConfiguration // For public clients, use root level fields (but not for SAML which has its own config) if (!isSaml && clientType === ClientType.Confidential) { + authConfig.callbackUrl = DEFAULT_CALLBACK_URL; authConfig.oidcConfiguration = { type: provider, id: '', secret: '', - scope: 'openid email profile', + scope: provider === AuthProvider.Azure ? azureScope : scope, discoveryUri, - useNonce: false, - preferredJwsAlgorithm: 'RS256', - responseType: 'code', - disablePkce: false, + useNonce: true, + preferredJwsAlgorithm, + disablePkce: true, maxClockSkew: 0, clientAuthenticationMethod: 'client_secret_post', tokenValidity, customParams: {}, - tenant: '', - serverUrl, callbackUrl: DEFAULT_CALLBACK_URL, maxAge: 0, prompt: '', @@ -457,9 +464,6 @@ const cleanupOidcConfiguration = ( if (typeof oidcConfig.callbackUrl === 'string') { authConfig.callbackUrl = oidcConfig.callbackUrl; } - if (typeof oidcConfig.serverUrl === 'string') { - oidcConfig.serverUrl = oidcConfig.serverUrl.replace(/\/callback\/?$/, ''); - } }; /** @@ -873,6 +877,23 @@ export const handleConfidentialToPublicSwitch = ( authConfig.callbackUrl ??= (oidcConfig?.callbackUrl as string) ?? DEFAULT_CALLBACK_URL; + // Promote OIDC-config fields to root so the Public-client validator (which + // reads root-level fields) sees the values the user already entered in the + // Confidential-shaped form. + const oidcClientId = + typeof oidcConfig?.id === 'string' ? oidcConfig.id : undefined; + if (!authConfig.clientId && oidcClientId) { + authConfig.clientId = oidcClientId; + } + if (!authConfig.authority) { + const discoveryUri = (oidcConfig?.discoveryUri as string | undefined) ?? ''; + if (discoveryUri) { + authConfig.authority = discoveryUri + .replace(/\/?\.well-known\/openid-configuration\/?$/, '') + .replace(/\/$/, ''); + } + } + // For Google SSO, prepopulate Authority and Public Key URLs when switching to Public const isGoogle = authConfig.provider === AuthProvider.Google; if (isGoogle) { @@ -881,6 +902,50 @@ export const handleConfidentialToPublicSwitch = ( } }; +/** + * Resolves a Discovery URI from oidcConfiguration or authority. Returns null + * if neither is set. + */ +export const resolveDiscoveryUri = ( + authConfig: AuthenticationConfiguration | undefined +): string | null => { + const fromOidc = authConfig?.oidcConfiguration?.discoveryUri as + | string + | undefined; + if (fromOidc) { + return fromOidc; + } + const authority = authConfig?.authority; + if (authority) { + return `${authority.replace(/\/$/, '')}/.well-known/openid-configuration`; + } + + return null; +}; + +/** + * Fetches an OIDC discovery document and returns the parsed JSON. Returns + * null on any failure (network, CORS, non-OK status, invalid JSON) — callers + * should treat the result as best-effort and fall back to manual entry. + */ +export const fetchOidcDiscoveryDocument = async ( + discoveryUri: string +): Promise | null> => { + try { + const response = await fetch(discoveryUri, { + method: 'GET', + credentials: 'omit', + }); + if (!response.ok) { + return null; + } + + return (await response.json()) as Record; + } catch { + return null; + } +}; + /** * Handles switching from Public to Confidential client type * Moves callback URL from root to OIDC config and adds Google-specific OIDC defaults if applicable @@ -904,20 +969,154 @@ export const handlePublicToConfidentialSwitch = ( oidcConfig.discoveryUri = GOOGLE_SSO_DEFAULTS.discoveryUri; oidcConfig.tokenValidity = OIDC_SSO_DEFAULTS.tokenValidity; oidcConfig.sessionExpiry = OIDC_SSO_DEFAULTS.sessionExpiry; - oidcConfig.serverUrl = OIDC_SSO_DEFAULTS.serverUrl; // Set default values for other required OIDC fields - oidcConfig.scope = oidcConfig.scope || 'openid email profile'; - oidcConfig.useNonce = oidcConfig.useNonce ?? false; + oidcConfig.scope = oidcConfig.scope || OIDC_SSO_DEFAULTS.scope; + oidcConfig.useNonce = oidcConfig.useNonce ?? true; oidcConfig.preferredJwsAlgorithm = - oidcConfig.preferredJwsAlgorithm || 'RS256'; - oidcConfig.responseType = oidcConfig.responseType || 'code'; - oidcConfig.disablePkce = oidcConfig.disablePkce ?? false; + oidcConfig.preferredJwsAlgorithm || + OIDC_SSO_DEFAULTS.preferredJwsAlgorithm; + oidcConfig.disablePkce = oidcConfig.disablePkce ?? true; oidcConfig.maxClockSkew = oidcConfig.maxClockSkew ?? 0; oidcConfig.clientAuthenticationMethod = oidcConfig.clientAuthenticationMethod || 'client_secret_post'; } }; +const isOidcProvider = (provider: string | undefined): boolean => + !!provider && OIDC_PROVIDERS.has(provider); + +/** + * Derives clientType for an OIDC provider from the presence of a Client + * Secret. Filled secret → Confidential. Empty/missing → Public. Non-OIDC + * providers are not the caller's concern; this helper is OIDC-only. + */ +export const deriveOidcClientType = ( + authConfig: AuthenticationConfiguration | undefined +): ClientType => { + const secret = authConfig?.oidcConfiguration?.secret; + + return typeof secret === 'string' && secret.trim() !== '' + ? ClientType.Confidential + : ClientType.Public; +}; + +/** + * Lifts a stored Public-OIDC config into the canonical Confidential-shape the + * form renders. Root clientId/authority become oidcConfiguration.id/ + * discoveryUri so the same 4 fields display populated. Root values are + * cleared so the submit-time reshape picks fresh values from oidcConfiguration. + * The form-state clientType is set to Confidential as a UI hint; the network + * payload value is derived from the secret at submit. + */ +export const liftPublicOidcToConfidentialShape = ( + authConfig: AuthenticationConfiguration | undefined +): void => { + if (!authConfig || !isOidcProvider(authConfig.provider)) { + return; + } + if (authConfig.clientType !== ClientType.Public) { + return; + } + + const existing = (authConfig.oidcConfiguration ?? {}) as Record< + string, + unknown + >; + const liftedClientId = authConfig.clientId ?? ''; + const liftedAuthority = authConfig.authority ?? ''; + const liftedDiscoveryUri = liftedAuthority + ? `${liftedAuthority.replace(/\/$/, '')}/.well-known/openid-configuration` + : ''; + const isAzure = authConfig.provider === AuthProvider.Azure; + const defaultScope = isAzure + ? OIDC_SSO_DEFAULTS.azureScope + : OIDC_SSO_DEFAULTS.scope; + + authConfig.oidcConfiguration = { + type: authConfig.provider, + id: liftedClientId, + secret: '', + scope: defaultScope, + discoveryUri: liftedDiscoveryUri, + useNonce: true, + preferredJwsAlgorithm: OIDC_SSO_DEFAULTS.preferredJwsAlgorithm, + disablePkce: true, + maxClockSkew: 0, + clientAuthenticationMethod: 'client_secret_post', + tokenValidity: OIDC_SSO_DEFAULTS.tokenValidity, + customParams: {}, + callbackUrl: authConfig.callbackUrl ?? DEFAULT_CALLBACK_URL, + maxAge: 0, + prompt: '', + sessionExpiry: OIDC_SSO_DEFAULTS.sessionExpiry, + ...existing, + }; + + authConfig.clientId = ''; + authConfig.authority = ''; + authConfig.clientType = ClientType.Confidential; +}; + +/** + * Reshapes the form's Confidential-shape OIDC payload into the network shape + * the backend expects, deriving clientType from secret presence. For + * Confidential (secret filled), the payload is just cleaned. For Public + * (secret blank), id/discoveryUri are promoted to root clientId/authority, + * the secret is dropped, and a slim oidcConfiguration with just discoveryUri + * is kept so backend validators that dereference oidcConfig.discoveryUri + * (e.g. CustomOidcValidator.extractDiscoveryUri) don't NPE. Non-OIDC + * providers fall through to the existing cleanup unchanged. + */ +export const prepareOidcSubmitPayload = ( + data: FormData | undefined +): FormData | undefined => { + if (!data?.authenticationConfiguration) { + return cleanupProviderSpecificFields(data, ''); + } + + const provider = data.authenticationConfiguration.provider as string; + if (!isOidcProvider(provider)) { + return cleanupProviderSpecificFields(data, provider); + } + + const cloned = structuredClone(data) as FormData; + const authConfig = cloned.authenticationConfiguration; + const derivedClientType = deriveOidcClientType(authConfig); + authConfig.clientType = derivedClientType; + + // Mirror nested oidcConfiguration.discoveryUri up to the root for both + // Public and Confidential paths so the backend's normalizeForPersistence + // can derive authority, publicKeyUrls (overwriting any stale value), and + // Azure tenant — it only reads root-level discoveryUri for derivation. + const authConfigWithDiscovery = authConfig as AuthenticationConfiguration & { + discoveryUri?: string; + }; + const nestedDiscoveryUri = authConfig.oidcConfiguration?.discoveryUri as + | string + | undefined; + if (!authConfigWithDiscovery.discoveryUri && nestedDiscoveryUri) { + authConfigWithDiscovery.discoveryUri = nestedDiscoveryUri; + } + + if (derivedClientType !== ClientType.Public) { + return cleanupProviderSpecificFields(cloned, provider); + } + + handleConfidentialToPublicSwitch(authConfig); + + const cleaned = cleanupProviderSpecificFields(cloned, provider); + if (!cleaned) { + return cleaned; + } + + // Public OIDC's canonical shape is root-level only — matches the schema + // (Public requires only ["publicKeyUrls","authority","callbackUrl","clientId"]) + // and the 5/6 sibling validators whose Public-path methods take only authConfig. + delete cleaned.authenticationConfiguration.oidcConfiguration; + + return cleaned; +}; + /** * Handles client type transitions for authentication configuration * Migrates fields between root and OIDC configuration based on client type change @@ -1222,3 +1421,44 @@ export const parseSamlMetadataXml = (xmlString: string): SamlIdpMetadata => { idpX509Certificate: `-----BEGIN CERTIFICATE-----\n${certText}\n-----END CERTIFICATE-----`, }; }; + +/** + * Returns true if any field changed between savedData and currentData is in + * the lockout-risk set for the given provider. Used to gate save behind a + * fresh Test Login on existing-config edits. + */ +export const hasLockoutRiskChange = ( + savedData: unknown, + currentData: unknown, + provider: string | undefined +): boolean => { + const lockoutRiskFields = getLockoutRiskFields(provider); + if (lockoutRiskFields.size === 0) { + return false; + } + const changedFields = findChangedFields(savedData, currentData); + + return changedFields.some((field) => lockoutRiskFields.has(field)); +}; + +/** + * Returns true if Save must be gated behind a fresh Test Login. New configs + * always require it; existing configs require it only when a lockout-risk + * field changed. A successful Test Login (testLoginPassed) lifts the gate. + */ +export const requiresFreshTestLogin = ( + hasExistingConfig: boolean, + savedData: FormData | undefined, + internalData: FormData | undefined, + provider: string | undefined, + testLoginPassed: boolean +): boolean => { + if (testLoginPassed) { + return false; + } + if (!hasExistingConfig) { + return true; + } + + return hasLockoutRiskChange(savedData, internalData, provider); +};