From 17e87a8a05b100ddb0716b2b4054cdab9614e4e3 Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Tue, 2 Dec 2025 10:23:30 +0100 Subject: [PATCH 01/18] fix(admin-ui): Use typescript generated Clients pages #2491 --- admin-ui/app/locales/en/translation.json | 79 +- admin-ui/app/locales/es/translation.json | 79 +- admin-ui/app/locales/fr/translation.json | 79 +- admin-ui/app/locales/pt/translation.json | 79 +- .../components/ClientAddPage.test.js | 64 -- .../components/ClientDetailPage.test.js | 35 - .../components/ClientEditPage.test.js | 55 -- .../components/ClientListPage.test.js | 80 -- .../components/ClientWizardForm.test.js | 111 --- .../Clients/ClientActiveTokenDetailPage.js | 75 -- .../components/Clients/ClientActiveTokens.js | 366 ---------- .../components/Clients/ClientAddPage.js | 101 --- .../components/Clients/ClientAddPage.tsx | 49 ++ .../components/Clients/ClientAdvancedPanel.js | 284 -------- .../components/Clients/ClientBasicPanel.js | 440 ----------- .../Clients/ClientCibaParUmaPanel.js | 456 ------------ .../components/Clients/ClientDetailPage.js | 224 ------ .../components/Clients/ClientEditPage.js | 87 --- .../components/Clients/ClientEditPage.tsx | 63 ++ .../Clients/ClientEncryptionSigningPanel.js | 411 ----------- .../components/Clients/ClientListPage.js | 440 ----------- .../components/Clients/ClientListPage.tsx | 650 +++++++++++++++++ .../components/Clients/ClientLogoutPanel.js | 123 ---- .../components/Clients/ClientScriptPanel.js | 186 ----- .../components/Clients/ClientShowScopes.js | 41 -- .../Clients/ClientShowSpontaneousScopes.js | 40 - .../components/Clients/ClientSoftwarePanel.js | 180 ----- .../components/Clients/ClientTokensPanel.js | 174 ----- .../components/Clients/ClientWizardForm.js | 598 --------------- .../Clients/components/ClientDetailView.tsx | 241 ++++++ .../Clients/components/ClientForm.tsx | 339 +++++++++ .../components/Clients/helper/constants.ts | 148 ++++ .../components/Clients/helper/utils.ts | 190 +++++ .../components/Clients/helper/validations.ts | 79 ++ .../components/Clients/hooks/index.ts | 10 + .../Clients/hooks/useClientActions.ts | 90 +++ .../components/Clients/hooks/useClientApi.ts | 160 ++++ .../components/Clients/hooks/useClientForm.ts | 77 ++ .../auth-server/components/Clients/index.ts | 10 + .../components/Clients/tabs/AdvancedTab.tsx | 684 ++++++++++++++++++ .../Clients/tabs/AuthenticationTab.tsx | 637 ++++++++++++++++ .../components/Clients/tabs/BasicInfoTab.tsx | 307 ++++++++ .../Clients/tabs/ScopesGrantsTab.tsx | 322 +++++++++ .../components/Clients/tabs/UrisTab.tsx | 463 ++++++++++++ .../components/Clients/types/clientTypes.ts | 199 +++++ .../components/Clients/types/formTypes.ts | 108 +++ .../components/Clients/types/index.ts | 2 + 47 files changed, 5128 insertions(+), 4587 deletions(-) delete mode 100644 admin-ui/plugins/auth-server/__tests__/components/ClientAddPage.test.js delete mode 100644 admin-ui/plugins/auth-server/__tests__/components/ClientDetailPage.test.js delete mode 100644 admin-ui/plugins/auth-server/__tests__/components/ClientEditPage.test.js delete mode 100644 admin-ui/plugins/auth-server/__tests__/components/ClientListPage.test.js delete mode 100644 admin-ui/plugins/auth-server/__tests__/components/ClientWizardForm.test.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientActiveTokenDetailPage.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientActiveTokens.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientAddPage.js create mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientAddPage.tsx delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientAdvancedPanel.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientBasicPanel.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientCibaParUmaPanel.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientEditPage.js create mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientEncryptionSigningPanel.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientListPage.js create mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientLogoutPanel.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientScriptPanel.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientShowScopes.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientShowSpontaneousScopes.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientSoftwarePanel.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientTokensPanel.js delete mode 100644 admin-ui/plugins/auth-server/components/Clients/ClientWizardForm.js create mode 100644 admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx create mode 100644 admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx create mode 100644 admin-ui/plugins/auth-server/components/Clients/helper/constants.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/helper/utils.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/helper/validations.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/hooks/index.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/hooks/useClientForm.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/index.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/tabs/AdvancedTab.tsx create mode 100644 admin-ui/plugins/auth-server/components/Clients/tabs/AuthenticationTab.tsx create mode 100644 admin-ui/plugins/auth-server/components/Clients/tabs/BasicInfoTab.tsx create mode 100644 admin-ui/plugins/auth-server/components/Clients/tabs/ScopesGrantsTab.tsx create mode 100644 admin-ui/plugins/auth-server/components/Clients/tabs/UrisTab.tsx create mode 100644 admin-ui/plugins/auth-server/components/Clients/types/clientTypes.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/types/index.ts diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index dd636233ba..582c1f7fb2 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -439,6 +439,7 @@ "status": "Status", "scopes": "Scopes", "sort_by": "Sort By", + "sort_order": "Sort Order", "smtp_host": "SMTP Host", "smtp_port": "SMTP Port", "trust_host": "Trust Server", @@ -625,7 +626,49 @@ "nothingToShowInTheList": "Nothing to show in the list", "auiPolicyStoreUrl": "Admin UI Remote Policy Store", "configApiPolicyStoreUrl": "Config API Policy Store", - "cedarlingPolicyStoreRetrievalPoint": "Policy Retrieval Point" + "cedarlingPolicyStoreRetrievalPoint": "Policy Retrieval Point", + "access_token_as_jwt": "Access Token as JWT", + "access_token_lifetime": "Access Token Lifetime", + "additional_audience": "Additional Audience", + "authorized_acr_values": "Authorized ACR Values", + "authorized_origins": "Authorized Origins", + "backchannel_client_notification_endpoint": "Backchannel Client Notification Endpoint", + "backchannel_logout_session_required": "Backchannel Logout Session Required", + "backchannel_logout_uris": "Backchannel Logout URIs", + "backchannel_token_delivery_mode": "Backchannel Token Delivery Mode", + "backchannel_user_code_parameter": "Backchannel User Code Parameter", + "claim_redirect_uris": "Claim Redirect URIs", + "client_uri": "Client URI", + "default_acr_values": "Default ACR Values", + "default_max_age": "Default Max Age", + "default_prompt_login": "Default Prompt Login", + "dpop_bound_access_token": "DPoP Bound Access Token", + "expirationDate": "Expiration Date", + "front_channel_logout_session_required": "Front Channel Logout Session Required", + "front_channel_logout_uri": "Front Channel Logout URI", + "include_claims_in_id_token": "Include Claims in ID Token", + "initiate_login_uri": "Initiate Login URI", + "jans_auth_enc_resp_alg": "JARM Encryption Algorithm", + "jans_auth_enc_resp_enc": "JARM Encryption Encoding", + "jans_auth_signed_resp_alg": "JARM Signing Algorithm", + "par_lifetime": "PAR Lifetime", + "redirect_uris_regex": "Redirect URIs Regex", + "refresh_token_lifetime": "Refresh Token Lifetime", + "request_uris": "Request URIs", + "require_par": "Require PAR", + "require_pkce": "Require PKCE", + "ropc_scripts": "ROPC Scripts", + "rpt_as_jwt": "RPT as JWT", + "rpt_claims_scripts": "RPT Claims Scripts", + "sector_identifier_uri": "Sector Identifier URI", + "software_statement": "Software Statement", + "software_version": "Software Version", + "spontaneous_scope_scripts": "Spontaneous Scope Scripts", + "tos_uri": "Terms of Service URI", + "update_token_scripts": "Update Token Scripts", + "userinfo_encrypted_response_alg": "UserInfo Encryption Algorithm", + "userinfo_encrypted_response_enc": "UserInfo Encryption Encoding", + "userinfo_signed_response_alg": "UserInfo Signing Algorithm" }, "languages": { "french": "French", @@ -833,7 +876,11 @@ "user_updated_successfully": "User updated successfully", "user_deleted_successfully": "User deleted successfully", "password_changed_successfully": "Password changed successfully", - "device_deleted_successfully": "Device deleted successfully" + "device_deleted_successfully": "Device deleted successfully", + "no_claims": "No claims selected", + "no_grant_types": "No grant types selected", + "no_response_types": "No response types selected", + "no_scopes": "No scopes selected" }, "errors": { "attribute_create_failed": "Error creating attribute", @@ -931,7 +978,11 @@ "expires_before": "Expiration Before Date", "charMoreThan512": "characters over limit (maximum 512)", "charLessThan10": "characters required (minimum 10)", - "more": " more" + "more": " more", + "add_claims": "Add claims", + "add_email": "Add email", + "search_clients": "Search clients...", + "search_scopes": "Search scopes..." }, "titles": { "activeTokens": "Active Tokens", @@ -1030,7 +1081,27 @@ "cedar_Init": "Cedarling is trying to initialize, please wait", "newPermission": "New Permission", "addingNewApiPermission": "Adding new API Permission", - "followingPermissionRequiredToBeAdded": "Following permission required to be added!" + "followingPermissionRequiredToBeAdded": "Following permission required to be added!", + "add_openid_connect_client": "Add OpenID Connect Client", + "ciba": "CIBA", + "client_details": "Client Details", + "contacts": "Contacts", + "edit_openid_connect_client": "Edit OpenID Connect Client", + "introspection": "Introspection", + "jarm": "JARM", + "logout_uris": "Logout URIs", + "openid_connect_clients": "OpenID Connect Clients", + "other_advanced": "Other Advanced Settings", + "other_uris": "Other URIs", + "par": "PAR", + "redirect_uris": "Redirect URIs", + "scopes_and_grants": "Scopes & Grants", + "token_endpoint_auth": "Token Endpoint Authentication", + "token_settings": "Token Settings", + "uma": "UMA", + "uris": "URIs", + "basic_info": "Basic Info", + "advanced": "Advanced" }, "links": { "support": "https://support.gluu.org/" diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index 27adc7388c..462b6c3f4d 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -439,6 +439,7 @@ "status": "Estado", "scopes": "Ámbitos", "sort_by": "Ordenar Por", + "sort_order": "Orden", "smtp_host": "Host SMTP", "smtp_port": "Puerto SMTP", "trust_host": "Servidor de Confianza", @@ -625,7 +626,49 @@ "nothingToShowInTheList": "Nada que mostrar en la lista", "auiPolicyStoreUrl": "Almacén de políticas remotas de la interfaz de administración", "configApiPolicyStoreUrl": "Almacén de políticas de la API de configuración", - "cedarlingPolicyStoreRetrievalPoint": "Punto de recuperación de políticas" + "cedarlingPolicyStoreRetrievalPoint": "Punto de recuperación de políticas", + "access_token_as_jwt": "Token de Acceso como JWT", + "access_token_lifetime": "Tiempo de Vida del Token de Acceso", + "additional_audience": "Audiencia Adicional", + "authorized_acr_values": "Valores ACR Autorizados", + "authorized_origins": "Orígenes Autorizados", + "backchannel_client_notification_endpoint": "Endpoint de Notificación del Cliente Backchannel", + "backchannel_logout_session_required": "Sesión de Cierre Backchannel Requerida", + "backchannel_logout_uris": "URIs de Cierre Backchannel", + "backchannel_token_delivery_mode": "Modo de Entrega de Token Backchannel", + "backchannel_user_code_parameter": "Parámetro de Código de Usuario Backchannel", + "claim_redirect_uris": "URIs de Redirección de Claim", + "client_uri": "URI del Cliente", + "default_acr_values": "Valores ACR Predeterminados", + "default_max_age": "Edad Máxima Predeterminada", + "default_prompt_login": "Inicio de Sesión con Prompt Predeterminado", + "dpop_bound_access_token": "Token de Acceso Vinculado a DPoP", + "expirationDate": "Fecha de Expiración", + "front_channel_logout_session_required": "Sesión de Cierre Front Channel Requerida", + "front_channel_logout_uri": "URI de Cierre Front Channel", + "include_claims_in_id_token": "Incluir Claims en ID Token", + "initiate_login_uri": "URI de Inicio de Sesión", + "jans_auth_enc_resp_alg": "Algoritmo de Cifrado JARM", + "jans_auth_enc_resp_enc": "Codificación de Cifrado JARM", + "jans_auth_signed_resp_alg": "Algoritmo de Firma JARM", + "par_lifetime": "Tiempo de Vida PAR", + "redirect_uris_regex": "Regex de URIs de Redirección", + "refresh_token_lifetime": "Tiempo de Vida del Token de Actualización", + "request_uris": "URIs de Solicitud", + "require_par": "Requerir PAR", + "require_pkce": "Requerir PKCE", + "ropc_scripts": "Scripts ROPC", + "rpt_as_jwt": "RPT como JWT", + "rpt_claims_scripts": "Scripts de Claims RPT", + "sector_identifier_uri": "URI del Identificador de Sector", + "software_statement": "Declaración de Software", + "software_version": "Versión del Software", + "spontaneous_scope_scripts": "Scripts de Alcance Espontáneo", + "tos_uri": "URI de Términos de Servicio", + "update_token_scripts": "Scripts de Actualización de Token", + "userinfo_encrypted_response_alg": "Algoritmo de Cifrado UserInfo", + "userinfo_encrypted_response_enc": "Codificación de Cifrado UserInfo", + "userinfo_signed_response_alg": "Algoritmo de Firma UserInfo" }, "languages": { "french": "Frances", @@ -832,7 +875,11 @@ "user_updated_successfully": "Usuario actualizado exitosamente", "user_deleted_successfully": "Usuario eliminado exitosamente", "password_changed_successfully": "Contraseña cambiada exitosamente", - "device_deleted_successfully": "Dispositivo eliminado exitosamente" + "device_deleted_successfully": "Dispositivo eliminado exitosamente", + "no_claims": "No hay claims seleccionados", + "no_grant_types": "No hay tipos de concesión seleccionados", + "no_response_types": "No hay tipos de respuesta seleccionados", + "no_scopes": "No hay alcances seleccionados" }, "errors": { "attribute_create_failed": "Error al crear el atributo", @@ -924,7 +971,11 @@ "expires_before": "Expira antes de la fecha", "charMoreThan512": "caracteres excedidos (máximo 512)", "charLessThan10": "se requieren más caracteres (mínimo 10)", - "more": " más" + "more": " más", + "add_claims": "Agregar claims", + "add_email": "Agregar correo electrónico", + "search_clients": "Buscar clientes...", + "search_scopes": "Buscar alcances..." }, "titles": { "activeTokens": "Tokens activos", @@ -1023,7 +1074,27 @@ "cedar_Init": "Cedarling está intentando inicializarse, por favor espera", "newPermission": "Nuevo permiso", "addingNewApiPermission": "Añadiendo nuevo permiso API", - "followingPermissionRequiredToBeAdded": "¡Es necesario añadir el siguiente permiso!" + "followingPermissionRequiredToBeAdded": "¡Es necesario añadir el siguiente permiso!", + "add_openid_connect_client": "Agregar Cliente OpenID Connect", + "ciba": "CIBA", + "client_details": "Detalles del Cliente", + "contacts": "Contactos", + "edit_openid_connect_client": "Editar Cliente OpenID Connect", + "introspection": "Introspección", + "jarm": "JARM", + "logout_uris": "URIs de Cierre de Sesión", + "openid_connect_clients": "Clientes OpenID Connect", + "other_advanced": "Otras Configuraciones Avanzadas", + "other_uris": "Otras URIs", + "par": "PAR", + "redirect_uris": "URIs de Redirección", + "scopes_and_grants": "Alcances y Concesiones", + "token_endpoint_auth": "Autenticación del Endpoint de Token", + "token_settings": "Configuración de Tokens", + "uma": "UMA", + "uris": "URIs", + "basic_info": "Información Básica", + "advanced": "Avanzado" }, "links": { "support": "https://support.gluu.org/" diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 6a553b7de0..50d6ed3263 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -362,6 +362,7 @@ "requirePar": "Requérir PAR", "scopes": "Portées", "sort_by": "Trier Par", + "sort_order": "Ordre de Tri", "activate": "Activer", "active": "Active", "application_type": "Type de demande", @@ -659,7 +660,49 @@ "skip_defined_password_validation": "Ignorer la Validation du Mot de Passe Défini", "auiPolicyStoreUrl": "Magasin de politiques à distance de l'interface d'administration", "configApiPolicyStoreUrl": "Magasin de politiques de configuration de l'API", - "cedarlingPolicyStoreRetrievalPoint": "Point de récupération des politiques" + "cedarlingPolicyStoreRetrievalPoint": "Point de récupération des politiques", + "access_token_as_jwt": "Jeton d'Accès en JWT", + "access_token_lifetime": "Durée de Vie du Jeton d'Accès", + "additional_audience": "Audience Supplémentaire", + "authorized_acr_values": "Valeurs ACR Autorisées", + "authorized_origins": "Origines Autorisées", + "backchannel_client_notification_endpoint": "Point de Terminaison de Notification Client Backchannel", + "backchannel_logout_session_required": "Session de Déconnexion Backchannel Requise", + "backchannel_logout_uris": "URIs de Déconnexion Backchannel", + "backchannel_token_delivery_mode": "Mode de Livraison de Jeton Backchannel", + "backchannel_user_code_parameter": "Paramètre de Code Utilisateur Backchannel", + "claim_redirect_uris": "URIs de Redirection de Claim", + "client_uri": "URI du Client", + "default_acr_values": "Valeurs ACR par Défaut", + "default_max_age": "Âge Maximum par Défaut", + "default_prompt_login": "Connexion avec Invite par Défaut", + "dpop_bound_access_token": "Jeton d'Accès Lié à DPoP", + "expirationDate": "Date d'Expiration", + "front_channel_logout_session_required": "Session de Déconnexion Front Channel Requise", + "front_channel_logout_uri": "URI de Déconnexion Front Channel", + "include_claims_in_id_token": "Inclure les Claims dans l'ID Token", + "initiate_login_uri": "URI d'Initiation de Connexion", + "jans_auth_enc_resp_alg": "Algorithme de Chiffrement JARM", + "jans_auth_enc_resp_enc": "Encodage de Chiffrement JARM", + "jans_auth_signed_resp_alg": "Algorithme de Signature JARM", + "par_lifetime": "Durée de Vie PAR", + "redirect_uris_regex": "Regex des URIs de Redirection", + "refresh_token_lifetime": "Durée de Vie du Jeton de Rafraîchissement", + "request_uris": "URIs de Requête", + "require_par": "Exiger PAR", + "require_pkce": "Exiger PKCE", + "ropc_scripts": "Scripts ROPC", + "rpt_as_jwt": "RPT en JWT", + "rpt_claims_scripts": "Scripts de Claims RPT", + "sector_identifier_uri": "URI de l'Identifiant de Secteur", + "software_statement": "Déclaration de Logiciel", + "software_version": "Version du Logiciel", + "spontaneous_scope_scripts": "Scripts de Scope Spontané", + "tos_uri": "URI des Conditions d'Utilisation", + "update_token_scripts": "Scripts de Mise à Jour de Jeton", + "userinfo_encrypted_response_alg": "Algorithme de Chiffrement UserInfo", + "userinfo_encrypted_response_enc": "Encodage de Chiffrement UserInfo", + "userinfo_signed_response_alg": "Algorithme de Signature UserInfo" }, "messages": { "add_permission": "Ajouter une autorisation", @@ -770,7 +813,11 @@ "user_updated_successfully": "Utilisateur mis à jour avec succès", "user_deleted_successfully": "Utilisateur supprimé avec succès", "password_changed_successfully": "Mot de passe modifié avec succès", - "device_deleted_successfully": "Appareil supprimé avec succès" + "device_deleted_successfully": "Appareil supprimé avec succès", + "no_claims": "Aucun claim sélectionné", + "no_grant_types": "Aucun type d'autorisation sélectionné", + "no_response_types": "Aucun type de réponse sélectionné", + "no_scopes": "Aucun scope sélectionné" }, "errors": { "attribute_create_failed": "Erreur lors de la création de l'attribut", @@ -855,7 +902,11 @@ "expires_before": "Expiration avant la date", "charMoreThan512": "caractères acima do limite (maximum 512)", "charLessThan10": "caractères nécessaires (minimum 10)", - "more": " plus" + "more": " plus", + "add_claims": "Ajouter des claims", + "add_email": "Ajouter un email", + "search_clients": "Rechercher des clients...", + "search_scopes": "Rechercher des scopes..." }, "titles": { "acrs": "ACR", @@ -924,7 +975,27 @@ "newPermission": "Nouvelle Permission", "addingNewApiPermission": "Ajout d'une nouvelle autorisation API", "followingPermissionRequiredToBeAdded": "Une autorisation est requise pour être ajouté!", - "authentication": "Authentification" + "authentication": "Authentification", + "add_openid_connect_client": "Ajouter un Client OpenID Connect", + "ciba": "CIBA", + "client_details": "Détails du Client", + "contacts": "Contacts", + "edit_openid_connect_client": "Modifier le Client OpenID Connect", + "introspection": "Introspection", + "jarm": "JARM", + "logout_uris": "URIs de Déconnexion", + "openid_connect_clients": "Clients OpenID Connect", + "other_advanced": "Autres Paramètres Avancés", + "other_uris": "Autres URIs", + "par": "PAR", + "redirect_uris": "URIs de Redirection", + "scopes_and_grants": "Scopes et Autorisations", + "token_endpoint_auth": "Authentification du Point de Terminaison de Jeton", + "token_settings": "Paramètres des Jetons", + "uma": "UMA", + "uris": "URIs", + "basic_info": "Informations de Base", + "advanced": "Avancé" }, "links": { "support": "https://support.gluu.org/" diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index 2638443aea..972e765ab1 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -329,6 +329,7 @@ "redirectUrisRegex": "Expressão regular de redirecionamento", "scopes": "Escopos", "sort_by": "Ordenar Por", + "sort_order": "Ordem", "requestUris": "URIs de solicitação", "defaultAcrValues": "ACR padrão", "authorizedAcrValues": "ACRs autorizados", @@ -654,7 +655,49 @@ "skip_defined_password_validation": "Pular Validação de Senha Definida", "auiPolicyStoreUrl": "Repositório de políticas remotas da interface de administração", "configApiPolicyStoreUrl": "Política de armazenamento da API de configuração", - "cedarlingPolicyStoreRetrievalPoint": "Ponto de Recuperação de Políticas" + "cedarlingPolicyStoreRetrievalPoint": "Ponto de Recuperação de Políticas", + "access_token_as_jwt": "Token de Acesso como JWT", + "access_token_lifetime": "Tempo de Vida do Token de Acesso", + "additional_audience": "Audiência Adicional", + "authorized_acr_values": "Valores ACR Autorizados", + "authorized_origins": "Origens Autorizadas", + "backchannel_client_notification_endpoint": "Endpoint de Notificação do Cliente Backchannel", + "backchannel_logout_session_required": "Sessão de Logout Backchannel Requerida", + "backchannel_logout_uris": "URIs de Logout Backchannel", + "backchannel_token_delivery_mode": "Modo de Entrega de Token Backchannel", + "backchannel_user_code_parameter": "Parâmetro de Código de Usuário Backchannel", + "claim_redirect_uris": "URIs de Redirecionamento de Claim", + "client_uri": "URI do Cliente", + "default_acr_values": "Valores ACR Padrão", + "default_max_age": "Idade Máxima Padrão", + "default_prompt_login": "Login com Prompt Padrão", + "dpop_bound_access_token": "Token de Acesso Vinculado a DPoP", + "expirationDate": "Data de Expiração", + "front_channel_logout_session_required": "Sessão de Logout Front Channel Requerida", + "front_channel_logout_uri": "URI de Logout Front Channel", + "include_claims_in_id_token": "Incluir Claims no ID Token", + "initiate_login_uri": "URI de Início de Login", + "jans_auth_enc_resp_alg": "Algoritmo de Criptografia JARM", + "jans_auth_enc_resp_enc": "Codificação de Criptografia JARM", + "jans_auth_signed_resp_alg": "Algoritmo de Assinatura JARM", + "par_lifetime": "Tempo de Vida PAR", + "redirect_uris_regex": "Regex de URIs de Redirecionamento", + "refresh_token_lifetime": "Tempo de Vida do Token de Atualização", + "request_uris": "URIs de Solicitação", + "require_par": "Exigir PAR", + "require_pkce": "Exigir PKCE", + "ropc_scripts": "Scripts ROPC", + "rpt_as_jwt": "RPT como JWT", + "rpt_claims_scripts": "Scripts de Claims RPT", + "sector_identifier_uri": "URI do Identificador de Setor", + "software_statement": "Declaração de Software", + "software_version": "Versão do Software", + "spontaneous_scope_scripts": "Scripts de Escopo Espontâneo", + "tos_uri": "URI dos Termos de Serviço", + "update_token_scripts": "Scripts de Atualização de Token", + "userinfo_encrypted_response_alg": "Algoritmo de Criptografia UserInfo", + "userinfo_encrypted_response_enc": "Codificação de Criptografia UserInfo", + "userinfo_signed_response_alg": "Algoritmo de Assinatura UserInfo" }, "messages": { "add_permission": "Adicionar permissão", @@ -765,7 +808,11 @@ "user_updated_successfully": "Usuário atualizado com sucesso", "user_deleted_successfully": "Usuário excluído com sucesso", "password_changed_successfully": "Senha alterada com sucesso", - "device_deleted_successfully": "Dispositivo excluído com sucesso" + "device_deleted_successfully": "Dispositivo excluído com sucesso", + "no_claims": "Nenhum claim selecionado", + "no_grant_types": "Nenhum tipo de concessão selecionado", + "no_response_types": "Nenhum tipo de resposta selecionado", + "no_scopes": "Nenhum escopo selecionado" }, "errors": { "attribute_create_failed": "Erro ao criar atributo", @@ -848,7 +895,11 @@ "expires_before": "Expira antes da data", "charMoreThan512": "caracteres acima do limite (máximo 512)", "charLessThan10": "caracteres necessários (mínimo 10)", - "more": " mais" + "more": " mais", + "add_claims": "Adicionar claims", + "add_email": "Adicionar email", + "search_clients": "Pesquisar clientes...", + "search_scopes": "Pesquisar escopos..." }, "titles": { "acrs": "ACRs", @@ -918,7 +969,27 @@ "newPermission": "Nova Permissão", "addingNewApiPermission": "Adicionando nova permissão de API", "followingPermissionRequiredToBeAdded": "É necessária a permissão seguinte para ser adicionado!", - "authentication": "Autenticação" + "authentication": "Autenticação", + "add_openid_connect_client": "Adicionar Cliente OpenID Connect", + "ciba": "CIBA", + "client_details": "Detalhes do Cliente", + "contacts": "Contatos", + "edit_openid_connect_client": "Editar Cliente OpenID Connect", + "introspection": "Introspecção", + "jarm": "JARM", + "logout_uris": "URIs de Logout", + "openid_connect_clients": "Clientes OpenID Connect", + "other_advanced": "Outras Configurações Avançadas", + "other_uris": "Outras URIs", + "par": "PAR", + "redirect_uris": "URIs de Redirecionamento", + "scopes_and_grants": "Escopos e Concessões", + "token_endpoint_auth": "Autenticação do Endpoint de Token", + "token_settings": "Configurações de Tokens", + "uma": "UMA", + "uris": "URIs", + "basic_info": "Informações Básicas", + "advanced": "Avançado" }, "links": { "support": "https://support.gluu.org/" diff --git a/admin-ui/plugins/auth-server/__tests__/components/ClientAddPage.test.js b/admin-ui/plugins/auth-server/__tests__/components/ClientAddPage.test.js deleted file mode 100644 index 29c14c1c39..0000000000 --- a/admin-ui/plugins/auth-server/__tests__/components/ClientAddPage.test.js +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import ClientAddPage from 'Plugins/auth-server/components/Clients/ClientAddPage' -import { Provider } from 'react-redux' -import { reducer as initReducer } from 'Redux/features/initSlice' -import oidcDiscoveryReducer from 'Redux/features/oidcDiscoverySlice' -import { reducer as scopeReducer } from 'Plugins/auth-server/redux/features/scopeSlice' -import { reducer as umaResourceReducer } from 'Plugins/auth-server/redux/features/umaResourceSlice' -import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper.test' -import { combineReducers, configureStore } from '@reduxjs/toolkit' -const permissions = [ - 'https://jans.io/oauth/config/openid/clients.readonly', - 'https://jans.io/oauth/config/openid/clients.write', - 'https://jans.io/oauth/config/openid/clients.delete', -] -const INIT_STATE = { - permissions: permissions, -} -const INIT_SCPOPES_STATE = { - items: [ - { - id: 'https://jans.io/oauth/config/smtp.delete', - scopeType: 'oauth', - dn: 'inum=1800.85A227,ou=scopes,o=jans', - inum: '1800.85A227', - displayName: 'Config API scope https://jans.io/oauth/config/smtp.delete', - description: 'Delete SMTP related information', - defaultScope: false, - attributes: { showInConfigurationEndpoint: false }, - umaType: false, - tableData: { id: 0 }, - }, - ], - item: {}, - loading: false, -} - -const store = configureStore({ - reducer: combineReducers({ - authReducer: (state = INIT_STATE) => state, - oidcReducer: (state = INIT_SCPOPES_STATE) => state, - umaResourceReducer, - scopeReducer, - initReducer, - oidcDiscoveryReducer, - noReducer: (state = {}) => state, - }), -}) - -const Wrapper = ({ children }) => ( - - {children} - -) - -it('Should render client add page properly', () => { - render(, { wrapper: Wrapper }) - screen.getByText(/Basic/) - screen.getByText(/Advanced/) - screen.getByText('Encription / Signing', { exact: false }) - screen.getByText(/Client Scripts/) - screen.getByText('Client Name', { exact: false }) - screen.getByText('description', { exact: false }) -}) diff --git a/admin-ui/plugins/auth-server/__tests__/components/ClientDetailPage.test.js b/admin-ui/plugins/auth-server/__tests__/components/ClientDetailPage.test.js deleted file mode 100644 index e79b9ea5a9..0000000000 --- a/admin-ui/plugins/auth-server/__tests__/components/ClientDetailPage.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import ClientDetailPage from 'Plugins/auth-server/components/Clients/ClientDetailPage' -import clients from './clients.test' -import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper.test' - -const Wrapper = ({ children }) => {children} -const permissions = [ - 'https://jans.io/oauth/config/openid/clients.readonly', - 'https://jans.io/oauth/config/openid/clients.write', - 'https://jans.io/oauth/config/openid/clients.delete', -] -const row = clients[0] -it('Should show client details properly', () => { - render(, { - wrapper: Wrapper, - }) - screen.getByText(/1801.a0beec01-617b-4607-8a35-3e46ac43deb5/) - screen.getByText('Jans Config Api Client') - screen.getByText('pairwise') - screen.getByText(/Name/) - - expect(screen.getByText(/Scopes/i)).toBeInTheDocument() - expect(screen.getByText(/Client Id/i)).toBeInTheDocument() - expect(screen.getByText(/Description/i)).toBeInTheDocument() - expect(screen.getByText(/Application type/i)).toBeInTheDocument() - expect(screen.getByText(/Status/i)).toBeInTheDocument() - expect(screen.getByText(/Grants/i)).toBeInTheDocument() - expect(screen.getByText(/Login URIs/i)).toBeInTheDocument() - expect(screen.getByText(/Authentication method for the Token Endpoint/i)).toBeInTheDocument() - expect(screen.getByText(/Logout Redirect URIs/i)).toBeInTheDocument() - expect(screen.getByText(/Response types/i)).toBeInTheDocument() - expect(screen.getByText(/Supress authorization/i)).toBeInTheDocument() - expect(screen.getByText(/id_token subject type/i)).toBeInTheDocument() -}) diff --git a/admin-ui/plugins/auth-server/__tests__/components/ClientEditPage.test.js b/admin-ui/plugins/auth-server/__tests__/components/ClientEditPage.test.js deleted file mode 100644 index d7b25488b0..0000000000 --- a/admin-ui/plugins/auth-server/__tests__/components/ClientEditPage.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import ClientEditPage from 'Plugins/auth-server/components/Clients/ClientEditPage' -import { Provider } from 'react-redux' -import clients from './clients.test' -import { reducer as initReducer } from 'Redux/features/initSlice' -import oidcDiscoveryReducer from 'Redux/features/oidcDiscoverySlice' -import { reducer as scopeReducer } from 'Plugins/auth-server/redux/features/scopeSlice' -import { reducer as umaResourceReducer } from 'Plugins/auth-server/redux/features/umaResourceSlice' -import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper.test' -import { combineReducers, configureStore } from '@reduxjs/toolkit' -const permissions = [ - 'https://jans.io/oauth/config/openid/clients.readonly', - 'https://jans.io/oauth/config/openid/clients.write', - 'https://jans.io/oauth/config/openid/clients.delete', -] -const INIT_STATE = { - permissions: permissions, -} - -const INIT_CLIENTS_STATE = { - items: [clients[0]], - item: clients[0], - saveOperationFlag: false, - errorInSaveOperationFlag: false, - loading: false, -} - -const store = configureStore({ - reducer: combineReducers({ - authReducer: (state = INIT_STATE) => state, - oidcReducer: (state = INIT_CLIENTS_STATE) => state, - umaResourceReducer, - scopeReducer, - initReducer, - oidcDiscoveryReducer: (state = { loading: false }) => state, - noReducer: (state = {}) => state, - }), -}) - -const Wrapper = ({ children }) => ( - - {children} - -) - -it('Should the client edit page properly', () => { - render(, { wrapper: Wrapper }) - screen.getByText(/Basic/) - screen.getByText(/Advanced/) - screen.getByText('Encription / Signing', { exact: false }) - screen.getByText(/Client Scripts/) - screen.getByText('Client Name', { exact: false }) - screen.getByText('inum', { exact: false }) -}) diff --git a/admin-ui/plugins/auth-server/__tests__/components/ClientListPage.test.js b/admin-ui/plugins/auth-server/__tests__/components/ClientListPage.test.js deleted file mode 100644 index 35eaa27a6d..0000000000 --- a/admin-ui/plugins/auth-server/__tests__/components/ClientListPage.test.js +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import ClientListPage from 'Plugins/auth-server/components/Clients/ClientListPage' -import { Provider } from 'react-redux' -import clients from './clients.test' -import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper.test' -import { combineReducers, configureStore } from '@reduxjs/toolkit' - -const permissions = [ - 'https://jans.io/oauth/config/openid/clients.readonly', - 'https://jans.io/oauth/config/openid/clients.write', - 'https://jans.io/oauth/config/openid/clients.delete', -] -const INIT_STATE = { - permissions: permissions, -} - -const INIT_CLIENTS_STATE = { - items: [clients[0]], - item: {}, - view: false, - loading: false, - totalItems: 0, -} - -const INIT_SCPOPES_STATE = { - items: [ - { - id: 'https://jans.io/oauth/config/smtp.delete', - scopeType: 'oauth', - dn: 'inum=1800.85A227,ou=scopes,o=jans', - inum: '1800.85A227', - displayName: 'Config API scope https://jans.io/oauth/config/smtp.delete', - description: 'Delete SMTP related information', - defaultScope: false, - attributes: { showInConfigurationEndpoint: false }, - umaType: false, - tableData: { id: 0 }, - totalItems: 0, - }, - ], - item: {}, - loading: false, -} - -const store = configureStore({ - reducer: combineReducers({ - authReducer: (state = INIT_STATE) => state, - oidcReducer: (state = INIT_CLIENTS_STATE) => state, - scopeReducer: (state = INIT_SCPOPES_STATE) => state, - noReducer: (state = {}) => state, - }), -}) - -const Wrapper = ({ children }) => ( - - {children} - -) - -it('Should show the sidebar properly', async () => { - const { container } = render(, { wrapper: Wrapper }) - - const addClientButton = container.querySelector(`button[aria-label="Add Client"]`) - expect(addClientButton).toBeInTheDocument() - - fireEvent.mouseOver(addClientButton) - - await waitFor(() => screen.getByText('Add Client')) - - const refreshClientButton = container.querySelector(`button[aria-label="Refresh data"]`) - expect(refreshClientButton).toBeInTheDocument() - - fireEvent.mouseOver(refreshClientButton) - - await waitFor(() => screen.getByText('Refresh data')) - - const inputSearch = container.querySelector(`input[placeholder="Search"]`) - expect(inputSearch).toBeInTheDocument() -}) diff --git a/admin-ui/plugins/auth-server/__tests__/components/ClientWizardForm.test.js b/admin-ui/plugins/auth-server/__tests__/components/ClientWizardForm.test.js deleted file mode 100644 index 98d02dff38..0000000000 --- a/admin-ui/plugins/auth-server/__tests__/components/ClientWizardForm.test.js +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react' -import { fireEvent, render, screen } from '@testing-library/react' -import ClientWizardForm from 'Plugins/auth-server/components/Clients/ClientWizardForm' -import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper.test' -import { Provider } from 'react-redux' -import { combineReducers, configureStore } from '@reduxjs/toolkit' -import clients from './clients.test' -import { reducer as scopeReducer } from 'Plugins/auth-server/redux/features/scopeSlice' -import oidcDiscoveryReducer from 'Redux/features/oidcDiscoverySlice' -import { t } from 'i18next' - -const initReducer = { - scripts: [], -} - -const store = configureStore({ - reducer: combineReducers({ - initReducer: (state = initReducer) => state, - scopeReducer: scopeReducer, - oidcDiscoveryReducer: oidcDiscoveryReducer, - }), -}) - -const Wrapper = ({ children }) => ( - - {children} - -) - -function hasInputValue(e, inputValue) { - return screen.getByDisplayValue(inputValue) === e -} - -describe('Should render client add/edit form properly', () => { - test('render and update input fields', async () => { - const { container } = render(, { - wrapper: Wrapper, - }) - - screen.debug(container, Infinity) - const clientNameInput = screen.getByTestId('clientName') - fireEvent.change(clientNameInput, { target: { value: 'test' } }) - expect(hasInputValue(clientNameInput, 'test')).toBe(true) - - const applicationType = screen.getByTestId('applicationType') - fireEvent.change(applicationType, { target: { value: 'web' } }) - expect(hasInputValue(applicationType, 'web')).toBe(true) - - const redirectUris = screen.getByTestId('new_entry') - fireEvent.change(redirectUris, { target: { value: 'www.gluu.org' } }) - const addButton = screen.getByTestId(t('actions.add')) - fireEvent.click(addButton) - screen.debug(await screen.findByText('www.gluu.org'), Infinity) - - fireEvent.change(redirectUris, { target: { value: 'www.google.com' } }) - fireEvent.click(addButton) - screen.debug(await screen.findByText('www.google.com'), Infinity) - }) - - test('should display tokens tab input fields', async () => { - //* By Default Basic Tab is Active - const { container } = render(, { - wrapper: Wrapper, - }) - - screen.debug(container, Infinity) - const tokensTab = screen.getByTestId('Tokens') - fireEvent.click(tokensTab) - - expect(await screen.findByText(/Access token type/i)).toBeVisible() - expect(await screen.findByText(/Default max authn age/i)).toBeVisible() - }) - - test('persist input values if tabs are switched', async () => { - render(, { - wrapper: Wrapper, - }) - - const tokensTab = screen.getByTestId('Tokens') - // Switch to Tokens Tab - fireEvent.click(tokensTab) - - const accessTokenLifetimeInput = screen.getByTestId('refreshTokenLifetime') - // Change value of Access token lifetime field on Tokens Tab - fireEvent.change(accessTokenLifetimeInput, { target: { value: 22 } }) - expect(await screen.getByDisplayValue('22')).toBeVisible() - - const logoutTab = screen.getByTestId('Logout') - // Switch to Logout Tab - fireEvent.click(logoutTab) - - const cibaParUmaTab = screen.getByTestId('CIBA/PAR/UMA') - // Switch to CIBA / PAR / UMA Tab - fireEvent.click(cibaParUmaTab) - - const claimsRedirectUris = screen.getByTestId('new_entry') - fireEvent.change(claimsRedirectUris, { target: { value: 'www.claims_gluu.org' } }) - // Update value of Claims redirect URI field under UMA - const addButton = screen.getByTestId(t('actions.add')) - fireEvent.click(addButton) - screen.debug(await screen.findByText('www.claims_gluu.org'), Infinity) - - // Switch back to Tokens Tab & checks if the modified value exists - fireEvent.click(tokensTab) - expect(await screen.getByDisplayValue('22')).toBeVisible() - - // Switch back to CIBA / PAR / UMA Tab & checks if the modified value exists - fireEvent.click(cibaParUmaTab) - expect(await screen.getByText('www.claims_gluu.org')).toBeInTheDocument() - }) -}) diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientActiveTokenDetailPage.js b/admin-ui/plugins/auth-server/components/Clients/ClientActiveTokenDetailPage.js deleted file mode 100644 index caa7a4d5ae..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientActiveTokenDetailPage.js +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react' -import { Container, Row, Col } from 'Components' -import GluuFormDetailRow from 'Routes/Apps/Gluu/GluuFormDetailRow' -import PropTypes from 'prop-types' -import customColors from '@/customColors' - -const ClientActiveTokenDetailPage = ({ row }) => { - const { rowData } = row - const DOC_SECTION = 'user' - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - ) -} - -ClientActiveTokenDetailPage.propTypes = { - row: PropTypes.any, -} -export default ClientActiveTokenDetailPage diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientActiveTokens.js b/admin-ui/plugins/auth-server/components/Clients/ClientActiveTokens.js deleted file mode 100644 index 278641354d..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientActiveTokens.js +++ /dev/null @@ -1,366 +0,0 @@ -import React, { useState, useEffect, useContext, useCallback } from 'react' -import MaterialTable from '@material-table/core' -import { Card, CardBody } from '../../../../app/components' -import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' -import GluuViewWrapper from 'Routes/Apps/Gluu/GluuViewWrapper' -import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' -import { DatePicker } from '@mui/x-date-pickers/DatePicker' -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' -import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' -import { ThemeContext } from 'Context/theme/themeContext' -import { Box, Grid, MenuItem, Paper, TablePagination, TextField } from '@mui/material' - -import { useDispatch, useSelector } from 'react-redux' -import { useTranslation } from 'react-i18next' -import getThemeColor from 'Context/theme/config' -import moment from 'moment' -import { deleteClientToken, getTokenByClient } from '../../redux/features/oidcSlice' -import ClientActiveTokenDetailPage from './ClientActiveTokenDetailPage' -import { Button } from 'Components' -import dayjs from 'dayjs' -import PropTypes from 'prop-types' -import { Button as MaterialButton } from '@mui/material' -import FilterListIcon from '@mui/icons-material/FilterList' -import GetAppIcon from '@mui/icons-material/GetApp' -import customColors from '@/customColors' -function ClientActiveTokens({ client }) { - const myActions = [] - const options = {} - const { t } = useTranslation() - const dispatch = useDispatch() - const theme = useContext(ThemeContext) - const selectedTheme = theme.state.theme - const themeColors = getThemeColor(selectedTheme) - const bgThemeColor = { background: themeColors.background } - const [data, setData] = useState([]) - - const [showFilter, setShowFilter] = useState(false) - const [searchFilter, setSearchFilter] = useState('expirationDate') - - const [pageNumber, setPageNumber] = useState(0) - const [limit, setLimit] = useState(10) - const [pattern, setPattern] = useState({ - dateAfter: null, - dateBefore: null, - }) - - const loading = useSelector((state) => state.oidcReducer.isTokenLoading) - const updatedToken = useSelector((state) => state.oidcReducer.tokens) - - const { totalItems } = useSelector((state) => state.oidcReducer.tokens) - - const onPageChangeClick = (page) => { - const startCount = page * limit - setPageNumber(page) - let conditionquery = `clnId=${client.inum}` - if (pattern.dateAfter && pattern.dateBefore) { - conditionquery += `,${searchFilter}>${dayjs(pattern.dateAfter).format('YYYY-MM-DD')}` - conditionquery += `,${searchFilter}<${dayjs(pattern.dateBefore).format('YYYY-MM-DD')}` - } - - getTokens(parseInt(startCount), limit, `${conditionquery}`) - } - - const onRowCountChangeClick = (count) => { - setPageNumber(0) - setLimit(count) - getTokens(0, limit, `clnId=${client.inum}`) - } - - const PaperContainer = useCallback((props) => , []) - - const PaginationWrapper = useCallback( - () => ( - { - onPageChangeClick(page) - }} - rowsPerPage={limit} - onRowsPerPageChange={(prop, count) => onRowCountChangeClick(count.props.value)} - /> - ), - [pageNumber, totalItems, onPageChangeClick, limit, onRowCountChangeClick], - ) - - const DetailPanel = useCallback((rowData) => { - return - }, []) - - const handleSearch = () => { - const startCount = pageNumber * limit - let conditionquery = `clnId=${client.inum}` - if (pattern.dateAfter && pattern.dateBefore) { - conditionquery += `,${searchFilter}>${dayjs(pattern.dateAfter).format('YYYY-MM-DD')}` - conditionquery += `,${searchFilter}<${dayjs(pattern.dateBefore).format('YYYY-MM-DD')}` - } - getTokens(startCount, limit, conditionquery) - } - - const handleClear = () => { - setPattern({ dateAfter: null, dateBefore: null }) - const startCount = pageNumber * limit - const conditionquery = `clnId=${client.inum}` - getTokens(startCount, limit, conditionquery) - } - - const handleRevokeToken = async (oldData) => { - await dispatch(deleteClientToken({ tknCode: oldData.tokenCode })) - const startCount = pageNumber * limit - getTokens(startCount, limit, `clnId=${client.inum}`) - } - - const getTokens = async (page, limit, fieldValuePair) => { - options['startIndex'] = parseInt(page) - options['limit'] = limit - options['fieldValuePair'] = fieldValuePair - await dispatch(getTokenByClient({ action: options })) - } - - // Convert data array into CSV string - const convertToCSV = (data) => { - const keys = Object.keys(data[0]).filter((item) => item !== 'attributes') // Get the headers from the first object - const header = keys - .filter((item) => item !== 'attributes') - .map((item) => item.replace(/-/g, ' ').toUpperCase()) - .join(',') // Create a comma-separated string of headers - - const updateData = data.map((row) => { - return { - scope: row.scope, - deletable: row.deletable, - grantType: row.grantType, - expirationDate: row.expirationDate, - creationDate: row.creationDate, - tokenType: row.tokenType, - } - }) - - const rows = updateData.map((row) => { - return keys.map((key) => row[key]).join(',') // Create a comma-separated string for each row - }) - - return [header, ...rows].join('\n') // Combine header and rows, separated by newlines - } - - // Function to handle file download - const downloadCSV = () => { - const csv = convertToCSV(data) - const blob = new Blob([csv], { type: 'text/csv' }) // Create a blob with the CSV data - const url = URL.createObjectURL(blob) - - // Create a temporary link element to trigger the download - const link = document.createElement('a') - link.href = url - link.setAttribute('download', `client-tokens.csv`) // Set the file name - document.body.appendChild(link) - link.click() - document.body.removeChild(link) // Clean up - } - - useEffect(() => { - getTokens(0, limit, `clnId=${client.inum}`) - }, []) - - useEffect(() => { - if (updatedToken && Object.keys(updatedToken).length > 0) { - const result = updatedToken?.items?.length - ? updatedToken?.items - .map((item) => { - return { - tokenType: item.tokenType, - scope: item.scope, - deletable: item.deletable, - attributes: item.attributes, - grantType: item.grantType, - expirationDate: moment(item.expirationDate).format('YYYY/DD/MM HH:mm:ss'), - creationDate: moment(item.creationDate).format('YYYY/DD/MM HH:mm:ss'), - } - }) - .sort((a, b) => { - // Compare based on creationDate - return moment(b.creationDate, 'YYYY/DD/MM HH:mm:ss').diff( - moment(a.creationDate, 'YYYY/DD/MM HH:mm:ss'), - ) - }) - : [] - setData(result) - } - }, [updatedToken]) - - return ( - - - - - - - } - onClick={() => setShowFilter(!showFilter)} - > - {t('titles.filters')} - - } sx={{ ml: 2 }}> - {t('titles.export_csv')} - - - - {showFilter && ( - - - - { - setSearchFilter(e.target.value) - }} - variant="outlined" - style={{ width: 150, marginTop: -3 }} - > - {t('titles.expiration_date')} - {t('titles.creation_date')} - - - - {(searchFilter === 'expirationDate' || searchFilter === 'creationDate') && ( - - - { - setPattern({ ...pattern, dateAfter: val }) - }} - renderInput={(params) => } - /> - - - )} - {(searchFilter === 'expirationDate' || searchFilter === 'creationDate') && ( - - - { - setPattern({ ...pattern, dateBefore: val }) - }} - renderInput={(params) => } - /> - - - )} - - - - - - - - - - - )} - - - ({ - backgroundColor: rowData.enabled ? customColors.logo : customColors.white, - }), - headerStyle: { - ...applicationStyle.tableHeaderStyle, - ...bgThemeColor, - }, - actionsColumnIndex: -1, - }} - editable={{ - isDeleteHidden: () => false, - onRowDelete: (oldData) => { - return new Promise((resolve) => { - handleRevokeToken(oldData) - resolve() - }) - }, - }} - detailPanel={DetailPanel} - /> - - - - - ) -} - -ClientActiveTokens.propTypes = { - client: PropTypes.any, -} -export default ClientActiveTokens diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientAddPage.js b/admin-ui/plugins/auth-server/components/Clients/ClientAddPage.js deleted file mode 100644 index c35491c1a5..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientAddPage.js +++ /dev/null @@ -1,101 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import ClientWizardForm from './ClientWizardForm' -import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' -import { useNavigate } from 'react-router-dom' -import { addNewClientAction } from 'Plugins/auth-server/redux/features/oidcSlice' -import { getOidcDiscovery } from 'Redux/features/oidcDiscoverySlice' -import { emptyScopes } from 'Plugins/auth-server/redux/features/scopeSlice' -import { getScripts } from 'Redux/features/initSlice' -import { buildPayload } from 'Utils/PermChecker' - -function ClientAddPage() { - const permissions = useSelector((state) => state.authReducer.permissions) - let scopes = useSelector((state) => state.scopeReducer.items) - const scripts = useSelector((state) => state.initReducer.scripts) - const loading = useSelector((state) => state.oidcReducer.loading) - const oidcConfiguration = useSelector((state) => state.oidcDiscoveryReducer.configuration) - const saveOperationFlag = useSelector((state) => state.oidcReducer.saveOperationFlag) - const errorInSaveOperationFlag = useSelector( - (state) => state.oidcReducer.errorInSaveOperationFlag, - ) - const [modifiedFields, setModifiedFields] = useState({}) - - const dispatch = useDispatch() - - const userAction = {} - const options = {} - options['limit'] = parseInt(100000) - const navigate = useNavigate() - useEffect(() => { - dispatch(emptyScopes()) - buildPayload(userAction, '', options) - if (scripts.length < 1) { - dispatch(getScripts({ action: userAction })) - } - dispatch(getOidcDiscovery()) - }, []) - - useEffect(() => { - if (saveOperationFlag && !errorInSaveOperationFlag) navigate('/auth-server/clients') - }, [saveOperationFlag]) - - scopes = scopes?.map((item) => ({ dn: item.dn, name: item.id })) - function handleSubmit(data) { - if (data) { - const postBody = {} - postBody['client'] = data - buildPayload(userAction, data.action_message, postBody) - delete userAction.action_data.client.action_message - dispatch(addNewClientAction({ action: userAction })) - } - } - const clientData = { - frontChannelLogoutSessionRequired: false, - includeClaimsInIdToken: false, - redirectUris: [], - claimRedirectUris: [], - responseTypes: [], - grantTypes: [], - postLogoutRedirectUris: [], - oxAuthScopes: [], - trustedClient: false, - persistClientAuthorizations: false, - customAttributes: [], - customObjectClasses: [], - rptAsJwt: false, - accessTokenAsJwt: false, - backchannelUserCodeParameter: false, - disabled: false, - attributes: { - tlsClientAuthSubjectDn: null, - runIntrospectionScriptBeforeJwtCreation: false, - keepClientAuthorizationAfterExpiration: false, - allowSpontaneousScopes: false, - backchannelLogoutSessionRequired: false, - backchannelLogoutUri: [], - rptClaimsScripts: [], - consentGatheringScripts: [], - spontaneousScopeScriptDns: [], - introspectionScripts: [], - postAuthnScripts: [], - additionalAudience: [], - }, - } - return ( - - - - ) -} - -export default ClientAddPage diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientAddPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientAddPage.tsx new file mode 100644 index 0000000000..e2aecd17c0 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/ClientAddPage.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' +import SetTitle from 'Utils/SetTitle' +import { useClientActions, useCreateClient } from './hooks' +import ClientForm from './components/ClientForm' +import type { ClientFormValues, ModifiedFields, EMPTY_CLIENT } from './types' +import { EMPTY_CLIENT as emptyClient } from './types' + +const ClientAddPage: React.FC = () => { + const { t } = useTranslation() + const { logClientCreation, navigateToClientList } = useClientActions() + + const handleSuccess = useCallback(() => { + navigateToClientList() + }, [navigateToClientList]) + + const createClient = useCreateClient(handleSuccess) + + const handleSubmit = useCallback( + async (values: ClientFormValues, message: string, modifiedFields: ModifiedFields) => { + try { + const result = await createClient.mutateAsync({ data: values }) + if (result) { + await logClientCreation(result, message, modifiedFields) + } + } catch (error) { + console.error('Error creating client:', error) + } + }, + [createClient, logClientCreation], + ) + + SetTitle(t('titles.add_openid_connect_client')) + + return ( + + + + ) +} + +export default ClientAddPage diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientAdvancedPanel.js b/admin-ui/plugins/auth-server/components/Clients/ClientAdvancedPanel.js deleted file mode 100644 index 87ce861109..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientAdvancedPanel.js +++ /dev/null @@ -1,284 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { Col, Container, FormGroup } from 'Components' -import GluuBooleanSelectBox from 'Routes/Apps/Gluu/GluuBooleanSelectBox' -import GluuLabel from 'Routes/Apps/Gluu/GluuLabel' -import GluuTypeAheadForDn from 'Routes/Apps/Gluu/GluuTypeAheadForDn' -import GluuToogleRow from 'Routes/Apps/Gluu/GluuToogleRow' -import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' -import GluuTypeAhead from 'Routes/Apps/Gluu/GluuTypeAhead' -import GluuTypeAheadWithAdd from 'Routes/Apps/Gluu/GluuTypeAheadWithAdd' -import { useTranslation } from 'react-i18next' -import ClientShowSpontaneousScopes from './ClientShowSpontaneousScopes' -import GluuTooltip from 'Routes/Apps/Gluu/GluuTooltip' -import { DateTimePicker, LocalizationProvider } from '@mui/x-date-pickers' -import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' -import dayjs from 'dayjs' -import PropTypes from 'prop-types' -const DOC_CATEGORY = 'openid_client' - -function ClientAdvancedPanel({ - client, - scripts, - formik, - viewOnly, - modifiedFields, - setModifiedFields, -}) { - const { t } = useTranslation() - const request_uri_id = 'request_uri_id' - const requestUris = [] - - const [expirable] = useState(formik.values.expirationDate ? formik.values.expirationDate : false) - const [scopesModal, setScopesModal] = useState(false) - const [expirationDate, setExpirationDate] = useState(expirable ? dayjs(expirable) : undefined) - const handler = () => { - setScopesModal(!scopesModal) - } - - const filteredScripts = scripts - ?.filter((item) => item.scriptType == 'person_authentication') - ?.filter((item) => item.enabled) - ?.map((item) => item.name) - function uriValidator(uri) { - return uri - } - function getMapping(partial, total) { - if (!partial) { - partial = [] - } - return total.filter((item) => partial.includes(item)) - } - - useEffect(() => { - // Listen for changes on expirable input switch - if (!formik.values.expirable) { - formik.setFieldValue('expirationDate', null) - setExpirationDate(null) - } - }, [formik.values.expirable]) - - return ( - - - { - formik.setFieldValue('persistClientAuthorizations', e.target.checked) - setModifiedFields({ - ...modifiedFields, - 'Persist Client Authorizations': e.target.checked, - }) - }} - label="fields.persist_client_authorizations" - value={formik.values.persistClientAuthorizations} - doc_category={DOC_CATEGORY} - disabled={viewOnly} - /> - { - setModifiedFields({ - ...modifiedFields, - 'Allow Spontaneous Scopes': e.target.value, - }) - }} - /> - { - setModifiedFields({ - ...modifiedFields, - 'Spontaneous Scopes': selected, - }) - }} - > - - - {client.inum && ( - - - - - View Current - - - - )} - - { - setModifiedFields({ - ...modifiedFields, - 'Initiate Login Uri': e.target.value, - }) - }} - /> - { - setModifiedFields({ - ...modifiedFields, - 'Request Uris': items, - }) - }} - > - { - setModifiedFields({ - ...modifiedFields, - 'Default Acr Values': selected, - }) - }} - > - { - setModifiedFields({ - ...modifiedFields, - 'Authorized Acr Values': selected, - }) - }} - > - { - formik.setFieldValue('attributes.jansDefaultPromptLogin', e.target.checked) - setModifiedFields({ - ...modifiedFields, - 'Default Prompt Login': e.target.checked, - }) - }} - doc_category={DOC_CATEGORY} - disabled={viewOnly} - /> - { - setModifiedFields({ - ...modifiedFields, - 'TLS Client Auth Subject Dn': e.target.value, - }) - }} - /> - - - - { - setModifiedFields({ - ...modifiedFields, - 'Is Expirable Client': e.target.checked, - }) - }} - /> - - - {formik.values.expirable ? ( - - - - { - formik.setFieldValue('expirationDate', new Date(date)) - setExpirationDate(date) - setModifiedFields({ - ...modifiedFields, - 'Expiration Date': new Date(date).toDateString(), - }) - }} - /> - - - - ) : null} - - - - ) -} - -export default ClientAdvancedPanel -ClientAdvancedPanel.propTypes = { - formik: PropTypes.any, - client: PropTypes.any, - scripts: PropTypes.any, - viewOnly: PropTypes.bool, - modifiedFields: PropTypes.any, - setModifiedFields: PropTypes.func, -} diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientBasicPanel.js b/admin-ui/plugins/auth-server/components/Clients/ClientBasicPanel.js deleted file mode 100644 index 3da445342e..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientBasicPanel.js +++ /dev/null @@ -1,440 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react' -import { Col, Container, FormGroup, Input, InputGroup, CustomInput } from 'Components' -import IconButton from '@mui/material/IconButton' -import Visibility from '@mui/icons-material/Visibility' -import VisibilityOff from '@mui/icons-material/VisibilityOff' -import GluuLabel from 'Routes/Apps/Gluu/GluuLabel' -import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' -import GluuToogleRow from 'Routes/Apps/Gluu/GluuToogleRow' -import GluuTypeAhead from 'Routes/Apps/Gluu/GluuTypeAhead' -import GluuTypeAheadForDn from 'Routes/Apps/Gluu/GluuTypeAheadForDn' -import GluuTypeAheadWithAdd from 'Routes/Apps/Gluu/GluuTypeAheadWithAdd' -import GluuTooltip from 'Routes/Apps/Gluu/GluuTooltip' -import GluuSelectRow from 'Routes/Apps/Gluu/GluuSelectRow' -import { useTranslation } from 'react-i18next' -import { getClientScopeByInum } from '../../../../app/utils/Util' -import { useDispatch, useSelector } from 'react-redux' -import { PER_PAGE_SCOPES } from '../../common/Constants' -import _debounce from 'lodash/debounce' -import PropTypes from 'prop-types' -import { - getScopes, - getClientScopes, - setClientSelectedScopes, -} from 'Plugins/auth-server/redux/features/scopeSlice' -import customColors from '@/customColors' -const DOC_CATEGORY = 'openid_client' - -const grantTypes = [ - 'authorization_code', - 'implicit', - 'refresh_token', - 'client_credentials', - 'password', - 'urn:ietf:params:oauth:grant-type:uma-ticket', -] - -const responseTypes = ['code', 'token', 'id_token'] -const uri_id = 'redirect_uri' - -const ClientBasicPanel = ({ - client, - scopes, - formik, - oidcConfiguration, - viewOnly, - modifiedFields, - setModifiedFields, -}) => { - const dispatch = useDispatch() - const totalItems = useSelector((state) => state.scopeReducer.totalItems) - const clientScopes = useSelector((state) => state.scopeReducer.clientScopes)?.map((item) => ({ - dn: item.dn, - name: item.id, - })) - const selectedClientScopes = useSelector((state) => state.scopeReducer.selectedClientScopes) - const isLoading = useSelector((state) => state.scopeReducer.loadingClientScopes) - const scopeLoading = useSelector((state) => state.scopeReducer.loading) - const clientScopeOptions = scopes?.filter((o1) => !clientScopes?.some((o2) => o1.dn === o2.dn)) - const scopeOptions = client?.scopes?.length ? clientScopeOptions : scopes - const { t } = useTranslation() - - const tokenEndpointAuthMethod = oidcConfiguration?.tokenEndpointAuthMethodsSupported || [] - - const [showClientSecret, setShowClientSecret] = useState(false) - const [userScopeAction] = useState({ - limit: PER_PAGE_SCOPES, - pattern: '', - startIndex: 0, - }) - - function uriValidator(uri) { - return uri - } - function handleClickShowClientSecret() { - setShowClientSecret(!showClientSecret) - } - - function handleMouseDownClientSecret(event) { - event.preventDefault() - } - - useEffect(() => { - const scopeInums = [] - if (client.inum) { - const userAction = {} - if (client?.scopes?.length) { - for (const scope of client.scopes) { - scopeInums.push(getClientScopeByInum(scope)) - } - } - userAction['pattern'] = scopeInums.join(',') - userAction['limit'] = PER_PAGE_SCOPES - dispatch(getClientScopes({ action: userAction })) - } - }, []) - - const handlePagination = () => { - userScopeAction['limit'] = PER_PAGE_SCOPES - userScopeAction['startIndex'] = 0 - if (!userScopeAction.pattern) { - delete userScopeAction.pattern - } - if (!userScopeAction.startIndex) { - delete userScopeAction.startIndex - } - if (totalItems + PER_PAGE_SCOPES > userScopeAction.limit) { - dispatch(getScopes({ action: userScopeAction })) - } - } - - const debounceFn = useCallback( - _debounce((query) => { - query && handleDebounceFn(query) - }, 500), - [], - ) - - function handleDebounceFn(inputValue) { - userScopeAction['pattern'] = inputValue - delete userScopeAction.startIndex - dispatch(getScopes({ action: userScopeAction })) - } - - const saveSelectedScopes = (scopes) => { - setModifiedFields({ - ...modifiedFields, - Scopes: scopes.map((scope) => scope.name), - }) - dispatch(setClientSelectedScopes(scopes)) - } - const defaultScopeValue = client?.scopes?.length ? clientScopes : [] - const scopeFieldValue = selectedClientScopes?.length ? selectedClientScopes : defaultScopeValue - - return ( - - {client.inum && ( - - - - - - - - - )} - { - setModifiedFields({ - ...modifiedFields, - 'Client Name': e.target.value, - }) - }} - /> - - - -
- - - {showClientSecret ? : } - -
- -
- { - formik.handleChange(e) - setModifiedFields({ - ...modifiedFields, - Description: e.target.value, - }) - }} - /> - { - setModifiedFields({ - ...modifiedFields, - 'Token Endpoint AuthMethod': e.target.value, - }) - }} - /> - - - - - - { - formik.handleChange(e) - setModifiedFields({ - ...modifiedFields, - 'Subject Type': e.target.value, - }) - }} - > - - - - - - - - { - setModifiedFields({ - ...modifiedFields, - 'Sector Identifier URI': formik.values.sectorIdentifierUri, - }) - }} - /> - { - setModifiedFields({ - ...modifiedFields, - 'Grant Types': e?.grantTypes ?? e, - }) - }} - /> - { - setModifiedFields({ - ...modifiedFields, - 'Response Types': e?.responseTypes ?? e, - }) - }} - /> - - - { - formik.setFieldValue('disabled', !event?.target?.checked) - setModifiedFields({ ...modifiedFields, 'Is Active': event?.target?.checked }) - }} - label="fields.is_active" - value={!formik.values.disabled} - doc_category={DOC_CATEGORY} - lsize={6} - rsize={3} - disabled={viewOnly} - /> - - - { - setModifiedFields({ ...modifiedFields, 'Trust Client': event?.target?.checked }) - }} - /> - - - - - - - { - formik.handleChange(e) - setModifiedFields({ - ...modifiedFields, - 'Application Type': e.target.value, - }) - }} - disabled={viewOnly} - > - - - - - - - - { - setModifiedFields({ - ...modifiedFields, - 'Redirect URIs': items, - }) - }} - /> - - { - setModifiedFields({ - ...modifiedFields, - 'Redirect URIs Regex': formik.values?.attributes?.redirectUrisRegex, - }) - }} - /> - {isLoading ? ( - 'Fetching Scopes...' - ) : ( - - )} -
- ) -} - -export default ClientBasicPanel -ClientBasicPanel.propTypes = { - formik: PropTypes.any, - client: PropTypes.any, - scopes: PropTypes.any, - viewOnly: PropTypes.bool, - oidcConfiguration: PropTypes.any, - modifiedFields: PropTypes.any, - setModifiedFields: PropTypes.func, -} diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientCibaParUmaPanel.js b/admin-ui/plugins/auth-server/components/Clients/ClientCibaParUmaPanel.js deleted file mode 100644 index 42e13160d6..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientCibaParUmaPanel.js +++ /dev/null @@ -1,456 +0,0 @@ -import React, { useState, useEffect } from 'react' -import Box from '@mui/material/Box' -import { useNavigate } from 'react-router-dom' -import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap' -import isEmpty from 'lodash/isEmpty' -import { useTranslation } from 'react-i18next' -import { useSelector } from 'react-redux' -import moment from 'moment' -import AceEditor from 'react-ace' -import { Card, Col, Container, FormGroup } from 'Components' -import GluuLabel from 'Routes/Apps/Gluu/GluuLabel' -import GluuSelectRow from 'Routes/Apps/Gluu/GluuSelectRow' -import GluuToogleRow from 'Routes/Apps/Gluu/GluuToogleRow' -import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' -import GluuTypeAheadWithAdd from 'Routes/Apps/Gluu/GluuTypeAheadWithAdd' -import { FormControlLabel, Link, Radio, RadioGroup } from '@mui/material' -import GluuTypeAheadForDn from 'Routes/Apps/Gluu/GluuTypeAheadForDn' -import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' -import { deleteUMAResource } from 'Plugins/auth-server/redux/features/umaResourceSlice' -import { setCurrentItem } from 'Plugins/auth-server/redux/features/scopeSlice' -import { setCurrentItem as setCurrentItemClient } from 'Plugins/auth-server/redux/features/oidcSlice' -import GluuDialog from 'Routes/Apps/Gluu/GluuDialog' -import 'ace-builds/src-noconflict/mode-json' -import 'ace-builds/src-noconflict/ext-language_tools' -import GluuTooltip from 'Routes/Apps/Gluu/GluuTooltip' -import PropTypes from 'prop-types' - -const DOC_CATEGORY = 'openid_client' - -function uriValidator(uri) { - return uri -} -const claim_uri_id = 'claim_uri_id' -const cibaDeliveryModes = ['poll', 'push', 'ping'] - -function ClientCibaParUmaPanel({ - client, - dispatch, - umaResources, - scopes, - setCurrentStep, - sequence, - formik, - viewOnly, - modifiedFields, - setModifiedFields, -}) { - const { t } = useTranslation() - const navigate = useNavigate() - const claimRedirectURI = [] - - const [open, setOpen] = useState(false) - const [selectedUMA, setSelectedUMA] = useState() - const [scopeExpression, setScopeExpression] = useState() - const [showScopeSection, setShowScopeSection] = useState('scope') - const [confirmModal, setConfirmModal] = useState(false) - const [scopeList, setScopeList] = useState([]) - const rptScripts = useSelector((state) => state.initReducer.scripts) - ?.filter((item) => item.scriptType == 'uma_rpt_claims') - ?.filter((item) => item.enabled) - ?.map((item) => ({ dn: item.dn, name: item.name })) - - const handleUMADetail = (uma) => { - if (!isEmpty(uma)) { - setSelectedUMA(uma) - if (!isEmpty(uma.scopeExpression)) { - setScopeExpression(JSON.parse(uma.scopeExpression)) - } - } - - setOpen(true) - } - - const handleDeleteUMA = (uma) => { - setSelectedUMA(uma) - setConfirmModal(true) - } - - const onDeletionConfirmed = (message) => { - const params = { - id: selectedUMA.id, - action_message: message, - action_data: selectedUMA.id, - } - dispatch(deleteUMAResource({ action: params })) - setConfirmModal(false) - setOpen(false) - } - - const handleScopeEdit = (scope) => { - dispatch(setCurrentItem({ item: scope })) - return navigate(`/auth-server/scope/edit:${scope.inum}`) - } - - const handleClientEdit = (inum) => { - dispatch(setCurrentItemClient({ item: client })) - setOpen(false) - dispatch(viewOnly(true)) - setCurrentStep(sequence[0]) - return navigate(`/auth-server/client/edit:${inum?.substring(0, 4)}`) - } - - useEffect(() => { - if (!isEmpty(selectedUMA) && !isEmpty(selectedUMA.scopes) && selectedUMA.scopes?.length > 0) { - const list = selectedUMA.scopes.map((scope) => { - // scope data example [string] inum=9bc94613-f678-4bb9-a19d-ed026b492247,ou=scopes,o=jans - const getInum = scope.split(',')[0] - const inumFromUMA = getInum.split('=')[1] - - return scopes.find(({ inum }) => inum === inumFromUMA) - }) - - setScopeList(list) - } - }, [selectedUMA]) - - return ( - -

{t(`titles.CIBA`)}

- { - setModifiedFields({ - ...modifiedFields, - 'Token Delivery Mode': e.target.value, - }) - }} - > - { - setModifiedFields({ - ...modifiedFields, - 'Client Notification Endpoint': e.target.value, - }) - }} - /> - - { - formik.setFieldValue('backchannelUserCodeParameter', e.target.checked) - setModifiedFields({ - ...modifiedFields, - 'User Code Parameter': e.target.checked, - }) - }} - disabled={viewOnly} - /> -

{t(`titles.PAR`)}

- { - setModifiedFields({ - ...modifiedFields, - 'PAR Lifetime': e.target.value, - }) - }} - /> - { - formik.setFieldValue('attributes.requirePar', e.target.checked) - setModifiedFields({ - ...modifiedFields, - 'Require Par': e.target.checked, - }) - }} - /> -

{t(`titles.UMA`)}

- - - - - { - formik.setFieldValue('rptAsJwt', e.target.value === 'true' ? 'true' : 'false') - setModifiedFields({ - ...modifiedFields, - 'RPT as JWT': e.target.value, - }) - }} - > - } - label="JWT" - disabled={viewOnly} - /> - } - label="Reference" - disabled={viewOnly} - /> - - - - - { - setModifiedFields({ - ...modifiedFields, - 'Claim Redirect URIs': items, - }) - }} - > - { - setModifiedFields({ - ...modifiedFields, - 'RPT Claims Scripts': items, - }) - }} - > - {!isEmpty(umaResources) && ( - - - - {umaResources?.length > 0 && - umaResources?.map((uma) => { - return ( - - - - handleUMADetail(uma)} - > - {uma.id} - - - - {uma.name} - - - - - - - ) - })} - - - )} - setOpen(!open)} - size="lg" - className="modal-outline-primary modal-lg-900" - > - setOpen(!open)}>UMA Resource Detail - - - - - - {selectedUMA?.id} - - - - - - {selectedUMA?.name} - - - - - - - {selectedUMA?.iconUri || '-'} - - - - - - - setShowScopeSection(e.target.value)} - > - } - label={t('fields.scope')} - checked={showScopeSection === 'scope'} - disabled={viewOnly} - /> - } - label={t('fields.scopeExpression')} - checked={showScopeSection === 'expression'} - disabled={viewOnly} - /> - - - - - - - {showScopeSection === 'scope' ? ( - - {!isEmpty(scopeList) && - scopeList?.map((scope, key) => { - return ( - - - handleScopeEdit(scope)} className="common-link"> - {scope?.displayName ? scope?.displayName : ''} - - - - ) - })} - - ) : ( - - {!isEmpty(scopeExpression) ? ( - - ) : ( - '-' - )} - - )} - - - - - - {!isEmpty(selectedUMA) && - selectedUMA.clients?.map((client, key) => { - const getInum = client.split(',')[0] - const inum = getInum.length > 0 ? getInum.split('=')[1] : null - - return ( - - - handleClientEdit(inum)} className="common-link"> - {inum} - - - - ) - })} - - - - - - {moment(selectedUMA?.creationDate).format('ddd, MMM DD, YYYY h:mm:ss A')} - - - - - - - - - {selectedUMA && ( - setConfirmModal(!confirmModal)} - modal={confirmModal} - subject="uma resources" - onAccept={onDeletionConfirmed} - /> - )} -
- ) -} - -export default ClientCibaParUmaPanel -ClientCibaParUmaPanel.propTypes = { - formik: PropTypes.any, - client: PropTypes.any, - scopes: PropTypes.any, - viewOnly: PropTypes.bool, - setCurrentStep: PropTypes.any, - sequence: PropTypes.any, - umaResources: PropTypes.any, - dispatch: PropTypes.func, - modifiedFields: PropTypes.any, - setModifiedFields: PropTypes.func, -} diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.js b/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.js deleted file mode 100644 index 8184e3dd10..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.js +++ /dev/null @@ -1,224 +0,0 @@ -import React, { useContext } from 'react' -import { Container, Badge, Col, FormGroup, Label } from 'Components' -import GluuFormDetailRow from 'Routes/Apps/Gluu/GluuFormDetailRow' -import GluuSecretDetail from 'Routes/Apps/Gluu/GluuSecretDetail' -import { useTranslation } from 'react-i18next' -import { ThemeContext } from 'Context/theme/themeContext' -import customColors from '@/customColors' - -const DOC_CATEGORY = 'openid_client' - -// Static style objects instantiated once at module scope -const detailContainerStyle = { - backgroundColor: customColors.whiteSmoke, - maxHeight: '420px', - overflowY: 'auto', - padding: '1rem', -} -const detailGridStyle = { - display: 'grid', - gridTemplateColumns: '1fr 1fr', - gap: '1.5rem', - alignItems: 'start', -} -const detailColumnStyle = { - display: 'flex', - flexDirection: 'column', - gap: '0.75rem', -} -const detailLabelStyle = { - fontWeight: 600, - wordWrap: 'break-word', - whiteSpace: 'normal', - paddingRight: '0.5rem', -} -const detailValueStyle = { - display: 'flex', - flexWrap: 'wrap', - gap: '0.35rem', - alignItems: 'center', -} - -function ClientDetailPage({ row, scopes }) { - const { t } = useTranslation() - const theme = useContext(ThemeContext) - const selectedTheme = theme.state.theme - - const scopesDns = row.scopes || [] - const clientScopes = scopes - .filter((item) => scopesDns.includes(item.dn, 0)) - .map((item) => item.id) - function extractDescription(customAttributes) { - const result = customAttributes.filter((item) => item.name === 'description') - if (result && result.length >= 1) { - return result[0].values - } - return '' - } - - const dash = '-' - const description = row.description || extractDescription(row.customAttributes || []) || dash - const displayName = row.clientName || row.displayName || dash - - const renderBadgeList = (items) => ( -
- {items?.length - ? items.map((item, key) => ( - - {item} - - )) - : dash} -
- ) - - return ( - - -
- {/* Column 1 */} -
- - - - - - -
- {row.trustedClient ? ( - {t('options.yes')} - ) : ( - {t('options.no')} - )} -
- -
- - - {renderBadgeList(clientScopes)} - - - - {renderBadgeList(row.responseTypes)} - - - - {renderBadgeList(row.postLogoutRedirectUris)} - -
- - {/* Column 2 */} -
- - - - - - -
- {!row.disabled ? ( - {t('options.enabled')} - ) : ( - {t('options.disabled')} - )} -
- -
- - - {renderBadgeList(row.grantTypes)} - - - - {renderBadgeList(row.redirectUris)} - - - - -
- {row.authenticationMethod ? ( - {row.authenticationMethod} - ) : ( - dash - )} -
- -
-
-
-
-
- ) -} - -export default ClientDetailPage diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.js b/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.js deleted file mode 100644 index 1698490e34..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.js +++ /dev/null @@ -1,87 +0,0 @@ -import React, { useEffect, useState } from 'react' -import ClientWizardForm from './ClientWizardForm' -import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' -import { useNavigate } from 'react-router-dom' -import { useDispatch, useSelector } from 'react-redux' -import { editClient } from 'Plugins/auth-server/redux/features/oidcSlice' -import { getScopeByCreator, emptyScopes } from 'Plugins/auth-server/redux/features/scopeSlice' -import { getOidcDiscovery } from 'Redux/features/oidcDiscoverySlice' -import { getUMAResourcesByClient } from 'Plugins/auth-server/redux/features/umaResourceSlice' -import { getScripts } from 'Redux/features/initSlice' -import { buildPayload } from 'Utils/PermChecker' -import isEmpty from 'lodash/isEmpty' - -function ClientEditPage() { - const clientData = useSelector((state) => state.oidcReducer.item) - const viewOnly = useSelector((state) => state.oidcReducer.view) - const loading = useSelector((state) => state.oidcReducer.loading) - let scopes = useSelector((state) => state.scopeReducer.items) - const scripts = useSelector((state) => state.initReducer.scripts) - const permissions = useSelector((state) => state.authReducer.permissions) - const oidcConfiguration = useSelector((state) => state.oidcDiscoveryReducer.configuration) - const saveOperationFlag = useSelector((state) => state.oidcReducer.saveOperationFlag) - const umaResources = useSelector((state) => state.umaResourceReducer.items) - const loadingOidcDiscovevry = useSelector((state) => state.oidcDiscoveryReducer.loading) - const [modifiedFields, setModifiedFields] = useState([]) - - const dispatch = useDispatch() - const userAction = {} - const options = {} - options['limit'] = parseInt(100000) - const navigate = useNavigate() - - useEffect(() => { - dispatch(emptyScopes()) - dispatch(getScopeByCreator({ action: { inum: clientData.inum } })) - buildPayload(userAction, '', options) - if (scripts.length < 1) { - dispatch(getScripts({ action: options })) - } - if (isEmpty(umaResources)) { - dispatch(getUMAResourcesByClient({ inum: clientData?.inum })) - } - dispatch(getOidcDiscovery()) - }, []) - useEffect(() => { - if (saveOperationFlag) navigate('/auth-server/clients') - }, [saveOperationFlag]) - - if (!clientData.attributes) { - clientData.attributes = {} - } - scopes = scopes?.map((item) => ({ - ...item, - name: item.id, - })) - - function handleSubmit(data) { - if (data) { - buildPayload(userAction, data.action_message, data) - delete userAction?.action_data?.action_message - dispatch(editClient({ action: userAction })) - } - } - return ( - - {!(loadingOidcDiscovevry || loading) && ( - <> - - - )} - - ) -} - -export default ClientEditPage diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx new file mode 100644 index 0000000000..3d94efd186 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx @@ -0,0 +1,63 @@ +import React, { useCallback, useMemo } from 'react' +import { useParams } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' +import SetTitle from 'Utils/SetTitle' +import { useClientActions, useClientById, useUpdateClient } from './hooks' +import ClientForm from './components/ClientForm' +import type { ClientFormValues, ModifiedFields } from './types' + +const ClientEditPage: React.FC = () => { + const { t } = useTranslation() + const { id } = useParams<{ id: string }>() + const { logClientUpdate, navigateToClientList } = useClientActions() + + const { data: client, isLoading: clientLoading } = useClientById(id || '', Boolean(id)) + + const handleSuccess = useCallback(() => { + navigateToClientList() + }, [navigateToClientList]) + + const updateClient = useUpdateClient(handleSuccess) + + const handleSubmit = useCallback( + async (values: ClientFormValues, message: string, modifiedFields: ModifiedFields) => { + try { + const result = await updateClient.mutateAsync({ data: values }) + if (result) { + await logClientUpdate(result, message, modifiedFields) + } + } catch (error) { + console.error('Error updating client:', error) + } + }, + [updateClient, logClientUpdate], + ) + + const isLoading = useMemo( + () => clientLoading || updateClient.isPending, + [clientLoading, updateClient.isPending], + ) + + SetTitle(t('titles.edit_openid_connect_client')) + + if (!id) { + return null + } + + return ( + + {client && ( + + )} + + ) +} + +export default ClientEditPage diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientEncryptionSigningPanel.js b/admin-ui/plugins/auth-server/components/Clients/ClientEncryptionSigningPanel.js deleted file mode 100644 index deaf0e5d6f..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientEncryptionSigningPanel.js +++ /dev/null @@ -1,411 +0,0 @@ -import React from 'react' -import { Col, Container, FormGroup } from 'Components' -import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' -import GluuSelectRow from 'Routes/Apps/Gluu/GluuSelectRow' -import { useTranslation } from 'react-i18next' -import PropTypes from 'prop-types' -const DOC_CATEGORY = 'openid_client' - -function ClientEncryptionSigningPanel({ - formik, - oidcConfiguration, - viewOnly, - modifiedFields, - setModifiedFields, -}) { - const { t } = useTranslation() - const accessTokenSigningAlg = oidcConfiguration.tokenEndpointAuthSigningAlgValuesSupported - ? oidcConfiguration.tokenEndpointAuthSigningAlgValuesSupported - : [] - //id_token - const idTokenSignedResponseAlg = oidcConfiguration.idTokenSigningAlgValuesSupported - ? oidcConfiguration.idTokenSigningAlgValuesSupported - : [] - - const idTokenEncryptedResponseAlg = oidcConfiguration.idTokenEncryptionAlgValuesSupported - ? oidcConfiguration.idTokenEncryptionAlgValuesSupported - : [] - - const idTokenEncryptedResponseEnc = oidcConfiguration.idTokenEncryptionEncValuesSupported - ? oidcConfiguration.idTokenEncryptionEncValuesSupported - : [] - //request-object - const requestObjectSignedResponseAlg = oidcConfiguration.requestObjectSigningAlgValuesSupported - ? oidcConfiguration.requestObjectSigningAlgValuesSupported - : [] - - const requestObjectEncryptedResponseAlg = - oidcConfiguration.requestObjectEncryptionAlgValuesSupported - ? oidcConfiguration.requestObjectEncryptionAlgValuesSupported - : [] - - const requestObjectEncryptedResponseEnc = - oidcConfiguration.requestObjectEncryptionEncValuesSupported - ? oidcConfiguration.requestObjectEncryptionEncValuesSupported - : [] - //user-info - const userInfoSignedResponseAlg = oidcConfiguration.userInfoSigningAlgValuesSupported - ? oidcConfiguration.userInfoSigningAlgValuesSupported - : [] - - const userInfoEncryptedResponseAlg = oidcConfiguration.userInfoEncryptionAlgValuesSupported - ? oidcConfiguration.userInfoEncryptionAlgValuesSupported - : [] - - const userInfoEncryptedResponseEnc = oidcConfiguration.userInfoEncryptionEncValuesSupported - ? oidcConfiguration.userInfoEncryptionEncValuesSupported - : [] - - return ( - - { - setModifiedFields({ ...modifiedFields, 'JWTs URI': e.target.value }) - }} - /> - { - setModifiedFields({ ...modifiedFields, JWTs: e.target.value }) - }} - /> -

{t(`titles.id_token`)}

- - - { - setModifiedFields({ - ...modifiedFields, - 'Id Token Signed Response': e.target.value, - }) - }} - /> - - - { - setModifiedFields({ - ...modifiedFields, - 'Id Token Encrypted Response Alg': e.target.value, - }) - }} - /> - - - { - setModifiedFields({ - ...modifiedFields, - 'Id Token Encrypted Response Enc': e.target.value, - }) - }} - /> - - -

{t(`titles.access_token`)}

- - - { - setModifiedFields({ - ...modifiedFields, - 'Access Token Signing Alg': e.target.value, - }) - }} - /> - - -

{t(`titles.userinfo`)}

- - - { - setModifiedFields({ - ...modifiedFields, - 'User Info Signed Response Alg': e.target.value, - }) - }} - /> - - - { - setModifiedFields({ - ...modifiedFields, - 'User Info Encrypted Response Alg': e.target.value, - }) - }} - /> - - - { - setModifiedFields({ - ...modifiedFields, - 'User Info Encrypted Response Enc': e.target.value, - }) - }} - /> - - -

{t(`titles.JARM`)}

- - - { - setModifiedFields({ - ...modifiedFields, - 'Authorization Signed Response Alg': e.target.value, - }) - }} - /> - - - { - setModifiedFields({ - ...modifiedFields, - 'Authorization Encrypted Response Alg': e.target.value, - }) - }} - /> - - - { - setModifiedFields({ - ...modifiedFields, - 'Authorization Encrypted Response Enc': e.target.value, - }) - }} - /> - - -

{t(`titles.request_object`)}

- - - { - setModifiedFields({ - ...modifiedFields, - 'Request Object Signing Alg': e.target.value, - }) - }} - /> - - - { - setModifiedFields({ - ...modifiedFields, - 'Request Object Encryption Alg': e.target.value, - }) - }} - /> - - - { - setModifiedFields({ - ...modifiedFields, - 'Request Object Encryption Enc': e.target.value, - }) - }} - /> - - -

{t(`titles.introspection_object`)}

- - - - - - { - setModifiedFields({ - ...modifiedFields, - 'Introspection Encrypted Response Alg': e.target.value, - }) - }} - /> - - - { - setModifiedFields({ - ...modifiedFields, - 'Introspection Encrypted Response Enc': e.target.value, - }) - }} - /> - - -
- ) -} - -ClientEncryptionSigningPanel.propTypes = { - formik: PropTypes.object, - oidcConfiguration: PropTypes.object, - viewOnly: PropTypes.bool, - modifiedFields: PropTypes.object, - setModifiedFields: PropTypes.func, -} - -export default ClientEncryptionSigningPanel diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.js b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.js deleted file mode 100644 index cdb4681799..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.js +++ /dev/null @@ -1,440 +0,0 @@ -import React, { useState, useEffect, useContext, useMemo } from 'react' -import MaterialTable from '@material-table/core' -import { DeleteOutlined } from '@mui/icons-material' -import { useNavigate, useLocation } from 'react-router-dom' -import { useSelector, useDispatch } from 'react-redux' -import { Link, Paper, TablePagination } from '@mui/material' -import { Card, CardBody, Badge } from 'Components' -import { getScopes } from 'Plugins/auth-server/redux/features/scopeSlice' -import { resetUMAResources } from 'Plugins/auth-server/redux/features/umaResourceSlice' -import GluuDialog from 'Routes/Apps/Gluu/GluuDialog' -import ClientDetailPage from '../Clients/ClientDetailPage' -import GluuAdvancedSearch from 'Routes/Apps/Gluu/GluuAdvancedSearch' -import GluuViewWrapper from 'Routes/Apps/Gluu/GluuViewWrapper' -import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' -import { useTranslation } from 'react-i18next' -import { LIMIT_ID, LIMIT, PATTERN, PATTERN_ID } from 'Plugins/auth-server/common/Constants' -import { - getOpenidClients, - setCurrentItem, - deleteClient, - viewOnly, -} from 'Plugins/auth-server/redux/features/oidcSlice' -import { buildPayload } from 'Utils/PermChecker' -import { useCedarling } from '@/cedarling' -import { CEDAR_RESOURCE_SCOPES } from '@/cedarling/constants/resourceScopes' -import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' -import ClientShowScopes from './ClientShowScopes' -import SetTitle from 'Utils/SetTitle' -import { ThemeContext } from 'Context/theme/themeContext' -import getThemeColor from 'Context/theme/config' -import { adminUiFeatures } from 'Plugins/admin/helper/utils' -import customColors from '@/customColors' - -function ClientListPage() { - const { t } = useTranslation() - const { - hasCedarReadPermission, - hasCedarWritePermission, - hasCedarDeletePermission, - authorizeHelper, - } = useCedarling() - const dispatch = useDispatch() - const nonExtensibleClients = useSelector((state) => state.oidcReducer.items) - const { totalItems } = useSelector((state) => state.oidcReducer) - const scopes = useSelector((state) => state.scopeReducer.items) - const loading = useSelector((state) => state.oidcReducer.loading) - const { permissions: cedarPermissions } = useSelector((state) => state.cedarPermissions) - let clients = [...(nonExtensibleClients ?? [])] - clients = clients?.map(addOrg) - const userAction = {} - const options = {} - const myActions = [] - const navigate = useNavigate() - const { search } = useLocation() - - const theme = useContext(ThemeContext) - const selectedTheme = theme.state.theme - const themeColors = getThemeColor(selectedTheme) - const bgThemeColor = { background: themeColors.background } - - const clientResourceId = ADMIN_UI_RESOURCES.Clients - const clientScopes = useMemo( - () => CEDAR_RESOURCE_SCOPES[clientResourceId] || [], - [clientResourceId], - ) - - const canReadClient = useMemo( - () => hasCedarReadPermission(clientResourceId), - [hasCedarReadPermission, clientResourceId], - ) - - const canWriteClient = useMemo( - () => hasCedarWritePermission(clientResourceId), - [hasCedarWritePermission, clientResourceId], - ) - - const canDeleteClient = useMemo( - () => hasCedarDeletePermission(clientResourceId), - [hasCedarDeletePermission, clientResourceId], - ) - - const [scopeClients, setScopeClients] = useState([]) - const [haveScopeINUMParam] = useState(search.indexOf('?scopeInum=') > -1) - const [isPageLoading, setIsPageLoading] = useState(loading) - const [pageNumber, setPageNumber] = useState(0) - SetTitle(t('titles.oidc_clients')) - - // Permission initialization - useEffect(() => { - authorizeHelper(clientScopes) - }, [authorizeHelper, clientScopes]) - - const [scopesModal, setScopesModal] = useState({ - data: [], - show: false, - }) - const [limit, setLimit] = useState(10) - const [pattern, setPattern] = useState(null) - const [item, setItem] = useState({}) - const [modal, setModal] = useState(false) - const toggle = () => setModal(!modal) - - let memoLimit = limit - let memoPattern = pattern - - function addOrg(...args) { - const client = { ...args[0] } - let org = '-' - if (Object.prototype.hasOwnProperty.call(client, 'o')) { - client['organization'] = client.o - return client - } - if ( - Object.prototype.hasOwnProperty.call(client, 'customAttributes') && - Array.isArray(client.customAttributes) - ) { - const results = client.customAttributes.filter( - (item) => item.name == 'o' || item.name == 'organization', - ) - if (results.length !== 0) { - org = results[0].values[0] - } - } - client['organization'] = org - return client - } - - function shouldHideOrgColumn(clients) { - return !clients?.some((client) => client.organization != '-') - } - - const handler = () => { - setScopesModal({ - data: [], - show: false, - }) - } - const setScopeData = (data) => { - setScopesModal({ - data: data, - show: true, - }) - } - - const tableColumns = [ - { - title: `${t('fields.inum')}`, - field: 'inum', - hidden: true, - sorting: true, - searchable: true, - }, - { title: `${t('fields.client_id')}`, field: 'inum' }, - { title: `${t('fields.client_name')}`, field: 'clientName' }, - { - title: `${t('fields.grant_types')}`, - field: 'grantTypes', - render: (rowData) => { - return rowData?.grantTypes?.map((data) => { - return ( -
- {data} -
- ) - }) - }, - }, - { - title: `${t('fields.scopes')}`, - field: 'scopes', - render: (rowData) => { - return ( - setScopeData(rowData.scopes)}> - {rowData.scopes?.length || '0'} - - ) - }, - }, - { - title: `${t('fields.is_trusted_client')}`, - field: 'trustedClient', - type: 'boolean', - render: (rowData) => ( - - {rowData.trustedClient ? t('options.yes') : t('options.no')} - - ), - }, - { - title: `${t('fields.organization')}`, - field: 'organization', - hidden: shouldHideOrgColumn(clients), - sorting: true, - searchable: true, - }, - ] - - useEffect(() => { - if (haveScopeINUMParam) { - const scopeInumParam = search.replace('?scopeInum=', '') - - if (scopeInumParam?.length > 0) { - const clientsScope = scopes.find(({ inum }) => inum === scopeInumParam)?.clients || [] - setScopeClients(clientsScope) - } - } else { - setIsPageLoading(true) - makeOptions() - dispatch(getOpenidClients({ action: options })) - - buildPayload(userAction, '', options) - userAction['limit'] = 100 - dispatch(getScopes({ action: userAction })) - - setTimeout(() => { - setIsPageLoading(false) - }, 3000) - } - }, [haveScopeINUMParam]) - - useEffect(() => { - dispatch(resetUMAResources()) - }, []) - useEffect(() => {}, [cedarPermissions]) - - function handleOptionsChange(event) { - if (event.target.name == 'limit') { - memoLimit = event.target.value - } else if (event.target.name == 'pattern') { - memoPattern = event.target.value - if (event.keyCode === 13) { - makeOptions() - dispatch(getOpenidClients({ action: options })) - } - } - } - function handleGoToClientEditPage(row, edition) { - dispatch(viewOnly({ view: edition })) - dispatch(setCurrentItem({ item: row })) - return navigate(`/auth-server/client/edit/:` + row.inum.substring(0, 4)) - } - function handleGoToClientAddPage() { - return navigate('/auth-server/client/new') - } - function handleClientDelete(row) { - dispatch(setCurrentItem({ item: row })) - setItem(row) - toggle() - } - function makeOptions() { - setLimit(memoLimit) - setPattern(memoPattern) - options[LIMIT] = memoLimit - if (memoPattern) { - options[PATTERN] = memoPattern - } - } - const removeClientFromList = (deletedClient) => { - if (haveScopeINUMParam) { - setScopeClients((prev) => prev.filter((client) => client.inum !== deletedClient.inum)) - } - } - - function onDeletionConfirmed(message) { - buildPayload(userAction, message, item) - dispatch(deleteClient({ action: userAction })) - removeClientFromList(item) - if (!haveScopeINUMParam) { - navigate('/auth-server/clients') - } - toggle() - } - - if (canWriteClient) { - myActions.push({ - icon: 'add', - tooltip: `${t('messages.add_client')}`, - iconProps: { color: 'primary' }, - ['data-testid']: `${t('messages.add_client')}`, - isFreeAction: true, - onClick: () => handleGoToClientAddPage(), - }) - myActions.push((rowData) => ({ - icon: 'edit', - iconProps: { - id: 'editClient' + rowData.inum, - style: { color: customColors.darkGray }, - }, - tooltip: `${t('messages.edit_client')}`, - onClick: (event, rowData) => handleGoToClientEditPage(rowData, false), - disabled: false, - })) - } - - if (canReadClient) { - myActions.push({ - icon: () => ( - - ), - tooltip: `${t('messages.advanced_search')}`, - iconProps: { - color: 'primary', - style: { - borderColor: customColors.lightBlue, - }, - }, - isFreeAction: true, - onClick: () => {}, - }) - myActions.push({ - icon: 'refresh', - tooltip: `${t('messages.refresh')}`, - iconProps: { color: 'primary', style: { color: customColors.lightBlue } }, - ['data-testid']: `${t('messages.refresh')}`, - isFreeAction: true, - onClick: () => { - makeOptions() - // buildPayload(userAction, SEARCHING_OIDC_CLIENTS, options) - dispatch(getOpenidClients({ action: options })) - }, - }) - myActions.push((rowData) => ({ - icon: 'visibility', - iconProps: { - id: 'viewClient' + rowData.inum, - style: { color: customColors.darkGray }, - }, - tooltip: `${t('messages.view_client_details')}`, - onClick: (event, rowData) => handleGoToClientEditPage(rowData, true), - disabled: false, - })) - } - if (canDeleteClient) { - myActions.push((rowData) => ({ - icon: () => , - iconProps: { - color: 'secondary', - id: 'deleteClient' + rowData.inum, - style: { color: customColors.darkGray }, - }, - tooltip: rowData.deletable - ? `${t('messages.delete_client')}` - : `${t('messages.not_deletable_client')}`, - onClick: (event, rowData) => handleClientDelete(rowData), - disabled: false, - })) - } - - function getTrustedTheme(status) { - if (status) { - return `primary-${selectedTheme}` - } else { - return 'secondary' - } - } - - const onPageChangeClick = (page) => { - makeOptions() - const startCount = page * limit - options['startIndex'] = parseInt(startCount) - options['limit'] = limit - setPageNumber(page) - dispatch(getOpenidClients({ action: options })) - } - const onRowCountChangeClick = (count) => { - makeOptions() - options['startIndex'] = 0 - options['limit'] = count - setPageNumber(0) - setLimit(count) - dispatch(getOpenidClients({ action: options })) - } - - return ( - - - - - , - Pagination: () => ( - { - onPageChangeClick(page) - }} - rowsPerPage={limit} - onRowsPerPageChange={(prop, count) => onRowCountChangeClick(count.props.value)} - /> - ), - }} - columns={tableColumns} - data={haveScopeINUMParam ? scopeClients : clients} - isLoading={isPageLoading ? isPageLoading : loading} - title="" - actions={myActions} - options={{ - search: false, - idSynonym: 'inum', - searchFieldAlignment: 'left', - selection: false, - pageSize: limit, - headerStyle: { - ...applicationStyle.tableHeaderStyle, - ...bgThemeColor, - }, - actionsColumnIndex: -1, - }} - detailPanel={(rowData) => { - return - }} - /> - - {canDeleteClient && ( - - )} - - - ) -} - -export default ClientListPage diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx new file mode 100644 index 0000000000..041137a4f7 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx @@ -0,0 +1,650 @@ +import React, { useState, useCallback, useMemo, useContext, useEffect, useRef } from 'react' +import MaterialTable from '@material-table/core' +import type { Column, Action } from '@material-table/core' +import { DeleteOutlined } from '@mui/icons-material' +import { + Paper, + TablePagination, + Box, + TextField, + IconButton, + Button, + InputAdornment, + Chip, + FormControl, + InputLabel, + Select, + MenuItem, +} from '@mui/material' +import type { SelectChangeEvent } from '@mui/material' +import SearchIcon from '@mui/icons-material/Search' +import ClearIcon from '@mui/icons-material/Clear' +import RefreshIcon from '@mui/icons-material/Refresh' +import SortIcon from '@mui/icons-material/Sort' +import { useNavigate } from 'react-router-dom' +import { useDispatch } from 'react-redux' +import { Badge } from 'reactstrap' +import { Card, CardBody } from 'Components' +import GluuDialog from 'Routes/Apps/Gluu/GluuDialog' +import GluuViewWrapper from 'Routes/Apps/Gluu/GluuViewWrapper' +import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' +import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' +import { useTranslation } from 'react-i18next' +import { useCedarling } from '@/cedarling' +import { CEDAR_RESOURCE_SCOPES } from '@/cedarling/constants/resourceScopes' +import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' +import SetTitle from 'Utils/SetTitle' +import { ThemeContext } from 'Context/theme/themeContext' +import getThemeColor from 'Context/theme/config' +import { adminUiFeatures } from 'Plugins/admin/helper/utils' +import customColors from '@/customColors' +import { useQueryClient } from '@tanstack/react-query' +import { updateToast } from 'Redux/features/toastSlice' +import { + useGetOauthOpenidClients, + useDeleteOauthOpenidClientByInum, + getGetOauthOpenidClientsQueryKey, +} from 'JansConfigApi' +import type { Client, ClientTableRow } from './types' +import { useClientActions } from './hooks' +import ClientDetailView from './components/ClientDetailView' +import { formatGrantTypeLabel, truncateText } from './helper/utils' +import { CLIENT_ROUTES, DEFAULT_PAGE_SIZE } from './helper/constants' + +interface DetailPanelProps { + rowData: ClientTableRow +} + +const FILTER_BOX_STYLES = { + mb: '10px', + p: 1, + backgroundColor: customColors.white, + borderRadius: 1, + border: `1px solid ${customColors.lightGray}`, +} as const + +const FILTER_CONTAINER_STYLES = { + display: 'flex', + gap: 2, + alignItems: 'center', + justifyContent: 'space-between', +} as const + +const FILTER_ITEMS_STYLES = { + display: 'flex', + gap: 2, + alignItems: 'center', + flexWrap: 'wrap', +} as const + +const ICON_BUTTON_STYLES = { color: customColors.lightBlue } as const + +const TEXT_FIELD_WIDTH = { width: '300px' } as const + +const ClientListPage: React.FC = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const theme = useContext(ThemeContext) + const dispatch = useDispatch() + const queryClient = useQueryClient() + + const { + hasCedarReadPermission, + hasCedarWritePermission, + hasCedarDeletePermission, + authorizeHelper, + } = useCedarling() + const { logClientDeletion, navigateToClientAdd, navigateToClientEdit } = useClientActions() + + const getInitialPageSize = (): number => { + const stored = localStorage.getItem('paggingSize') + return stored ? parseInt(stored) : DEFAULT_PAGE_SIZE + } + + const [limit, setLimit] = useState(getInitialPageSize()) + const [pageNumber, setPageNumber] = useState(0) + const [searchInput, setSearchInput] = useState('') + const [pattern, setPattern] = useState(null) + const [startIndex, setStartIndex] = useState(0) + const [sortBy, setSortBy] = useState('') + const [sortOrder, setSortOrder] = useState<'ascending' | 'descending'>('ascending') + const [selectedClient, setSelectedClient] = useState(null) + const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const debounceTimerRef = useRef(null) + + const clientsQueryParams = useMemo( + () => ({ + limit, + pattern: pattern || undefined, + startIndex, + sortBy: sortBy || undefined, + sortOrder: sortBy ? sortOrder : undefined, + }), + [limit, pattern, startIndex, sortBy, sortOrder], + ) + + const clientsQueryOptions = useMemo( + () => ({ + query: { + refetchOnMount: 'always' as const, + refetchOnWindowFocus: false, + refetchOnReconnect: true, + staleTime: 30000, + }, + }), + [], + ) + + const { + data: clientsResponse, + isLoading, + isFetching, + } = useGetOauthOpenidClients(clientsQueryParams, clientsQueryOptions) + + const loading = isLoading || isFetching + + const handleDeleteSuccess = useCallback(() => { + dispatch(updateToast(true, 'success')) + queryClient.invalidateQueries({ + predicate: (query) => { + const queryKey = query.queryKey[0] as string + return queryKey === getGetOauthOpenidClientsQueryKey()[0] + }, + }) + }, [dispatch, queryClient]) + + const handleDeleteError = useCallback( + (error: Error) => { + const errorMessage = error?.message || 'Failed to delete client' + dispatch(updateToast(true, 'error', errorMessage)) + }, + [dispatch], + ) + + const deleteClient = useDeleteOauthOpenidClientByInum({ + mutation: { + onSuccess: handleDeleteSuccess, + onError: handleDeleteError, + }, + }) + + const clientResourceId = ADMIN_UI_RESOURCES.Clients + + const selectedTheme = useMemo(() => theme?.state?.theme || 'light', [theme?.state?.theme]) + + const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) + + const bgThemeColor = useMemo( + () => ({ background: themeColors.background }), + [themeColors.background], + ) + + const clientScopes = useMemo( + () => CEDAR_RESOURCE_SCOPES[clientResourceId] || [], + [clientResourceId], + ) + + const canReadClients = useMemo( + () => hasCedarReadPermission(clientResourceId), + [hasCedarReadPermission, clientResourceId], + ) + + const canWriteClients = useMemo( + () => hasCedarWritePermission(clientResourceId), + [hasCedarWritePermission, clientResourceId], + ) + + const canDeleteClients = useMemo( + () => hasCedarDeletePermission(clientResourceId), + [hasCedarDeletePermission, clientResourceId], + ) + + const clients = useMemo( + () => (clientsResponse?.entries || []) as ClientTableRow[], + [clientsResponse?.entries], + ) + + const totalItems = useMemo( + () => clientsResponse?.totalEntriesCount || 0, + [clientsResponse?.totalEntriesCount], + ) + + const tableOptions = useMemo( + () => ({ + idSynonym: 'inum', + columnsButton: true, + search: false, + selection: false, + pageSize: limit, + headerStyle: { + ...applicationStyle.tableHeaderStyle, + ...bgThemeColor, + textTransform: applicationStyle.tableHeaderStyle.textTransform as + | 'uppercase' + | 'lowercase' + | 'none' + | 'capitalize' + | undefined, + }, + actionsColumnIndex: -1, + }), + [limit, bgThemeColor], + ) + + const toggleDeleteModal = useCallback(() => setDeleteModalOpen((prev) => !prev), []) + + const handlePatternChange = useCallback((event: React.ChangeEvent): void => { + setSearchInput(event.target.value) + }, []) + + const handlePatternKeyDown = useCallback( + (event: React.KeyboardEvent): void => { + if (event.key === 'Enter') { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + debounceTimerRef.current = null + } + setPattern(searchInput || null) + setPageNumber(0) + setStartIndex(0) + } + }, + [searchInput], + ) + + const handleClearFilters = useCallback((): void => { + setSearchInput('') + setPattern(null) + setSortBy('') + setSortOrder('ascending') + setPageNumber(0) + setStartIndex(0) + }, []) + + const handleSortByChange = useCallback((event: SelectChangeEvent): void => { + setSortBy(event.target.value) + setPageNumber(0) + setStartIndex(0) + }, []) + + const handleSortOrderChange = useCallback((event: SelectChangeEvent): void => { + setSortOrder(event.target.value as 'ascending' | 'descending') + setPageNumber(0) + setStartIndex(0) + }, []) + + const handleRefresh = useCallback((): void => { + setStartIndex(0) + setPageNumber(0) + queryClient.invalidateQueries({ + predicate: (query) => { + const queryKey = query.queryKey[0] as string + return queryKey === getGetOauthOpenidClientsQueryKey()[0] + }, + }) + }, [queryClient]) + + const handleGoToClientAddPage = useCallback(() => { + navigateToClientAdd() + }, [navigateToClientAdd]) + + const handleGoToClientEditPage = useCallback( + (row: ClientTableRow) => { + if (row.inum) { + navigateToClientEdit(row.inum) + } + }, + [navigateToClientEdit], + ) + + const handleClientDelete = useCallback( + (row: ClientTableRow) => { + setSelectedClient(row as Client) + toggleDeleteModal() + }, + [toggleDeleteModal], + ) + + const onDeletionConfirmed = useCallback( + async (message: string) => { + if (!selectedClient?.inum) return + + try { + await deleteClient.mutateAsync({ inum: selectedClient.inum }) + await logClientDeletion(selectedClient, message) + toggleDeleteModal() + } catch (error) { + console.error('Error deleting client:', error) + } + }, + [selectedClient, deleteClient, logClientDeletion, toggleDeleteModal], + ) + + const onPageChangeClick = useCallback( + (page: number): void => { + const newStartIndex = page * limit + setStartIndex(newStartIndex) + setPageNumber(page) + }, + [limit], + ) + + const onRowCountChangeClick = useCallback((count: number): void => { + setStartIndex(0) + setPageNumber(0) + setLimit(count) + }, []) + + const handleEditClick = useCallback( + (data: ClientTableRow | ClientTableRow[]) => { + if (data && !Array.isArray(data)) { + handleGoToClientEditPage(data) + } + }, + [handleGoToClientEditPage], + ) + + const handleDeleteClick = useCallback( + (data: ClientTableRow | ClientTableRow[]) => { + if (data && !Array.isArray(data)) { + handleClientDelete(data) + } + }, + [handleClientDelete], + ) + + const DeleteIcon = () => + + const detailPanel = useCallback( + (props: DetailPanelProps): React.ReactElement => ( + + ), + [], + ) + + const PaperContainer = useCallback( + (props: React.ComponentProps) => , + [], + ) + + const handlePageChange = useCallback( + (_event: React.MouseEvent | null, page: number) => { + onPageChangeClick(page) + }, + [onPageChangeClick], + ) + + const handleRowsPerPageChange = useCallback( + (event: React.ChangeEvent) => { + onRowCountChangeClick(parseInt(event.target.value, 10)) + }, + [onRowCountChangeClick], + ) + + const PaginationComponent = useCallback( + () => ( + + ), + [totalItems, pageNumber, handlePageChange, limit, handleRowsPerPageChange], + ) + + const renderGrantTypesColumn = useCallback((rowData: ClientTableRow) => { + const grants = rowData.grantTypes || [] + if (grants.length === 0) return '-' + const displayGrants = grants.slice(0, 2) + const remaining = grants.length - 2 + return ( + + {displayGrants.map((grant, index) => ( + + ))} + {remaining > 0 && } + + ) + }, []) + + const renderStatusColumn = useCallback( + (rowData: ClientTableRow) => ( + + {rowData.disabled ? t('fields.disabled') : t('fields.active')} + + ), + [selectedTheme, t], + ) + + const tableColumns = useMemo[]>( + () => [ + { + title: t('fields.client_name'), + field: 'clientName', + render: (rowData) => truncateText(rowData.clientName || rowData.displayName || '-', 30), + }, + { + title: t('fields.client_id'), + field: 'inum', + render: (rowData) => truncateText(rowData.inum || '', 20), + }, + { + title: t('fields.grant_types'), + field: 'grantTypes', + render: renderGrantTypesColumn, + }, + { + title: t('fields.status'), + field: 'disabled', + render: renderStatusColumn, + }, + ], + [t, renderGrantTypesColumn, renderStatusColumn], + ) + + const myActions = useMemo< + Array | ((rowData: ClientTableRow) => Action)> + >(() => { + const actions: Array< + Action | ((rowData: ClientTableRow) => Action) + > = [] + + if (canWriteClients) { + actions.push((rowData: ClientTableRow) => ({ + icon: 'edit', + iconProps: { + id: `editClient${rowData.inum}`, + }, + tooltip: t('messages.edit_client'), + onClick: (_event, data) => handleEditClick(data), + disabled: !canWriteClients, + })) + actions.push({ + icon: 'add', + tooltip: t('messages.add_client'), + iconProps: { color: 'primary', style: { color: customColors.lightBlue } }, + isFreeAction: true, + onClick: handleGoToClientAddPage, + disabled: !canWriteClients, + }) + } + + if (canDeleteClients) { + actions.push((rowData: ClientTableRow) => ({ + icon: DeleteIcon, + iconProps: { + color: 'secondary', + id: `deleteClient${rowData.inum}`, + }, + tooltip: t('messages.delete_client'), + onClick: (_event, data) => handleDeleteClick(data), + disabled: !canDeleteClients, + })) + } + + return actions + }, [ + canWriteClients, + canDeleteClients, + t, + handleEditClick, + handleGoToClientAddPage, + handleDeleteClick, + ]) + + const tableComponents = useMemo( + () => ({ + Container: PaperContainer, + Pagination: PaginationComponent, + }), + [PaperContainer, PaginationComponent], + ) + + SetTitle(t('titles.openid_connect_clients')) + + useEffect(() => { + authorizeHelper(clientScopes) + }, [authorizeHelper, clientScopes]) + + useEffect(() => { + debounceTimerRef.current = setTimeout(() => { + setPattern(searchInput || null) + if (searchInput !== (pattern || '')) { + setPageNumber(0) + setStartIndex(0) + } + debounceTimerRef.current = null + }, 500) + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + debounceTimerRef.current = null + } + } + }, [searchInput, pattern]) + + return ( + + + + + + + + + + + ), + }} + /> + + + + + + {t('fields.sort_by')} + + + + + + {sortBy && ( + + {t('fields.sort_order')} + + + )} + + + + + + + + + + + + + {canDeleteClients && selectedClient && ( + + )} + + + + ) +} + +export default ClientListPage diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientLogoutPanel.js b/admin-ui/plugins/auth-server/components/Clients/ClientLogoutPanel.js deleted file mode 100644 index 32a3c0e989..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientLogoutPanel.js +++ /dev/null @@ -1,123 +0,0 @@ -import React from 'react' -import { Container } from 'Components' -import GluuToogleRow from 'Routes/Apps/Gluu/GluuToogleRow' -import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' -import GluuTypeAheadWithAdd from 'Routes/Apps/Gluu/GluuTypeAheadWithAdd' -import GluuBooleanSelectBox from 'Routes/Apps/Gluu/GluuBooleanSelectBox' -import { useTranslation } from 'react-i18next' -import PropTypes from 'prop-types' -const DOC_CATEGORY = 'openid_client' - -function uriValidator(uri) { - return uri -} -const post_uri_id = 'post_uri_id' -const postLogoutRedirectUris = [] -function postUriValidator(uri) { - return uri -} -const backchannelLogoutUris = [] -const backchannel_uri_id = 'backchannel_uri_id' - -function ClientLogoutPanel({ formik, viewOnly, modifiedFields, setModifiedFields }) { - const { t } = useTranslation() - - return ( - - { - setModifiedFields({ - ...modifiedFields, - 'Front Channel Logout Uri': e.target.value, - }) - }} - /> - { - setModifiedFields({ - ...modifiedFields, - 'Post Logout Redirect Uris': items, - }) - }} - > - - { - setModifiedFields({ - ...modifiedFields, - 'Backchannel Logout Uri': items, - }) - }} - > - { - setModifiedFields({ - ...modifiedFields, - 'Logout Session Required': e.target.value, - }) - }} - /> - - { - setModifiedFields({ - ...modifiedFields, - 'Front Channel Logout Session Required': e.target.value, - }) - }} - /> - - ) -} - -export default ClientLogoutPanel -ClientLogoutPanel.propTypes = { - formik: PropTypes.any, - viewOnly: PropTypes.bool, - modifiedFields: PropTypes.object, - setModifiedFields: PropTypes.func, -} diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientScriptPanel.js b/admin-ui/plugins/auth-server/components/Clients/ClientScriptPanel.js deleted file mode 100644 index 8ac85f3d94..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientScriptPanel.js +++ /dev/null @@ -1,186 +0,0 @@ -import React from 'react' -import { Container } from 'Components' -import GluuTypeAheadForDn from 'Routes/Apps/Gluu/GluuTypeAheadForDn' -import PropTypes from 'prop-types' - -const DOC_CATEGORY = 'openid_client' - -function ClientScriptPanel({ scripts, formik, viewOnly, modifiedFields, setModifiedFields }) { - const postScripts = scripts - .filter((item) => item.scriptType == 'post_authn') - .filter((item) => item.enabled) - .map((item) => ({ dn: item.dn, name: item.name })) - - const spontaneousScripts = scripts - .filter((item) => item.scriptType == 'spontaneous_scope') - .filter((item) => item.enabled) - .map((item) => ({ dn: item.dn, name: item.name })) - - const consentScripts = scripts - .filter((item) => item.scriptType == 'consent_gathering') - .filter((item) => item.enabled) - .map((item) => ({ dn: item.dn, name: item.name })) - const instrospectionScripts = scripts - .filter((item) => item.scriptType == 'introspection') - .filter((item) => item.enabled) - .map((item) => ({ dn: item.dn, name: item.name })) - - const ropcScripts = scripts - .filter((item) => item.scriptType == 'resource_owner_password_credentials') - .filter((item) => item.enabled) - .map((item) => ({ dn: item.dn, name: item.name })) - const updateTokenScriptDns = scripts - .filter((item) => item.scriptType == 'update_token') - .filter((item) => item.enabled) - .map((item) => ({ dn: item.dn, name: item.name })) - function getMapping(exiting, fullArray) { - if (!exiting) { - exiting = [] - } - return fullArray.filter((item) => exiting.includes(item.dn)) - } - - return ( - - { - setModifiedFields({ - ...modifiedFields, - 'Spontaneous Scope Script Dns': selected, - }) - }} - > - { - setModifiedFields({ - ...modifiedFields, - 'Update Token Script Dns': selected.map((item) => item.name), - }) - }} - > - { - setModifiedFields({ - ...modifiedFields, - 'Post Authn Script': selected, - }) - }} - > - { - setModifiedFields({ - ...modifiedFields, - 'Introspection Scripts': selected.map((item) => item.name), - }) - }} - > - { - setModifiedFields({ - ...modifiedFields, - 'ROPC Scripts': selected.map((item) => item.name), - }) - }} - > - { - setModifiedFields({ - ...modifiedFields, - 'Consent Gathering Scripts': selected.map((item) => item.name), - }) - }} - > - - ) -} - -export default ClientScriptPanel -ClientScriptPanel.propTypes = { - formik: PropTypes.any, - scripts: PropTypes.any, - viewOnly: PropTypes.bool, - modifiedFields: PropTypes.any, - setModifiedFields: PropTypes.func, -} diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientShowScopes.js b/admin-ui/plugins/auth-server/components/Clients/ClientShowScopes.js deleted file mode 100644 index 163d03ef32..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientShowScopes.js +++ /dev/null @@ -1,41 +0,0 @@ -import React, { useContext } from 'react' -import { useTranslation } from 'react-i18next' -import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap' -import { Badge } from 'Components' -import { useSelector } from 'react-redux' -import { ThemeContext } from 'Context/theme/themeContext' - -function ClientShowScopes({ handler, data, isOpen }) { - const { t } = useTranslation() - const scopes = useSelector((state) => state.scopeReducer.items) - const theme = useContext(ThemeContext) - const selectedTheme = theme.state.theme - const clientScopes = data - ? scopes.filter((item) => data.includes(item.dn, 0)).map((item) => item.id) - : [] - - return ( - - Scopes - - {clientScopes.length > 0 ? ( - clientScopes?.map((scope, key) => { - return ( -
- {scope} -
- ) - }) - ) : ( -
{t('messages.no_scope_in_client')}
- )} -
- - - -
- ) -} -export default ClientShowScopes diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientShowSpontaneousScopes.js b/admin-ui/plugins/auth-server/components/Clients/ClientShowSpontaneousScopes.js deleted file mode 100644 index 8e2444d99d..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientShowSpontaneousScopes.js +++ /dev/null @@ -1,40 +0,0 @@ -import React, { useContext } from 'react' -import { useTranslation } from 'react-i18next' -import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap' -import { Badge } from 'Components' -import { useSelector } from 'react-redux' -import { ThemeContext } from 'Context/theme/themeContext' - -function ClientShowSpontaneousScopes({ handler, isOpen }) { - const { t } = useTranslation() - const scopesByCreator = useSelector((state) => state.scopeReducer.scopesByCreator) - - const printableScopes = scopesByCreator.filter((item) => item.scopeType == 'spontaneous') - const theme = useContext(ThemeContext) - const selectedTheme = theme.state.theme - - return ( - - {t('fields.spontaneousScopes')} - - {printableScopes.length > 0 ? ( - printableScopes?.map((scope, key) => { - return ( -
- {scope?.id} -
- ) - }) - ) : ( -
{t('messages.no_scope_in_spontaneous_client')}
- )} -
- - - -
- ) -} -export default ClientShowSpontaneousScopes diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientSoftwarePanel.js b/admin-ui/plugins/auth-server/components/Clients/ClientSoftwarePanel.js deleted file mode 100644 index 532375350c..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientSoftwarePanel.js +++ /dev/null @@ -1,180 +0,0 @@ -import React from 'react' -import { Container } from 'Components' -import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' -import GluuTypeAheadWithAdd from 'Routes/Apps/Gluu/GluuTypeAheadWithAdd' -import { useTranslation } from 'react-i18next' -import isEmpty from 'lodash/isEmpty' -import PropTypes from 'prop-types' -const DOC_CATEGORY = 'openid_client' - -const EMPTY = '' -const origin_uri_id = 'origin_uri_id' -const contact_uri_id = 'contact_uri_id' -const contacts = [] -const authorizedOrigins = [] - -function uriValidator(uri) { - return uri -} - -function emailValidator(email) { - return /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/.test(email) -} - -function ClientSoftwarePanel({ formik, viewOnly, modifiedFields, setModifiedFields }) { - const { t } = useTranslation() - - return ( - - { - setModifiedFields({ - ...modifiedFields, - 'Client Uri': e.target.value, - }) - }} - /> - { - setModifiedFields({ - ...modifiedFields, - 'Policy Uri': e.target.value, - }) - }} - /> - { - setModifiedFields({ - ...modifiedFields, - 'logo Uri': e.target.value, - }) - }} - /> - { - setModifiedFields({ - ...modifiedFields, - 'Tos Uri': e.target.value, - }) - }} - /> - { - setModifiedFields({ - ...modifiedFields, - Contacts: items, - }) - }} - > - { - setModifiedFields({ - ...modifiedFields, - 'Authorized Origins': items, - }) - }} - > - - { - setModifiedFields({ - ...modifiedFields, - 'Software Id': e.target.value, - }) - }} - /> - - { - setModifiedFields({ - ...modifiedFields, - 'Software Version': e.target.value, - }) - }} - /> - - { - setModifiedFields({ - ...modifiedFields, - 'Software Statement': e.target.value, - }) - }} - /> - - ) -} - -ClientSoftwarePanel.propTypes = { - formik: PropTypes.object, - viewOnly: PropTypes.bool, - modifiedFields: PropTypes.object, - setModifiedFields: PropTypes.func, -} -export default ClientSoftwarePanel diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientTokensPanel.js b/admin-ui/plugins/auth-server/components/Clients/ClientTokensPanel.js deleted file mode 100644 index 15d5f5f968..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientTokensPanel.js +++ /dev/null @@ -1,174 +0,0 @@ -import React from 'react' -import { Col, Container, FormGroup } from 'Components' -import GluuLabel from 'Routes/Apps/Gluu/GluuLabel' -import GluuToogleRow from 'Routes/Apps/Gluu/GluuToogleRow' -import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' -import GluuBooleanSelectBox from 'Routes/Apps/Gluu/GluuBooleanSelectBox' -import GluuTypeAheadWithAdd from 'Routes/Apps/Gluu/GluuTypeAheadWithAdd' -import { FormControlLabel, Radio, RadioGroup } from '@mui/material' -import PropTypes from 'prop-types' -const DOC_CATEGORY = 'openid_client' - -const audience_id = 'audience_id' -function audienceValidator(aud) { - return aud -} - -function ClientTokensPanel({ formik, viewOnly, modifiedFields, setModifiedFields }) { - return ( - - - - - - - { - formik.setFieldValue( - 'accessTokenAsJwt', - e.target.value === 'true' ? 'true' : 'false', - ) - setModifiedFields({ ...modifiedFields, 'Access Token as jwt': e.target.value }) - }} - > - } - label="JWT" - disabled={viewOnly} - /> - } - label="Reference" - disabled={viewOnly} - /> - - - - - - { - setModifiedFields({ - ...modifiedFields, - 'Include claims in id token': e.target.checked, - }) - }} - /> - - - - { - setModifiedFields({ - ...modifiedFields, - 'Run introspection script before jwt creation': e.target.value, - }) - }} - /> - { - setModifiedFields({ ...modifiedFields, 'Id token token binding cnf': e.target.value }) - }} - /> - { - setModifiedFields({ ...modifiedFields, 'Additional audience': items }) - }} - > - - { - setModifiedFields({ ...modifiedFields, 'Access token lifetime': e.target.value }) - }} - /> - { - setModifiedFields({ ...modifiedFields, 'Refresh token lifetime': e.target.value }) - }} - /> - { - setModifiedFields({ ...modifiedFields, 'Default max age': e.target.value }) - }} - /> - - ) -} - -export default ClientTokensPanel -ClientTokensPanel.propTypes = { - formik: PropTypes.any, - viewOnly: PropTypes.bool, - modifiedFields: PropTypes.any, - setModifiedFields: PropTypes.func, -} diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientWizardForm.js b/admin-ui/plugins/auth-server/components/Clients/ClientWizardForm.js deleted file mode 100644 index f28fc4db2e..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/ClientWizardForm.js +++ /dev/null @@ -1,598 +0,0 @@ -import React, { useState, useContext, useRef, useEffect, useMemo } from 'react' -import { Card, CardFooter, CardBody, Button, Wizard, WizardStep } from 'Components' -import { Form } from 'reactstrap' -import ClientBasic from './ClientBasicPanel' -import ClientAdvanced from './ClientAdvancedPanel' -import ClientScript from './ClientScriptPanel' -import ClientActiveTokens from './ClientActiveTokens' -import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' -import { Formik } from 'formik' -import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' -import { useCedarling } from '@/cedarling' -import { CEDAR_RESOURCE_SCOPES } from '@/cedarling/constants/resourceScopes' -import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' -import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' -import { ThemeContext } from 'Context/theme/themeContext' -import ClientTokensPanel from './ClientTokensPanel' -import ClientLogoutPanel from './ClientLogoutPanel' -import ClientSoftwarePanel from './ClientSoftwarePanel' -import ClientCibaParUmaPanel from './ClientCibaParUmaPanel' -import ClientEncryptionSigningPanel from './ClientEncryptionSigningPanel' -import { toast } from 'react-toastify' -import { setClientSelectedScopes } from 'Plugins/auth-server/redux/features/scopeSlice' -import { cloneDeep } from 'lodash' -import { useDispatch, useSelector } from 'react-redux' -import PropTypes from 'prop-types' -import { adminUiFeatures } from 'Plugins/admin/helper/utils' - -const sequence = [ - 'Basic', - 'Tokens', - 'Logout', - 'SoftwareInfo', - 'CIBA/PAR/UMA', - 'Encryption/Signing', - 'AdvancedClientProperties', - 'ClientScripts', - 'ClientActiveTokens', -] - -const ATTRIBUTE = 'attributes' -let commitMessage = '' -function ClientWizardForm({ - client_data, - viewOnly, - scopes, - scripts, - - customOnSubmit, - oidcConfiguration, - umaResources, - isEdit = false, - modifiedFields, - setModifiedFields, -}) { - const { hasCedarWritePermission, authorizeHelper } = useCedarling() - const formRef = useRef() - const { t } = useTranslation() - const navigate = useNavigate() - const theme = useContext(ThemeContext) - const selectedTheme = theme.state.theme - const [modal, setModal] = useState(false) - const [currentStep, setCurrentStep] = useState(sequence[0]) - const dispatch = useDispatch() - const { permissions: cedarPermissions } = useSelector((state) => state.cedarPermissions) - - const clientResourceId = ADMIN_UI_RESOURCES.Clients - const clientScopes = useMemo( - () => CEDAR_RESOURCE_SCOPES[clientResourceId] || [], - [clientResourceId], - ) - - const canWriteClient = useMemo( - () => hasCedarWritePermission(clientResourceId), - [hasCedarWritePermission, clientResourceId], - ) - - // Permission initialization - useEffect(() => { - authorizeHelper(clientScopes) - }, [authorizeHelper, clientScopes]) - - const initialValues = { - inum: client_data.inum, - dn: client_data.dn, - clientSecret: client_data.clientSecret, - displayName: client_data.displayName, - clientName: client_data.clientName, - description: client_data.description, - applicationType: client_data.applicationType, - subjectType: client_data.subjectType, - registrationAccessToken: client_data.registrationAccessToken, - clientIdIssuedAt: client_data.clientIdIssuedAt, - initiateLoginUri: client_data.initiateLoginUri, - logoUri: client_data.logoUri, - clientUri: client_data.clientUri, - tosUri: client_data.tosUri, - jwksUri: client_data.jwksUri, - jwks: client_data.jwks, - expirable: !!client_data.expirationDate, - expirationDate: client_data.expirationDate, - softwareStatement: client_data.softwareStatement, - softwareVersion: client_data.softwareVersion, - softwareId: client_data.softwareId, - idTokenSignedResponseAlg: client_data.idTokenSignedResponseAlg, - idTokenEncryptedResponseAlg: client_data.idTokenEncryptedResponseAlg, - tokenEndpointAuthMethod: client_data.tokenEndpointAuthMethod, - accessTokenSigningAlg: client_data.accessTokenSigningAlg, - idTokenEncryptedResponseEnc: client_data.idTokenEncryptedResponseEnc, - requestObjectEncryptionAlg: client_data.requestObjectEncryptionAlg, - requestObjectSigningAlg: client_data.requestObjectSigningAlg, - requestObjectEncryptionEnc: client_data.requestObjectEncryptionEnc, - userInfoEncryptedResponseAlg: client_data.userInfoEncryptedResponseAlg, - userInfoSignedResponseAlg: client_data.userInfoSignedResponseAlg, - userInfoEncryptedResponseEnc: client_data.userInfoEncryptedResponseEnc, - idTokenTokenBindingCnf: client_data.idTokenTokenBindingCnf, - backchannelUserCodeParameter: client_data.backchannelUserCodeParameter, - refreshTokenLifetime: client_data.refreshTokenLifetime, - defaultMaxAge: client_data.defaultMaxAge, - accessTokenLifetime: client_data.accessTokenLifetime, - backchannelTokenDeliveryMode: client_data.backchannelTokenDeliveryMode, - backchannelClientNotificationEndpoint: client_data.backchannelClientNotificationEndpoint, - frontChannelLogoutUri: client_data.frontChannelLogoutUri, - policyUri: client_data.policyUri, - logoURI: client_data.logoURI, - sectorIdentifierUri: client_data.sectorIdentifierUri, - redirectUris: client_data.redirectUris, - claimRedirectUris: client_data.claimRedirectUris || [], - authorizedOrigins: client_data.authorizedOrigins || [], - requestUris: client_data.requestUris || [], - postLogoutRedirectUris: client_data.postLogoutRedirectUris, - responseTypes: client_data.responseTypes, - grantTypes: client_data.grantTypes, - contacts: client_data.contacts, - defaultAcrValues: client_data.defaultAcrValues, - scopes: client_data.scopes, - oxAuthClaims: client_data.oxAuthClaims, - attributes: client_data.attributes, - frontChannelLogoutSessionRequired: client_data.frontChannelLogoutSessionRequired, - customObjectClasses: client_data.customObjectClasses || [], - trustedClient: client_data.trustedClient, - persistClientAuthorizations: client_data.persistClientAuthorizations, - includeClaimsInIdToken: client_data.includeClaimsInIdToken, - rptAsJwt: client_data.rptAsJwt, - accessTokenAsJwt: client_data.accessTokenAsJwt, - disabled: client_data.disabled, - deletable: client_data.deletable, - tokenBindingSupported: client_data.tokenBindingSupported, - } - - const [client] = useState(initialValues) - - function changeStep(stepId) { - setCurrentStep(stepId) - } - function toggle() { - setModal(!modal) - } - - function validateFinish() { - if ( - formRef.current.values.grantTypes.includes('authorization_code') || - formRef.current.values.grantTypes.includes('implicit') || - formRef.current.values.grantTypes.length == 0 - ) { - if (formRef && formRef.current && formRef.current.values.redirectUris.length > 0) { - toggle() - } else { - toast.info('Please add atleast 1 redirect URL') - } - } else { - toggle() - } - } - function setId(index) { - return sequence[index] - } - function prevStep() { - setCurrentStep(sequence[sequence.indexOf(currentStep) - 1]) - } - function nextStep() { - if ( - formRef.current.values.grantTypes.includes('authorization_code') || - formRef.current.values.grantTypes.includes('implicit') - ) { - if (formRef && formRef.current && formRef.current.values.redirectUris.length > 0) { - setCurrentStep(sequence[sequence.indexOf(currentStep) + 1]) - } else { - toast.info('Please add atleast 1 redirect URL') - } - } else { - setCurrentStep(sequence[sequence.indexOf(currentStep) + 1]) - } - } - function isComplete(stepId) { - return sequence.indexOf(stepId) < sequence.indexOf(currentStep) - } - function submitForm(message) { - commitMessage = message - toggle() - document.getElementsByClassName('UserActionSubmitButton')[0].click() - } - - useEffect(() => { - return function cleanup() { - dispatch(setClientSelectedScopes([])) - } - }, []) - useEffect(() => {}, [cedarPermissions]) - - const activeClientStep = (formik) => { - switch (currentStep) { - case sequence[0]: - return ( -
- -
- ) - case sequence[1]: - return ( -
- -
- ) - case sequence[2]: - return ( -
- -
- ) - case sequence[3]: - return ( -
- -
- ) - case sequence[4]: - return ( -
- -
- ) - case sequence[5]: - return ( -
- -
- ) - case sequence[6]: - return ( -
- -
- ) - case sequence[7]: - return ( -
- -
- ) - - case sequence[8]: - return ( -
- -
- ) - } - } - - function onKeyDown(keyEvent) { - if ((keyEvent.charCode || keyEvent.keyCode) === 13) { - keyEvent.preventDefault() - } - } - - const downloadClientData = (values) => { - const jsonData = JSON.stringify(values, null, 2) - - const blob = new Blob([jsonData], { type: 'application/json' }) - - const link = document.createElement('a') - link.href = URL.createObjectURL(blob) - link.download = values.displayName ? `${values.displayName}.json` : 'client-summary.json' - - document.body.appendChild(link) - - link.click() - - document.body.removeChild(link) - URL.revokeObjectURL(link.href) - } - - return ( - - - { - const values = { - ...args[0], - accessTokenAsJwt: args[0]?.accessTokenAsJwt && JSON.parse(args[0]?.accessTokenAsJwt), - rptAsJwt: args[0]?.rptAsJwt && JSON.parse(args[0]?.rptAsJwt), - [ATTRIBUTE]: args[0][ATTRIBUTE] && { ...args[0][ATTRIBUTE] }, - } - delete values.expirable - values['action_message'] = commitMessage - values['modifiedFields'] = modifiedFields - customOnSubmit(JSON.parse(JSON.stringify(values))) - }} - > - {(formik) => ( -
- -
- -
- - - } - complete={isComplete(sequence[0])} - > - {t('titles.client_basic')} - - } - complete={isComplete(sequence[1])} - > - {t('titles.token')} - - } - complete={isComplete(sequence[2])} - > - {t('titles.log_out')} - - } - complete={isComplete(sequence[3])} - > - {t('titles.software_info')} - - } - complete={isComplete(sequence[4])} - > - {t('titles.CIBA_PAR_UMA')} - - } - complete={isComplete(sequence[5])} - > - {t('titles.encryption_signing')} - - } - complete={isComplete(sequence[6])} - > - {t('titles.client_advanced')} - - } - complete={isComplete(sequence[7])} - > - {t('titles.client_scripts')} - - - {isEdit ? ( - } - complete={isComplete(sequence[8])} - > - {t('titles.activeTokens')} - - ) : ( - <> - )} - - - {activeClientStep(formik)} - -
-
- {!viewOnly && currentStep === sequence[0] && ( - - )} - {currentStep !== sequence[0] && ( - - )} -
-
- {currentStep !== sequence[sequence.length - 1] && ( - - )} - {!viewOnly && canWriteClient && ( - - )} -
-
-
- -
-
- )} -
-
- { - return { path: item, value: modifiedFields[item] } - }) - : [] - } - /> -
- ) -} - -ClientWizardForm.propTypes = { - client_data: PropTypes.any, - viewOnly: PropTypes.bool, - scopes: PropTypes.array, - scripts: PropTypes.array, - permissions: PropTypes.array, - customOnSubmit: PropTypes.func, - oidcConfiguration: PropTypes.object, - umaResources: PropTypes.array, - isEdit: PropTypes.bool, - modifiedFields: PropTypes.any, - setModifiedFields: PropTypes.func, -} - -export default ClientWizardForm diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx new file mode 100644 index 0000000000..542862b789 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx @@ -0,0 +1,241 @@ +import React, { useCallback, useContext, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Box, Grid, Typography, Chip, Divider } from '@mui/material' +import { ThemeContext } from 'Context/theme/themeContext' +import getThemeColor from 'Context/theme/config' +import type { Client } from '../types' +import { formatGrantTypeLabel, formatResponseTypeLabel, formatScopeDisplay } from '../helper/utils' + +interface ClientDetailViewProps { + client: Client +} + +const ClientDetailView: React.FC = ({ client }) => { + const { t } = useTranslation() + const theme = useContext(ThemeContext) + const selectedTheme = theme?.state?.theme || 'darkBlue' + + const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) + + const containerStyle = useMemo( + () => ({ + p: 3, + backgroundColor: themeColors?.lightBackground || '#fafafa', + }), + [themeColors], + ) + + const sectionStyle = useMemo( + () => ({ + mb: 3, + }), + [], + ) + + const labelStyle = useMemo( + () => ({ + fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, + color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#555', + fontSize: '0.85rem', + mb: 0.5, + }), + [themeColors, selectedTheme], + ) + + const valueStyle = useMemo( + () => ({ + fontWeight: selectedTheme === 'darkBlack' ? 600 : 400, + color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + fontSize: '0.9rem', + wordBreak: 'break-all' as const, + }), + [themeColors, selectedTheme], + ) + + const sectionTitleStyle = useMemo( + () => ({ + mb: 2, + fontWeight: 700, + color: themeColors?.background || '#1976d2', + }), + [themeColors], + ) + + const chipStyle = useMemo( + () => ({ + m: 0.25, + fontSize: '0.75rem', + }), + [], + ) + + const chipColors = useMemo( + () => ({ + scopes: themeColors?.lightBackground || '#e3f2fd', + grants: themeColors?.lightBackground || '#fff3e0', + responses: themeColors?.lightBackground || '#e8f5e9', + uris: themeColors?.lightBackground || '#e1f5fe', + logout: themeColors?.lightBackground || '#fce4ec', + contacts: themeColors?.lightBackground || '#f3e5f5', + }), + [themeColors], + ) + + const renderField = useCallback( + (label: string, value: string | number | boolean | undefined | null) => { + if (value === undefined || value === null || value === '') return null + return ( + + {label} + + {typeof value === 'boolean' + ? value + ? t('options.yes') + : t('options.no') + : String(value)} + + + ) + }, + [labelStyle, valueStyle, t], + ) + + const renderChipList = useCallback( + (label: string, items: string[] | undefined, color: string) => { + if (!items || items.length === 0) return null + return ( + + {label} + + {items.map((item, index) => ( + + ))} + + + ) + }, + [labelStyle, chipStyle], + ) + + return ( + + + + {t('titles.client_details')} + + + {renderField(t('fields.client_id'), client.inum)} + {renderField(t('fields.client_name'), client.clientName)} + {renderField(t('fields.displayname'), client.displayName)} + {renderField(t('fields.description'), client.description)} + {renderField(t('fields.application_type'), client.applicationType)} + {renderField(t('fields.subject_type'), client.subjectType)} + {renderField( + t('fields.status'), + client.disabled ? t('fields.disabled') : t('fields.active'), + )} + {renderField(t('fields.is_trusted_client'), client.trustedClient)} + + + + + + + + {t('titles.scopes_and_grants')} + + + {renderChipList( + t('fields.scopes'), + client.scopes?.map((s) => formatScopeDisplay(s)), + chipColors.scopes, + )} + {renderChipList( + t('fields.grant_types'), + client.grantTypes?.map((g) => formatGrantTypeLabel(g)), + chipColors.grants, + )} + {renderChipList( + t('fields.response_types'), + client.responseTypes?.map((r) => formatResponseTypeLabel(r)), + chipColors.responses, + )} + + + + + + + + {t('titles.uris')} + + + {renderChipList(t('fields.redirect_uris'), client.redirectUris, chipColors.uris)} + {renderChipList( + t('fields.post_logout_redirect_uris'), + client.postLogoutRedirectUris, + chipColors.logout, + )} + {renderField(t('fields.client_uri'), client.clientUri)} + {renderField(t('fields.logo_uri'), client.logoUri)} + {renderField(t('fields.policy_uri'), client.policyUri)} + {renderField(t('fields.tos_uri'), client.tosUri)} + + + + + + + + {t('titles.authentication')} + + + {renderField(t('fields.token_endpoint_auth_method'), client.tokenEndpointAuthMethod)} + {renderField(t('fields.id_token_signed_response_alg'), client.idTokenSignedResponseAlg)} + {renderField(t('fields.access_token_signing_alg'), client.accessTokenSigningAlg)} + {renderField(t('fields.jwks_uri'), client.jwksUri)} + + + + + + + + {t('titles.token_settings')} + + + {renderField(t('fields.access_token_lifetime'), client.accessTokenLifetime)} + {renderField(t('fields.refresh_token_lifetime'), client.refreshTokenLifetime)} + {renderField(t('fields.default_max_age'), client.defaultMaxAge)} + {renderField(t('fields.access_token_as_jwt'), client.accessTokenAsJwt)} + {renderField(t('fields.include_claims_in_id_token'), client.includeClaimsInIdToken)} + {renderField( + t('fields.persist_client_authorizations'), + client.persistClientAuthorizations, + )} + + + + {client.contacts && client.contacts.length > 0 && ( + <> + + + + {t('titles.contacts')} + + + {renderChipList(t('fields.contacts'), client.contacts, chipColors.contacts)} + + + + )} + + ) +} + +export default ClientDetailView diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx new file mode 100644 index 0000000000..1a5b92338b --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx @@ -0,0 +1,339 @@ +import React, { useCallback, useContext, useMemo, useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { Formik, Form } from 'formik' +import { Box, Tabs, Tab, Button, Paper } from '@mui/material' +import { Card, CardBody } from 'Components' +import { ThemeContext } from 'Context/theme/themeContext' +import getThemeColor from 'Context/theme/config' +import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' +import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' +import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' +import { adminUiFeatures } from 'Plugins/admin/helper/utils' +import { useGetOauthScopes } from 'JansConfigApi' +import { useSelector, useDispatch } from 'react-redux' +import { getScripts } from 'Redux/features/initSlice' +import { getOidcDiscovery } from 'Redux/features/oidcDiscoverySlice' +import type { + ClientFormProps, + ClientFormValues, + ClientTab, + ModifiedFields, + ClientScope, + RootState, +} from '../types' +import { clientValidationSchema } from '../helper/validations' +import { buildClientInitialValues, buildClientPayload, downloadClientAsJson } from '../helper/utils' +import { TAB_LABELS, DEFAULT_SCOPE_SEARCH_LIMIT } from '../helper/constants' +import BasicInfoTab from '../tabs/BasicInfoTab' +import AuthenticationTab from '../tabs/AuthenticationTab' +import ScopesGrantsTab from '../tabs/ScopesGrantsTab' +import AdvancedTab from '../tabs/AdvancedTab' +import UrisTab from '../tabs/UrisTab' + +interface InitReducerState { + scripts: Array<{ dn?: string; name?: string; scriptType?: string; enabled?: boolean }> +} + +interface OidcDiscoveryState { + configuration: Record + loading: boolean +} + +interface ExtendedRootState extends RootState { + initReducer: InitReducerState + oidcDiscoveryReducer: OidcDiscoveryState +} + +const TABS: ClientTab[] = ['basic', 'authentication', 'scopes', 'advanced', 'uris'] + +const ClientForm: React.FC = ({ + client, + isEdit = false, + viewOnly = false, + onSubmit, + onCancel, +}) => { + const { t } = useTranslation() + const dispatch = useDispatch() + const theme = useContext(ThemeContext) + const selectedTheme = theme?.state?.theme || 'darkBlue' + const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) + + const [activeTab, setActiveTab] = useState('basic') + const [modifiedFields, setModifiedFields] = useState({}) + const [commitModalOpen, setCommitModalOpen] = useState(false) + const [formValues, setFormValues] = useState(null) + const [scopeSearchPattern, setScopeSearchPattern] = useState('') + + const reduxScripts = useSelector((state: ExtendedRootState) => state.initReducer?.scripts || []) + + const scripts = useMemo( + () => + reduxScripts.map( + (s): { dn: string; name: string; scriptType?: string; enabled?: boolean } => ({ + dn: s.dn || '', + name: s.name || '', + scriptType: s.scriptType, + enabled: s.enabled, + }), + ), + [reduxScripts], + ) + const oidcConfiguration = useSelector( + (state: ExtendedRootState) => state.oidcDiscoveryReducer?.configuration || {}, + ) + + const scopeQueryParams = useMemo( + () => ({ + limit: DEFAULT_SCOPE_SEARCH_LIMIT, + pattern: scopeSearchPattern || undefined, + }), + [scopeSearchPattern], + ) + + const { data: scopesResponse, isLoading: scopesLoading } = useGetOauthScopes(scopeQueryParams, { + query: { + refetchOnMount: 'always' as const, + refetchOnWindowFocus: false, + staleTime: 30000, + }, + }) + + const scopes = useMemo((): ClientScope[] => { + const entries = (scopesResponse?.entries || []) as Array<{ + dn?: string + inum?: string + id?: string + displayName?: string + description?: string + }> + return entries.map( + (scope): ClientScope => ({ + dn: scope.dn || '', + inum: scope.inum, + id: scope.id, + displayName: scope.displayName || scope.id, + description: scope.description, + }), + ) + }, [scopesResponse?.entries]) + + const initialValues = useMemo(() => buildClientInitialValues(client), [client]) + + useEffect(() => { + dispatch(getScripts({ action: { limit: 100000 } })) + dispatch(getOidcDiscovery()) + }, [dispatch]) + + const handleTabChange = useCallback((_: React.SyntheticEvent, newValue: ClientTab) => { + setActiveTab(newValue) + }, []) + + const handleScopeSearch = useCallback((pattern: string) => { + setScopeSearchPattern(pattern) + }, []) + + const handleFormSubmit = useCallback((values: ClientFormValues) => { + setFormValues(values) + setCommitModalOpen(true) + }, []) + + const handleCommitAccept = useCallback( + (message: string) => { + if (formValues) { + const payload = buildClientPayload(formValues) + onSubmit(payload as ClientFormValues, message, modifiedFields) + } + setCommitModalOpen(false) + }, + [formValues, modifiedFields, onSubmit], + ) + + const handleCommitClose = useCallback(() => { + setCommitModalOpen(false) + }, []) + + const handleDownload = useCallback((values: ClientFormValues) => { + downloadClientAsJson(values) + }, []) + + const tabStyle = useMemo( + () => ({ + borderBottom: 1, + borderColor: 'divider', + mb: 2, + }), + [], + ) + + const buttonStyle = useMemo( + () => ({ + ...applicationStyle.buttonStyle, + mr: 1, + }), + [], + ) + + const operations = useMemo(() => { + return Object.keys(modifiedFields).map((key) => ({ + path: key, + value: modifiedFields[key], + })) + }, [modifiedFields]) + + const renderTabContent = useCallback( + (formik: { + values: ClientFormValues + setFieldValue: (field: string, value: unknown) => void + }) => { + const tabProps = { + formik: formik as any, + viewOnly, + modifiedFields, + setModifiedFields, + } + + switch (activeTab) { + case 'basic': + return + case 'authentication': + return + case 'scopes': + return ( + + ) + case 'advanced': + return + case 'uris': + return + default: + return null + } + }, + [ + activeTab, + viewOnly, + modifiedFields, + oidcConfiguration, + scopes, + scopesLoading, + handleScopeSearch, + scripts, + isEdit, + ], + ) + + return ( + + + + {(formik) => ( +
+ + + + + + + + {TABS.map((tab) => ( + + ))} + + + + {renderTabContent(formik)} + + + {!viewOnly && ( + + {onCancel && ( + + )} + + + )} + + )} +
+
+ + +
+ ) +} + +export default ClientForm diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts b/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts new file mode 100644 index 0000000000..f72e1b5665 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts @@ -0,0 +1,148 @@ +export const GRANT_TYPES = [ + { value: 'authorization_code', label: 'Authorization Code' }, + { value: 'implicit', label: 'Implicit' }, + { value: 'refresh_token', label: 'Refresh Token' }, + { value: 'client_credentials', label: 'Client Credentials' }, + { value: 'password', label: 'Resource Owner Password Credentials' }, + { value: 'urn:ietf:params:oauth:grant-type:uma-ticket', label: 'UMA Ticket' }, + { value: 'urn:openid:params:grant-type:ciba', label: 'CIBA' }, + { value: 'urn:ietf:params:oauth:grant-type:device_code', label: 'Device Code' }, + { value: 'urn:ietf:params:oauth:grant-type:token-exchange', label: 'Token Exchange' }, + { value: 'urn:ietf:params:oauth:grant-type:jwt-bearer', label: 'JWT Bearer' }, +] as const + +export const RESPONSE_TYPES = [ + { value: 'code', label: 'Code' }, + { value: 'token', label: 'Token' }, + { value: 'id_token', label: 'ID Token' }, +] as const + +export const APPLICATION_TYPES = [ + { value: 'web', label: 'Web' }, + { value: 'native', label: 'Native' }, +] as const + +export const SUBJECT_TYPES = [ + { value: 'public', label: 'Public' }, + { value: 'pairwise', label: 'Pairwise' }, +] as const + +export const TOKEN_ENDPOINT_AUTH_METHODS = [ + { value: 'client_secret_basic', label: 'Client Secret Basic' }, + { value: 'client_secret_post', label: 'Client Secret Post' }, + { value: 'client_secret_jwt', label: 'Client Secret JWT' }, + { value: 'private_key_jwt', label: 'Private Key JWT' }, + { value: 'tls_client_auth', label: 'TLS Client Auth' }, + { value: 'self_signed_tls_client_auth', label: 'Self-Signed TLS Client Auth' }, + { value: 'access_token', label: 'Access Token' }, + { value: 'none', label: 'None' }, +] as const + +export const BACKCHANNEL_TOKEN_DELIVERY_MODES = [ + { value: 'poll', label: 'Poll' }, + { value: 'ping', label: 'Ping' }, + { value: 'push', label: 'Push' }, +] as const + +export const SIGNING_ALGORITHMS = [ + 'none', + 'HS256', + 'HS384', + 'HS512', + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES256K', + 'ES384', + 'ES512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA', +] as const + +export const ENCRYPTION_ALGORITHMS = [ + 'RSA1_5', + 'RSA-OAEP', + 'RSA-OAEP-256', + 'A128KW', + 'A192KW', + 'A256KW', + 'dir', + 'ECDH-ES', + 'ECDH-ES+A128KW', + 'ECDH-ES+A192KW', + 'ECDH-ES+A256KW', + 'A128GCMKW', + 'A192GCMKW', + 'A256GCMKW', + 'PBES2-HS256+A128KW', + 'PBES2-HS384+A192KW', + 'PBES2-HS512+A256KW', +] as const + +export const ENCRYPTION_ENCODING = [ + 'A128CBC-HS256', + 'A192CBC-HS384', + 'A256CBC-HS512', + 'A128GCM', + 'A192GCM', + 'A256GCM', +] as const + +export const SCRIPT_TYPES = { + POST_AUTHN: 'post_authn', + SPONTANEOUS_SCOPE: 'spontaneous_scope', + CONSENT_GATHERING: 'consent_gathering', + INTROSPECTION: 'introspection', + RESOURCE_OWNER_PASSWORD_CREDENTIALS: 'resource_owner_password_credentials', + UPDATE_TOKEN: 'update_token', + CLIENT_REGISTRATION: 'client_registration', + ID_GENERATOR: 'id_generator', + CIBA_END_USER_NOTIFICATION: 'ciba_end_user_notification', + REVOKE_TOKEN: 'revoke_token', + PERSISTENCE_EXTENSION: 'persistence_extension', + IDP: 'idp', + APPLICATION_SESSION: 'application_session', + END_SESSION: 'end_session', + DYNAMIC_SCOPE: 'dynamic_scope', + UMA_RPT_POLICY: 'uma_rpt_policy', + UMA_RPT_CLAIMS: 'uma_rpt_claims', + UMA_CLAIMS_GATHERING: 'uma_claims_gathering', +} as const + +export const TOKEN_TYPE_OPTIONS = [ + { value: 'true', label: 'JWT' }, + { value: 'false', label: 'Reference' }, +] as const + +export const BOOLEAN_OPTIONS = [ + { value: 'true', label: 'Yes' }, + { value: 'false', label: 'No' }, +] as const + +export const CLIENT_ROUTES = { + LIST: '/auth-server/clients', + ADD: '/auth-server/client/new', + EDIT: '/auth-server/client/edit', +} as const + +export const DEFAULT_PAGE_SIZE = 10 +export const DEFAULT_SCOPE_SEARCH_LIMIT = 100 + +export const TAB_LABELS = { + basic: 'titles.basic_info', + authentication: 'titles.authentication', + scopes: 'titles.scopes_and_grants', + advanced: 'titles.advanced', + uris: 'titles.uris', +} as const + +export type GrantType = (typeof GRANT_TYPES)[number]['value'] +export type ResponseType = (typeof RESPONSE_TYPES)[number]['value'] +export type ApplicationType = (typeof APPLICATION_TYPES)[number]['value'] +export type SubjectType = (typeof SUBJECT_TYPES)[number]['value'] +export type TokenEndpointAuthMethod = (typeof TOKEN_ENDPOINT_AUTH_METHODS)[number]['value'] +export type BackchannelTokenDeliveryMode = + (typeof BACKCHANNEL_TOKEN_DELIVERY_MODES)[number]['value'] diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts new file mode 100644 index 0000000000..5ffffbde5d --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts @@ -0,0 +1,190 @@ +import type { ExtendedClient, ClientFormValues, ModifiedFields } from '../types' +import { EMPTY_CLIENT } from '../types' + +export function buildClientInitialValues(client: Partial): ClientFormValues { + const merged = { + ...EMPTY_CLIENT, + ...client, + attributes: { + ...EMPTY_CLIENT.attributes, + ...client.attributes, + }, + expirable: Boolean(client.expirationDate), + } + + return merged as ClientFormValues +} + +export function buildClientPayload(values: ClientFormValues): ExtendedClient { + const payload: ExtendedClient = { ...values } + + if (!payload.expirable) { + delete payload.expirationDate + } + delete payload.expirable + + if (typeof payload.accessTokenAsJwt === 'string') { + payload.accessTokenAsJwt = payload.accessTokenAsJwt === 'true' + } + if (typeof payload.rptAsJwt === 'string') { + payload.rptAsJwt = payload.rptAsJwt === 'true' + } + + return payload +} + +export function hasFormChanges( + currentValues: ClientFormValues, + initialValues: ClientFormValues, +): boolean { + return JSON.stringify(currentValues) !== JSON.stringify(initialValues) +} + +export function trackFieldChange( + fieldLabel: string, + value: unknown, + setModifiedFields: React.Dispatch>, +): void { + setModifiedFields((prev) => ({ + ...prev, + [fieldLabel]: value, + })) +} + +export function formatScopeDisplay(scopeDn: string): string { + const parts = scopeDn.split(',') + const inumPart = parts.find((p) => p.startsWith('inum=')) + if (inumPart) { + return inumPart.replace('inum=', '') + } + return scopeDn +} + +export function formatGrantTypeLabel(grantType: string): string { + const labels: Record = { + 'authorization_code': 'Authorization Code', + 'implicit': 'Implicit', + 'refresh_token': 'Refresh Token', + 'client_credentials': 'Client Credentials', + 'password': 'Password', + 'urn:ietf:params:oauth:grant-type:uma-ticket': 'UMA Ticket', + 'urn:openid:params:grant-type:ciba': 'CIBA', + 'urn:ietf:params:oauth:grant-type:device_code': 'Device Code', + 'urn:ietf:params:oauth:grant-type:token-exchange': 'Token Exchange', + 'urn:ietf:params:oauth:grant-type:jwt-bearer': 'JWT Bearer', + } + return labels[grantType] || grantType +} + +export function formatResponseTypeLabel(responseType: string): string { + const labels: Record = { + code: 'Code', + token: 'Token', + id_token: 'ID Token', + } + return labels[responseType] || responseType +} + +export function extractInumFromDn(dn: string): string { + const match = dn.match(/inum=([^,]+)/) + return match ? match[1] : dn +} + +export function buildScopeDn(inum: string): string { + return `inum=${inum},ou=scopes,o=jans` +} + +export function filterScriptsByType( + scripts: Array<{ scriptType?: string; enabled?: boolean; dn?: string; name?: string }>, + scriptType: string, +): Array<{ dn: string; name: string }> { + return scripts + .filter((script) => script.scriptType === scriptType && script.enabled) + .map((script) => ({ + dn: script.dn || '', + name: script.name || '', + })) +} + +export function getScriptNameFromDn( + dn: string, + scripts: Array<{ dn?: string; name?: string }>, +): string { + const script = scripts.find((s) => s.dn === dn) + return script?.name || extractInumFromDn(dn) +} + +export function formatDateForDisplay(dateString: string | undefined): string { + if (!dateString) return '' + try { + const date = new Date(dateString) + return date.toLocaleString() + } catch { + return dateString + } +} + +export function formatDateForInput(dateString: string | undefined): string { + if (!dateString) return '' + try { + const date = new Date(dateString) + return date.toISOString().slice(0, 16) + } catch { + return '' + } +} + +export function isValidUrl(url: string): boolean { + try { + new URL(url) + return true + } catch { + return false + } +} + +export function isValidUri(uri: string): boolean { + if (!uri) return false + const uriPattern = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ + return uriPattern.test(uri) +} + +export function downloadClientAsJson(client: ExtendedClient): void { + const jsonData = JSON.stringify(client, null, 2) + const blob = new Blob([jsonData], { type: 'application/json' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = client.displayName + ? `${client.displayName}.json` + : client.clientName + ? `${client.clientName}.json` + : 'client-data.json' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(link.href) +} + +export function generateClientSecretPlaceholder(): string { + return '••••••••••••••••' +} + +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + return `${text.substring(0, maxLength)}...` +} + +export function sortByName(items: T[]): T[] { + return [...items].sort((a, b) => (a.name || '').localeCompare(b.name || '')) +} + +export function removeDuplicates(arr: T[]): T[] { + return [...new Set(arr)] +} + +export function arrayEquals(a: unknown[], b: unknown[]): boolean { + if (a.length !== b.length) return false + const sortedA = [...a].sort() + const sortedB = [...b].sort() + return sortedA.every((val, idx) => val === sortedB[idx]) +} diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts new file mode 100644 index 0000000000..df20fea379 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts @@ -0,0 +1,79 @@ +import * as Yup from 'yup' + +const urlSchema = Yup.string().url('Must be a valid URL') + +const uriArraySchema = Yup.array().of(Yup.string().url('Must be a valid URL')) + +export const clientValidationSchema = Yup.object().shape({ + clientName: Yup.string().nullable(), + displayName: Yup.string().nullable(), + redirectUris: Yup.array() + .of(Yup.string()) + .when('grantTypes', { + is: (grantTypes: string[]) => + grantTypes?.includes('authorization_code') || grantTypes?.includes('implicit'), + then: (schema) => + schema.min( + 1, + 'At least one redirect URI is required for authorization_code or implicit grants', + ), + otherwise: (schema) => schema, + }), + postLogoutRedirectUris: uriArraySchema.nullable(), + frontChannelLogoutUri: urlSchema.nullable(), + initiateLoginUri: urlSchema.nullable(), + clientUri: urlSchema.nullable(), + logoUri: urlSchema.nullable(), + policyUri: urlSchema.nullable(), + tosUri: urlSchema.nullable(), + jwksUri: urlSchema.nullable(), + sectorIdentifierUri: urlSchema.nullable(), + backchannelClientNotificationEndpoint: urlSchema.nullable(), + accessTokenLifetime: Yup.number().nullable().min(0, 'Must be a positive number'), + refreshTokenLifetime: Yup.number().nullable().min(0, 'Must be a positive number'), + defaultMaxAge: Yup.number().nullable().min(0, 'Must be a positive number'), + expirationDate: Yup.string() + .nullable() + .when('expirable', { + is: true, + then: (schema) => schema.required('Expiration date is required when client is set to expire'), + otherwise: (schema) => schema, + }), + contacts: Yup.array().of(Yup.string().email('Must be a valid email')).nullable(), +}) + +export function validateRedirectUri(uri: string): string | null { + if (!uri) return 'URI is required' + try { + new URL(uri) + return null + } catch { + if (uri.startsWith('http://localhost') || uri.startsWith('http://127.0.0.1')) { + return null + } + return 'Must be a valid URL' + } +} + +export function validateEmail(email: string): string | null { + if (!email) return null + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) ? null : 'Must be a valid email address' +} + +export function validatePositiveNumber(value: number | undefined | null): string | null { + if (value === undefined || value === null) return null + if (typeof value !== 'number') return 'Must be a number' + if (value < 0) return 'Must be a positive number' + return null +} + +export function validateRequiredArray( + arr: unknown[] | undefined | null, + minLength = 1, +): string | null { + if (!arr || arr.length < minLength) { + return `At least ${minLength} item(s) required` + } + return null +} diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/index.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/index.ts new file mode 100644 index 0000000000..58afeaf12e --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/index.ts @@ -0,0 +1,10 @@ +export { useClientActions } from './useClientActions' +export { + useClientList, + useClientById, + useCreateClient, + useUpdateClient, + useDeleteClient, + useInvalidateClientQueries, +} from './useClientApi' +export { useClientForm, useFieldTracking } from './useClientForm' diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts new file mode 100644 index 0000000000..31f6312d9a --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts @@ -0,0 +1,90 @@ +import { useCallback } from 'react' +import { useSelector } from 'react-redux' +import { useNavigate } from 'react-router-dom' +import { logAuditUserAction } from 'Utils/AuditLogger' +import { CREATE, UPDATE, DELETION } from '../../../../../app/audit/UserActionType' +import { OIDC } from '../../../redux/audit/Resources' +import type { ExtendedClient, ModifiedFields, RootState } from '../types' +import { CLIENT_ROUTES } from '../helper/constants' + +export function useClientActions() { + const navigate = useNavigate() + const authState = useSelector((state: RootState) => state.authReducer) + const token = authState?.token?.access_token + const client_id = authState?.config?.clientId + const userinfo = authState?.userinfo + + const logClientCreation = useCallback( + async (client: ExtendedClient, message: string, modifiedFields?: ModifiedFields) => { + await logAuditUserAction({ + token, + userinfo, + action: CREATE, + resource: OIDC, + message, + modifiedFields, + performedOn: client.inum, + client_id, + payload: client, + }) + }, + [token, userinfo, client_id], + ) + + const logClientUpdate = useCallback( + async (client: ExtendedClient, message: string, modifiedFields?: ModifiedFields) => { + await logAuditUserAction({ + token, + userinfo, + action: UPDATE, + resource: OIDC, + message, + modifiedFields, + performedOn: client.inum, + client_id, + payload: client, + }) + }, + [token, userinfo, client_id], + ) + + const logClientDeletion = useCallback( + async (client: ExtendedClient, message: string) => { + await logAuditUserAction({ + token, + userinfo, + action: DELETION, + resource: OIDC, + message, + performedOn: client.inum, + client_id, + payload: { inum: client.inum, clientName: client.clientName }, + }) + }, + [token, userinfo, client_id], + ) + + const navigateToClientList = useCallback(() => { + navigate(CLIENT_ROUTES.LIST) + }, [navigate]) + + const navigateToClientAdd = useCallback(() => { + navigate(CLIENT_ROUTES.ADD) + }, [navigate]) + + const navigateToClientEdit = useCallback( + (inum: string) => { + navigate(`${CLIENT_ROUTES.EDIT}/${inum}`) + }, + [navigate], + ) + + return { + logClientCreation, + logClientUpdate, + logClientDeletion, + navigateToClientList, + navigateToClientAdd, + navigateToClientEdit, + } +} diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts new file mode 100644 index 0000000000..4c88e2a8c7 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts @@ -0,0 +1,160 @@ +import { useCallback, useMemo } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useDispatch } from 'react-redux' +import { updateToast } from 'Redux/features/toastSlice' +import { + useGetOauthOpenidClients, + useGetOauthOpenidClientsByInum, + usePostOauthOpenidClient, + usePutOauthOpenidClient, + useDeleteOauthOpenidClientByInum, + getGetOauthOpenidClientsQueryKey, +} from 'JansConfigApi' +import type { Client, ClientListOptions } from '../types' + +export function useClientList(params: ClientListOptions) { + const queryOptions = useMemo( + () => ({ + query: { + refetchOnMount: 'always' as const, + refetchOnWindowFocus: false, + refetchOnReconnect: true, + staleTime: 30000, + }, + }), + [], + ) + + return useGetOauthOpenidClients(params, queryOptions) +} + +export function useClientById(inum: string, enabled = true) { + const queryOptions = useMemo( + () => ({ + query: { + enabled, + refetchOnMount: 'always' as const, + refetchOnWindowFocus: false, + staleTime: 30000, + }, + }), + [enabled], + ) + + return useGetOauthOpenidClientsByInum(inum, queryOptions) +} + +export function useCreateClient(onSuccess?: () => void, onError?: (error: Error) => void) { + const queryClient = useQueryClient() + const dispatch = useDispatch() + + const handleSuccess = useCallback(() => { + dispatch(updateToast(true, 'success')) + queryClient.invalidateQueries({ + predicate: (query) => { + const queryKey = query.queryKey[0] as string + return queryKey === getGetOauthOpenidClientsQueryKey()[0] + }, + }) + onSuccess?.() + }, [dispatch, queryClient, onSuccess]) + + const handleError = useCallback( + (error: Error) => { + const errorMessage = error?.message || 'Failed to create client' + dispatch(updateToast(true, 'error', errorMessage)) + onError?.(error) + }, + [dispatch, onError], + ) + + return usePostOauthOpenidClient({ + mutation: { + onSuccess: handleSuccess, + onError: handleError, + }, + }) +} + +export function useUpdateClient(onSuccess?: () => void, onError?: (error: Error) => void) { + const queryClient = useQueryClient() + const dispatch = useDispatch() + + const handleSuccess = useCallback(() => { + dispatch(updateToast(true, 'success')) + queryClient.invalidateQueries({ + predicate: (query) => { + const queryKey = query.queryKey[0] as string + return ( + queryKey === getGetOauthOpenidClientsQueryKey()[0] || + queryKey === 'getOauthOpenidClientsByInum' + ) + }, + }) + onSuccess?.() + }, [dispatch, queryClient, onSuccess]) + + const handleError = useCallback( + (error: Error) => { + const errorMessage = error?.message || 'Failed to update client' + dispatch(updateToast(true, 'error', errorMessage)) + onError?.(error) + }, + [dispatch, onError], + ) + + return usePutOauthOpenidClient({ + mutation: { + onSuccess: handleSuccess, + onError: handleError, + }, + }) +} + +export function useDeleteClient(onSuccess?: () => void, onError?: (error: Error) => void) { + const queryClient = useQueryClient() + const dispatch = useDispatch() + + const handleSuccess = useCallback(() => { + dispatch(updateToast(true, 'success')) + queryClient.invalidateQueries({ + predicate: (query) => { + const queryKey = query.queryKey[0] as string + return queryKey === getGetOauthOpenidClientsQueryKey()[0] + }, + }) + onSuccess?.() + }, [dispatch, queryClient, onSuccess]) + + const handleError = useCallback( + (error: Error) => { + const errorMessage = error?.message || 'Failed to delete client' + dispatch(updateToast(true, 'error', errorMessage)) + onError?.(error) + }, + [dispatch, onError], + ) + + return useDeleteOauthOpenidClientByInum({ + mutation: { + onSuccess: handleSuccess, + onError: handleError, + }, + }) +} + +export function useInvalidateClientQueries() { + const queryClient = useQueryClient() + + return useCallback(() => { + queryClient.invalidateQueries({ + predicate: (query) => { + const queryKey = query.queryKey[0] as string + return ( + queryKey === getGetOauthOpenidClientsQueryKey()[0] || + queryKey === 'getOauthOpenidClientsByInum' + ) + }, + }) + }, [queryClient]) +} diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientForm.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientForm.ts new file mode 100644 index 0000000000..d68235a794 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientForm.ts @@ -0,0 +1,77 @@ +import { useState, useCallback, useMemo } from 'react' +import type { ExtendedClient, ClientFormValues, ClientTab, ModifiedFields } from '../types' +import { buildClientInitialValues, hasFormChanges } from '../helper/utils' + +export function useClientForm(client: Partial) { + const [activeTab, setActiveTab] = useState('basic') + const [modifiedFields, setModifiedFields] = useState({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [commitModalOpen, setCommitModalOpen] = useState(false) + + const initialValues = useMemo(() => buildClientInitialValues(client), [client]) + + const handleTabChange = useCallback((tab: ClientTab) => { + setActiveTab(tab) + }, []) + + const trackFieldChange = useCallback((fieldLabel: string, value: unknown) => { + setModifiedFields((prev) => ({ + ...prev, + [fieldLabel]: value, + })) + }, []) + + const resetModifiedFields = useCallback(() => { + setModifiedFields({}) + }, []) + + const resetForm = useCallback(() => { + setModifiedFields({}) + setActiveTab('basic') + setIsSubmitting(false) + }, []) + + const checkHasChanges = useCallback( + (currentValues: ClientFormValues) => hasFormChanges(currentValues, initialValues), + [initialValues], + ) + + const openCommitModal = useCallback(() => { + setCommitModalOpen(true) + }, []) + + const closeCommitModal = useCallback(() => { + setCommitModalOpen(false) + }, []) + + return { + activeTab, + setActiveTab: handleTabChange, + modifiedFields, + setModifiedFields, + trackFieldChange, + resetModifiedFields, + resetForm, + checkHasChanges, + initialValues, + isSubmitting, + setIsSubmitting, + commitModalOpen, + openCommitModal, + closeCommitModal, + } +} + +export function useFieldTracking( + setModifiedFields: React.Dispatch>, +) { + return useCallback( + (fieldLabel: string, value: unknown) => { + setModifiedFields((prev) => ({ + ...prev, + [fieldLabel]: value, + })) + }, + [setModifiedFields], + ) +} diff --git a/admin-ui/plugins/auth-server/components/Clients/index.ts b/admin-ui/plugins/auth-server/components/Clients/index.ts new file mode 100644 index 0000000000..42f055d407 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/index.ts @@ -0,0 +1,10 @@ +export { default as ClientListPage } from './ClientListPage' +export { default as ClientAddPage } from './ClientAddPage' +export { default as ClientEditPage } from './ClientEditPage' +export { default as ClientForm } from './components/ClientForm' +export { default as ClientDetailView } from './components/ClientDetailView' + +export * from './types' +export * from './hooks' +export * from './helper/constants' +export * from './helper/utils' diff --git a/admin-ui/plugins/auth-server/components/Clients/tabs/AdvancedTab.tsx b/admin-ui/plugins/auth-server/components/Clients/tabs/AdvancedTab.tsx new file mode 100644 index 0000000000..5dc2e7f9bd --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/tabs/AdvancedTab.tsx @@ -0,0 +1,684 @@ +import React, { useCallback, useContext, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { + Box, + Grid, + TextField, + Typography, + Switch, + FormControlLabel, + Chip, + Autocomplete, +} from '@mui/material' +import { ThemeContext } from 'Context/theme/themeContext' +import getThemeColor from 'Context/theme/config' +import type { AdvancedTabProps } from '../types' +import { BACKCHANNEL_TOKEN_DELIVERY_MODES, SCRIPT_TYPES } from '../helper/constants' +import { filterScriptsByType, getScriptNameFromDn } from '../helper/utils' + +const AdvancedTab: React.FC = ({ + formik, + viewOnly = false, + modifiedFields, + setModifiedFields, + scripts = [], + isEdit = false, +}) => { + const { t } = useTranslation() + const theme = useContext(ThemeContext) + const selectedTheme = theme?.state?.theme || 'darkBlue' + const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) + + const handleFieldChange = useCallback( + (fieldName: string, fieldLabel: string, value: unknown) => { + formik.setFieldValue(fieldName, value) + setModifiedFields((prev) => ({ + ...prev, + [fieldLabel]: value, + })) + }, + [formik, setModifiedFields], + ) + + const handleAttributeChange = useCallback( + (attrName: string, fieldLabel: string, value: unknown) => { + formik.setFieldValue(`attributes.${attrName}`, value) + setModifiedFields((prev) => ({ + ...prev, + [fieldLabel]: value, + })) + }, + [formik, setModifiedFields], + ) + + const fieldStyle = useMemo( + () => ({ + '& .MuiOutlinedInput-root': { + backgroundColor: viewOnly ? themeColors?.lightBackground : 'white', + }, + }), + [viewOnly, themeColors], + ) + + const sectionStyle = useMemo( + () => ({ + mb: 3, + p: 2, + borderRadius: 1, + border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, + backgroundColor: themeColors?.lightBackground || '#fafafa', + }), + [themeColors], + ) + + const sectionTitleStyle = useMemo( + () => ({ + mb: 2, + fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, + color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + fontSize: '0.95rem', + }), + [themeColors, selectedTheme], + ) + + const switchStyle = useMemo( + () => ({ + '& .MuiSwitch-switchBase.Mui-checked': { + color: themeColors?.background, + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + backgroundColor: themeColors?.background, + }, + }), + [themeColors], + ) + + const postAuthnScripts = useMemo( + () => filterScriptsByType(scripts, SCRIPT_TYPES.POST_AUTHN), + [scripts], + ) + + const spontaneousScopeScripts = useMemo( + () => filterScriptsByType(scripts, SCRIPT_TYPES.SPONTANEOUS_SCOPE), + [scripts], + ) + + const consentScripts = useMemo( + () => filterScriptsByType(scripts, SCRIPT_TYPES.CONSENT_GATHERING), + [scripts], + ) + + const introspectionScripts = useMemo( + () => filterScriptsByType(scripts, SCRIPT_TYPES.INTROSPECTION), + [scripts], + ) + + const ropcScripts = useMemo( + () => filterScriptsByType(scripts, SCRIPT_TYPES.RESOURCE_OWNER_PASSWORD_CREDENTIALS), + [scripts], + ) + + const updateTokenScripts = useMemo( + () => filterScriptsByType(scripts, SCRIPT_TYPES.UPDATE_TOKEN), + [scripts], + ) + + const rptClaimsScripts = useMemo( + () => filterScriptsByType(scripts, SCRIPT_TYPES.UMA_RPT_CLAIMS), + [scripts], + ) + + const renderScriptSelector = useCallback( + ( + label: string, + attrName: string, + availableScripts: Array<{ dn: string; name: string }>, + currentValue: string[] | undefined, + ) => ( + option.name} + value={availableScripts.filter((s) => (currentValue || []).includes(s.dn))} + onChange={(_, newValue) => { + const dnValues = newValue.map((v) => v.dn) + handleAttributeChange(attrName, label, dnValues) + }} + disabled={viewOnly} + isOptionEqualToValue={(option, value) => option.dn === value.dn} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + ), + [handleAttributeChange, viewOnly, fieldStyle], + ) + + return ( + + + {t('titles.token_settings')} + + + + handleFieldChange( + 'accessTokenLifetime', + t('fields.access_token_lifetime'), + e.target.value ? parseInt(e.target.value) : null, + ) + } + disabled={viewOnly} + sx={fieldStyle} + InputProps={{ inputProps: { min: 0 } }} + /> + + + + + handleFieldChange( + 'refreshTokenLifetime', + t('fields.refresh_token_lifetime'), + e.target.value ? parseInt(e.target.value) : null, + ) + } + disabled={viewOnly} + sx={fieldStyle} + InputProps={{ inputProps: { min: 0 } }} + /> + + + + + handleFieldChange( + 'defaultMaxAge', + t('fields.default_max_age'), + e.target.value ? parseInt(e.target.value) : null, + ) + } + disabled={viewOnly} + sx={fieldStyle} + InputProps={{ inputProps: { min: 0 } }} + /> + + + + + handleFieldChange( + 'accessTokenAsJwt', + t('fields.access_token_as_jwt'), + e.target.checked, + ) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.access_token_as_jwt')} + /> + + + + + handleFieldChange( + 'includeClaimsInIdToken', + t('fields.include_claims_in_id_token'), + e.target.checked, + ) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.include_claims_in_id_token')} + /> + + + + + + {t('titles.ciba')} + + + + handleFieldChange( + 'backchannelTokenDeliveryMode', + t('fields.backchannel_token_delivery_mode'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {BACKCHANNEL_TOKEN_DELIVERY_MODES.map((mode) => ( + + ))} + + + + + + handleFieldChange( + 'backchannelClientNotificationEndpoint', + t('fields.backchannel_client_notification_endpoint'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + + handleFieldChange( + 'backchannelUserCodeParameter', + t('fields.backchannel_user_code_parameter'), + e.target.checked, + ) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.backchannel_user_code_parameter')} + /> + + + + + + {t('titles.par')} + + + + handleAttributeChange( + 'parLifetime', + t('fields.par_lifetime'), + e.target.value ? parseInt(e.target.value) : null, + ) + } + disabled={viewOnly} + sx={fieldStyle} + InputProps={{ inputProps: { min: 0 } }} + /> + + + + + handleAttributeChange('requirePar', t('fields.require_par'), e.target.checked) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.require_par')} + /> + + + + + + {t('titles.uma')} + + + + handleFieldChange('rptAsJwt', t('fields.rpt_as_jwt'), e.target.checked) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.rpt_as_jwt')} + /> + + + + {renderScriptSelector( + t('fields.rpt_claims_scripts'), + 'rptClaimsScripts', + rptClaimsScripts, + formik.values.attributes?.rptClaimsScripts, + )} + + + + + + {t('titles.client_scripts')} + + + {renderScriptSelector( + t('fields.post_authn_scripts'), + 'postAuthnScripts', + postAuthnScripts, + formik.values.attributes?.postAuthnScripts, + )} + + + + {renderScriptSelector( + t('fields.spontaneous_scope_scripts'), + 'spontaneousScopeScriptDns', + spontaneousScopeScripts, + formik.values.attributes?.spontaneousScopeScriptDns, + )} + + + + {renderScriptSelector( + t('fields.consent_gathering_scripts'), + 'consentGatheringScripts', + consentScripts, + formik.values.attributes?.consentGatheringScripts, + )} + + + + {renderScriptSelector( + t('fields.introspection_scripts'), + 'introspectionScripts', + introspectionScripts, + formik.values.attributes?.introspectionScripts, + )} + + + + {renderScriptSelector( + t('fields.ropc_scripts'), + 'ropcScripts', + ropcScripts, + formik.values.attributes?.ropcScripts, + )} + + + + {renderScriptSelector( + t('fields.update_token_scripts'), + 'updateTokenScriptDns', + updateTokenScripts, + formik.values.attributes?.updateTokenScriptDns, + )} + + + + + + {t('titles.other_advanced')} + + + + handleFieldChange( + 'persistClientAuthorizations', + t('fields.persist_client_authorizations'), + e.target.checked, + ) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.persist_client_authorizations')} + /> + + + + + handleAttributeChange( + 'allowSpontaneousScopes', + t('fields.allow_spontaneous_scopes'), + e.target.checked, + ) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.allow_spontaneous_scopes')} + /> + + + + + handleAttributeChange('requirePkce', t('fields.require_pkce'), e.target.checked) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.require_pkce')} + /> + + + + + handleAttributeChange( + 'dpopBoundAccessToken', + t('fields.dpop_bound_access_token'), + e.target.checked, + ) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.dpop_bound_access_token')} + /> + + + + + handleAttributeChange( + 'jansDefaultPromptLogin', + t('fields.default_prompt_login'), + e.target.checked, + ) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.default_prompt_login')} + /> + + + + + handleAttributeChange( + 'tlsClientAuthSubjectDn', + t('fields.tls_client_auth_subject_dn'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + + handleFieldChange('defaultAcrValues', t('fields.default_acr_values'), newValue) + } + disabled={viewOnly} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + + + + + handleAttributeChange( + 'jansAuthorizedAcr', + t('fields.authorized_acr_values'), + newValue, + ) + } + disabled={viewOnly} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + + + + + handleAttributeChange( + 'additionalAudience', + t('fields.additional_audience'), + newValue, + ) + } + disabled={viewOnly} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + + + + + ) +} + +export default AdvancedTab diff --git a/admin-ui/plugins/auth-server/components/Clients/tabs/AuthenticationTab.tsx b/admin-ui/plugins/auth-server/components/Clients/tabs/AuthenticationTab.tsx new file mode 100644 index 0000000000..d58c387125 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/tabs/AuthenticationTab.tsx @@ -0,0 +1,637 @@ +import React, { useCallback, useContext, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Box, Grid, TextField, Typography } from '@mui/material' +import { ThemeContext } from 'Context/theme/themeContext' +import getThemeColor from 'Context/theme/config' +import type { AuthenticationTabProps } from '../types' +import { TOKEN_ENDPOINT_AUTH_METHODS } from '../helper/constants' + +const AuthenticationTab: React.FC = ({ + formik, + viewOnly = false, + modifiedFields, + setModifiedFields, + oidcConfiguration, +}) => { + const { t } = useTranslation() + const theme = useContext(ThemeContext) + const selectedTheme = theme?.state?.theme || 'darkBlue' + const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) + + const handleFieldChange = useCallback( + (fieldName: string, fieldLabel: string, value: unknown) => { + formik.setFieldValue(fieldName, value) + setModifiedFields((prev) => ({ + ...prev, + [fieldLabel]: value, + })) + }, + [formik, setModifiedFields], + ) + + const handleAttributeChange = useCallback( + (attrName: string, fieldLabel: string, value: unknown) => { + formik.setFieldValue(`attributes.${attrName}`, value) + setModifiedFields((prev) => ({ + ...prev, + [fieldLabel]: value, + })) + }, + [formik, setModifiedFields], + ) + + const fieldStyle = useMemo( + () => ({ + '& .MuiOutlinedInput-root': { + backgroundColor: viewOnly ? themeColors?.lightBackground : 'white', + }, + }), + [viewOnly, themeColors], + ) + + const sectionStyle = useMemo( + () => ({ + mb: 3, + p: 2, + borderRadius: 1, + border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, + backgroundColor: themeColors?.lightBackground || '#fafafa', + }), + [themeColors], + ) + + const sectionTitleStyle = useMemo( + () => ({ + mb: 2, + fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, + color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + fontSize: '0.95rem', + }), + [themeColors, selectedTheme], + ) + + const signingAlgorithms = useMemo( + () => oidcConfiguration?.id_token_signing_alg_values_supported || [], + [oidcConfiguration], + ) + + const encryptionAlgorithms = useMemo( + () => oidcConfiguration?.id_token_encryption_alg_values_supported || [], + [oidcConfiguration], + ) + + const encryptionEncodings = useMemo( + () => oidcConfiguration?.id_token_encryption_enc_values_supported || [], + [oidcConfiguration], + ) + + const accessTokenSigningAlgs = useMemo( + () => oidcConfiguration?.access_token_signing_alg_values_supported || signingAlgorithms, + [oidcConfiguration, signingAlgorithms], + ) + + return ( + + + {t('titles.token_endpoint_auth')} + + + + handleFieldChange( + 'tokenEndpointAuthMethod', + t('fields.token_endpoint_auth_method'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {TOKEN_ENDPOINT_AUTH_METHODS.map((option) => ( + + ))} + + + + + handleFieldChange('jwksUri', t('fields.jwks_uri'), e.target.value)} + disabled={viewOnly} + sx={fieldStyle} + /> + + + + handleFieldChange('jwks', t('fields.jwks'), e.target.value)} + disabled={viewOnly} + multiline + rows={3} + sx={fieldStyle} + /> + + + + + + {t('titles.id_token')} + + + + handleFieldChange( + 'idTokenSignedResponseAlg', + t('fields.id_token_signed_response_alg'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {signingAlgorithms.map((alg) => ( + + ))} + + + + + + handleFieldChange( + 'idTokenEncryptedResponseAlg', + t('fields.id_token_encrypted_response_alg'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {encryptionAlgorithms.map((alg) => ( + + ))} + + + + + + handleFieldChange( + 'idTokenEncryptedResponseEnc', + t('fields.id_token_encrypted_response_enc'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {encryptionEncodings.map((enc) => ( + + ))} + + + + + + + {t('titles.access_token')} + + + + handleFieldChange( + 'accessTokenSigningAlg', + t('fields.access_token_signing_alg'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {accessTokenSigningAlgs.map((alg) => ( + + ))} + + + + + + + {t('titles.userinfo')} + + + + handleFieldChange( + 'userInfoSignedResponseAlg', + t('fields.userinfo_signed_response_alg'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {signingAlgorithms.map((alg) => ( + + ))} + + + + + + handleFieldChange( + 'userInfoEncryptedResponseAlg', + t('fields.userinfo_encrypted_response_alg'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {encryptionAlgorithms.map((alg) => ( + + ))} + + + + + + handleFieldChange( + 'userInfoEncryptedResponseEnc', + t('fields.userinfo_encrypted_response_enc'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {encryptionEncodings.map((enc) => ( + + ))} + + + + + + + {t('titles.request_object')} + + + + handleFieldChange( + 'requestObjectSigningAlg', + t('fields.request_object_signing_alg'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {signingAlgorithms.map((alg) => ( + + ))} + + + + + + handleFieldChange( + 'requestObjectEncryptionAlg', + t('fields.request_object_encryption_alg'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {encryptionAlgorithms.map((alg) => ( + + ))} + + + + + + handleFieldChange( + 'requestObjectEncryptionEnc', + t('fields.request_object_encryption_enc'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {encryptionEncodings.map((enc) => ( + + ))} + + + + + + + {t('titles.introspection')} + + + + handleAttributeChange( + 'introspectionSignedResponseAlg', + t('fields.introspection_signed_response_alg'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {signingAlgorithms.map((alg) => ( + + ))} + + + + + + handleAttributeChange( + 'introspectionEncryptedResponseAlg', + t('fields.introspection_encrypted_response_alg'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {encryptionAlgorithms.map((alg) => ( + + ))} + + + + + + handleAttributeChange( + 'introspectionEncryptedResponseEnc', + t('fields.introspection_encrypted_response_enc'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {encryptionEncodings.map((enc) => ( + + ))} + + + + + + + {t('titles.jarm')} + + + + handleAttributeChange( + 'jansAuthSignedRespAlg', + t('fields.jans_auth_signed_resp_alg'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {signingAlgorithms.map((alg) => ( + + ))} + + + + + + handleAttributeChange( + 'jansAuthEncRespAlg', + t('fields.jans_auth_enc_resp_alg'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {encryptionAlgorithms.map((alg) => ( + + ))} + + + + + + handleAttributeChange( + 'jansAuthEncRespEnc', + t('fields.jans_auth_enc_resp_enc'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + + {encryptionEncodings.map((enc) => ( + + ))} + + + + + + ) +} + +export default AuthenticationTab diff --git a/admin-ui/plugins/auth-server/components/Clients/tabs/BasicInfoTab.tsx b/admin-ui/plugins/auth-server/components/Clients/tabs/BasicInfoTab.tsx new file mode 100644 index 0000000000..725c581fdf --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/tabs/BasicInfoTab.tsx @@ -0,0 +1,307 @@ +import React, { useCallback, useContext, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { + Box, + Grid, + TextField, + Switch, + FormControlLabel, + InputAdornment, + IconButton, +} from '@mui/material' +import { Visibility, VisibilityOff } from '@mui/icons-material' +import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import dayjs from 'dayjs' +import { ThemeContext } from 'Context/theme/themeContext' +import getThemeColor from 'Context/theme/config' +import type { BasicInfoTabProps } from '../types' +import { APPLICATION_TYPES, SUBJECT_TYPES } from '../helper/constants' + +const BasicInfoTab: React.FC = ({ + formik, + viewOnly = false, + modifiedFields, + setModifiedFields, +}) => { + const { t } = useTranslation() + const theme = useContext(ThemeContext) + const selectedTheme = theme?.state?.theme || 'darkBlue' + const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) + const [showSecret, setShowSecret] = React.useState(false) + + const handleFieldChange = useCallback( + (fieldName: string, fieldLabel: string, value: unknown) => { + formik.setFieldValue(fieldName, value) + setModifiedFields((prev) => ({ + ...prev, + [fieldLabel]: value, + })) + }, + [formik, setModifiedFields], + ) + + const toggleSecretVisibility = useCallback(() => { + setShowSecret((prev) => !prev) + }, []) + + const fieldStyle = useMemo( + () => ({ + '& .MuiOutlinedInput-root': { + backgroundColor: viewOnly ? themeColors?.lightBackground : 'white', + }, + }), + [viewOnly, themeColors], + ) + + const sectionStyle = useMemo( + () => ({ + mb: 3, + p: 2, + borderRadius: 1, + border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, + backgroundColor: themeColors?.lightBackground || '#fafafa', + }), + [themeColors], + ) + + const switchStyle = useMemo( + () => ({ + '& .MuiSwitch-switchBase.Mui-checked': { + color: themeColors?.background, + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + backgroundColor: themeColors?.background, + }, + }), + [themeColors], + ) + + return ( + + + + + + + + + + handleFieldChange('clientName', t('fields.client_name'), e.target.value) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + + handleFieldChange('displayName', t('fields.displayname'), e.target.value) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + + handleFieldChange('clientSecret', t('fields.client_secret'), e.target.value) + } + disabled={viewOnly} + sx={fieldStyle} + InputProps={{ + endAdornment: ( + + + {showSecret ? : } + + + ), + }} + /> + + + + + handleFieldChange('description', t('fields.description'), e.target.value) + } + disabled={viewOnly} + multiline + rows={2} + sx={fieldStyle} + /> + + + + + + + + + handleFieldChange('applicationType', t('fields.application_type'), e.target.value) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + {APPLICATION_TYPES.map((option) => ( + + ))} + + + + + + handleFieldChange('subjectType', t('fields.subject_type'), e.target.value) + } + disabled={viewOnly} + sx={fieldStyle} + SelectProps={{ native: true }} + > + {SUBJECT_TYPES.map((option) => ( + + ))} + + + + + + + + + + handleFieldChange('disabled', t('fields.status'), !e.target.checked) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.active')} + /> + + + + + handleFieldChange( + 'trustedClient', + t('fields.is_trusted_client'), + e.target.checked, + ) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.is_trusted_client')} + /> + + + + + handleFieldChange( + 'expirable', + t('fields.is_expirable_client'), + e.target.checked, + ) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.is_expirable_client')} + /> + + + {formik.values.expirable && ( + + + { + const isoValue = newValue ? newValue.toISOString() : null + handleFieldChange('expirationDate', t('fields.expirationDate'), isoValue) + }} + disabled={viewOnly} + slotProps={{ + textField: { + fullWidth: true, + size: 'small', + sx: fieldStyle, + }, + }} + /> + + + )} + + + + ) +} + +export default BasicInfoTab diff --git a/admin-ui/plugins/auth-server/components/Clients/tabs/ScopesGrantsTab.tsx b/admin-ui/plugins/auth-server/components/Clients/tabs/ScopesGrantsTab.tsx new file mode 100644 index 0000000000..61c8b6ece6 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/tabs/ScopesGrantsTab.tsx @@ -0,0 +1,322 @@ +import React, { useCallback, useContext, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + Box, + Grid, + Typography, + Chip, + TextField, + Autocomplete, + Checkbox, + FormControlLabel, + FormGroup, +} from '@mui/material' +import { ThemeContext } from 'Context/theme/themeContext' +import getThemeColor from 'Context/theme/config' +import type { ScopesGrantsTabProps, ClientScope } from '../types' +import { GRANT_TYPES, RESPONSE_TYPES } from '../helper/constants' +import { formatScopeDisplay } from '../helper/utils' + +const ScopesGrantsTab: React.FC = ({ + formik, + viewOnly = false, + modifiedFields, + setModifiedFields, + scopes = [], + scopesLoading = false, + onScopeSearch, +}) => { + const { t } = useTranslation() + const theme = useContext(ThemeContext) + const selectedTheme = theme?.state?.theme || 'darkBlue' + const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) + const [scopeInputValue, setScopeInputValue] = useState('') + + const handleFieldChange = useCallback( + (fieldName: string, fieldLabel: string, value: unknown) => { + formik.setFieldValue(fieldName, value) + setModifiedFields((prev) => ({ + ...prev, + [fieldLabel]: value, + })) + }, + [formik, setModifiedFields], + ) + + const handleGrantTypeChange = useCallback( + (grantType: string, checked: boolean) => { + const currentGrants = formik.values.grantTypes || [] + const newGrants = checked + ? [...currentGrants, grantType] + : currentGrants.filter((g) => g !== grantType) + handleFieldChange('grantTypes', t('fields.grant_types'), newGrants) + }, + [formik.values.grantTypes, handleFieldChange, t], + ) + + const handleResponseTypeChange = useCallback( + (responseType: string, checked: boolean) => { + const currentTypes = formik.values.responseTypes || [] + const newTypes = checked + ? [...currentTypes, responseType] + : currentTypes.filter((r) => r !== responseType) + handleFieldChange('responseTypes', t('fields.response_types'), newTypes) + }, + [formik.values.responseTypes, handleFieldChange, t], + ) + + const handleScopeChange = useCallback( + (newScopes: ClientScope[]) => { + const scopeValues = newScopes.map((s) => s.dn || s.inum || '') + handleFieldChange('scopes', t('fields.scopes'), scopeValues) + }, + [handleFieldChange, t], + ) + + const handleScopeInputChange = useCallback( + (value: string) => { + setScopeInputValue(value) + if (onScopeSearch && value.length >= 2) { + onScopeSearch(value) + } + }, + [onScopeSearch], + ) + + const selectedScopes = useMemo(() => { + const scopeValues = formik.values.scopes || [] + return scopeValues.map((scopeValue) => { + const found = scopes.find((s) => s.dn === scopeValue || s.inum === scopeValue) + return found || { dn: scopeValue, displayName: formatScopeDisplay(scopeValue) } + }) + }, [formik.values.scopes, scopes]) + + const sectionStyle = useMemo( + () => ({ + mb: 3, + p: 2, + borderRadius: 1, + border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, + backgroundColor: themeColors?.lightBackground || '#fafafa', + }), + [themeColors], + ) + + const sectionTitleStyle = useMemo( + () => ({ + mb: 2, + fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, + color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + fontSize: '0.95rem', + }), + [themeColors, selectedTheme], + ) + + const chipStyle = useMemo( + () => ({ + 'm': 0.5, + 'backgroundColor': themeColors?.lightBackground || '#e3f2fd', + '& .MuiChip-deleteIcon': { + color: themeColors?.background || '#1976d2', + }, + }), + [themeColors], + ) + + return ( + + + {t('fields.scopes')} + {viewOnly ? ( + + {(formik.values.scopes || []).map((scope, index) => ( + + ))} + {(!formik.values.scopes || formik.values.scopes.length === 0) && ( + + {t('messages.no_scopes')} + + )} + + ) : ( + + option.displayName || option.id || formatScopeDisplay(option.dn || '') + } + value={selectedScopes} + onChange={(_, newValue) => handleScopeChange(newValue as ClientScope[])} + inputValue={scopeInputValue} + onInputChange={(_, value) => handleScopeInputChange(value)} + loading={scopesLoading} + filterSelectedOptions + isOptionEqualToValue={(option, value) => + option.dn === value.dn || option.inum === value.inum + } + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + )} + + + + {t('fields.grant_types')} + {viewOnly ? ( + + {(formik.values.grantTypes || []).map((grant, index) => { + const grantOption = GRANT_TYPES.find((g) => g.value === grant) + return ( + + ) + })} + {(!formik.values.grantTypes || formik.values.grantTypes.length === 0) && ( + + {t('messages.no_grant_types')} + + )} + + ) : ( + + + {GRANT_TYPES.map((grant) => ( + + handleGrantTypeChange(grant.value, e.target.checked)} + size="small" + /> + } + label={{grant.label}} + /> + + ))} + + + )} + + + + {t('fields.response_types')} + {viewOnly ? ( + + {(formik.values.responseTypes || []).map((type, index) => { + const typeOption = RESPONSE_TYPES.find((r) => r.value === type) + return ( + + ) + })} + {(!formik.values.responseTypes || formik.values.responseTypes.length === 0) && ( + + {t('messages.no_response_types')} + + )} + + ) : ( + + {RESPONSE_TYPES.map((type) => ( + handleResponseTypeChange(type.value, e.target.checked)} + size="small" + /> + } + label={{type.label}} + /> + ))} + + )} + + + + {t('fields.claims')} + {viewOnly ? ( + + {(formik.values.claims || []).map((claim, index) => ( + + ))} + {(!formik.values.claims || formik.values.claims.length === 0) && ( + + {t('messages.no_claims')} + + )} + + ) : ( + handleFieldChange('claims', t('fields.claims'), newValue)} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + )} + + + ) +} + +export default ScopesGrantsTab diff --git a/admin-ui/plugins/auth-server/components/Clients/tabs/UrisTab.tsx b/admin-ui/plugins/auth-server/components/Clients/tabs/UrisTab.tsx new file mode 100644 index 0000000000..f214a3cb11 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/tabs/UrisTab.tsx @@ -0,0 +1,463 @@ +import React, { useCallback, useContext, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { + Box, + Grid, + TextField, + Typography, + Switch, + FormControlLabel, + Chip, + Autocomplete, +} from '@mui/material' +import { ThemeContext } from 'Context/theme/themeContext' +import getThemeColor from 'Context/theme/config' +import type { UrisTabProps } from '../types' + +const UrisTab: React.FC = ({ + formik, + viewOnly = false, + modifiedFields, + setModifiedFields, +}) => { + const { t } = useTranslation() + const theme = useContext(ThemeContext) + const selectedTheme = theme?.state?.theme || 'darkBlue' + const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) + + const handleFieldChange = useCallback( + (fieldName: string, fieldLabel: string, value: unknown) => { + formik.setFieldValue(fieldName, value) + setModifiedFields((prev) => ({ + ...prev, + [fieldLabel]: value, + })) + }, + [formik, setModifiedFields], + ) + + const handleAttributeChange = useCallback( + (attrName: string, fieldLabel: string, value: unknown) => { + formik.setFieldValue(`attributes.${attrName}`, value) + setModifiedFields((prev) => ({ + ...prev, + [fieldLabel]: value, + })) + }, + [formik, setModifiedFields], + ) + + const fieldStyle = useMemo( + () => ({ + '& .MuiOutlinedInput-root': { + backgroundColor: viewOnly ? themeColors?.lightBackground : 'white', + }, + }), + [viewOnly, themeColors], + ) + + const sectionStyle = useMemo( + () => ({ + mb: 3, + p: 2, + borderRadius: 1, + border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, + backgroundColor: themeColors?.lightBackground || '#fafafa', + }), + [themeColors], + ) + + const sectionTitleStyle = useMemo( + () => ({ + mb: 2, + fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, + color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + fontSize: '0.95rem', + }), + [themeColors, selectedTheme], + ) + + const chipStyle = useMemo( + () => ({ + m: 0.5, + backgroundColor: themeColors?.lightBackground || '#e3f2fd', + }), + [themeColors], + ) + + const switchStyle = useMemo( + () => ({ + '& .MuiSwitch-switchBase.Mui-checked': { + color: themeColors?.background, + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + backgroundColor: themeColors?.background, + }, + }), + [themeColors], + ) + + const renderUriList = useCallback( + (label: string, fieldName: string, value: string[] | undefined, isReadOnly = viewOnly) => { + if (isReadOnly) { + return ( + + + {label} + + + {(value || []).map((uri, index) => ( + + ))} + {(!value || value.length === 0) && ( + + - + + )} + + + ) + } + + return ( + handleFieldChange(fieldName, label, newValue)} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + ) + }, + [viewOnly, handleFieldChange, fieldStyle, chipStyle], + ) + + return ( + + + {t('titles.redirect_uris')} + + + {renderUriList(t('fields.redirect_uris'), 'redirectUris', formik.values.redirectUris)} + + + + + handleAttributeChange( + 'redirectUrisRegex', + t('fields.redirect_uris_regex'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + + handleFieldChange( + 'sectorIdentifierUri', + t('fields.sector_identifier_uri'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + + + {t('titles.logout_uris')} + + + {renderUriList( + t('fields.post_logout_redirect_uris'), + 'postLogoutRedirectUris', + formik.values.postLogoutRedirectUris, + )} + + + + + handleFieldChange( + 'frontChannelLogoutUri', + t('fields.front_channel_logout_uri'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + + handleFieldChange( + 'frontChannelLogoutSessionRequired', + t('fields.front_channel_logout_session_required'), + e.target.checked, + ) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.front_channel_logout_session_required')} + /> + + + + {renderUriList( + t('fields.backchannel_logout_uris'), + 'attributes.backchannelLogoutUri', + formik.values.attributes?.backchannelLogoutUri, + )} + + + + + handleAttributeChange( + 'backchannelLogoutSessionRequired', + t('fields.backchannel_logout_session_required'), + e.target.checked, + ) + } + disabled={viewOnly} + sx={switchStyle} + /> + } + label={t('fields.backchannel_logout_session_required')} + /> + + + + + + {t('titles.other_uris')} + + + + handleFieldChange( + 'initiateLoginUri', + t('fields.initiate_login_uri'), + e.target.value, + ) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + + handleFieldChange('clientUri', t('fields.client_uri'), e.target.value) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + handleFieldChange('logoUri', t('fields.logo_uri'), e.target.value)} + disabled={viewOnly} + sx={fieldStyle} + /> + + + + + handleFieldChange('policyUri', t('fields.policy_uri'), e.target.value) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + handleFieldChange('tosUri', t('fields.tos_uri'), e.target.value)} + disabled={viewOnly} + sx={fieldStyle} + /> + + + + {renderUriList(t('fields.request_uris'), 'requestUris', formik.values.requestUris)} + + + + {renderUriList( + t('fields.authorized_origins'), + 'authorizedOrigins', + formik.values.authorizedOrigins, + )} + + + + {renderUriList( + t('fields.claim_redirect_uris'), + 'claimRedirectUris', + formik.values.claimRedirectUris, + )} + + + + + + {t('titles.contacts')} + + + + handleFieldChange('contacts', t('fields.contacts'), newValue) + } + disabled={viewOnly} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + + + + + + {t('titles.software_info')} + + + + handleFieldChange('softwareId', t('fields.software_id'), e.target.value) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + + handleFieldChange('softwareVersion', t('fields.software_version'), e.target.value) + } + disabled={viewOnly} + sx={fieldStyle} + /> + + + + + handleFieldChange( + 'softwareStatement', + t('fields.software_statement'), + e.target.value, + ) + } + disabled={viewOnly} + multiline + rows={3} + sx={fieldStyle} + /> + + + + + ) +} + +export default UrisTab diff --git a/admin-ui/plugins/auth-server/components/Clients/types/clientTypes.ts b/admin-ui/plugins/auth-server/components/Clients/types/clientTypes.ts new file mode 100644 index 0000000000..48f0729575 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/types/clientTypes.ts @@ -0,0 +1,199 @@ +import type { + Client, + ClientAttributes, + ClientGrantTypesItem, + ClientResponseTypesItem, + ClientApplicationType, + ClientSubjectType, + ClientTokenEndpointAuthMethod, + ClientBackchannelTokenDeliveryMode, + ClientBackchannelAuthenticationRequestSigningAlg, + GetOauthOpenidClientsParams, + PagedResult, + Scope, +} from 'JansConfigApi' + +export type { + Client, + ClientAttributes, + ClientGrantTypesItem, + ClientResponseTypesItem, + ClientApplicationType, + ClientSubjectType, + ClientTokenEndpointAuthMethod, + ClientBackchannelTokenDeliveryMode, + ClientBackchannelAuthenticationRequestSigningAlg, + GetOauthOpenidClientsParams, + PagedResult, + Scope, +} + +export interface ExtendedClient extends Client { + expirable?: boolean +} + +export interface ClientScope { + dn: string + inum?: string + id?: string + displayName?: string + description?: string +} + +export interface ClientScript { + dn: string + inum?: string + name?: string + scriptType?: string + enabled?: boolean +} + +export interface ClientListOptions extends GetOauthOpenidClientsParams { + startIndex?: number +} + +export interface ModifiedFields { + [key: string]: unknown +} + +export interface ClientTableRow extends Client { + tableData?: { + id: number + } +} + +export interface UmaResource { + dn?: string + inum?: string + id?: string + name?: string + iconUri?: string + scopes?: string[] + scopeExpression?: string + clients?: string[] + resources?: string[] + rev?: string + creator?: string + description?: string + type?: string + creationDate?: string + expirationDate?: string + deletable?: boolean +} + +export interface TokenEntry { + tknCde?: string + tknTyp?: string + usrId?: string + clnId?: string + creationDate?: string + expirationDate?: string + scope?: string + authzCode?: string + grtId?: string + jwtReq?: string + authMode?: string + ssnId?: string + dn?: string +} + +export const EMPTY_CLIENT: Partial = { + dn: undefined, + inum: undefined, + clientSecret: undefined, + clientName: undefined, + displayName: undefined, + description: undefined, + applicationType: 'web', + subjectType: 'public', + registrationAccessToken: undefined, + clientIdIssuedAt: undefined, + initiateLoginUri: undefined, + logoUri: undefined, + clientUri: undefined, + tosUri: undefined, + policyUri: undefined, + jwksUri: undefined, + jwks: undefined, + sectorIdentifierUri: undefined, + softwareStatement: undefined, + softwareVersion: undefined, + softwareId: undefined, + expirationDate: undefined, + expirable: false, + idTokenSignedResponseAlg: undefined, + idTokenEncryptedResponseAlg: undefined, + idTokenEncryptedResponseEnc: undefined, + tokenEndpointAuthMethod: 'client_secret_basic', + tokenEndpointAuthSigningAlg: undefined, + accessTokenSigningAlg: undefined, + requestObjectEncryptionAlg: undefined, + requestObjectSigningAlg: undefined, + requestObjectEncryptionEnc: undefined, + userInfoEncryptedResponseAlg: undefined, + userInfoSignedResponseAlg: undefined, + userInfoEncryptedResponseEnc: undefined, + idTokenTokenBindingCnf: undefined, + backchannelUserCodeParameter: false, + refreshTokenLifetime: undefined, + defaultMaxAge: undefined, + accessTokenLifetime: undefined, + backchannelTokenDeliveryMode: undefined, + backchannelClientNotificationEndpoint: undefined, + backchannelAuthenticationRequestSigningAlg: undefined, + frontChannelLogoutUri: undefined, + frontChannelLogoutSessionRequired: false, + redirectUris: [], + claimRedirectUris: [], + authorizedOrigins: [], + requestUris: [], + postLogoutRedirectUris: [], + responseTypes: [], + grantTypes: [], + contacts: [], + defaultAcrValues: [], + scopes: [], + claims: [], + customObjectClasses: ['top'], + trustedClient: false, + persistClientAuthorizations: false, + includeClaimsInIdToken: false, + rptAsJwt: false, + accessTokenAsJwt: false, + disabled: false, + deletable: true, + attributes: { + runIntrospectionScriptBeforeJwtCreation: false, + keepClientAuthorizationAfterExpiration: false, + allowSpontaneousScopes: false, + backchannelLogoutSessionRequired: false, + backchannelLogoutUri: [], + rptClaimsScripts: [], + consentGatheringScripts: [], + spontaneousScopeScriptDns: [], + introspectionScripts: [], + postAuthnScripts: [], + ropcScripts: [], + updateTokenScriptDns: [], + additionalAudience: [], + spontaneousScopes: [], + jansAuthorizedAcr: [], + parLifetime: undefined, + requirePar: false, + dpopBoundAccessToken: false, + jansDefaultPromptLogin: false, + redirectUrisRegex: undefined, + tlsClientAuthSubjectDn: undefined, + minimumAcrLevel: undefined, + minimumAcrLevelAutoresolve: false, + requirePkce: false, + jansAuthSignedRespAlg: undefined, + jansAuthEncRespAlg: undefined, + jansAuthEncRespEnc: undefined, + introspectionSignedResponseAlg: undefined, + introspectionEncryptedResponseAlg: undefined, + introspectionEncryptedResponseEnc: undefined, + txTokenLifetime: undefined, + idTokenLifetime: undefined, + }, +} diff --git a/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts b/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts new file mode 100644 index 0000000000..116105fe39 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts @@ -0,0 +1,108 @@ +import type { FormikProps } from 'formik' +import type { ExtendedClient, ModifiedFields, ClientScope, ClientScript } from './clientTypes' + +export type ClientTab = 'basic' | 'authentication' | 'scopes' | 'advanced' | 'uris' + +export interface ClientFormValues extends ExtendedClient { + expirable: boolean +} + +export interface ClientFormProps { + client: Partial + isEdit?: boolean + viewOnly?: boolean + onSubmit: (values: ClientFormValues, message: string, modifiedFields: ModifiedFields) => void + onCancel?: () => void +} + +export interface TabPanelProps { + formik: FormikProps + viewOnly?: boolean + modifiedFields: ModifiedFields + setModifiedFields: React.Dispatch> +} + +export interface BasicInfoTabProps extends TabPanelProps { + oidcConfiguration?: OidcConfiguration +} + +export interface AuthenticationTabProps extends TabPanelProps { + oidcConfiguration?: OidcConfiguration +} + +export interface ScopesGrantsTabProps extends TabPanelProps { + scopes: ClientScope[] + scopesLoading?: boolean + onScopeSearch?: (pattern: string) => void +} + +export interface AdvancedTabProps extends TabPanelProps { + scripts: Array<{ dn: string; name: string; scriptType?: string; enabled?: boolean }> + umaResources?: UmaResourceForTab[] + isEdit?: boolean + clientInum?: string +} + +export interface UrisTabProps extends TabPanelProps {} + +export interface OidcConfiguration { + id_token_signing_alg_values_supported?: string[] + id_token_encryption_alg_values_supported?: string[] + id_token_encryption_enc_values_supported?: string[] + userinfo_signing_alg_values_supported?: string[] + userinfo_encryption_alg_values_supported?: string[] + userinfo_encryption_enc_values_supported?: string[] + request_object_signing_alg_values_supported?: string[] + request_object_encryption_alg_values_supported?: string[] + request_object_encryption_enc_values_supported?: string[] + token_endpoint_auth_methods_supported?: string[] + token_endpoint_auth_signing_alg_values_supported?: string[] + access_token_signing_alg_values_supported?: string[] + authorization_signing_alg_values_supported?: string[] + authorization_encryption_alg_values_supported?: string[] + authorization_encryption_enc_values_supported?: string[] + introspection_signing_alg_values_supported?: string[] + introspection_encryption_alg_values_supported?: string[] + introspection_encryption_enc_values_supported?: string[] + tx_token_signing_alg_values_supported?: string[] + tx_token_encryption_alg_values_supported?: string[] + tx_token_encryption_enc_values_supported?: string[] + backchannel_authentication_request_signing_alg_values_supported?: string[] + response_types_supported?: string[] + grant_types_supported?: string[] + acr_values_supported?: string[] + subject_types_supported?: string[] + scopes_supported?: string[] + claims_supported?: string[] +} + +export interface UmaResourceForTab { + dn?: string + inum?: string + id?: string + name?: string + description?: string + scopes?: string[] + scopeExpression?: string + iconUri?: string +} + +export interface AuthState { + token?: { + access_token: string + } + config?: { + clientId: string + } + userinfo?: { + inum: string + name: string + } + location?: { + IPv4?: string + } +} + +export interface RootState { + authReducer: AuthState +} diff --git a/admin-ui/plugins/auth-server/components/Clients/types/index.ts b/admin-ui/plugins/auth-server/components/Clients/types/index.ts new file mode 100644 index 0000000000..dce3b274d0 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/types/index.ts @@ -0,0 +1,2 @@ +export * from './clientTypes' +export * from './formTypes' From 9376ece2389e79aa2c4f0641fbc1dfa40c542fbb Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Thu, 4 Dec 2025 20:43:12 +0100 Subject: [PATCH 02/18] fix(admin-ui): Use typescript generated Clients pages #2491 --- admin-ui/app/locales/en/translation.json | 58 ++- admin-ui/app/locales/es/translation.json | 58 ++- admin-ui/app/locales/fr/translation.json | 58 ++- admin-ui/app/locales/pt/translation.json | 58 ++- .../components/Clients/ClientListPage.tsx | 25 +- .../Clients/components/ClientDetailView.tsx | 331 +++++---------- .../Clients/components/ClientForm.tsx | 12 +- .../components/Clients/helper/constants.ts | 3 + .../components/Clients/tabs/AdvancedTab.tsx | 385 +++++++++++++++++- .../Clients/tabs/AuthenticationTab.tsx | 174 ++++++++ .../components/Clients/tabs/BasicInfoTab.tsx | 181 ++++++++ .../Clients/tabs/ScopesGrantsTab.tsx | 94 +++-- .../components/Clients/tabs/UrisTab.tsx | 140 +++++++ 13 files changed, 1292 insertions(+), 285 deletions(-) diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index 582c1f7fb2..893d60e35f 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -664,11 +664,26 @@ "software_statement": "Software Statement", "software_version": "Software Version", "spontaneous_scope_scripts": "Spontaneous Scope Scripts", + "spontaneous_scopes": "Spontaneous Scopes", "tos_uri": "Terms of Service URI", "update_token_scripts": "Update Token Scripts", "userinfo_encrypted_response_alg": "UserInfo Encryption Algorithm", "userinfo_encrypted_response_enc": "UserInfo Encryption Encoding", - "userinfo_signed_response_alg": "UserInfo Signing Algorithm" + "userinfo_signed_response_alg": "UserInfo Signing Algorithm", + "id_token_lifetime": "ID Token Lifetime (seconds)", + "tx_token_lifetime": "TX Token Lifetime (seconds)", + "tx_token_signed_response_alg": "TX Token Signing Algorithm", + "tx_token_encrypted_response_alg": "TX Token Encryption Algorithm", + "tx_token_encrypted_response_enc": "TX Token Encryption Encoding", + "token_endpoint_auth_signing_alg": "Token Endpoint Auth Signing Algorithm", + "minimum_acr_level": "Minimum ACR Level", + "minimum_acr_level_autoresolve": "Auto-resolve ACR Level", + "minimum_acr_priority_list": "ACR Priority List", + "additional_token_endpoint_auth_methods": "Additional Token Endpoint Auth Methods", + "allow_offline_access_without_consent": "Allow Offline Access Without Consent", + "token_exchange_scripts": "Token Exchange Scripts", + "par_scripts": "PAR Scripts", + "logout_status_jwt_scripts": "Logout Status JWT Scripts" }, "languages": { "french": "French", @@ -880,7 +895,28 @@ "no_claims": "No claims selected", "no_grant_types": "No grant types selected", "no_response_types": "No response types selected", - "no_scopes": "No scopes selected" + "no_scopes": "No scopes selected", + "dn": "DN", + "ttl": "TTL (seconds)", + "groups": "Groups", + "client_id_issued_at": "Client ID Issued At", + "client_secret_expires_at": "Client Secret Expires At", + "last_access_time": "Last Access Time", + "last_logon_time": "Last Logon Time", + "registration_access_token": "Registration Access Token", + "requested_lifetime": "Requested Lifetime", + "run_introspection_script_before_jwt_creation": "Run Introspection Script Before JWT Creation", + "keep_client_authorization_after_expiration": "Keep Client Authorization After Expiration", + "jans_sub_attr": "Subject Attribute", + "id_token_token_binding_cnf": "ID Token Token Binding Confirmation", + "evidence": "Evidence", + "authorization_details_types": "Authorization Details Types", + "logout_status_jwt_signed_response_alg": "Logout Status JWT Signed Response Alg", + "client_name_localized": "Client Name (Localized)", + "logo_uri_localized": "Logo URI (Localized)", + "client_uri_localized": "Client URI (Localized)", + "policy_uri_localized": "Policy URI (Localized)", + "tos_uri_localized": "ToS URI (Localized)" }, "errors": { "attribute_create_failed": "Error creating attribute", @@ -982,9 +1018,17 @@ "add_claims": "Add claims", "add_email": "Add email", "search_clients": "Search clients...", - "search_scopes": "Search scopes..." + "search_scopes": "Search scopes...", + "localized_json_format": "JSON format: {\"en\": \"value\", \"es\": \"valor\"}" }, "titles": { + "id_token": "ID Token", + "access_token": "Access Token", + "userinfo": "UserInfo", + "request_object": "Request Object", + "introspection": "Introspection", + "jarm": "JARM", + "token_endpoint_auth": "Token Endpoint Authentication", "activeTokens": "Active Tokens", "acrs": "ACRs", "active_users": "Actives Users && Access Token Stats", @@ -1098,10 +1142,16 @@ "scopes_and_grants": "Scopes & Grants", "token_endpoint_auth": "Token Endpoint Authentication", "token_settings": "Token Settings", + "tx_token": "TX Token", + "acr_security": "ACR & Security", "uma": "UMA", "uris": "URIs", "basic_info": "Basic Info", - "advanced": "Advanced" + "advanced": "Advanced", + "system_information": "System Information", + "organization": "Organization", + "logout": "Logout", + "localization": "Localization" }, "links": { "support": "https://support.gluu.org/" diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index 462b6c3f4d..4e7ca8e028 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -664,11 +664,26 @@ "software_statement": "Declaración de Software", "software_version": "Versión del Software", "spontaneous_scope_scripts": "Scripts de Alcance Espontáneo", + "spontaneous_scopes": "Alcances Espontáneos", "tos_uri": "URI de Términos de Servicio", "update_token_scripts": "Scripts de Actualización de Token", "userinfo_encrypted_response_alg": "Algoritmo de Cifrado UserInfo", "userinfo_encrypted_response_enc": "Codificación de Cifrado UserInfo", - "userinfo_signed_response_alg": "Algoritmo de Firma UserInfo" + "userinfo_signed_response_alg": "Algoritmo de Firma UserInfo", + "id_token_lifetime": "Vida Útil del Token ID (segundos)", + "tx_token_lifetime": "Vida Útil del Token TX (segundos)", + "tx_token_signed_response_alg": "Algoritmo de Firma del Token TX", + "tx_token_encrypted_response_alg": "Algoritmo de Cifrado del Token TX", + "tx_token_encrypted_response_enc": "Codificación de Cifrado del Token TX", + "token_endpoint_auth_signing_alg": "Algoritmo de Firma de Auth del Endpoint de Token", + "minimum_acr_level": "Nivel ACR Mínimo", + "minimum_acr_level_autoresolve": "Auto-resolver Nivel ACR", + "minimum_acr_priority_list": "Lista de Prioridad ACR", + "additional_token_endpoint_auth_methods": "Métodos de Auth Adicionales del Endpoint de Token", + "allow_offline_access_without_consent": "Permitir Acceso Offline Sin Consentimiento", + "token_exchange_scripts": "Scripts de Intercambio de Token", + "par_scripts": "Scripts PAR", + "logout_status_jwt_scripts": "Scripts JWT de Estado de Logout" }, "languages": { "french": "Frances", @@ -879,7 +894,28 @@ "no_claims": "No hay claims seleccionados", "no_grant_types": "No hay tipos de concesión seleccionados", "no_response_types": "No hay tipos de respuesta seleccionados", - "no_scopes": "No hay alcances seleccionados" + "no_scopes": "No hay alcances seleccionados", + "dn": "DN", + "ttl": "TTL (segundos)", + "groups": "Grupos", + "client_id_issued_at": "Fecha de Emisión del ID de Cliente", + "client_secret_expires_at": "Fecha de Expiración del Secreto del Cliente", + "last_access_time": "Último Tiempo de Acceso", + "last_logon_time": "Último Tiempo de Inicio de Sesión", + "registration_access_token": "Token de Acceso de Registro", + "requested_lifetime": "Tiempo de Vida Solicitado", + "run_introspection_script_before_jwt_creation": "Ejecutar Script de Introspección Antes de Crear JWT", + "keep_client_authorization_after_expiration": "Mantener Autorización del Cliente Después de Expirar", + "jans_sub_attr": "Atributo de Sujeto", + "id_token_token_binding_cnf": "Confirmación de Enlace de Token de ID", + "evidence": "Evidencia", + "authorization_details_types": "Tipos de Detalles de Autorización", + "logout_status_jwt_signed_response_alg": "Algoritmo de Firma JWT del Estado de Cierre de Sesión", + "client_name_localized": "Nombre del Cliente (Localizado)", + "logo_uri_localized": "URI del Logo (Localizado)", + "client_uri_localized": "URI del Cliente (Localizado)", + "policy_uri_localized": "URI de Política (Localizado)", + "tos_uri_localized": "URI de ToS (Localizado)" }, "errors": { "attribute_create_failed": "Error al crear el atributo", @@ -975,9 +1011,17 @@ "add_claims": "Agregar claims", "add_email": "Agregar correo electrónico", "search_clients": "Buscar clientes...", - "search_scopes": "Buscar alcances..." + "search_scopes": "Buscar alcances...", + "localized_json_format": "Formato JSON: {\"en\": \"valor\", \"es\": \"valor\"}" }, "titles": { + "id_token": "Token de ID", + "access_token": "Token de Acceso", + "userinfo": "Información de Usuario", + "request_object": "Objeto de Solicitud", + "introspection": "Introspección", + "jarm": "JARM", + "token_endpoint_auth": "Autenticación del Endpoint de Token", "activeTokens": "Tokens activos", "acrs": "ACRs", "active_users": "Usuarios activos y estadísticas de tokens de acceso", @@ -1091,10 +1135,16 @@ "scopes_and_grants": "Alcances y Concesiones", "token_endpoint_auth": "Autenticación del Endpoint de Token", "token_settings": "Configuración de Tokens", + "tx_token": "Token TX", + "acr_security": "ACR y Seguridad", "uma": "UMA", "uris": "URIs", "basic_info": "Información Básica", - "advanced": "Avanzado" + "advanced": "Avanzado", + "system_information": "Información del Sistema", + "organization": "Organización", + "logout": "Cerrar Sesión", + "localization": "Localización" }, "links": { "support": "https://support.gluu.org/" diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 50d6ed3263..d1d189dd72 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -698,11 +698,26 @@ "software_statement": "Déclaration de Logiciel", "software_version": "Version du Logiciel", "spontaneous_scope_scripts": "Scripts de Scope Spontané", + "spontaneous_scopes": "Scopes Spontanés", "tos_uri": "URI des Conditions d'Utilisation", "update_token_scripts": "Scripts de Mise à Jour de Jeton", "userinfo_encrypted_response_alg": "Algorithme de Chiffrement UserInfo", "userinfo_encrypted_response_enc": "Encodage de Chiffrement UserInfo", - "userinfo_signed_response_alg": "Algorithme de Signature UserInfo" + "userinfo_signed_response_alg": "Algorithme de Signature UserInfo", + "id_token_lifetime": "Durée de Vie du Token ID (secondes)", + "tx_token_lifetime": "Durée de Vie du Token TX (secondes)", + "tx_token_signed_response_alg": "Algorithme de Signature du Token TX", + "tx_token_encrypted_response_alg": "Algorithme de Chiffrement du Token TX", + "tx_token_encrypted_response_enc": "Encodage de Chiffrement du Token TX", + "token_endpoint_auth_signing_alg": "Algorithme de Signature d'Auth du Endpoint de Token", + "minimum_acr_level": "Niveau ACR Minimum", + "minimum_acr_level_autoresolve": "Auto-résoudre le Niveau ACR", + "minimum_acr_priority_list": "Liste de Priorité ACR", + "additional_token_endpoint_auth_methods": "Méthodes d'Auth Supplémentaires du Endpoint de Token", + "allow_offline_access_without_consent": "Autoriser l'Accès Hors Ligne Sans Consentement", + "token_exchange_scripts": "Scripts d'Échange de Token", + "par_scripts": "Scripts PAR", + "logout_status_jwt_scripts": "Scripts JWT de Statut de Déconnexion" }, "messages": { "add_permission": "Ajouter une autorisation", @@ -817,7 +832,28 @@ "no_claims": "Aucun claim sélectionné", "no_grant_types": "Aucun type d'autorisation sélectionné", "no_response_types": "Aucun type de réponse sélectionné", - "no_scopes": "Aucun scope sélectionné" + "no_scopes": "Aucun scope sélectionné", + "dn": "DN", + "ttl": "TTL (secondes)", + "groups": "Groupes", + "client_id_issued_at": "Date d'Émission de l'ID Client", + "client_secret_expires_at": "Date d'Expiration du Secret Client", + "last_access_time": "Dernier Temps d'Accès", + "last_logon_time": "Dernier Temps de Connexion", + "registration_access_token": "Jeton d'Accès d'Enregistrement", + "requested_lifetime": "Durée de Vie Demandée", + "run_introspection_script_before_jwt_creation": "Exécuter le Script d'Introspection Avant la Création du JWT", + "keep_client_authorization_after_expiration": "Conserver l'Autorisation du Client Après Expiration", + "jans_sub_attr": "Attribut du Sujet", + "id_token_token_binding_cnf": "Confirmation de Liaison du Jeton d'ID", + "evidence": "Preuve", + "authorization_details_types": "Types de Détails d'Autorisation", + "logout_status_jwt_signed_response_alg": "Algorithme de Signature JWT du Statut de Déconnexion", + "client_name_localized": "Nom du Client (Localisé)", + "logo_uri_localized": "URI du Logo (Localisé)", + "client_uri_localized": "URI du Client (Localisé)", + "policy_uri_localized": "URI de Politique (Localisé)", + "tos_uri_localized": "URI des CGU (Localisé)" }, "errors": { "attribute_create_failed": "Erreur lors de la création de l'attribut", @@ -906,9 +942,17 @@ "add_claims": "Ajouter des claims", "add_email": "Ajouter un email", "search_clients": "Rechercher des clients...", - "search_scopes": "Rechercher des scopes..." + "search_scopes": "Rechercher des scopes...", + "localized_json_format": "Format JSON: {\"en\": \"valeur\", \"fr\": \"valeur\"}" }, "titles": { + "id_token": "Jeton d'ID", + "access_token": "Jeton d'Accès", + "userinfo": "Informations Utilisateur", + "request_object": "Objet de Requête", + "introspection": "Introspection", + "jarm": "JARM", + "token_endpoint_auth": "Authentification du Point de Terminaison de Jeton", "acrs": "ACR", "assets": "Jans Assets", "asset_add": "Adding new Jans Asset", @@ -992,10 +1036,16 @@ "scopes_and_grants": "Scopes et Autorisations", "token_endpoint_auth": "Authentification du Point de Terminaison de Jeton", "token_settings": "Paramètres des Jetons", + "tx_token": "Token TX", + "acr_security": "ACR et Sécurité", "uma": "UMA", "uris": "URIs", "basic_info": "Informations de Base", - "advanced": "Avancé" + "advanced": "Avancé", + "system_information": "Informations Système", + "organization": "Organisation", + "logout": "Déconnexion", + "localization": "Localisation" }, "links": { "support": "https://support.gluu.org/" diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index 972e765ab1..a68e1a2b47 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -693,11 +693,26 @@ "software_statement": "Declaração de Software", "software_version": "Versão do Software", "spontaneous_scope_scripts": "Scripts de Escopo Espontâneo", + "spontaneous_scopes": "Escopos Espontâneos", "tos_uri": "URI dos Termos de Serviço", "update_token_scripts": "Scripts de Atualização de Token", "userinfo_encrypted_response_alg": "Algoritmo de Criptografia UserInfo", "userinfo_encrypted_response_enc": "Codificação de Criptografia UserInfo", - "userinfo_signed_response_alg": "Algoritmo de Assinatura UserInfo" + "userinfo_signed_response_alg": "Algoritmo de Assinatura UserInfo", + "id_token_lifetime": "Tempo de Vida do Token ID (segundos)", + "tx_token_lifetime": "Tempo de Vida do Token TX (segundos)", + "tx_token_signed_response_alg": "Algoritmo de Assinatura do Token TX", + "tx_token_encrypted_response_alg": "Algoritmo de Criptografia do Token TX", + "tx_token_encrypted_response_enc": "Codificação de Criptografia do Token TX", + "token_endpoint_auth_signing_alg": "Algoritmo de Assinatura de Auth do Endpoint de Token", + "minimum_acr_level": "Nível ACR Mínimo", + "minimum_acr_level_autoresolve": "Auto-resolver Nível ACR", + "minimum_acr_priority_list": "Lista de Prioridade ACR", + "additional_token_endpoint_auth_methods": "Métodos de Auth Adicionais do Endpoint de Token", + "allow_offline_access_without_consent": "Permitir Acesso Offline Sem Consentimento", + "token_exchange_scripts": "Scripts de Troca de Token", + "par_scripts": "Scripts PAR", + "logout_status_jwt_scripts": "Scripts JWT de Status de Logout" }, "messages": { "add_permission": "Adicionar permissão", @@ -812,7 +827,28 @@ "no_claims": "Nenhum claim selecionado", "no_grant_types": "Nenhum tipo de concessão selecionado", "no_response_types": "Nenhum tipo de resposta selecionado", - "no_scopes": "Nenhum escopo selecionado" + "no_scopes": "Nenhum escopo selecionado", + "dn": "DN", + "ttl": "TTL (segundos)", + "groups": "Grupos", + "client_id_issued_at": "Data de Emissão do ID do Cliente", + "client_secret_expires_at": "Data de Expiração do Segredo do Cliente", + "last_access_time": "Último Tempo de Acesso", + "last_logon_time": "Último Tempo de Login", + "registration_access_token": "Token de Acesso de Registro", + "requested_lifetime": "Tempo de Vida Solicitado", + "run_introspection_script_before_jwt_creation": "Executar Script de Introspecção Antes da Criação do JWT", + "keep_client_authorization_after_expiration": "Manter Autorização do Cliente Após Expiração", + "jans_sub_attr": "Atributo do Sujeito", + "id_token_token_binding_cnf": "Confirmação de Vinculação do Token de ID", + "evidence": "Evidência", + "authorization_details_types": "Tipos de Detalhes de Autorização", + "logout_status_jwt_signed_response_alg": "Algoritmo de Assinatura JWT do Status de Logout", + "client_name_localized": "Nome do Cliente (Localizado)", + "logo_uri_localized": "URI do Logo (Localizado)", + "client_uri_localized": "URI do Cliente (Localizado)", + "policy_uri_localized": "URI de Política (Localizado)", + "tos_uri_localized": "URI dos Termos de Serviço (Localizado)" }, "errors": { "attribute_create_failed": "Erro ao criar atributo", @@ -899,9 +935,17 @@ "add_claims": "Adicionar claims", "add_email": "Adicionar email", "search_clients": "Pesquisar clientes...", - "search_scopes": "Pesquisar escopos..." + "search_scopes": "Pesquisar escopos...", + "localized_json_format": "Formato JSON: {\"en\": \"valor\", \"pt\": \"valor\"}" }, "titles": { + "id_token": "Token de ID", + "access_token": "Token de Acesso", + "userinfo": "Informações do Usuário", + "request_object": "Objeto de Solicitação", + "introspection": "Introspecção", + "jarm": "JARM", + "token_endpoint_auth": "Autenticação do Endpoint de Token", "acrs": "ACRs", "assets": "Jans Assets", "asset_add": "Adding new Jans Asset", @@ -986,10 +1030,16 @@ "scopes_and_grants": "Escopos e Concessões", "token_endpoint_auth": "Autenticação do Endpoint de Token", "token_settings": "Configurações de Tokens", + "tx_token": "Token TX", + "acr_security": "ACR e Segurança", "uma": "UMA", "uris": "URIs", "basic_info": "Informações Básicas", - "advanced": "Avançado" + "advanced": "Avançado", + "system_information": "Informações do Sistema", + "organization": "Organização", + "logout": "Logout", + "localization": "Localização" }, "links": { "support": "https://support.gluu.org/" diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx index 041137a4f7..22af815522 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx @@ -44,6 +44,7 @@ import { useGetOauthOpenidClients, useDeleteOauthOpenidClientByInum, getGetOauthOpenidClientsQueryKey, + useGetOauthScopes, } from 'JansConfigApi' import type { Client, ClientTableRow } from './types' import { useClientActions } from './hooks' @@ -168,6 +169,12 @@ const ClientListPage: React.FC = () => { }, }) + const { data: scopesData } = useGetOauthScopes({ limit: 200 }, { query: { staleTime: 60000 } }) + + const scopesList = useMemo(() => { + return (scopesData?.entries || []) as Array<{ dn?: string; id?: string; displayName?: string }> + }, [scopesData?.entries]) + const clientResourceId = ADMIN_UI_RESOURCES.Clients const selectedTheme = useMemo(() => theme?.state?.theme || 'light', [theme?.state?.theme]) @@ -353,13 +360,13 @@ const ClientListPage: React.FC = () => { [handleClientDelete], ) - const DeleteIcon = () => + const DeleteIcon = () => const detailPanel = useCallback( (props: DetailPanelProps): React.ReactElement => ( - + ), - [], + [scopesList], ) const PaperContainer = useCallback( @@ -431,9 +438,9 @@ const ClientListPage: React.FC = () => { render: (rowData) => truncateText(rowData.clientName || rowData.displayName || '-', 30), }, { - title: t('fields.client_id'), - field: 'inum', - render: (rowData) => truncateText(rowData.inum || '', 20), + title: t('fields.application_type'), + field: 'applicationType', + render: (rowData) => rowData.applicationType || '-', }, { title: t('fields.grant_types'), @@ -573,7 +580,7 @@ const ClientListPage: React.FC = () => { onChange={handleSortByChange} label={t('fields.sort_by')} > - {t('fields.none')} + {t('options.none')} {t('fields.displayname')} {t('fields.client_name')} {t('fields.client_id')} @@ -589,8 +596,8 @@ const ClientListPage: React.FC = () => { onChange={handleSortOrderChange} label={t('fields.sort_order')} > - {t('fields.ascending')} - {t('fields.descending')} + {t('options.ascending')} + {t('options.descending')} )} diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx index 542862b789..2a967e3ca2 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx @@ -1,240 +1,135 @@ -import React, { useCallback, useContext, useMemo } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' -import { Box, Grid, Typography, Chip, Divider } from '@mui/material' -import { ThemeContext } from 'Context/theme/themeContext' -import getThemeColor from 'Context/theme/config' +import { Box, Grid, Typography, Chip, Paper } from '@mui/material' import type { Client } from '../types' import { formatGrantTypeLabel, formatResponseTypeLabel, formatScopeDisplay } from '../helper/utils' +interface ScopeItem { + dn?: string + id?: string + displayName?: string +} + interface ClientDetailViewProps { client: Client + scopes?: ScopeItem[] } -const ClientDetailView: React.FC = ({ client }) => { +const ClientDetailView: React.FC = ({ client, scopes = [] }) => { const { t } = useTranslation() - const theme = useContext(ThemeContext) - const selectedTheme = theme?.state?.theme || 'darkBlue' - - const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) - - const containerStyle = useMemo( - () => ({ - p: 3, - backgroundColor: themeColors?.lightBackground || '#fafafa', - }), - [themeColors], - ) - - const sectionStyle = useMemo( - () => ({ - mb: 3, - }), - [], - ) - - const labelStyle = useMemo( - () => ({ - fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, - color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#555', - fontSize: '0.85rem', - mb: 0.5, - }), - [themeColors, selectedTheme], - ) - - const valueStyle = useMemo( - () => ({ - fontWeight: selectedTheme === 'darkBlack' ? 600 : 400, - color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', - fontSize: '0.9rem', - wordBreak: 'break-all' as const, - }), - [themeColors, selectedTheme], - ) - - const sectionTitleStyle = useMemo( - () => ({ - mb: 2, - fontWeight: 700, - color: themeColors?.background || '#1976d2', - }), - [themeColors], - ) - - const chipStyle = useMemo( - () => ({ - m: 0.25, - fontSize: '0.75rem', - }), - [], - ) - const chipColors = useMemo( - () => ({ - scopes: themeColors?.lightBackground || '#e3f2fd', - grants: themeColors?.lightBackground || '#fff3e0', - responses: themeColors?.lightBackground || '#e8f5e9', - uris: themeColors?.lightBackground || '#e1f5fe', - logout: themeColors?.lightBackground || '#fce4ec', - contacts: themeColors?.lightBackground || '#f3e5f5', - }), - [themeColors], - ) - - const renderField = useCallback( - (label: string, value: string | number | boolean | undefined | null) => { - if (value === undefined || value === null || value === '') return null - return ( - - {label} - - {typeof value === 'boolean' - ? value - ? t('options.yes') - : t('options.no') - : String(value)} - - - ) - }, - [labelStyle, valueStyle, t], - ) - - const renderChipList = useCallback( - (label: string, items: string[] | undefined, color: string) => { - if (!items || items.length === 0) return null - return ( - - {label} - - {items.map((item, index) => ( - - ))} - - - ) - }, - [labelStyle, chipStyle], - ) + const getScopeNames = (): string[] => { + if (!client.scopes || client.scopes.length === 0) return [] + const matchedScopes = scopes + .filter((scope) => scope.dn && client.scopes?.includes(scope.dn)) + .map((scope) => scope.id || scope.displayName || '') + .filter(Boolean) + + const matchedDns = scopes.filter((s) => s.dn && client.scopes?.includes(s.dn)).map((s) => s.dn) + const unmatchedScopes = client.scopes + .filter((s) => !matchedDns.includes(s)) + .map((s) => formatScopeDisplay(s)) + + return [...matchedScopes, ...unmatchedScopes] + } + + const labelSx = { + fontSize: '0.7rem', + fontWeight: 600, + color: 'text.secondary', + textTransform: 'uppercase' as const, + letterSpacing: 0.5, + } + + const valueSx = { + fontSize: '0.85rem', + fontWeight: 400, + color: 'text.primary', + wordBreak: 'break-word' as const, + } + + const chipSx = { + fontSize: '0.7rem', + height: 24, + backgroundColor: 'action.hover', + } return ( - - - - {t('titles.client_details')} + + + + {client.displayName || client.clientName || t('fields.unnamed_client')} - - {renderField(t('fields.client_id'), client.inum)} - {renderField(t('fields.client_name'), client.clientName)} - {renderField(t('fields.displayname'), client.displayName)} - {renderField(t('fields.description'), client.description)} - {renderField(t('fields.application_type'), client.applicationType)} - {renderField(t('fields.subject_type'), client.subjectType)} - {renderField( - t('fields.status'), - client.disabled ? t('fields.disabled') : t('fields.active'), - )} - {renderField(t('fields.is_trusted_client'), client.trustedClient)} - + - - - - - {t('titles.scopes_and_grants')} - - - {renderChipList( - t('fields.scopes'), - client.scopes?.map((s) => formatScopeDisplay(s)), - chipColors.scopes, - )} - {renderChipList( - t('fields.grant_types'), - client.grantTypes?.map((g) => formatGrantTypeLabel(g)), - chipColors.grants, - )} - {renderChipList( - t('fields.response_types'), - client.responseTypes?.map((r) => formatResponseTypeLabel(r)), - chipColors.responses, - )} + + + {t('fields.client_id')} + {client.inum} - - - - - - - {t('titles.uris')} - - - {renderChipList(t('fields.redirect_uris'), client.redirectUris, chipColors.uris)} - {renderChipList( - t('fields.post_logout_redirect_uris'), - client.postLogoutRedirectUris, - chipColors.logout, - )} - {renderField(t('fields.client_uri'), client.clientUri)} - {renderField(t('fields.logo_uri'), client.logoUri)} - {renderField(t('fields.policy_uri'), client.policyUri)} - {renderField(t('fields.tos_uri'), client.tosUri)} + + {t('fields.client_name')} + {client.clientName || '-'} - - - - - - - {t('titles.authentication')} - - - {renderField(t('fields.token_endpoint_auth_method'), client.tokenEndpointAuthMethod)} - {renderField(t('fields.id_token_signed_response_alg'), client.idTokenSignedResponseAlg)} - {renderField(t('fields.access_token_signing_alg'), client.accessTokenSigningAlg)} - {renderField(t('fields.jwks_uri'), client.jwksUri)} + + {t('fields.application_type')} + {client.applicationType || '-'} - - - - - - - {t('titles.token_settings')} - - - {renderField(t('fields.access_token_lifetime'), client.accessTokenLifetime)} - {renderField(t('fields.refresh_token_lifetime'), client.refreshTokenLifetime)} - {renderField(t('fields.default_max_age'), client.defaultMaxAge)} - {renderField(t('fields.access_token_as_jwt'), client.accessTokenAsJwt)} - {renderField(t('fields.include_claims_in_id_token'), client.includeClaimsInIdToken)} - {renderField( - t('fields.persist_client_authorizations'), - client.persistClientAuthorizations, - )} - - - {client.contacts && client.contacts.length > 0 && ( - <> - - - - {t('titles.contacts')} - - - {renderChipList(t('fields.contacts'), client.contacts, chipColors.contacts)} - - - - )} - + {client.description && ( + + {t('fields.description')} + {client.description} + + )} + + {client.scopes && client.scopes.length > 0 && ( + + {t('fields.scopes')} + + {getScopeNames().map((name, i) => ( + + ))} + + + )} + + {client.grantTypes && client.grantTypes.length > 0 && ( + + {t('fields.grant_types')} + + {client.grantTypes.map((g, i) => ( + + ))} + + + )} + {client.responseTypes && client.responseTypes.length > 0 && ( + + {t('fields.response_types')} + + {client.responseTypes.map((r, i) => ( + + ))} + + + )} + +
) } diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx index 1a5b92338b..eb1b97ad1a 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx @@ -286,15 +286,15 @@ const ClientForm: React.FC = ({ > {onCancel && ( + + )} + + + + + {t('fields.logout_uris')} + + {({ push, remove }) => ( + + {formik.values.postLogoutRedirectUris.map((uri, index) => ( + + + + handleCopyToClipboard(uri)} + edge="end" + size="small" + > + + + + + ), + }} + /> + {formik.values.postLogoutRedirectUris.length > 1 && ( + remove(index)} + size="small" + color="error" + > + + + )} + + ))} + + + )} + + +
+ + + + + {onCancel && ( + + )} + + + + )} + + + + + value !== undefined && + value !== '' && + (!Array.isArray(value) || value.length > 0), + ) + .map(([key, value]) => ({ path: key, value })) + : [] + } + /> + + ) +} + +export default ClientAddForm diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx index 2a967e3ca2..ea63114b8f 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx @@ -1,6 +1,8 @@ -import React from 'react' +import React, { useContext, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Box, Grid, Typography, Chip, Paper } from '@mui/material' +import { Badge } from 'reactstrap' +import { ThemeContext } from 'Context/theme/themeContext' import type { Client } from '../types' import { formatGrantTypeLabel, formatResponseTypeLabel, formatScopeDisplay } from '../helper/utils' @@ -17,6 +19,8 @@ interface ClientDetailViewProps { const ClientDetailView: React.FC = ({ client, scopes = [] }) => { const { t } = useTranslation() + const theme = useContext(ThemeContext) + const selectedTheme = useMemo(() => theme?.state?.theme || 'light', [theme?.state?.theme]) const getScopeNames = (): string[] => { if (!client.scopes || client.scopes.length === 0) return [] @@ -68,12 +72,9 @@ const ClientDetailView: React.FC = ({ client, scopes = [] {client.displayName || client.clientName || t('fields.unnamed_client')} - + + {client.disabled ? t('fields.disabled') : t('fields.active')} + diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx index eb1b97ad1a..8c8f50b734 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx @@ -1,136 +1,169 @@ -import React, { useCallback, useContext, useMemo, useState, useEffect } from 'react' +import React, { useCallback, useContext, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Formik, Form } from 'formik' -import { Box, Tabs, Tab, Button, Paper } from '@mui/material' +import { + Box, + Button, + Paper, + List, + ListItemButton, + ListItemIcon, + ListItemText, + IconButton, + Tooltip, + useMediaQuery, + useTheme, + Select, + MenuItem, + FormControl, + InputLabel, +} from '@mui/material' +import { + InfoOutlined, + LockOutlined, + VpnKeyOutlined, + LinkOutlined, + TokenOutlined, + PhoneCallbackOutlined, + CodeOutlined, + LanguageOutlined, + SettingsOutlined, + ChevronLeft, + ChevronRight, +} from '@mui/icons-material' import { Card, CardBody } from 'Components' import { ThemeContext } from 'Context/theme/themeContext' import getThemeColor from 'Context/theme/config' import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' -import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' import { adminUiFeatures } from 'Plugins/admin/helper/utils' -import { useGetOauthScopes } from 'JansConfigApi' -import { useSelector, useDispatch } from 'react-redux' -import { getScripts } from 'Redux/features/initSlice' -import { getOidcDiscovery } from 'Redux/features/oidcDiscoverySlice' +import { useGetConfigScripts, useGetProperties } from 'JansConfigApi' import type { ClientFormProps, ClientFormValues, - ClientTab, + ClientSection, ModifiedFields, ClientScope, - RootState, } from '../types' import { clientValidationSchema } from '../helper/validations' import { buildClientInitialValues, buildClientPayload, downloadClientAsJson } from '../helper/utils' -import { TAB_LABELS, DEFAULT_SCOPE_SEARCH_LIMIT } from '../helper/constants' -import BasicInfoTab from '../tabs/BasicInfoTab' -import AuthenticationTab from '../tabs/AuthenticationTab' -import ScopesGrantsTab from '../tabs/ScopesGrantsTab' -import AdvancedTab from '../tabs/AdvancedTab' -import UrisTab from '../tabs/UrisTab' +import { SECTIONS } from '../helper/constants' +import { + BasicInfoSection, + AuthenticationSection, + ScopesGrantsSection, + UrisSection, + TokensSection, + CibaSection, + ScriptsSection, + LocalizationSection, + SystemInfoSection, +} from '../sections' -interface InitReducerState { - scripts: Array<{ dn?: string; name?: string; scriptType?: string; enabled?: boolean }> -} - -interface OidcDiscoveryState { - configuration: Record - loading: boolean -} +const NAV_COLLAPSED_KEY = 'clientFormNavCollapsed' -interface ExtendedRootState extends RootState { - initReducer: InitReducerState - oidcDiscoveryReducer: OidcDiscoveryState +const SECTION_ICONS: Record = { + InfoOutlined: , + LockOutlined: , + VpnKeyOutlined: , + LinkOutlined: , + TokenOutlined: , + PhoneCallbackOutlined: , + CodeOutlined: , + LanguageOutlined: , + SettingsOutlined: , } -const TABS: ClientTab[] = ['basic', 'authentication', 'scopes', 'advanced', 'uris'] - const ClientForm: React.FC = ({ client, isEdit = false, viewOnly = false, onSubmit, onCancel, + scopes: propScopes = [], + scopesLoading: propScopesLoading = false, + onScopeSearch: propOnScopeSearch, }) => { const { t } = useTranslation() - const dispatch = useDispatch() + const muiTheme = useTheme() + const isMobile = useMediaQuery(muiTheme.breakpoints.down('md')) const theme = useContext(ThemeContext) const selectedTheme = theme?.state?.theme || 'darkBlue' const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) - const [activeTab, setActiveTab] = useState('basic') + const [activeSection, setActiveSection] = useState('basic') + const [navCollapsed, setNavCollapsed] = useState(() => { + const stored = localStorage.getItem(NAV_COLLAPSED_KEY) + return stored === 'true' + }) const [modifiedFields, setModifiedFields] = useState({}) const [commitModalOpen, setCommitModalOpen] = useState(false) const [formValues, setFormValues] = useState(null) - const [scopeSearchPattern, setScopeSearchPattern] = useState('') - const reduxScripts = useSelector((state: ExtendedRootState) => state.initReducer?.scripts || []) + const { data: propertiesData } = useGetProperties({ + query: { + refetchOnMount: false, + refetchOnWindowFocus: false, + staleTime: 300000, + }, + }) - const scripts = useMemo( - () => - reduxScripts.map( - (s): { dn: string; name: string; scriptType?: string; enabled?: boolean } => ({ - dn: s.dn || '', - name: s.name || '', - scriptType: s.scriptType, - enabled: s.enabled, - }), - ), - [reduxScripts], - ) - const oidcConfiguration = useSelector( - (state: ExtendedRootState) => state.oidcDiscoveryReducer?.configuration || {}, - ) + const oidcConfiguration = useMemo(() => propertiesData || {}, [propertiesData]) - const scopeQueryParams = useMemo( - () => ({ - limit: DEFAULT_SCOPE_SEARCH_LIMIT, - pattern: scopeSearchPattern || undefined, - }), - [scopeSearchPattern], + const scopes = propScopes + const scopesLoading = propScopesLoading + const handleScopeSearch = useCallback( + (pattern: string) => { + if (propOnScopeSearch) { + propOnScopeSearch(pattern) + } + }, + [propOnScopeSearch], ) - const { data: scopesResponse, isLoading: scopesLoading } = useGetOauthScopes(scopeQueryParams, { - query: { - refetchOnMount: 'always' as const, - refetchOnWindowFocus: false, - staleTime: 30000, + const { data: scriptsResponse } = useGetConfigScripts( + { limit: 200 }, + { + query: { + refetchOnMount: 'always' as const, + refetchOnWindowFocus: false, + staleTime: 30000, + }, }, - }) + ) - const scopes = useMemo((): ClientScope[] => { - const entries = (scopesResponse?.entries || []) as Array<{ + const scripts = useMemo((): Array<{ + dn: string + name: string + scriptType?: string + enabled?: boolean + }> => { + const entries = (scriptsResponse?.entries || []) as Array<{ dn?: string - inum?: string - id?: string - displayName?: string - description?: string + name?: string + scriptType?: string + enabled?: boolean }> - return entries.map( - (scope): ClientScope => ({ - dn: scope.dn || '', - inum: scope.inum, - id: scope.id, - displayName: scope.displayName || scope.id, - description: scope.description, - }), - ) - }, [scopesResponse?.entries]) + return entries.map((script) => ({ + dn: script.dn || '', + name: script.name || '', + scriptType: script.scriptType, + enabled: script.enabled, + })) + }, [scriptsResponse?.entries]) const initialValues = useMemo(() => buildClientInitialValues(client), [client]) - useEffect(() => { - dispatch(getScripts({ action: { limit: 100000 } })) - dispatch(getOidcDiscovery()) - }, [dispatch]) - - const handleTabChange = useCallback((_: React.SyntheticEvent, newValue: ClientTab) => { - setActiveTab(newValue) + const handleNavCollapse = useCallback(() => { + setNavCollapsed((prev) => { + const newValue = !prev + localStorage.setItem(NAV_COLLAPSED_KEY, String(newValue)) + return newValue + }) }, []) - const handleScopeSearch = useCallback((pattern: string) => { - setScopeSearchPattern(pattern) + const handleSectionChange = useCallback((section: ClientSection) => { + setActiveSection(section) }, []) const handleFormSubmit = useCallback((values: ClientFormValues) => { @@ -157,15 +190,6 @@ const ClientForm: React.FC = ({ downloadClientAsJson(values) }, []) - const tabStyle = useMemo( - () => ({ - borderBottom: 1, - borderColor: 'divider', - mb: 2, - }), - [], - ) - const buttonStyle = useMemo( () => ({ ...applicationStyle.buttonStyle, @@ -181,53 +205,182 @@ const ClientForm: React.FC = ({ })) }, [modifiedFields]) - const renderTabContent = useCallback( + const navPanelStyle = useMemo( + () => ({ + width: navCollapsed ? 64 : 240, + minWidth: navCollapsed ? 64 : 240, + transition: 'width 0.2s ease, min-width 0.2s ease', + borderRight: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, + backgroundColor: themeColors?.lightBackground || '#fafafa', + display: 'flex', + flexDirection: 'column' as const, + }), + [navCollapsed, themeColors], + ) + + const listItemStyle = useMemo( + () => ({ + 'py': 1.5, + 'px': navCollapsed ? 2 : 2, + 'justifyContent': navCollapsed ? 'center' : 'flex-start', + '&.Mui-selected': { + backgroundColor: `${themeColors?.background}15`, + borderRight: `3px solid ${themeColors?.background}`, + }, + '&.Mui-selected:hover': { + backgroundColor: `${themeColors?.background}20`, + }, + '&:hover': { + backgroundColor: `${themeColors?.background}10`, + }, + }), + [navCollapsed, themeColors], + ) + + const renderSectionContent = useCallback( (formik: { values: ClientFormValues setFieldValue: (field: string, value: unknown) => void + dirty: boolean }) => { - const tabProps = { + const sectionProps = { formik: formik as any, viewOnly, - modifiedFields, setModifiedFields, + scripts, + scopes, + scopesLoading, + onScopeSearch: handleScopeSearch, + oidcConfiguration, } - switch (activeTab) { + switch (activeSection) { case 'basic': - return + return case 'authentication': - return + return case 'scopes': - return ( - - ) - case 'advanced': - return + return case 'uris': - return + return + case 'tokens': + return + case 'ciba': + return + case 'scripts': + return + case 'localization': + return + case 'system': + return default: return null } }, - [ - activeTab, - viewOnly, - modifiedFields, - oidcConfiguration, - scopes, - scopesLoading, - handleScopeSearch, - scripts, - isEdit, - ], + [activeSection, viewOnly, scripts, scopes, scopesLoading, handleScopeSearch, oidcConfiguration], ) + const renderNavigation = useCallback(() => { + if (isMobile) { + return ( + + {t('fields.section')} + + + ) + } + + return ( + + + {SECTIONS.map((section) => ( + + handleSectionChange(section.id as ClientSection)} + sx={listItemStyle} + > + + {SECTION_ICONS[section.icon]} + + {!navCollapsed && ( + + )} + + + ))} + + + + {navCollapsed ? : } + + + + ) + }, [ + isMobile, + activeSection, + navCollapsed, + themeColors, + t, + handleSectionChange, + handleNavCollapse, + navPanelStyle, + listItemStyle, + ]) + return ( @@ -239,7 +392,7 @@ const ClientForm: React.FC = ({ > {(formik) => (
- + + {!viewOnly && ( + + )} - - - - {TABS.map((tab) => ( - - ))} - + {isMobile ? ( + + {renderNavigation()} + + {renderSectionContent(formik)} + - - {renderTabContent(formik)} - + ) : ( + + {renderNavigation()} + {renderSectionContent(formik)} + + )} {!viewOnly && ( - {onCancel && ( + {onCancel ? ( + ) : ( + )} - ) : ( - - )} + + {onCancel ? ( + + ) : ( + + )} + {!viewOnly && ( - - )} + )} + )} diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts b/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts index 06d4b112e0..419958026f 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts @@ -129,6 +129,7 @@ export const CLIENT_ROUTES = { LIST: '/auth-server/clients', ADD: '/auth-server/client/new', EDIT: '/auth-server/client/edit', + VIEW: '/auth-server/client/view', } as const export const DEFAULT_PAGE_SIZE = 10 @@ -152,6 +153,7 @@ export const SECTIONS = [ { id: 'scripts', labelKey: 'sections.scripts', icon: 'CodeOutlined' }, { id: 'localization', labelKey: 'sections.localization', icon: 'LanguageOutlined' }, { id: 'system', labelKey: 'sections.system_info', icon: 'SettingsOutlined' }, + { id: 'activeTokens', labelKey: 'sections.active_tokens', icon: 'CreditCardOutlined' }, ] as const export type GrantType = (typeof GRANT_TYPES)[number]['value'] diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts index 31f6312d9a..a840caa4d2 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts @@ -79,6 +79,13 @@ export function useClientActions() { [navigate], ) + const navigateToClientView = useCallback( + (inum: string) => { + navigate(`${CLIENT_ROUTES.VIEW}/${inum}`) + }, + [navigate], + ) + return { logClientCreation, logClientUpdate, @@ -86,5 +93,6 @@ export function useClientActions() { navigateToClientList, navigateToClientAdd, navigateToClientEdit, + navigateToClientView, } } diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts index 4c88e2a8c7..799a6aa88b 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import { useDispatch } from 'react-redux' import { updateToast } from 'Redux/features/toastSlice' +import { triggerWebhook } from 'Plugins/admin/redux/features/WebhookSlice' import { useGetOauthOpenidClients, useGetOauthOpenidClientsByInum, @@ -48,16 +49,20 @@ export function useCreateClient(onSuccess?: () => void, onError?: (error: Error) const queryClient = useQueryClient() const dispatch = useDispatch() - const handleSuccess = useCallback(() => { - dispatch(updateToast(true, 'success')) - queryClient.invalidateQueries({ - predicate: (query) => { - const queryKey = query.queryKey[0] as string - return queryKey === getGetOauthOpenidClientsQueryKey()[0] - }, - }) - onSuccess?.() - }, [dispatch, queryClient, onSuccess]) + const handleSuccess = useCallback( + (data: Client) => { + dispatch(updateToast(true, 'success')) + queryClient.invalidateQueries({ + predicate: (query) => { + const queryKey = query.queryKey[0] as string + return queryKey === getGetOauthOpenidClientsQueryKey()[0] + }, + }) + dispatch(triggerWebhook({ createdFeatureValue: data })) + onSuccess?.() + }, + [dispatch, queryClient, onSuccess], + ) const handleError = useCallback( (error: Error) => { @@ -80,19 +85,23 @@ export function useUpdateClient(onSuccess?: () => void, onError?: (error: Error) const queryClient = useQueryClient() const dispatch = useDispatch() - const handleSuccess = useCallback(() => { - dispatch(updateToast(true, 'success')) - queryClient.invalidateQueries({ - predicate: (query) => { - const queryKey = query.queryKey[0] as string - return ( - queryKey === getGetOauthOpenidClientsQueryKey()[0] || - queryKey === 'getOauthOpenidClientsByInum' - ) - }, - }) - onSuccess?.() - }, [dispatch, queryClient, onSuccess]) + const handleSuccess = useCallback( + (data: Client) => { + dispatch(updateToast(true, 'success')) + queryClient.invalidateQueries({ + predicate: (query) => { + const queryKey = query.queryKey[0] as string + return ( + queryKey === getGetOauthOpenidClientsQueryKey()[0] || + queryKey === 'getOauthOpenidClientsByInum' + ) + }, + }) + dispatch(triggerWebhook({ createdFeatureValue: data })) + onSuccess?.() + }, + [dispatch, queryClient, onSuccess], + ) const handleError = useCallback( (error: Error) => { diff --git a/admin-ui/plugins/auth-server/components/Clients/index.ts b/admin-ui/plugins/auth-server/components/Clients/index.ts index 42f055d407..1efc7b51b6 100644 --- a/admin-ui/plugins/auth-server/components/Clients/index.ts +++ b/admin-ui/plugins/auth-server/components/Clients/index.ts @@ -1,6 +1,7 @@ export { default as ClientListPage } from './ClientListPage' export { default as ClientAddPage } from './ClientAddPage' export { default as ClientEditPage } from './ClientEditPage' +export { default as ClientDetailPage } from './ClientDetailPage' export { default as ClientForm } from './components/ClientForm' export { default as ClientDetailView } from './components/ClientDetailView' diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/ActiveTokensSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/ActiveTokensSection.tsx new file mode 100644 index 0000000000..7f774ccb5b --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/sections/ActiveTokensSection.tsx @@ -0,0 +1,450 @@ +import React, { useState, useCallback, useContext, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { useQueryClient } from '@tanstack/react-query' +import MaterialTable from '@material-table/core' +import { + Box, + Button, + Grid, + MenuItem, + Paper, + TablePagination, + TextField, + Typography, + CircularProgress, +} from '@mui/material' +import { + FilterList as FilterListIcon, + GetApp as GetAppIcon, + Delete as DeleteIcon, +} from '@mui/icons-material' +import { DatePicker } from '@mui/x-date-pickers/DatePicker' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import dayjs, { Dayjs } from 'dayjs' +import { ThemeContext } from 'Context/theme/themeContext' +import getThemeColor from 'Context/theme/config' +import { updateToast } from 'Redux/features/toastSlice' +import ActiveTokenDetailPanel from '../components/ActiveTokenDetailPanel' +import type { ExtendedClient } from '../types' +import { + useGetTokenByClient, + useRevokeToken, + getGetTokenByClientQueryKey, + type TokenEntity, +} from 'JansConfigApi' + +interface ActiveTokensSectionProps { + formik: { + values: ExtendedClient + } + viewOnly?: boolean +} + +const ActiveTokensSection: React.FC = ({ formik, viewOnly = false }) => { + const { t } = useTranslation() + const dispatch = useDispatch() + const queryClient = useQueryClient() + const theme = useContext(ThemeContext) + const selectedTheme = theme?.state?.theme || 'darkBlue' + const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) + + const [pageNumber, setPageNumber] = useState(0) + const [limit, setLimit] = useState(10) + const [showFilter, setShowFilter] = useState(false) + const [searchFilter, setSearchFilter] = useState<'expirationDate' | 'creationDate'>( + 'expirationDate', + ) + const [dateAfter, setDateAfter] = useState(null) + const [dateBefore, setDateBefore] = useState(null) + + const clientInum = formik.values.inum + + // Fetch tokens using Orval-generated hook + const { + data: tokenData, + isLoading: loading, + refetch, + } = useGetTokenByClient(clientInum || '', { + query: { + enabled: !!clientInum, + refetchOnMount: 'always' as const, + refetchOnWindowFocus: false, + }, + }) + + // Revoke token mutation + const revokeTokenMutation = useRevokeToken({ + mutation: { + onSuccess: () => { + dispatch(updateToast(true, 'success', t('messages.token_revoked'))) + queryClient.invalidateQueries({ + queryKey: getGetTokenByClientQueryKey(clientInum), + }) + }, + onError: (error: Error) => { + console.error('Error revoking token:', error) + dispatch(updateToast(true, 'error', t('messages.error_revoking_token'))) + }, + }, + }) + + const formatDate = (dateString?: string): string => { + if (!dateString) return '--' + try { + return dayjs(dateString).format('YYYY/MM/DD HH:mm:ss') + } catch { + return dateString + } + } + + // Filter tokens based on date criteria (client-side filtering) + const filteredTokens = useMemo(() => { + const entries = tokenData?.entries || [] + + if (!dateAfter || !dateBefore) { + return entries + } + + return entries.filter((token) => { + const dateField = + searchFilter === 'expirationDate' ? token.expirationDate : token.creationDate + if (!dateField) return false + + const tokenDate = dayjs(dateField) + return tokenDate.isAfter(dateAfter) && tokenDate.isBefore(dateBefore) + }) + }, [tokenData?.entries, dateAfter, dateBefore, searchFilter]) + + // Paginate tokens client-side + const paginatedTokens = useMemo(() => { + const startIndex = pageNumber * limit + return filteredTokens.slice(startIndex, startIndex + limit).map((token) => ({ + ...token, + formattedCreationDate: formatDate(token.creationDate), + formattedExpirationDate: formatDate(token.expirationDate), + })) + }, [filteredTokens, pageNumber, limit]) + + const totalItems = filteredTokens.length + + const handlePageChange = useCallback((_event: unknown, page: number) => { + setPageNumber(page) + }, []) + + const handleRowsPerPageChange = useCallback((event: React.ChangeEvent) => { + const newLimit = parseInt(event.target.value, 10) + setLimit(newLimit) + setPageNumber(0) + }, []) + + const handleSearch = useCallback(() => { + setPageNumber(0) + setShowFilter(false) + }, []) + + const handleClear = useCallback(() => { + setDateAfter(null) + setDateBefore(null) + setPageNumber(0) + }, []) + + const handleRevokeToken = useCallback( + async ( + oldData: TokenEntity & { formattedCreationDate?: string; formattedExpirationDate?: string }, + ): Promise => { + const tokenCode = oldData.tokenCode + if (!tokenCode) { + dispatch(updateToast(true, 'error', t('messages.token_code_missing'))) + return + } + + await revokeTokenMutation.mutateAsync({ tknCde: tokenCode }) + }, + [revokeTokenMutation, dispatch, t], + ) + + const convertToCSV = (data: TokenEntity[]): string => { + if (data.length === 0) return '' + + const headers = [ + 'scope', + 'deletable', + 'grantType', + 'expirationDate', + 'creationDate', + 'tokenType', + ] + const headerRow = headers.map((h) => h.toUpperCase()).join(',') + + const rows = data.map((row) => + headers + .map((key) => { + const value = row[key as keyof TokenEntity] + if (value === undefined || value === null) return '' + if (typeof value === 'boolean') return value.toString() + return String(value).replace(/,/g, ';') + }) + .join(','), + ) + + return [headerRow, ...rows].join('\n') + } + + const handleDownloadCSV = useCallback(() => { + const csv = convertToCSV(filteredTokens) + const blob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + + const link = document.createElement('a') + link.href = url + link.setAttribute('download', 'client-tokens.csv') + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + }, [filteredTokens]) + + const PaperContainer = useCallback( + (props: React.ComponentProps) => , + [], + ) + + const PaginationWrapper = useCallback( + () => ( + + ), + [totalItems, pageNumber, limit, handlePageChange, handleRowsPerPageChange], + ) + + const DetailPanel = useCallback( + (rowData: { rowData: TokenEntity }) => , + [], + ) + + const tableColumns = useMemo( + () => [ + { + title: t('fields.creationDate'), + field: 'formattedCreationDate', + }, + { + title: t('fields.token_type'), + field: 'tokenType', + }, + { + title: t('fields.grant_type'), + field: 'grantType', + }, + { + title: t('fields.expiration_date'), + field: 'formattedExpirationDate', + }, + ], + [t], + ) + + if (!clientInum) { + return ( + + {t('messages.save_client_first')} + + ) + } + + return ( + + + + + + {showFilter && ( + + + + + setSearchFilter(e.target.value as 'expirationDate' | 'creationDate') + } + > + {t('fields.expiration_date')} + {t('fields.creationDate')} + + + + + + setDateAfter(val)} + slotProps={{ + textField: { + fullWidth: true, + size: 'small', + }, + }} + /> + + + + + + setDateBefore(val)} + slotProps={{ + textField: { + fullWidth: true, + size: 'small', + }, + }} + /> + + + + + + + + + + + + + )} + + + {loading && ( + + + + )} + + {!loading && ( + rowData.deletable !== false, + onRowDelete: (oldData) => + new Promise((resolve, reject) => { + handleRevokeToken(oldData) + .then(() => resolve()) + .catch(() => reject()) + }), + } + } + detailPanel={DetailPanel} + icons={{ + Delete: () => , + }} + localization={{ + body: { + emptyDataSourceMessage: t('messages.no_tokens_found'), + deleteTooltip: t('actions.revoke'), + }, + header: { + actions: t('titles.actions'), + }, + }} + /> + )} + + ) +} + +export default ActiveTokensSection diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx index 161f9aeb44..69fbb30719 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx @@ -10,14 +10,22 @@ import { IconButton, Autocomplete, Chip, + Tooltip, } from '@mui/material' -import { Visibility, VisibilityOff } from '@mui/icons-material' +import { + Visibility, + VisibilityOff, + ContentCopy, + Autorenew as GenerateIcon, +} from '@mui/icons-material' import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker' import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import dayjs from 'dayjs' import { ThemeContext } from 'Context/theme/themeContext' import getThemeColor from 'Context/theme/config' +import { useDispatch } from 'react-redux' +import { updateToast } from 'Redux/features/toastSlice' import type { SectionProps } from '../types' import { APPLICATION_TYPES, SUBJECT_TYPES } from '../helper/constants' @@ -27,6 +35,7 @@ const BasicInfoSection: React.FC = ({ setModifiedFields, }) => { const { t } = useTranslation() + const dispatch = useDispatch() const theme = useContext(ThemeContext) const selectedTheme = theme?.state?.theme || 'darkBlue' const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) @@ -47,6 +56,33 @@ const BasicInfoSection: React.FC = ({ setShowSecret((prev) => !prev) }, []) + const handleCopyClientId = useCallback(() => { + if (formik.values.inum) { + navigator.clipboard.writeText(formik.values.inum) + dispatch(updateToast(true, 'success', t('messages.client_id_copied'))) + } + }, [formik.values.inum, dispatch, t]) + + const handleCopyClientSecret = useCallback(() => { + if (formik.values.clientSecret) { + navigator.clipboard.writeText(formik.values.clientSecret) + dispatch(updateToast(true, 'success', t('messages.client_secret_copied'))) + } + }, [formik.values.clientSecret, dispatch, t]) + + const generateSecret = useCallback((length = 32): string => { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*' + const array = new Uint8Array(length) + crypto.getRandomValues(array) + return Array.from(array, (byte) => charset[byte % charset.length]).join('') + }, []) + + const handleGenerateSecret = useCallback(() => { + const newSecret = generateSecret() + handleFieldChange('clientSecret', t('fields.client_secret'), newSecret) + setShowSecret(true) + }, [generateSecret, handleFieldChange, t]) + const fieldStyle = useMemo( () => ({ '& .MuiOutlinedInput-root': { @@ -98,34 +134,72 @@ const BasicInfoSection: React.FC = ({ value={formik.values.inum || ''} disabled sx={fieldStyle} - /> -
- - - - handleFieldChange('clientSecret', t('fields.client_secret'), e.target.value) - } - disabled={viewOnly} - sx={fieldStyle} InputProps={{ - endAdornment: ( + endAdornment: formik.values.inum && ( - - {showSecret ? : } - + + + + + ), }} /> + + + + handleFieldChange('clientSecret', t('fields.client_secret'), e.target.value) + } + disabled={viewOnly} + sx={fieldStyle} + InputProps={{ + endAdornment: ( + + {formik.values.clientSecret && ( + + + + + + )} + + {showSecret ? : } + + + ), + }} + /> + {!viewOnly && ( + + + + + + )} + + + Date: Sun, 7 Dec 2025 07:07:34 +0100 Subject: [PATCH 05/18] ix(admin-ui): update client edit #2491 --- .../components/Clients/helper/utils.ts | 22 +++++++++---------- .../components/Clients/types/clientTypes.ts | 2 ++ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts index 5ffffbde5d..45bb1a032f 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts @@ -16,21 +16,19 @@ export function buildClientInitialValues(client: Partial): Clien } export function buildClientPayload(values: ClientFormValues): ExtendedClient { - const payload: ExtendedClient = { ...values } - - if (!payload.expirable) { - delete payload.expirationDate + const payload: ExtendedClient = { + ...values, + accessTokenAsJwt: + typeof values.accessTokenAsJwt === 'string' + ? values.accessTokenAsJwt === 'true' + : values.accessTokenAsJwt, + rptAsJwt: typeof values.rptAsJwt === 'string' ? values.rptAsJwt === 'true' : values.rptAsJwt, + attributes: values.attributes ? { ...values.attributes } : undefined, } - delete payload.expirable - if (typeof payload.accessTokenAsJwt === 'string') { - payload.accessTokenAsJwt = payload.accessTokenAsJwt === 'true' - } - if (typeof payload.rptAsJwt === 'string') { - payload.rptAsJwt = payload.rptAsJwt === 'true' - } + delete payload.expirable - return payload + return JSON.parse(JSON.stringify(payload)) } export function hasFormChanges( diff --git a/admin-ui/plugins/auth-server/components/Clients/types/clientTypes.ts b/admin-ui/plugins/auth-server/components/Clients/types/clientTypes.ts index 48f0729575..5a461ef665 100644 --- a/admin-ui/plugins/auth-server/components/Clients/types/clientTypes.ts +++ b/admin-ui/plugins/auth-server/components/Clients/types/clientTypes.ts @@ -30,6 +30,8 @@ export type { export interface ExtendedClient extends Client { expirable?: boolean + authenticationMethod?: string + allAuthenticationMethods?: string[] } export interface ClientScope { From e32b12f84e65dda5c71d19394b4b1bd78487450c Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Thu, 11 Dec 2025 11:58:32 +0100 Subject: [PATCH 06/18] fix(admin-ui): synchronize with main #2491 --- admin-ui/app/locales/en/translation.json | 1 + admin-ui/app/locales/es/translation.json | 1 + admin-ui/app/locales/fr/translation.json | 1 + admin-ui/app/locales/pt/translation.json | 1 + .../components/Clients/ClientForm.test.tsx | 134 +++++++++ .../Clients/ClientListPage.test.tsx | 119 ++++++++ .../components/Clients/useClientApi.test.tsx | 164 +++++++++++ .../components/Clients/utils.test.ts | 276 ++++++++++++++++++ .../components/Clients/ClientAddPage.tsx | 12 +- .../components/Clients/ClientDetailPage.tsx | 1 + .../components/Clients/ClientEditPage.tsx | 12 +- .../components/Clients/ClientListPage.tsx | 59 +++- .../Clients/components/ClientAddForm.tsx | 36 +-- .../Clients/components/ClientForm.tsx | 33 ++- .../components/SectionErrorFallback.tsx | 47 +++ .../components/Clients/helper/constants.ts | 16 + .../components/Clients/helper/utils.ts | 64 +++- .../components/Clients/helper/validations.ts | 58 +++- .../components/Clients/hooks/index.ts | 1 - .../components/Clients/hooks/useClientApi.ts | 27 +- .../components/Clients/hooks/useClientForm.ts | 77 ----- .../Clients/sections/ActiveTokensSection.tsx | 34 +-- .../sections/AuthenticationSection.tsx | 13 +- .../Clients/sections/BasicInfoSection.tsx | 10 +- 24 files changed, 1016 insertions(+), 181 deletions(-) create mode 100644 admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx create mode 100644 admin-ui/plugins/auth-server/__tests__/components/Clients/ClientListPage.test.tsx create mode 100644 admin-ui/plugins/auth-server/__tests__/components/Clients/useClientApi.test.tsx create mode 100644 admin-ui/plugins/auth-server/__tests__/components/Clients/utils.test.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/components/SectionErrorFallback.tsx delete mode 100644 admin-ui/plugins/auth-server/components/Clients/hooks/useClientForm.ts diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index 0e892a94c6..25e8a1b5d9 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -841,6 +841,7 @@ "permission_name_error": "Please ensure the permission name is at least 5 characters long.", "role_name_error": "Please ensure the role name is at least 5 characters long.", "error_message": "Error Message", + "section_error": "Something went wrong in this section", "webhook_dialog_note": "NOTE: Webhook execution has failed, and you won't be able to continue at the moment.", "http_method_error": "Please select a HTTP Method.", "request_body_error": "HTTP Request Body is required for selected HTTP Method", diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index af83b2eca7..5022a97c76 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -827,6 +827,7 @@ "permission_name_error": "Asegúrese de que el nombre del permiso tenga al menos 5 caracteres.", "role_name_error": "Asegúrese de que el nombre del rol tenga al menos 5 caracteres.", "error_message": "Mensaje de Error", + "section_error": "Algo salió mal en esta sección", "webhook_dialog_note": "NOTA: La ejecución del Webhook ha fallado y no podrá continuar en este momento.", "http_method_error": "Seleccione un Método HTTP.", "request_body_error": "El cuerpo de la solicitud HTTP es obligatorio para el método HTTP seleccionado", diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 5c24eb53f7..d433f05c4a 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -769,6 +769,7 @@ "additional_parameters_required": "Both key and value are required for each custom parameter.", "webhook_execution_information": "Informations sur l'exécution du webhook", "error_message": "Message d'erreur", + "section_error": "Quelque chose s'est mal passé dans cette section", "add_service_provider": "Ajouter un fournisseur de services", "edit_service_provider": "Modifier le fournisseur de services", "view_service_provider": "Voir le fournisseur de services", diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index 072b62e77b..0c6e127506 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -760,6 +760,7 @@ "session_timeout_required_error": "O tempo limite da sessão é obrigatório", "additional_parameters_required": "Both key and value are required for each custom parameter.", "error_message": "Mensagem de erro", + "section_error": "Algo deu errado nesta seção", "add_service_provider": "Adicionar Provedor de Serviços", "delete_script": "Excluir roteiro", "edit_service_provider": "Editar Provedor de Serviços", diff --git a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx new file mode 100644 index 0000000000..478e2c786b --- /dev/null +++ b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { Provider } from 'react-redux' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { combineReducers, configureStore } from '@reduxjs/toolkit' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper.test' + +const mockClient = { + inum: '1801.a0beec01-617b-4607-8a35-3e46ac43deb5', + clientName: 'Jans Config Api Client', + displayName: 'Jans Config Api Client', + applicationType: 'web', + disabled: false, + grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'], + responseTypes: ['code'], + tokenEndpointAuthMethod: 'client_secret_basic', +} + +jest.mock('@janssenproject/cedarling_wasm', () => ({ + __esModule: true, + default: jest.fn(), + init: jest.fn(), + Cedarling: jest.fn(), + AuthorizeResult: jest.fn(), +})) + +jest.mock('@/cedarling', () => ({ + useCedarling: jest.fn(() => ({ + authorize: jest.fn(() => true), + hasCedarReadPermission: jest.fn(() => true), + hasCedarWritePermission: jest.fn(() => true), + hasCedarDeletePermission: jest.fn(() => true), + })), + AdminUiFeatureResource: {}, +})) + +jest.mock('JansConfigApi', () => ({ + useGetConfigScripts: jest.fn(() => ({ + data: { + entries: [], + totalEntriesCount: 0, + }, + isLoading: false, + })), + useGetProperties: jest.fn(() => ({ + data: {}, + isLoading: false, + })), +})) + +const permissions = [ + 'https://jans.io/oauth/config/openid/clients.readonly', + 'https://jans.io/oauth/config/openid/clients.write', + 'https://jans.io/oauth/config/openid/clients.delete', +] + +const INIT_STATE = { + permissions: permissions, +} + +const WEBHOOK_STATE = { + loadingWebhooks: false, + webhookModal: false, + webhooks: [], +} + +const store = configureStore({ + reducer: combineReducers({ + authReducer: (state = INIT_STATE) => state, + cedarPermissions: (state = { permissions: [] }) => state, + webhookReducer: (state = WEBHOOK_STATE) => state, + noReducer: (state = {}) => state, + }), +}) + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + + {children} + + +) + +describe('ClientForm', () => { + const mockOnSubmit = jest.fn() + const mockOnCancel = jest.fn() + const mockOnScopeSearch = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + queryClient.clear() + }) + + it('provides correct mock client data', () => { + expect(mockClient.inum).toBe('1801.a0beec01-617b-4607-8a35-3e46ac43deb5') + expect(mockClient.clientName).toBe('Jans Config Api Client') + expect(mockClient.grantTypes).toContain('authorization_code') + expect(mockClient.responseTypes).toContain('code') + }) + + it('has correct structure for edit mode client', () => { + expect(mockClient).toHaveProperty('inum') + expect(mockClient).toHaveProperty('clientName') + expect(mockClient).toHaveProperty('displayName') + expect(mockClient).toHaveProperty('applicationType') + expect(mockClient).toHaveProperty('grantTypes') + expect(mockClient).toHaveProperty('responseTypes') + expect(mockClient).toHaveProperty('tokenEndpointAuthMethod') + }) + + it('onCancel mock is callable', () => { + mockOnCancel() + expect(mockOnCancel).toHaveBeenCalledTimes(1) + }) + + it('onSubmit mock can be called with values', () => { + const formValues = { ...mockClient, expirable: false } + mockOnSubmit(formValues, 'test message', {}) + expect(mockOnSubmit).toHaveBeenCalledWith(formValues, 'test message', {}) + }) + + it('onScopeSearch mock can search scopes', () => { + mockOnScopeSearch('openid') + expect(mockOnScopeSearch).toHaveBeenCalledWith('openid') + }) +}) diff --git a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientListPage.test.tsx b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientListPage.test.tsx new file mode 100644 index 0000000000..15a190709c --- /dev/null +++ b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientListPage.test.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { Provider } from 'react-redux' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { combineReducers, configureStore } from '@reduxjs/toolkit' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper.test' + +const mockNavigate = jest.fn() +const mockUseGetOauthOpenidClients = jest.fn() + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})) + +jest.mock('@janssenproject/cedarling_wasm', () => ({ + __esModule: true, + default: jest.fn(), + init: jest.fn(), + Cedarling: jest.fn(), + AuthorizeResult: jest.fn(), +})) + +jest.mock('@/cedarling', () => ({ + useCedarling: jest.fn(() => ({ + authorize: jest.fn(() => true), + hasCedarReadPermission: jest.fn(() => true), + hasCedarWritePermission: jest.fn(() => true), + hasCedarDeletePermission: jest.fn(() => true), + })), + AdminUiFeatureResource: {}, +})) + +jest.mock('JansConfigApi', () => ({ + useGetOauthOpenidClients: () => mockUseGetOauthOpenidClients(), + useDeleteOauthOpenidClientByInum: jest.fn(() => ({ + mutate: jest.fn(), + isPending: false, + })), + getGetOauthOpenidClientsQueryKey: jest.fn(() => ['oauth-openid-clients']), +})) + +const permissions = [ + 'https://jans.io/oauth/config/openid/clients.readonly', + 'https://jans.io/oauth/config/openid/clients.write', + 'https://jans.io/oauth/config/openid/clients.delete', +] + +const INIT_STATE = { + permissions: permissions, +} + +const WEBHOOK_STATE = { + loadingWebhooks: false, + webhookModal: false, + webhooks: [], +} + +const store = configureStore({ + reducer: combineReducers({ + authReducer: (state = INIT_STATE) => state, + cedarPermissions: (state = { permissions: [] }) => state, + webhookReducer: (state = WEBHOOK_STATE) => state, + noReducer: (state = {}) => state, + }), +}) + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + + {children} + + +) + +describe('ClientListPage', () => { + beforeEach(() => { + jest.clearAllMocks() + queryClient.clear() + mockUseGetOauthOpenidClients.mockReturnValue({ + data: { + entries: [ + { + inum: '1801.a0beec01-617b-4607-8a35-3e46ac43deb5', + clientName: 'Jans Config Api Client', + displayName: 'Jans Config Api Client', + applicationType: 'web', + disabled: false, + }, + ], + totalEntriesCount: 1, + entriesCount: 1, + }, + isLoading: false, + error: null, + refetch: jest.fn(), + }) + }) + + it('returns client list data from hook', () => { + const result = mockUseGetOauthOpenidClients() + expect(result.data?.entries).toHaveLength(1) + expect(result.isLoading).toBe(false) + expect(result.data?.entries[0].clientName).toBe('Jans Config Api Client') + }) + + it('mock returns correct client inum', () => { + const result = mockUseGetOauthOpenidClients() + expect(result.data?.entries[0].inum).toBe('1801.a0beec01-617b-4607-8a35-3e46ac43deb5') + }) +}) diff --git a/admin-ui/plugins/auth-server/__tests__/components/Clients/useClientApi.test.tsx b/admin-ui/plugins/auth-server/__tests__/components/Clients/useClientApi.test.tsx new file mode 100644 index 0000000000..7b38a74ade --- /dev/null +++ b/admin-ui/plugins/auth-server/__tests__/components/Clients/useClientApi.test.tsx @@ -0,0 +1,164 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Provider } from 'react-redux' +import { combineReducers, configureStore } from '@reduxjs/toolkit' +import React from 'react' +import { + useClientList, + useClientById, + useCreateClient, + useUpdateClient, + useDeleteClient, +} from 'Plugins/auth-server/components/Clients/hooks/useClientApi' + +const mockClients = [ + { + inum: '1801.a0beec01-617b-4607-8a35-3e46ac43deb5', + clientName: 'Jans Config Api Client', + displayName: 'Jans Config Api Client', + }, + { + inum: '1001.7f0a05b2-0976-475f-8048-50d4cc5e845f', + clientName: 'oxTrust Admin GUI', + displayName: 'oxTrust Admin GUI', + }, + { + inum: '1202.22bd540e-e14e-4416-a9e9-8076053f7d24', + clientName: 'SCIM Requesting Party Client', + displayName: 'SCIM Requesting Party Client', + }, +] + +const mockMutate = jest.fn() +const mockInvalidateQueries = jest.fn() + +jest.mock('JansConfigApi', () => ({ + useGetOauthOpenidClients: jest.fn(() => ({ + data: { + entries: [ + { + inum: '1801.a0beec01-617b-4607-8a35-3e46ac43deb5', + clientName: 'Jans Config Api Client', + displayName: 'Jans Config Api Client', + }, + { + inum: '1001.7f0a05b2-0976-475f-8048-50d4cc5e845f', + clientName: 'oxTrust Admin GUI', + displayName: 'oxTrust Admin GUI', + }, + { + inum: '1202.22bd540e-e14e-4416-a9e9-8076053f7d24', + clientName: 'SCIM Requesting Party Client', + displayName: 'SCIM Requesting Party Client', + }, + ], + totalEntriesCount: 3, + entriesCount: 3, + }, + isLoading: false, + error: null, + })), + usePostOauthOpenidClient: jest.fn(() => ({ + mutate: mockMutate, + isPending: false, + })), + usePutOauthOpenidClient: jest.fn(() => ({ + mutate: mockMutate, + isPending: false, + })), + useDeleteOauthOpenidClientByInum: jest.fn(() => ({ + mutate: mockMutate, + isPending: false, + })), + getGetOauthOpenidClientsQueryKey: jest.fn(() => ['oauth-openid-clients']), +})) + +jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries, + }), +})) + +const store = configureStore({ + reducer: combineReducers({ + authReducer: (state = { permissions: [] }) => state, + noReducer: (state = {}) => state, + }), +}) + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +) + +describe('useClientApi hooks', () => { + beforeEach(() => { + jest.clearAllMocks() + queryClient.clear() + }) + + describe('useClientList', () => { + it('returns client list data', () => { + const { result } = renderHook(() => useClientList({ limit: 10, startIndex: 0 }), { wrapper }) + + expect(result.current.data?.entries).toHaveLength(3) + expect(result.current.isLoading).toBe(false) + }) + }) + + describe('useClientById', () => { + it('finds client by inum from list', () => { + const targetInum = '1801.a0beec01-617b-4607-8a35-3e46ac43deb5' + const { result } = renderHook(() => useClientById(targetInum), { + wrapper, + }) + + expect(result.current.data?.inum).toBe(targetInum) + }) + + it('returns undefined for non-existent inum', () => { + const { result } = renderHook(() => useClientById('non-existent-inum'), { + wrapper, + }) + + expect(result.current.data).toBeUndefined() + }) + }) + + describe('useCreateClient', () => { + it('returns mutation function', () => { + const { result } = renderHook(() => useCreateClient(), { wrapper }) + + expect(result.current.mutate).toBeDefined() + expect(result.current.isPending).toBe(false) + }) + }) + + describe('useUpdateClient', () => { + it('returns mutation function', () => { + const { result } = renderHook(() => useUpdateClient(), { wrapper }) + + expect(result.current.mutate).toBeDefined() + expect(result.current.isPending).toBe(false) + }) + }) + + describe('useDeleteClient', () => { + it('returns mutation function', () => { + const { result } = renderHook(() => useDeleteClient(), { wrapper }) + + expect(result.current.mutate).toBeDefined() + expect(result.current.isPending).toBe(false) + }) + }) +}) diff --git a/admin-ui/plugins/auth-server/__tests__/components/Clients/utils.test.ts b/admin-ui/plugins/auth-server/__tests__/components/Clients/utils.test.ts new file mode 100644 index 0000000000..00a32f7823 --- /dev/null +++ b/admin-ui/plugins/auth-server/__tests__/components/Clients/utils.test.ts @@ -0,0 +1,276 @@ +import { + buildClientInitialValues, + buildClientPayload, + hasFormChanges, + formatScopeDisplay, + formatGrantTypeLabel, + formatResponseTypeLabel, + extractInumFromDn, + buildScopeDn, + filterScriptsByType, + getScriptNameFromDn, + formatDateForDisplay, + formatDateForInput, + isValidUrl, + isValidUri, + truncateText, + sortByName, + removeDuplicates, + arrayEquals, +} from 'Plugins/auth-server/components/Clients/helper/utils' +import { EMPTY_CLIENT } from 'Plugins/auth-server/components/Clients/types' +import { clients } from '../../components/clients.test' + +describe('Client Utils', () => { + describe('buildClientInitialValues', () => { + it('merges client data with empty client defaults', () => { + const result = buildClientInitialValues({ clientName: 'Test Client' }) + + expect(result.clientName).toBe('Test Client') + expect(result.expirable).toBe(false) + }) + + it('sets expirable to true when expirationDate exists', () => { + const result = buildClientInitialValues({ + clientName: 'Test', + expirationDate: '2025-12-31', + }) + + expect(result.expirable).toBe(true) + }) + + it('merges nested attributes', () => { + const result = buildClientInitialValues({ + attributes: { + allowSpontaneousScopes: true, + }, + }) + + expect(result.attributes?.allowSpontaneousScopes).toBe(true) + }) + }) + + describe('buildClientPayload', () => { + it('removes expirable field from payload', () => { + const formValues = { + ...EMPTY_CLIENT, + clientName: 'Test', + expirable: true, + } + const result = buildClientPayload(formValues as any) + + expect((result as any).expirable).toBeUndefined() + }) + + it('converts string accessTokenAsJwt to boolean', () => { + const formValues = { + ...EMPTY_CLIENT, + accessTokenAsJwt: 'true' as any, + } + const result = buildClientPayload(formValues as any) + + expect(result.accessTokenAsJwt).toBe(true) + }) + + it('removes dn and baseDn from payload', () => { + const formValues = { + ...EMPTY_CLIENT, + dn: 'some-dn', + baseDn: 'some-baseDn', + } as any + const result = buildClientPayload(formValues) + + expect((result as any).dn).toBeUndefined() + expect((result as any).baseDn).toBeUndefined() + }) + }) + + describe('hasFormChanges', () => { + it('returns false when values are equal', () => { + const values = { clientName: 'Test' } as any + expect(hasFormChanges(values, values)).toBe(false) + }) + + it('returns true when values differ', () => { + const current = { clientName: 'Test1' } as any + const initial = { clientName: 'Test2' } as any + expect(hasFormChanges(current, initial)).toBe(true) + }) + }) + + describe('formatScopeDisplay', () => { + it('extracts inum from scope DN', () => { + const dn = 'inum=F0C4,ou=scopes,o=jans' + expect(formatScopeDisplay(dn)).toBe('F0C4') + }) + + it('returns original string if no inum found', () => { + const dn = 'ou=scopes,o=jans' + expect(formatScopeDisplay(dn)).toBe(dn) + }) + }) + + describe('formatGrantTypeLabel', () => { + it('returns human-readable label for known grant types', () => { + expect(formatGrantTypeLabel('authorization_code')).toBe('Authorization Code') + expect(formatGrantTypeLabel('client_credentials')).toBe('Client Credentials') + expect(formatGrantTypeLabel('refresh_token')).toBe('Refresh Token') + }) + + it('returns original value for unknown grant types', () => { + expect(formatGrantTypeLabel('custom_grant')).toBe('custom_grant') + }) + }) + + describe('formatResponseTypeLabel', () => { + it('returns human-readable label for known response types', () => { + expect(formatResponseTypeLabel('code')).toBe('Code') + expect(formatResponseTypeLabel('token')).toBe('Token') + expect(formatResponseTypeLabel('id_token')).toBe('ID Token') + }) + + it('returns original value for unknown response types', () => { + expect(formatResponseTypeLabel('custom_type')).toBe('custom_type') + }) + }) + + describe('extractInumFromDn', () => { + it('extracts inum from DN string', () => { + const dn = 'inum=abc123,ou=clients,o=jans' + expect(extractInumFromDn(dn)).toBe('abc123') + }) + + it('returns original string if no inum pattern', () => { + expect(extractInumFromDn('no-inum-here')).toBe('no-inum-here') + }) + }) + + describe('buildScopeDn', () => { + it('builds scope DN from inum', () => { + expect(buildScopeDn('F0C4')).toBe('inum=F0C4,ou=scopes,o=jans') + }) + }) + + describe('filterScriptsByType', () => { + it('filters scripts by type and enabled status', () => { + const scripts = [ + { scriptType: 'person_authentication', enabled: true, dn: 'dn1', name: 'Script1' }, + { scriptType: 'person_authentication', enabled: false, dn: 'dn2', name: 'Script2' }, + { scriptType: 'introspection', enabled: true, dn: 'dn3', name: 'Script3' }, + ] + + const result = filterScriptsByType(scripts, 'person_authentication') + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('Script1') + }) + }) + + describe('getScriptNameFromDn', () => { + it('returns script name when found', () => { + const scripts = [ + { dn: 'dn1', name: 'Script1' }, + { dn: 'dn2', name: 'Script2' }, + ] + + expect(getScriptNameFromDn('dn1', scripts)).toBe('Script1') + }) + + it('returns extracted inum when script not found', () => { + expect(getScriptNameFromDn('inum=abc123,ou=scripts,o=jans', [])).toBe('abc123') + }) + }) + + describe('formatDateForDisplay', () => { + it('formats valid date string', () => { + const result = formatDateForDisplay('2025-01-15T10:30:00') + expect(result).toBeTruthy() + }) + + it('returns empty string for undefined', () => { + expect(formatDateForDisplay(undefined)).toBe('') + }) + }) + + describe('formatDateForInput', () => { + it('formats date for input field', () => { + const result = formatDateForInput('2025-01-15T10:30:00Z') + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/) + }) + + it('returns empty string for undefined', () => { + expect(formatDateForInput(undefined)).toBe('') + }) + }) + + describe('isValidUrl', () => { + it('returns true for valid URLs', () => { + expect(isValidUrl('https://example.com')).toBe(true) + expect(isValidUrl('http://localhost:4100/')).toBe(true) + }) + + it('returns false for invalid URLs', () => { + expect(isValidUrl('not-a-url')).toBe(false) + expect(isValidUrl('')).toBe(false) + }) + }) + + describe('isValidUri', () => { + it('returns true for valid URIs', () => { + expect(isValidUri('https://example.com')).toBe(true) + expect(isValidUri('custom-scheme://app')).toBe(true) + }) + + it('returns false for invalid URIs', () => { + expect(isValidUri('')).toBe(false) + expect(isValidUri('no-scheme')).toBe(false) + }) + }) + + describe('truncateText', () => { + it('returns original text if within limit', () => { + expect(truncateText('short', 10)).toBe('short') + }) + + it('truncates text exceeding limit', () => { + expect(truncateText('this is a long text', 10)).toBe('this is a ...') + }) + }) + + describe('sortByName', () => { + it('sorts items by name alphabetically', () => { + const items = [{ name: 'Charlie' }, { name: 'Alice' }, { name: 'Bob' }] + const result = sortByName(items) + + expect(result[0].name).toBe('Alice') + expect(result[1].name).toBe('Bob') + expect(result[2].name).toBe('Charlie') + }) + + it('handles undefined names', () => { + const items = [{ name: 'Bob' }, { name: undefined }, { name: 'Alice' }] + const result = sortByName(items) + + expect(result).toHaveLength(3) + }) + }) + + describe('removeDuplicates', () => { + it('removes duplicate values', () => { + expect(removeDuplicates([1, 2, 2, 3, 3, 3])).toEqual([1, 2, 3]) + expect(removeDuplicates(['a', 'b', 'a'])).toEqual(['a', 'b']) + }) + }) + + describe('arrayEquals', () => { + it('returns true for equal arrays', () => { + expect(arrayEquals([1, 2, 3], [1, 2, 3])).toBe(true) + expect(arrayEquals([1, 2, 3], [3, 2, 1])).toBe(true) + }) + + it('returns false for different arrays', () => { + expect(arrayEquals([1, 2], [1, 2, 3])).toBe(false) + expect(arrayEquals([1, 2, 3], [1, 2, 4])).toBe(false) + }) + }) +}) diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientAddPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientAddPage.tsx index 4e0179c1e0..8276fbbcf4 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientAddPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientAddPage.tsx @@ -1,13 +1,16 @@ import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' import SetTitle from 'Utils/SetTitle' +import { updateToast } from 'Redux/features/toastSlice' import { useClientActions, useCreateClient } from './hooks' import ClientAddForm from './components/ClientAddForm' import type { ClientFormValues, ModifiedFields } from './types' const ClientAddPage: React.FC = () => { const { t } = useTranslation() + const dispatch = useDispatch() const { logClientCreation, navigateToClientList } = useClientActions() const handleSuccess = useCallback(() => { @@ -21,13 +24,18 @@ const ClientAddPage: React.FC = () => { try { const result = await createClient.mutateAsync({ data: values }) if (result) { - await logClientCreation(result, message, modifiedFields) + try { + await logClientCreation(result, message, modifiedFields) + } catch (auditError) { + console.error('Error logging client creation:', auditError) + dispatch(updateToast(true, 'warning', t('messages.audit_log_failed'))) + } } } catch (error) { console.error('Error creating client:', error) } }, - [createClient, logClientCreation], + [createClient, logClientCreation, dispatch, t], ) SetTitle(t('titles.add_openid_connect_client')) diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx index 57880dcdb5..760866fb62 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx @@ -52,6 +52,7 @@ const ClientDetailPage: React.FC = () => { {client && ( { const { t } = useTranslation() const { id } = useParams<{ id: string }>() + const dispatch = useDispatch() const { logClientUpdate, navigateToClientList } = useClientActions() const [scopeSearchPattern, setScopeSearchPattern] = useState('') @@ -66,13 +69,18 @@ const ClientEditPage: React.FC = () => { try { const result = await updateClient.mutateAsync({ data: values }) if (result) { - await logClientUpdate(result, message, modifiedFields) + try { + await logClientUpdate(result, message, modifiedFields) + } catch (auditError) { + console.error('Error logging client update:', auditError) + dispatch(updateToast(true, 'warning', t('messages.audit_log_failed'))) + } } } catch (error) { console.error('Error updating client:', error) } }, - [updateClient, logClientUpdate], + [updateClient, logClientUpdate, dispatch, t], ) const isLoading = useMemo( diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx index 41f32b0947..b933fc2d39 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx @@ -21,7 +21,7 @@ import SearchIcon from '@mui/icons-material/Search' import ClearIcon from '@mui/icons-material/Clear' import RefreshIcon from '@mui/icons-material/Refresh' import SortIcon from '@mui/icons-material/Sort' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useLocation } from 'react-router-dom' import { useDispatch } from 'react-redux' import { Badge } from 'reactstrap' import { Card, CardBody } from 'Components' @@ -86,10 +86,16 @@ const TEXT_FIELD_WIDTH = { width: '300px' } as const const ClientListPage: React.FC = () => { const { t } = useTranslation() const navigate = useNavigate() + const location = useLocation() const theme = useContext(ThemeContext) const dispatch = useDispatch() const queryClient = useQueryClient() + const scopeInumFilter = useMemo(() => { + const searchParams = new URLSearchParams(location.search) + return searchParams.get('scopeInum') + }, [location.search]) + const { hasCedarReadPermission, hasCedarWritePermission, @@ -212,10 +218,21 @@ const ClientListPage: React.FC = () => { [hasCedarDeletePermission, clientResourceId], ) - const clients = useMemo( - () => (clientsResponse?.entries || []) as ClientTableRow[], - [clientsResponse?.entries], - ) + const clients = useMemo(() => { + const allClients = (clientsResponse?.entries || []) as ClientTableRow[] + if (scopeInumFilter) { + return allClients.filter((client) => { + const clientScopes = client.scopes || [] + return clientScopes.some((scope) => { + if (typeof scope === 'string') { + return scope.includes(scopeInumFilter) + } + return false + }) + }) + } + return allClients + }, [clientsResponse?.entries, scopeInumFilter]) const totalItems = useMemo( () => clientsResponse?.totalEntriesCount || 0, @@ -225,7 +242,6 @@ const ClientListPage: React.FC = () => { const tableOptions = useMemo( () => ({ idSynonym: 'inum', - columnsButton: true, search: false, selection: false, pageSize: limit, @@ -272,7 +288,23 @@ const ClientListPage: React.FC = () => { setSortOrder('ascending') setPageNumber(0) setStartIndex(0) - }, []) + if (scopeInumFilter) { + navigate(location.pathname, { replace: true }) + } + }, [scopeInumFilter, navigate, location.pathname]) + + const handleClearScopeFilter = useCallback((): void => { + navigate(location.pathname, { replace: true }) + }, [navigate, location.pathname]) + + // Get scope display name for the filter indicator + const scopeFilterDisplayName = useMemo(() => { + if (!scopeInumFilter) return null + const scope = scopesList.find( + (s) => s.dn?.includes(scopeInumFilter) || s.dn === scopeInumFilter, + ) + return scope?.id || scope?.displayName || scopeInumFilter + }, [scopeInumFilter, scopesList]) const handleSortByChange = useCallback((event: SelectChangeEvent): void => { setSortBy(event.target.value) @@ -611,6 +643,19 @@ const ClientListPage: React.FC = () => { }} /> + {scopeFilterDisplayName && ( + + )} + diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx index 228eb0acab..8d527d4261 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx @@ -33,8 +33,9 @@ import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' import { adminUiFeatures } from 'Plugins/admin/helper/utils' import { useGetOauthScopes } from 'JansConfigApi' -import { GRANT_TYPES, DEFAULT_SCOPE_SEARCH_LIMIT } from '../helper/constants' +import { GRANT_TYPES, DEFAULT_SCOPE_SEARCH_LIMIT, SECRET_GENERATION } from '../helper/constants' import { buildClientPayload } from '../helper/utils' +import { uriValidation } from '../helper/validations' import type { ClientFormValues, ModifiedFields, ClientScope } from '../types' interface ClientAddFormProps { @@ -64,27 +65,6 @@ const initialValues: AddFormValues = { postLogoutRedirectUris: [''], } -const urlValidation = Yup.string().test( - 'is-valid-uri', - 'Invalid URI format. Must be a valid URL (e.g., https://example.com) or custom scheme (e.g., myapp://callback)', - (value) => { - if (!value || value.trim() === '') return true - try { - new URL(value) - return true - } catch { - if (value.startsWith('http://localhost') || value.startsWith('http://127.0.0.1')) { - return true - } - const customSchemeRegex = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.+$/ - if (customSchemeRegex.test(value)) { - return true - } - return false - } - }, -) - const validationSchema = Yup.object().shape({ clientName: Yup.string().required('Client name is required'), clientSecret: Yup.string().required('Client secret is required'), @@ -92,12 +72,12 @@ const validationSchema = Yup.object().shape({ scopes: Yup.array().of(Yup.string()), grantTypes: Yup.array().of(Yup.string()), redirectUris: Yup.array() - .of(urlValidation) + .of(uriValidation) .test('has-valid-uri', 'At least one valid redirect URI is required', (value) => { if (!value || value.length === 0) return false return value.some((uri) => uri && uri.trim() !== '') }), - postLogoutRedirectUris: Yup.array().of(urlValidation), + postLogoutRedirectUris: Yup.array().of(uriValidation), }) const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => { @@ -150,11 +130,11 @@ const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => setShowSecret((prev) => !prev) }, []) - const generateSecret = useCallback((length = 32): string => { - const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*' - const array = new Uint8Array(length) + const generateSecret = useCallback((): string => { + const { LENGTH, CHARSET } = SECRET_GENERATION + const array = new Uint8Array(LENGTH) crypto.getRandomValues(array) - return Array.from(array, (byte) => charset[byte % charset.length]).join('') + return Array.from(array, (byte) => CHARSET[byte % CHARSET.length]).join('') }, []) const handleCopyToClipboard = useCallback((text: string) => { diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx index fcb41d164a..ac3dfa6dbf 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useContext, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Formik, Form } from 'formik' +import { Formik, Form, FormikProps } from 'formik' +import { ErrorBoundary } from 'react-error-boundary' import { Box, Button, @@ -45,7 +46,9 @@ import type { ClientSection, ModifiedFields, ClientScope, + SectionProps, } from '../types' +import SectionErrorFallback from './SectionErrorFallback' import { clientValidationSchema } from '../helper/validations' import { buildClientInitialValues, buildClientPayload, downloadClientAsJson } from '../helper/utils' import { SECTIONS } from '../helper/constants' @@ -248,13 +251,9 @@ const ClientForm: React.FC = ({ ) const renderSectionContent = useCallback( - (formik: { - values: ClientFormValues - setFieldValue: (field: string, value: unknown) => void - dirty: boolean - }) => { - const sectionProps = { - formik: formik as any, + (formik: FormikProps) => { + const sectionProps: SectionProps = { + formik, viewOnly, setModifiedFields, scripts, @@ -284,7 +283,7 @@ const ClientForm: React.FC = ({ case 'system': return case 'activeTokens': - return + return default: return null } @@ -448,7 +447,12 @@ const ClientForm: React.FC = ({ {renderNavigation()} - {renderSectionContent(formik)} + + {renderSectionContent(formik)} + ) : ( @@ -461,7 +465,14 @@ const ClientForm: React.FC = ({ }} > {renderNavigation()} - {renderSectionContent(formik)} + + + {renderSectionContent(formik)} + + )} diff --git a/admin-ui/plugins/auth-server/components/Clients/components/SectionErrorFallback.tsx b/admin-ui/plugins/auth-server/components/Clients/components/SectionErrorFallback.tsx new file mode 100644 index 0000000000..321bee4092 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/components/SectionErrorFallback.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Box, Typography, Button, Alert } from '@mui/material' +import { ErrorOutline, Refresh } from '@mui/icons-material' + +interface SectionErrorFallbackProps { + error: Error + resetErrorBoundary: () => void +} + +const SectionErrorFallback: React.FC = ({ + error, + resetErrorBoundary, +}) => { + const { t } = useTranslation() + + return ( + + + + {t('messages.section_error')} + + + {error.message} + + + + ) +} + +export default SectionErrorFallback diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts b/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts index 419958026f..ec49c4334a 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts @@ -163,3 +163,19 @@ export type SubjectType = (typeof SUBJECT_TYPES)[number]['value'] export type TokenEndpointAuthMethod = (typeof TOKEN_ENDPOINT_AUTH_METHODS)[number]['value'] export type BackchannelTokenDeliveryMode = (typeof BACKCHANNEL_TOKEN_DELIVERY_MODES)[number]['value'] + +export const THEME_DEFAULTS = { + BORDER_COLOR: '#e0e0e0', + LIGHT_BG: '#fafafa', + FONT_COLOR: '#333', + DARK_FONT: '#000000', + WHITE: '#fff', + ICON_OPACITY: 0.7, +} as const + +export const SECRET_GENERATION = { + LENGTH: 32, + CHARSET: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', +} as const + +export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss' as const diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts index 45bb1a032f..5ff79049a9 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts @@ -16,17 +16,67 @@ export function buildClientInitialValues(client: Partial): Clien } export function buildClientPayload(values: ClientFormValues): ExtendedClient { + const { + expirable, + authenticationMethod, + allAuthenticationMethods, + customAttributes, + dn, + baseDn, + deletable, + ...rest + } = values as ClientFormValues & { + authenticationMethod?: string + allAuthenticationMethods?: string[] + dn?: string + baseDn?: string + deletable?: boolean + } + const payload: ExtendedClient = { - ...values, + ...rest, accessTokenAsJwt: - typeof values.accessTokenAsJwt === 'string' - ? values.accessTokenAsJwt === 'true' - : values.accessTokenAsJwt, - rptAsJwt: typeof values.rptAsJwt === 'string' ? values.rptAsJwt === 'true' : values.rptAsJwt, - attributes: values.attributes ? { ...values.attributes } : undefined, + typeof rest.accessTokenAsJwt === 'string' + ? rest.accessTokenAsJwt === 'true' + : rest.accessTokenAsJwt, + rptAsJwt: typeof rest.rptAsJwt === 'string' ? rest.rptAsJwt === 'true' : rest.rptAsJwt, + attributes: rest.attributes ? { ...rest.attributes } : undefined, } - delete payload.expirable + if (customAttributes && Array.isArray(customAttributes)) { + const localizedNames = [ + 'displayNameLocalized', + 'jansClntURILocalized', + 'jansLogoURILocalized', + 'jansPolicyURILocalized', + 'jansTosURILocalized', + ] + const filteredCustomAttributes = customAttributes.filter((attr) => { + if (!attr.name) return false + if (localizedNames.includes(attr.name)) { + const value = attr.value || attr.values?.[0] + if (value === '{}' || value === '') return false + } + return true + }) + if (filteredCustomAttributes.length > 0) { + payload.customAttributes = filteredCustomAttributes + } + } + + const localizedFields = [ + 'clientNameLocalized', + 'logoUriLocalized', + 'clientUriLocalized', + 'policyUriLocalized', + 'tosUriLocalized', + ] as const + + for (const field of localizedFields) { + if (payload[field] && Object.keys(payload[field] as object).length === 0) { + delete payload[field] + } + } return JSON.parse(JSON.stringify(payload)) } diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts index df20fea379..d2706e7ab7 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts @@ -1,8 +1,31 @@ import * as Yup from 'yup' +export function isValidUri(value: string | undefined | null): boolean { + if (!value || value.trim() === '') return true + try { + new URL(value) + return true + } catch { + if (value.startsWith('http://localhost') || value.startsWith('http://127.0.0.1')) { + return true + } + const customSchemeRegex = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.+$/ + if (customSchemeRegex.test(value)) { + return true + } + return false + } +} + +export const uriValidation = Yup.string().test( + 'is-valid-uri', + 'Invalid URI format. Must be a valid URL (e.g., https://example.com) or custom scheme (e.g., myapp://callback)', + isValidUri, +) + const urlSchema = Yup.string().url('Must be a valid URL') -const uriArraySchema = Yup.array().of(Yup.string().url('Must be a valid URL')) +const uriArraySchema = Yup.array().of(uriValidation) export const clientValidationSchema = Yup.object().shape({ clientName: Yup.string().nullable(), @@ -77,3 +100,36 @@ export function validateRequiredArray( } return null } + +export function validateExpirationDate(expirationDate: string | undefined | null): string | null { + if (!expirationDate) return null + const expDate = new Date(expirationDate) + const now = new Date() + if (expDate <= now) { + return 'Expiration date must be in the future' + } + return null +} + +export function validateTokenLifetime( + value: number | undefined | null, + maxSeconds = 315360000, +): string | null { + if (value === undefined || value === null) return null + if (value < 0) return 'Must be a positive number' + if (value > maxSeconds) return `Value exceeds maximum allowed (${maxSeconds} seconds)` + return null +} + +export function isValidDn(dn: string | undefined | null): boolean { + if (!dn) return true + const dnPattern = /^[a-zA-Z]+=.+/ + return dnPattern.test(dn) +} + +export function validateAcrLevel(level: number | undefined | null): string | null { + if (level === undefined || level === null) return null + if (!Number.isInteger(level)) return 'ACR level must be an integer' + if (level < -1 || level > 10) return 'ACR level must be between -1 and 10' + return null +} diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/index.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/index.ts index 58afeaf12e..4cc9463846 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/index.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/index.ts @@ -7,4 +7,3 @@ export { useDeleteClient, useInvalidateClientQueries, } from './useClientApi' -export { useClientForm, useFieldTracking } from './useClientForm' diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts index 799a6aa88b..325ac9efa8 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts @@ -5,7 +5,6 @@ import { updateToast } from 'Redux/features/toastSlice' import { triggerWebhook } from 'Plugins/admin/redux/features/WebhookSlice' import { useGetOauthOpenidClients, - useGetOauthOpenidClientsByInum, usePostOauthOpenidClient, usePutOauthOpenidClient, useDeleteOauthOpenidClientByInum, @@ -42,7 +41,13 @@ export function useClientById(inum: string, enabled = true) { [enabled], ) - return useGetOauthOpenidClientsByInum(inum, queryOptions) + const { data, ...rest } = useGetOauthOpenidClients({ pattern: inum, limit: 1 }, queryOptions) + + const client = useMemo(() => { + return data?.entries?.find((c: Client) => c.inum === inum) + }, [data?.entries, inum]) + + return { data: client, ...rest } } export function useCreateClient(onSuccess?: () => void, onError?: (error: Error) => void) { @@ -53,10 +58,7 @@ export function useCreateClient(onSuccess?: () => void, onError?: (error: Error) (data: Client) => { dispatch(updateToast(true, 'success')) queryClient.invalidateQueries({ - predicate: (query) => { - const queryKey = query.queryKey[0] as string - return queryKey === getGetOauthOpenidClientsQueryKey()[0] - }, + queryKey: getGetOauthOpenidClientsQueryKey(), }) dispatch(triggerWebhook({ createdFeatureValue: data })) onSuccess?.() @@ -89,13 +91,7 @@ export function useUpdateClient(onSuccess?: () => void, onError?: (error: Error) (data: Client) => { dispatch(updateToast(true, 'success')) queryClient.invalidateQueries({ - predicate: (query) => { - const queryKey = query.queryKey[0] as string - return ( - queryKey === getGetOauthOpenidClientsQueryKey()[0] || - queryKey === 'getOauthOpenidClientsByInum' - ) - }, + queryKey: getGetOauthOpenidClientsQueryKey(), }) dispatch(triggerWebhook({ createdFeatureValue: data })) onSuccess?.() @@ -127,10 +123,7 @@ export function useDeleteClient(onSuccess?: () => void, onError?: (error: Error) const handleSuccess = useCallback(() => { dispatch(updateToast(true, 'success')) queryClient.invalidateQueries({ - predicate: (query) => { - const queryKey = query.queryKey[0] as string - return queryKey === getGetOauthOpenidClientsQueryKey()[0] - }, + queryKey: getGetOauthOpenidClientsQueryKey(), }) onSuccess?.() }, [dispatch, queryClient, onSuccess]) diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientForm.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientForm.ts deleted file mode 100644 index d68235a794..0000000000 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientForm.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useState, useCallback, useMemo } from 'react' -import type { ExtendedClient, ClientFormValues, ClientTab, ModifiedFields } from '../types' -import { buildClientInitialValues, hasFormChanges } from '../helper/utils' - -export function useClientForm(client: Partial) { - const [activeTab, setActiveTab] = useState('basic') - const [modifiedFields, setModifiedFields] = useState({}) - const [isSubmitting, setIsSubmitting] = useState(false) - const [commitModalOpen, setCommitModalOpen] = useState(false) - - const initialValues = useMemo(() => buildClientInitialValues(client), [client]) - - const handleTabChange = useCallback((tab: ClientTab) => { - setActiveTab(tab) - }, []) - - const trackFieldChange = useCallback((fieldLabel: string, value: unknown) => { - setModifiedFields((prev) => ({ - ...prev, - [fieldLabel]: value, - })) - }, []) - - const resetModifiedFields = useCallback(() => { - setModifiedFields({}) - }, []) - - const resetForm = useCallback(() => { - setModifiedFields({}) - setActiveTab('basic') - setIsSubmitting(false) - }, []) - - const checkHasChanges = useCallback( - (currentValues: ClientFormValues) => hasFormChanges(currentValues, initialValues), - [initialValues], - ) - - const openCommitModal = useCallback(() => { - setCommitModalOpen(true) - }, []) - - const closeCommitModal = useCallback(() => { - setCommitModalOpen(false) - }, []) - - return { - activeTab, - setActiveTab: handleTabChange, - modifiedFields, - setModifiedFields, - trackFieldChange, - resetModifiedFields, - resetForm, - checkHasChanges, - initialValues, - isSubmitting, - setIsSubmitting, - commitModalOpen, - openCommitModal, - closeCommitModal, - } -} - -export function useFieldTracking( - setModifiedFields: React.Dispatch>, -) { - return useCallback( - (fieldLabel: string, value: unknown) => { - setModifiedFields((prev) => ({ - ...prev, - [fieldLabel]: value, - })) - }, - [setModifiedFields], - ) -} diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/ActiveTokensSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/ActiveTokensSection.tsx index 7f774ccb5b..c4afbffb1b 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/ActiveTokensSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/ActiveTokensSection.tsx @@ -27,7 +27,7 @@ import { ThemeContext } from 'Context/theme/themeContext' import getThemeColor from 'Context/theme/config' import { updateToast } from 'Redux/features/toastSlice' import ActiveTokenDetailPanel from '../components/ActiveTokenDetailPanel' -import type { ExtendedClient } from '../types' +import type { SectionProps } from '../types' import { useGetTokenByClient, useRevokeToken, @@ -35,14 +35,10 @@ import { type TokenEntity, } from 'JansConfigApi' -interface ActiveTokensSectionProps { - formik: { - values: ExtendedClient - } - viewOnly?: boolean -} - -const ActiveTokensSection: React.FC = ({ formik, viewOnly = false }) => { +const ActiveTokensSection: React.FC> = ({ + formik, + viewOnly = false, +}) => { const { t } = useTranslation() const dispatch = useDispatch() const queryClient = useQueryClient() @@ -213,15 +209,17 @@ const ActiveTokensSection: React.FC = ({ formik, viewO const PaginationWrapper = useCallback( () => ( - + + + ), [totalItems, pageNumber, limit, handlePageChange, handleRowsPerPageChange], ) diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx index 2648ad6900..7c82fc4d3f 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx @@ -76,23 +76,26 @@ const AuthenticationSection: React.FC = ({ ) const signingAlgorithms = useMemo( - () => (oidcConfiguration?.idTokenSigningAlgValuesSupported as string[]) || [], + () => [...new Set((oidcConfiguration?.idTokenSigningAlgValuesSupported as string[]) || [])], [oidcConfiguration], ) const encryptionAlgorithms = useMemo( - () => (oidcConfiguration?.idTokenEncryptionAlgValuesSupported as string[]) || [], + () => [...new Set((oidcConfiguration?.idTokenEncryptionAlgValuesSupported as string[]) || [])], [oidcConfiguration], ) const encryptionEncodings = useMemo( - () => (oidcConfiguration?.idTokenEncryptionEncValuesSupported as string[]) || [], + () => [...new Set((oidcConfiguration?.idTokenEncryptionEncValuesSupported as string[]) || [])], [oidcConfiguration], ) const accessTokenSigningAlgs = useMemo( - () => - (oidcConfiguration?.accessTokenSigningAlgValuesSupported as string[]) || signingAlgorithms, + () => [ + ...new Set( + (oidcConfiguration?.accessTokenSigningAlgValuesSupported as string[]) || signingAlgorithms, + ), + ], [oidcConfiguration, signingAlgorithms], ) diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx index 69fbb30719..d06420f087 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx @@ -27,7 +27,7 @@ import getThemeColor from 'Context/theme/config' import { useDispatch } from 'react-redux' import { updateToast } from 'Redux/features/toastSlice' import type { SectionProps } from '../types' -import { APPLICATION_TYPES, SUBJECT_TYPES } from '../helper/constants' +import { APPLICATION_TYPES, SUBJECT_TYPES, SECRET_GENERATION } from '../helper/constants' const BasicInfoSection: React.FC = ({ formik, @@ -70,11 +70,11 @@ const BasicInfoSection: React.FC = ({ } }, [formik.values.clientSecret, dispatch, t]) - const generateSecret = useCallback((length = 32): string => { - const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*' - const array = new Uint8Array(length) + const generateSecret = useCallback((): string => { + const { LENGTH, CHARSET } = SECRET_GENERATION + const array = new Uint8Array(LENGTH) crypto.getRandomValues(array) - return Array.from(array, (byte) => charset[byte % charset.length]).join('') + return Array.from(array, (byte) => CHARSET[byte % CHARSET.length]).join('') }, []) const handleGenerateSecret = useCallback(() => { From 2cfc58c24b7bdcc23dd1374a7954738c6258ab2b Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Thu, 11 Dec 2025 17:42:07 +0100 Subject: [PATCH 07/18] fix(admin-ui): fix code review #2491 --- admin-ui/app/locales/en/translation.json | 12 +- admin-ui/app/locales/es/translation.json | 3 + admin-ui/app/locales/fr/translation.json | 3 + admin-ui/app/locales/pt/translation.json | 3 + .../components/Clients/ClientForm.test.tsx | 156 +++++- .../Clients/ClientListPage.test.tsx | 125 ++++- .../components/Clients/useClientApi.test.tsx | 186 +++++-- .../components/Clients/utils.test.ts | 172 +++++- .../components/Clients/ClientDetailPage.tsx | 24 +- .../components/Clients/ClientEditPage.tsx | 30 +- .../components/Clients/ClientListPage.tsx | 4 +- .../components/ActiveTokenDetailPanel.tsx | 27 +- .../Clients/components/ClientAddForm.tsx | 92 +-- .../Clients/components/ClientDetailView.tsx | 18 +- .../Clients/components/ClientForm.tsx | 27 +- .../components/Clients/helper/utils.ts | 101 +++- .../components/Clients/helper/validations.ts | 18 +- .../Clients/hooks/useClientActions.ts | 76 +-- .../components/Clients/hooks/useClientApi.ts | 131 +++-- .../Clients/sections/ActiveTokensSection.tsx | 9 +- .../sections/AuthenticationSection.tsx | 523 ++++++------------ .../Clients/sections/BasicInfoSection.tsx | 22 +- .../Clients/sections/LocalizationSection.tsx | 71 ++- .../Clients/sections/ScopesGrantsSection.tsx | 2 - .../Clients/sections/ScriptsSection.tsx | 9 +- .../Clients/sections/SystemInfoSection.tsx | 20 +- .../Clients/sections/TokensSection.tsx | 16 +- .../Clients/sections/UrisSection.tsx | 4 +- .../components/Clients/types/formTypes.ts | 12 + 29 files changed, 1159 insertions(+), 737 deletions(-) diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index d29b7368d0..3d5c8ae478 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -727,7 +727,6 @@ "id_token_token_binding_cnf": "ID Token Token Binding Confirmation", "evidence": "Evidence", "authorization_details_types": "Authorization Details Types", - "logout_status_jwt_signed_response_alg": "Logout Status JWT Signed Response Alg", "client_name_localized": "Client Name (Localized)", "logo_uri_localized": "Logo URI (Localized)", "client_uri_localized": "Client URI (Localized)", @@ -826,6 +825,7 @@ "add_community_project": "Add a Community Project" }, "messages": { + "token_attributes_notice": "Token attributes may contain custom claims and sensitive metadata. This data is visible to users with token management permissions.", "add_permission": "Add Permission", "add_asset": "Add Jans Asset", "asset_document_error": "Document is mandatory.", @@ -898,6 +898,8 @@ "add_client": "Add Client", "client_id_copied": "Client ID copied to clipboard", "client_secret_copied": "Client secret copied to clipboard", + "copy_failed": "Failed to copy to clipboard", + "scripts_truncated_warning": "Some scripts may not be shown. There are more scripts available than can be displayed. Visit the Scripts page to see all scripts.", "token_revoked": "Token revoked successfully", "error_fetching_tokens": "Error fetching tokens", "error_revoking_token": "Error revoking token", @@ -1203,11 +1205,6 @@ "CIBA": "CIBA", "PAR": "PAR", "UMA": "UMA", - "id_token": "id_token", - "access_token": "Access token", - "userinfo": "Userinfo", - "JARM": "JARM", - "request_object": "Request Object", "agama": "Agama", "ssa_management": "SSA Management", "change_backend_bind_password": "Change Backend Bind Password", @@ -1229,8 +1226,6 @@ "client_details": "Client Details", "contacts": "Contacts", "edit_openid_connect_client": "Edit OpenID Connect Client", - "introspection": "Introspection", - "jarm": "JARM", "logout_uris": "Logout URIs", "openid_connect_clients": "OpenID Connect Clients", "other_advanced": "Other Advanced Settings", @@ -1238,7 +1233,6 @@ "par": "PAR", "redirect_uris": "Redirect URIs", "scopes_and_grants": "Scopes & Grants", - "token_endpoint_auth": "Token Endpoint Authentication", "token_settings": "Token Settings", "tx_token": "TX Token", "acr_security": "ACR & Security", diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index da3310d004..b11af7031a 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -812,6 +812,7 @@ "add_community_project": "Agregar un Proyecto de la Comunidad" }, "messages": { + "token_attributes_notice": "Los atributos del token pueden contener claims personalizados y metadatos sensibles. Estos datos son visibles para usuarios con permisos de gestión de tokens.", "add_permission": "Agregar Permiso", "add_asset": "Agregar Jans Asset", "asset_document_error": "El documento es obligatorio.", @@ -884,6 +885,8 @@ "add_client": "Agregar Cliente", "client_id_copied": "ID de cliente copiado al portapapeles", "client_secret_copied": "Secreto del cliente copiado al portapapeles", + "copy_failed": "Error al copiar al portapapeles", + "scripts_truncated_warning": "Es posible que no se muestren algunos scripts. Hay más scripts disponibles de los que se pueden mostrar. Visite la página de Scripts para ver todos los scripts.", "token_revoked": "Token revocado exitosamente", "error_fetching_tokens": "Error al obtener tokens", "error_revoking_token": "Error al revocar token", diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 5d68f84192..7d1e731958 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -756,6 +756,7 @@ "tos_uri_localized": "URI des CGU (Localisé)" }, "messages": { + "token_attributes_notice": "Les attributs du jeton peuvent contenir des claims personnalisés et des métadonnées sensibles. Ces données sont visibles par les utilisateurs ayant des permissions de gestion des jetons.", "add_permission": "Ajouter une autorisation", "add_asset": "Add Jans Asset", "asset_document_error": "Document is mandatory.", @@ -825,6 +826,8 @@ "add_client": "Ajouter un client", "client_id_copied": "ID client copié dans le presse-papiers", "client_secret_copied": "Secret client copié dans le presse-papiers", + "copy_failed": "Échec de la copie dans le presse-papiers", + "scripts_truncated_warning": "Certains scripts peuvent ne pas être affichés. Il y a plus de scripts disponibles que ce qui peut être affiché. Visitez la page Scripts pour voir tous les scripts.", "token_revoked": "Jeton révoqué avec succès", "error_fetching_tokens": "Erreur lors de la récupération des jetons", "error_revoking_token": "Erreur lors de la révocation du jeton", diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index 31ec117bbe..b694dd6dea 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -751,6 +751,7 @@ "tos_uri_localized": "URI dos Termos de Serviço (Localizado)" }, "messages": { + "token_attributes_notice": "Os atributos do token podem conter claims personalizados e metadados sensíveis. Esses dados são visíveis para usuários com permissões de gerenciamento de tokens.", "add_permission": "Adicionar permissão", "add_asset": "Add Jans Asset", "asset_document_error": "Document is mandatory.", @@ -820,6 +821,8 @@ "add_client": "Adicionar Cliente", "client_id_copied": "ID do cliente copiado para a área de transferência", "client_secret_copied": "Segredo do cliente copiado para a área de transferência", + "copy_failed": "Falha ao copiar para a área de transferência", + "scripts_truncated_warning": "Alguns scripts podem não ser exibidos. Há mais scripts disponíveis do que podem ser exibidos. Visite a página de Scripts para ver todos os scripts.", "token_revoked": "Token revogado com sucesso", "error_fetching_tokens": "Erro ao buscar tokens", "error_revoking_token": "Erro ao revogar token", diff --git a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx index 478e2c786b..5d7045dde2 100644 --- a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx +++ b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx @@ -1,9 +1,10 @@ import React from 'react' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { Provider } from 'react-redux' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { combineReducers, configureStore } from '@reduxjs/toolkit' import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper.test' +import ClientForm from 'Plugins/auth-server/components/Clients/components/ClientForm' const mockClient = { inum: '1801.a0beec01-617b-4607-8a35-3e46ac43deb5', @@ -14,6 +15,7 @@ const mockClient = { grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'], responseTypes: ['code'], tokenEndpointAuthMethod: 'client_secret_basic', + description: 'Test client description', } jest.mock('@janssenproject/cedarling_wasm', () => ({ @@ -27,6 +29,7 @@ jest.mock('@janssenproject/cedarling_wasm', () => ({ jest.mock('@/cedarling', () => ({ useCedarling: jest.fn(() => ({ authorize: jest.fn(() => true), + authorizeHelper: jest.fn(), hasCedarReadPermission: jest.fn(() => true), hasCedarWritePermission: jest.fn(() => true), hasCedarDeletePermission: jest.fn(() => true), @@ -62,6 +65,8 @@ const WEBHOOK_STATE = { loadingWebhooks: false, webhookModal: false, webhooks: [], + featureWebhooks: [], + triggerWebhookInProgress: false, } const store = configureStore({ @@ -99,36 +104,141 @@ describe('ClientForm', () => { queryClient.clear() }) - it('provides correct mock client data', () => { - expect(mockClient.inum).toBe('1801.a0beec01-617b-4607-8a35-3e46ac43deb5') - expect(mockClient.clientName).toBe('Jans Config Api Client') - expect(mockClient.grantTypes).toContain('authorization_code') - expect(mockClient.responseTypes).toContain('code') + it('renders form with client name', () => { + render( + + + , + ) + + const clientNameInputs = screen.getAllByDisplayValue(mockClient.clientName) + expect(clientNameInputs.length).toBeGreaterThan(0) }) - it('has correct structure for edit mode client', () => { - expect(mockClient).toHaveProperty('inum') - expect(mockClient).toHaveProperty('clientName') - expect(mockClient).toHaveProperty('displayName') - expect(mockClient).toHaveProperty('applicationType') - expect(mockClient).toHaveProperty('grantTypes') - expect(mockClient).toHaveProperty('responseTypes') - expect(mockClient).toHaveProperty('tokenEndpointAuthMethod') + it('renders navigation sections', () => { + render( + + + , + ) + + expect(screen.getByText(/basic info/i)).toBeInTheDocument() + }) + + it('renders cancel button', () => { + render( + + + , + ) + + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + expect(cancelButton).toBeInTheDocument() }) - it('onCancel mock is callable', () => { - mockOnCancel() + it('calls onCancel when cancel button is clicked', () => { + render( + + + , + ) + + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + fireEvent.click(cancelButton) expect(mockOnCancel).toHaveBeenCalledTimes(1) }) - it('onSubmit mock can be called with values', () => { - const formValues = { ...mockClient, expirable: false } - mockOnSubmit(formValues, 'test message', {}) - expect(mockOnSubmit).toHaveBeenCalledWith(formValues, 'test message', {}) + it('renders in view-only mode without save button', () => { + render( + + + , + ) + + expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument() }) - it('onScopeSearch mock can search scopes', () => { - mockOnScopeSearch('openid') - expect(mockOnScopeSearch).toHaveBeenCalledWith('openid') + it('renders save button in edit mode', () => { + render( + + + , + ) + + const saveButtons = screen.getAllByRole('button', { name: /save/i }) + expect(saveButtons.length).toBeGreaterThan(0) + }) + + it('allows editing client name field', () => { + render( + + + , + ) + + const clientNameInputs = screen.getAllByDisplayValue(mockClient.clientName) + fireEvent.change(clientNameInputs[0], { target: { value: 'Updated Client Name' } }) + expect(screen.getAllByDisplayValue('Updated Client Name').length).toBeGreaterThan(0) + }) + + it('renders description field with initial value', () => { + render( + + + , + ) + + expect(screen.getByDisplayValue(mockClient.description)).toBeInTheDocument() + }) + + it('renders application type selector', () => { + render( + + + , + ) + + expect(screen.getByText(/web/i)).toBeInTheDocument() + }) + + it('renders disabled checkbox', () => { + render( + + + , + ) + + const disabledCheckboxes = screen.getAllByRole('checkbox') + expect(disabledCheckboxes.length).toBeGreaterThan(0) + }) + + it('navigates to authentication section when clicked', async () => { + render( + + + , + ) + + const authSection = screen.getByText(/authentication/i) + fireEvent.click(authSection) + + await waitFor(() => { + expect(screen.getByText('Token Endpoint Authentication')).toBeInTheDocument() + }) }) }) diff --git a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientListPage.test.tsx b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientListPage.test.tsx index 15a190709c..704e512114 100644 --- a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientListPage.test.tsx +++ b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientListPage.test.tsx @@ -1,9 +1,10 @@ import React from 'react' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { Provider } from 'react-redux' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { combineReducers, configureStore } from '@reduxjs/toolkit' import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper.test' +import ClientListPage from 'Plugins/auth-server/components/Clients/ClientListPage' const mockNavigate = jest.fn() const mockUseGetOauthOpenidClients = jest.fn() @@ -24,6 +25,7 @@ jest.mock('@janssenproject/cedarling_wasm', () => ({ jest.mock('@/cedarling', () => ({ useCedarling: jest.fn(() => ({ authorize: jest.fn(() => true), + authorizeHelper: jest.fn(), hasCedarReadPermission: jest.fn(() => true), hasCedarWritePermission: jest.fn(() => true), hasCedarDeletePermission: jest.fn(() => true), @@ -38,6 +40,10 @@ jest.mock('JansConfigApi', () => ({ isPending: false, })), getGetOauthOpenidClientsQueryKey: jest.fn(() => ['oauth-openid-clients']), + useGetOauthScopes: jest.fn(() => ({ + data: { entries: [] }, + isLoading: false, + })), })) const permissions = [ @@ -54,6 +60,8 @@ const WEBHOOK_STATE = { loadingWebhooks: false, webhookModal: false, webhooks: [], + featureWebhooks: [], + triggerWebhookInProgress: false, } const store = configureStore({ @@ -94,10 +102,19 @@ describe('ClientListPage', () => { displayName: 'Jans Config Api Client', applicationType: 'web', disabled: false, + grantTypes: ['authorization_code', 'client_credentials'], + }, + { + inum: '1001.7f0a05b2-0976-475f-8048-50d4cc5e845f', + clientName: 'Admin UI Client', + displayName: 'Admin UI Client', + applicationType: 'web', + disabled: true, + grantTypes: ['refresh_token'], }, ], - totalEntriesCount: 1, - entriesCount: 1, + totalEntriesCount: 2, + entriesCount: 2, }, isLoading: false, error: null, @@ -105,15 +122,101 @@ describe('ClientListPage', () => { }) }) - it('returns client list data from hook', () => { - const result = mockUseGetOauthOpenidClients() - expect(result.data?.entries).toHaveLength(1) - expect(result.isLoading).toBe(false) - expect(result.data?.entries[0].clientName).toBe('Jans Config Api Client') + it('renders client list page', async () => { + render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add client/i })).toBeInTheDocument() + }) + }) + + it('displays client names in the list', async () => { + render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(screen.getByText('Jans Config Api Client')).toBeInTheDocument() + expect(screen.getByText('Admin UI Client')).toBeInTheDocument() + }) + }) + + it('does not display client data when loading', async () => { + mockUseGetOauthOpenidClients.mockReturnValue({ + data: null, + isLoading: true, + error: null, + refetch: jest.fn(), + }) + + render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(screen.queryByText('Jans Config Api Client')).not.toBeInTheDocument() + }) + }) + + it('renders add client button', async () => { + render(, { wrapper: Wrapper }) + + await waitFor(() => { + const addButton = screen.getByRole('button', { name: /add client/i }) + expect(addButton).toBeInTheDocument() + }) + }) + + it('navigates to add client page when add button is clicked', async () => { + render(, { wrapper: Wrapper }) + + await waitFor(() => { + const addButton = screen.getByRole('button', { name: /add client/i }) + fireEvent.click(addButton) + }) + + expect(mockNavigate).toHaveBeenCalledWith('/auth-server/client/new') + }) + + it('renders search input', async () => { + render(, { wrapper: Wrapper }) + + await waitFor(() => { + const searchInput = screen.getByPlaceholderText(/search/i) + expect(searchInput).toBeInTheDocument() + }) }) - it('mock returns correct client inum', () => { - const result = mockUseGetOauthOpenidClients() - expect(result.data?.entries[0].inum).toBe('1801.a0beec01-617b-4607-8a35-3e46ac43deb5') + it('renders refresh button', async () => { + render(, { wrapper: Wrapper }) + + await waitFor(() => { + const refreshButton = screen.getByTestId('RefreshIcon') + expect(refreshButton).toBeInTheDocument() + }) + }) + + it('displays correct number of clients', async () => { + render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(screen.getByText('Jans Config Api Client')).toBeInTheDocument() + expect(screen.getByText('Admin UI Client')).toBeInTheDocument() + }) + }) + + it('renders empty state when no clients exist', async () => { + mockUseGetOauthOpenidClients.mockReturnValue({ + data: { + entries: [], + totalEntriesCount: 0, + entriesCount: 0, + }, + isLoading: false, + error: null, + refetch: jest.fn(), + }) + + render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(screen.getByText(/no records to display/i)).toBeInTheDocument() + }) }) }) diff --git a/admin-ui/plugins/auth-server/__tests__/components/Clients/useClientApi.test.tsx b/admin-ui/plugins/auth-server/__tests__/components/Clients/useClientApi.test.tsx index 7b38a74ade..1070aa4ccb 100644 --- a/admin-ui/plugins/auth-server/__tests__/components/Clients/useClientApi.test.tsx +++ b/admin-ui/plugins/auth-server/__tests__/components/Clients/useClientApi.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react' +import { renderHook, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Provider } from 'react-redux' import { combineReducers, configureStore } from '@reduxjs/toolkit' @@ -9,29 +9,16 @@ import { useCreateClient, useUpdateClient, useDeleteClient, + useInvalidateClientQueries, } from 'Plugins/auth-server/components/Clients/hooks/useClientApi' -const mockClients = [ - { - inum: '1801.a0beec01-617b-4607-8a35-3e46ac43deb5', - clientName: 'Jans Config Api Client', - displayName: 'Jans Config Api Client', - }, - { - inum: '1001.7f0a05b2-0976-475f-8048-50d4cc5e845f', - clientName: 'oxTrust Admin GUI', - displayName: 'oxTrust Admin GUI', - }, - { - inum: '1202.22bd540e-e14e-4416-a9e9-8076053f7d24', - clientName: 'SCIM Requesting Party Client', - displayName: 'SCIM Requesting Party Client', - }, -] - const mockMutate = jest.fn() const mockInvalidateQueries = jest.fn() +let capturedCreateOptions: any = null +let capturedUpdateOptions: any = null +let capturedDeleteOptions: any = null + jest.mock('JansConfigApi', () => ({ useGetOauthOpenidClients: jest.fn(() => ({ data: { @@ -58,21 +45,36 @@ jest.mock('JansConfigApi', () => ({ isLoading: false, error: null, })), - usePostOauthOpenidClient: jest.fn(() => ({ - mutate: mockMutate, - isPending: false, - })), - usePutOauthOpenidClient: jest.fn(() => ({ - mutate: mockMutate, - isPending: false, - })), - useDeleteOauthOpenidClientByInum: jest.fn(() => ({ - mutate: mockMutate, - isPending: false, - })), + usePostOauthOpenidClient: jest.fn((options: any) => { + capturedCreateOptions = options + return { + mutate: mockMutate, + isPending: false, + } + }), + usePutOauthOpenidClient: jest.fn((options: any) => { + capturedUpdateOptions = options + return { + mutate: mockMutate, + isPending: false, + } + }), + useDeleteOauthOpenidClientByInum: jest.fn((options: any) => { + capturedDeleteOptions = options + return { + mutate: mockMutate, + isPending: false, + } + }), getGetOauthOpenidClientsQueryKey: jest.fn(() => ['oauth-openid-clients']), })) +const mockDispatch = jest.fn() +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})) + jest.mock('@tanstack/react-query', () => ({ ...jest.requireActual('@tanstack/react-query'), useQueryClient: () => ({ @@ -105,6 +107,9 @@ describe('useClientApi hooks', () => { beforeEach(() => { jest.clearAllMocks() queryClient.clear() + capturedCreateOptions = null + capturedUpdateOptions = null + capturedDeleteOptions = null }) describe('useClientList', () => { @@ -142,6 +147,49 @@ describe('useClientApi hooks', () => { expect(result.current.mutate).toBeDefined() expect(result.current.isPending).toBe(false) }) + + it('calls onSuccess callback and invalidates queries on success', () => { + const onSuccess = jest.fn() + renderHook(() => useCreateClient(onSuccess), { wrapper }) + + const mockClientData = { inum: 'new-client', clientName: 'New Client' } + act(() => { + capturedCreateOptions.mutation.onSuccess(mockClientData) + }) + + expect(mockDispatch).toHaveBeenCalled() + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['oauth-openid-clients'], + }) + expect(onSuccess).toHaveBeenCalled() + }) + + it('calls onError callback and dispatches error toast on error', () => { + const onError = jest.fn() + renderHook(() => useCreateClient(undefined, onError), { wrapper }) + + const mockError = new Error('Create failed') + act(() => { + capturedCreateOptions.mutation.onError(mockError) + }) + + expect(mockDispatch).toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith(mockError) + }) + + it('uses default error message when error has no message', () => { + const onError = jest.fn() + renderHook(() => useCreateClient(undefined, onError), { wrapper }) + + const mockError = new Error() + mockError.message = '' + act(() => { + capturedCreateOptions.mutation.onError(mockError) + }) + + expect(mockDispatch).toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith(mockError) + }) }) describe('useUpdateClient', () => { @@ -151,6 +199,35 @@ describe('useClientApi hooks', () => { expect(result.current.mutate).toBeDefined() expect(result.current.isPending).toBe(false) }) + + it('calls onSuccess callback and invalidates queries on success', () => { + const onSuccess = jest.fn() + renderHook(() => useUpdateClient(onSuccess), { wrapper }) + + const mockClientData = { inum: 'updated-client', clientName: 'Updated Client' } + act(() => { + capturedUpdateOptions.mutation.onSuccess(mockClientData) + }) + + expect(mockDispatch).toHaveBeenCalled() + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['oauth-openid-clients'], + }) + expect(onSuccess).toHaveBeenCalled() + }) + + it('calls onError callback and dispatches error toast on error', () => { + const onError = jest.fn() + renderHook(() => useUpdateClient(undefined, onError), { wrapper }) + + const mockError = new Error('Update failed') + act(() => { + capturedUpdateOptions.mutation.onError(mockError) + }) + + expect(mockDispatch).toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith(mockError) + }) }) describe('useDeleteClient', () => { @@ -160,5 +237,52 @@ describe('useClientApi hooks', () => { expect(result.current.mutate).toBeDefined() expect(result.current.isPending).toBe(false) }) + + it('calls onSuccess callback and invalidates queries on success', () => { + const onSuccess = jest.fn() + renderHook(() => useDeleteClient(onSuccess), { wrapper }) + + act(() => { + capturedDeleteOptions.mutation.onSuccess() + }) + + expect(mockDispatch).toHaveBeenCalled() + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['oauth-openid-clients'], + }) + expect(onSuccess).toHaveBeenCalled() + }) + + it('calls onError callback and dispatches error toast on error', () => { + const onError = jest.fn() + renderHook(() => useDeleteClient(undefined, onError), { wrapper }) + + const mockError = new Error('Delete failed') + act(() => { + capturedDeleteOptions.mutation.onError(mockError) + }) + + expect(mockDispatch).toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith(mockError) + }) + }) + + describe('useInvalidateClientQueries', () => { + it('returns a function', () => { + const { result } = renderHook(() => useInvalidateClientQueries(), { wrapper }) + + expect(typeof result.current).toBe('function') + }) + + it('calls invalidateQueries with correct query key when invoked', () => { + const { result } = renderHook(() => useInvalidateClientQueries(), { wrapper }) + + result.current() + + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['oauth-openid-clients'], + }) + }) }) }) diff --git a/admin-ui/plugins/auth-server/__tests__/components/Clients/utils.test.ts b/admin-ui/plugins/auth-server/__tests__/components/Clients/utils.test.ts index 00a32f7823..4341dbe8e8 100644 --- a/admin-ui/plugins/auth-server/__tests__/components/Clients/utils.test.ts +++ b/admin-ui/plugins/auth-server/__tests__/components/Clients/utils.test.ts @@ -1,4 +1,5 @@ import { + buildAddFormPayload, buildClientInitialValues, buildClientPayload, hasFormChanges, @@ -10,18 +11,145 @@ import { filterScriptsByType, getScriptNameFromDn, formatDateForDisplay, + formatDateTime, formatDateForInput, isValidUrl, - isValidUri, + hasValidUriScheme, truncateText, sortByName, removeDuplicates, arrayEquals, + transformScopesResponse, } from 'Plugins/auth-server/components/Clients/helper/utils' import { EMPTY_CLIENT } from 'Plugins/auth-server/components/Clients/types' -import { clients } from '../../components/clients.test' describe('Client Utils', () => { + describe('transformScopesResponse', () => { + it('transforms scope entries to ClientScope format', () => { + const entries = [ + { + dn: 'inum=F0C4,ou=scopes,o=jans', + inum: 'F0C4', + id: 'openid', + displayName: 'OpenID', + description: 'OpenID scope', + }, + { + dn: 'inum=F0C5,ou=scopes,o=jans', + inum: 'F0C5', + id: 'profile', + displayName: 'Profile', + description: 'Profile scope', + }, + ] + + const result = transformScopesResponse(entries) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + dn: 'inum=F0C4,ou=scopes,o=jans', + inum: 'F0C4', + id: 'openid', + displayName: 'OpenID', + description: 'OpenID scope', + }) + }) + + it('uses id as displayName when displayName is missing', () => { + const entries = [{ dn: 'inum=F0C4,ou=scopes,o=jans', inum: 'F0C4', id: 'openid' }] + + const result = transformScopesResponse(entries) + + expect(result[0].displayName).toBe('openid') + }) + + it('uses empty string for dn when missing', () => { + const entries = [{ inum: 'F0C4', id: 'openid' }] + + const result = transformScopesResponse(entries) + + expect(result[0].dn).toBe('') + }) + + it('returns empty array for null or undefined input', () => { + expect(transformScopesResponse(null)).toEqual([]) + expect(transformScopesResponse(undefined)).toEqual([]) + }) + + it('returns empty array for empty array input', () => { + expect(transformScopesResponse([])).toEqual([]) + }) + }) + + describe('buildAddFormPayload', () => { + const mockAddFormValues = { + clientName: 'Test Client', + clientSecret: 'secret123', + disabled: false, + description: 'Test description', + scopes: ['inum=F0C4,ou=scopes,o=jans'], + grantTypes: ['authorization_code', 'refresh_token'], + redirectUris: ['https://example.com/callback', ''], + postLogoutRedirectUris: ['https://example.com/logout'], + } + + it('transforms add form values to ClientFormValues with defaults', () => { + const result = buildAddFormPayload(mockAddFormValues) + + expect(result.clientName).toBe('Test Client') + expect(result.clientSecret).toBe('secret123') + expect(result.disabled).toBe(false) + expect(result.description).toBe('Test description') + }) + + it('sets default application type and subject type', () => { + const result = buildAddFormPayload(mockAddFormValues) + + expect(result.applicationType).toBe('web') + expect(result.subjectType).toBe('public') + expect(result.tokenEndpointAuthMethod).toBe('client_secret_basic') + }) + + it('filters out empty redirect URIs', () => { + const result = buildAddFormPayload(mockAddFormValues) + + expect(result.redirectUris).toEqual(['https://example.com/callback']) + expect(result.postLogoutRedirectUris).toEqual(['https://example.com/logout']) + }) + + it('sets default boolean values', () => { + const result = buildAddFormPayload(mockAddFormValues) + + expect(result.trustedClient).toBe(false) + expect(result.persistClientAuthorizations).toBe(false) + expect(result.includeClaimsInIdToken).toBe(false) + expect(result.rptAsJwt).toBe(false) + expect(result.accessTokenAsJwt).toBe(false) + expect(result.deletable).toBe(true) + expect(result.expirable).toBe(false) + }) + + it('sets default attributes', () => { + const result = buildAddFormPayload(mockAddFormValues) + + expect(result.attributes).toBeDefined() + expect(result.attributes?.requirePar).toBe(false) + expect(result.attributes?.requirePkce).toBe(false) + expect(result.attributes?.dpopBoundAccessToken).toBe(false) + expect(result.attributes?.backchannelLogoutUri).toEqual([]) + }) + + it('sets default empty arrays', () => { + const result = buildAddFormPayload(mockAddFormValues) + + expect(result.contacts).toEqual([]) + expect(result.defaultAcrValues).toEqual([]) + expect(result.claims).toEqual([]) + expect(result.responseTypes).toEqual([]) + expect(result.customObjectClasses).toEqual(['top']) + }) + }) + describe('buildClientInitialValues', () => { it('merges client data with empty client defaults', () => { const result = buildClientInitialValues({ clientName: 'Test Client' }) @@ -184,7 +312,8 @@ describe('Client Utils', () => { describe('formatDateForDisplay', () => { it('formats valid date string', () => { const result = formatDateForDisplay('2025-01-15T10:30:00') - expect(result).toBeTruthy() + expect(result).toContain('2025') + expect(result).toContain('15') }) it('returns empty string for undefined', () => { @@ -192,6 +321,27 @@ describe('Client Utils', () => { }) }) + describe('formatDateTime', () => { + it('formats valid date string', () => { + const result = formatDateTime('2025-01-15T10:30:00') + expect(result).toContain('2025') + expect(result).toContain('15') + }) + + it('returns default fallback for undefined', () => { + expect(formatDateTime(undefined)).toBe('--') + }) + + it('returns custom fallback when provided', () => { + expect(formatDateTime(undefined, '-')).toBe('-') + expect(formatDateTime(undefined, 'N/A')).toBe('N/A') + }) + + it('returns original string for invalid date', () => { + expect(formatDateTime('invalid-date')).toBe('invalid-date') + }) + }) + describe('formatDateForInput', () => { it('formats date for input field', () => { const result = formatDateForInput('2025-01-15T10:30:00Z') @@ -215,15 +365,17 @@ describe('Client Utils', () => { }) }) - describe('isValidUri', () => { - it('returns true for valid URIs', () => { - expect(isValidUri('https://example.com')).toBe(true) - expect(isValidUri('custom-scheme://app')).toBe(true) + describe('hasValidUriScheme', () => { + it('returns true for strings with valid URI scheme prefix', () => { + expect(hasValidUriScheme('https://example.com')).toBe(true) + expect(hasValidUriScheme('custom-scheme://app')).toBe(true) + expect(hasValidUriScheme('mailto:test@example.com')).toBe(true) }) - it('returns false for invalid URIs', () => { - expect(isValidUri('')).toBe(false) - expect(isValidUri('no-scheme')).toBe(false) + it('returns false for strings without valid URI scheme', () => { + expect(hasValidUriScheme('')).toBe(false) + expect(hasValidUriScheme('no-scheme')).toBe(false) + expect(hasValidUriScheme('123://invalid')).toBe(false) }) }) diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx index 760866fb62..0c2a659036 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx @@ -6,7 +6,7 @@ import SetTitle from 'Utils/SetTitle' import { useGetOauthScopes } from 'JansConfigApi' import { useClientActions, useClientById } from './hooks' import ClientForm from './components/ClientForm' -import type { ClientScope } from './types' +import { transformScopesResponse } from './helper/utils' const ClientDetailPage: React.FC = () => { const { t } = useTranslation() @@ -20,24 +20,10 @@ const ClientDetailPage: React.FC = () => { { query: { staleTime: 60000 } }, ) - const scopes = useMemo((): ClientScope[] => { - const entries = (scopesResponse?.entries || []) as Array<{ - dn?: string - inum?: string - id?: string - displayName?: string - description?: string - }> - return entries.map( - (scope): ClientScope => ({ - dn: scope.dn || '', - inum: scope.inum, - id: scope.id, - displayName: scope.displayName || scope.id, - description: scope.description, - }), - ) - }, [scopesResponse?.entries]) + const scopes = useMemo( + () => transformScopesResponse(scopesResponse?.entries), + [scopesResponse?.entries], + ) const isLoading = clientLoading || scopesLoading diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx index 686b935dff..766ee9e52b 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx @@ -8,7 +8,8 @@ import { updateToast } from 'Redux/features/toastSlice' import { useGetOauthScopes } from 'JansConfigApi' import { useClientActions, useClientById, useUpdateClient } from './hooks' import ClientForm from './components/ClientForm' -import type { ClientFormValues, ModifiedFields, ClientScope } from './types' +import type { ClientFormValues, ModifiedFields } from './types' +import { transformScopesResponse } from './helper/utils' const ClientEditPage: React.FC = () => { const { t } = useTranslation() @@ -35,24 +36,10 @@ const ClientEditPage: React.FC = () => { }, }) - const scopes = useMemo((): ClientScope[] => { - const entries = (scopesResponse?.entries || []) as Array<{ - dn?: string - inum?: string - id?: string - displayName?: string - description?: string - }> - return entries.map( - (scope): ClientScope => ({ - dn: scope.dn || '', - inum: scope.inum, - id: scope.id, - displayName: scope.displayName || scope.id, - description: scope.description, - }), - ) - }, [scopesResponse?.entries]) + const scopes = useMemo( + () => transformScopesResponse(scopesResponse?.entries), + [scopesResponse?.entries], + ) const handleScopeSearch = useCallback((pattern: string) => { setScopeSearchPattern(pattern) @@ -83,10 +70,7 @@ const ClientEditPage: React.FC = () => { [updateClient, logClientUpdate, dispatch, t], ) - const isLoading = useMemo( - () => clientLoading || updateClient.isPending, - [clientLoading, updateClient.isPending], - ) + const isLoading = clientLoading || updateClient.isPending SetTitle(t('titles.edit_openid_connect_client')) diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx index b933fc2d39..9833fe8a6d 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx @@ -107,7 +107,9 @@ const ClientListPage: React.FC = () => { const getInitialPageSize = (): number => { const stored = localStorage.getItem('paggingSize') - return stored ? parseInt(stored) : DEFAULT_PAGE_SIZE + if (!stored) return DEFAULT_PAGE_SIZE + const parsed = parseInt(stored, 10) + return Number.isNaN(parsed) ? DEFAULT_PAGE_SIZE : parsed } const [limit, setLimit] = useState(getInitialPageSize()) diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ActiveTokenDetailPanel.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ActiveTokenDetailPanel.tsx index 3d74cd4731..e23e346982 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ActiveTokenDetailPanel.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ActiveTokenDetailPanel.tsx @@ -1,9 +1,16 @@ import React, { useContext, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Box, Grid, Typography, Chip } from '@mui/material' +import { Box, Grid, Typography, Chip, Alert } from '@mui/material' import { ThemeContext } from 'Context/theme/themeContext' import getThemeColor from 'Context/theme/config' import type { TokenEntity } from 'JansConfigApi' +import { formatDateTime } from '../helper/utils' + +/** + * Token attributes may contain custom claims and metadata added during token issuance. + * These can include user identifiers, session data, or custom application-specific values. + * Administrators should be aware that this data is visible to users with token management permissions. + */ interface ActiveTokenDetailPanelProps { rowData: TokenEntity @@ -31,15 +38,6 @@ const ActiveTokenDetailPanel: React.FC = ({ rowData wordBreak: 'break-word' as const, } - const formatDate = (dateString?: string): string => { - if (!dateString) return '--' - try { - return new Date(dateString).toLocaleString() - } catch { - return dateString - } - } - return ( = ({ rowData {t('fields.creationDate')} - {formatDate(rowData.creationDate)} + {formatDateTime(rowData.creationDate)} {t('fields.expiration_date')} - {formatDate(rowData.expirationDate)} + {formatDateTime(rowData.expirationDate)} @@ -76,7 +74,7 @@ const ActiveTokenDetailPanel: React.FC = ({ rowData - {t('fields.deleteable')} + {t('fields.deletable')} = ({ rowData {rowData.attributes && Object.keys(rowData.attributes).length > 0 && ( {t('fields.attributes')} + + {t('messages.token_attributes_notice')} + void onCancel?: () => void } -interface AddFormValues { - clientName: string - clientSecret: string - disabled: boolean - description: string - scopes: string[] - grantTypes: string[] - redirectUris: string[] - postLogoutRedirectUris: string[] -} - const initialValues: AddFormValues = { clientName: '', clientSecret: '', @@ -107,24 +96,10 @@ const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => }, }) - const scopes = useMemo((): ClientScope[] => { - const entries = (scopesResponse?.entries || []) as Array<{ - dn?: string - inum?: string - id?: string - displayName?: string - description?: string - }> - return entries.map( - (scope): ClientScope => ({ - dn: scope.dn || '', - inum: scope.inum, - id: scope.id, - displayName: scope.displayName || scope.id, - description: scope.description, - }), - ) - }, [scopesResponse?.entries]) + const scopes = useMemo( + () => transformScopesResponse(scopesResponse?.entries), + [scopesResponse?.entries], + ) const handleToggleSecret = useCallback(() => { setShowSecret((prev) => !prev) @@ -138,7 +113,9 @@ const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => }, []) const handleCopyToClipboard = useCallback((text: string) => { - navigator.clipboard.writeText(text) + navigator.clipboard.writeText(text).catch(() => { + // Clipboard API may fail in non-secure contexts or due to permissions + }) }, []) const handleFormSubmit = useCallback((values: AddFormValues) => { @@ -149,60 +126,15 @@ const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => const handleCommitAccept = useCallback( (message: string) => { if (formValues) { - const fullPayload = { - ...formValues, - applicationType: 'web' as const, - subjectType: 'public' as const, - tokenEndpointAuthMethod: 'client_secret_basic' as const, - responseTypes: [] as string[], - redirectUris: formValues.redirectUris.filter(Boolean), - postLogoutRedirectUris: formValues.postLogoutRedirectUris.filter(Boolean), - frontChannelLogoutSessionRequired: false, - backchannelUserCodeParameter: false, - trustedClient: false, - persistClientAuthorizations: false, - includeClaimsInIdToken: false, - rptAsJwt: false, - accessTokenAsJwt: false, - deletable: true, - customObjectClasses: ['top'], - contacts: [] as string[], - defaultAcrValues: [] as string[], - claims: [] as string[], - claimRedirectUris: [] as string[], - authorizedOrigins: [] as string[], - requestUris: [] as string[], - attributes: { - runIntrospectionScriptBeforeJwtCreation: false, - keepClientAuthorizationAfterExpiration: false, - allowSpontaneousScopes: false, - backchannelLogoutSessionRequired: false, - backchannelLogoutUri: [] as string[], - rptClaimsScripts: [] as string[], - consentGatheringScripts: [] as string[], - spontaneousScopeScriptDns: [] as string[], - introspectionScripts: [] as string[], - postAuthnScripts: [] as string[], - ropcScripts: [] as string[], - updateTokenScriptDns: [] as string[], - additionalAudience: [] as string[], - spontaneousScopes: [] as string[], - jansAuthorizedAcr: [] as string[], - requirePar: false, - dpopBoundAccessToken: false, - jansDefaultPromptLogin: false, - minimumAcrLevelAutoresolve: false, - requirePkce: false, - }, - } - const payload = buildClientPayload(fullPayload as unknown as ClientFormValues) + const fullPayload = buildAddFormPayload(formValues) + const payload = buildClientPayload(fullPayload) as ClientFormValues const modifiedFields: ModifiedFields = {} Object.entries(formValues).forEach(([key, value]) => { if (value !== undefined && value !== '' && (!Array.isArray(value) || value.length > 0)) { modifiedFields[key] = value } }) - onSubmit(payload as ClientFormValues, message, modifiedFields) + onSubmit(payload, message, modifiedFields) } setCommitModalOpen(false) }, diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx index e3dbd235bb..4d7c634a56 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientDetailView.tsx @@ -28,19 +28,23 @@ const ClientDetailView: React.FC = ({ client, scopes = [] const handleCopyClientId = () => { if (client.inum) { - navigator.clipboard.writeText(client.inum) - dispatch(updateToast(true, 'success', t('messages.client_id_copied'))) + navigator.clipboard + .writeText(client.inum) + .then(() => dispatch(updateToast(true, 'success', t('messages.client_id_copied')))) + .catch(() => dispatch(updateToast(true, 'error', t('messages.copy_failed')))) } } const handleCopyClientSecret = () => { if (client.clientSecret) { - navigator.clipboard.writeText(client.clientSecret) - dispatch(updateToast(true, 'success', t('messages.client_secret_copied'))) + navigator.clipboard + .writeText(client.clientSecret) + .then(() => dispatch(updateToast(true, 'success', t('messages.client_secret_copied')))) + .catch(() => dispatch(updateToast(true, 'error', t('messages.copy_failed')))) } } - const getScopeNames = (): string[] => { + const scopeNames = useMemo((): string[] => { if (!client.scopes || client.scopes.length === 0) return [] const matchedScopes = scopes .filter((scope) => scope.dn && client.scopes?.includes(scope.dn)) @@ -53,7 +57,7 @@ const ClientDetailView: React.FC = ({ client, scopes = [] .map((s) => formatScopeDisplay(s)) return [...matchedScopes, ...unmatchedScopes] - } + }, [client.scopes, scopes]) const labelSx = { fontSize: '0.85rem', @@ -165,7 +169,7 @@ const ClientDetailView: React.FC = ({ client, scopes = [] {t('fields.scopes')} - {getScopeNames().map((name, i) => ( + {scopeNames.map((name, i) => ( ))} diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx index ac3dfa6dbf..7539e323b1 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { Formik, Form, FormikProps } from 'formik' import { ErrorBoundary } from 'react-error-boundary' import { + Alert, Box, Button, Paper, @@ -66,6 +67,7 @@ import { } from '../sections' const NAV_COLLAPSED_KEY = 'clientFormNavCollapsed' +const SCRIPTS_FETCH_LIMIT = 200 const SECTION_ICONS: Record = { InfoOutlined: , @@ -116,8 +118,6 @@ const ClientForm: React.FC = ({ const oidcConfiguration = useMemo(() => propertiesData || {}, [propertiesData]) - const scopes = propScopes - const scopesLoading = propScopesLoading const handleScopeSearch = useCallback( (pattern: string) => { if (propOnScopeSearch) { @@ -128,7 +128,7 @@ const ClientForm: React.FC = ({ ) const { data: scriptsResponse } = useGetConfigScripts( - { limit: 200 }, + { limit: SCRIPTS_FETCH_LIMIT }, { query: { refetchOnMount: 'always' as const, @@ -158,6 +158,11 @@ const ClientForm: React.FC = ({ })) }, [scriptsResponse?.entries]) + const scriptsTruncated = useMemo(() => { + const totalCount = scriptsResponse?.totalEntriesCount + return typeof totalCount === 'number' && totalCount > SCRIPTS_FETCH_LIMIT + }, [scriptsResponse?.totalEntriesCount]) + const initialValues = useMemo(() => buildClientInitialValues(client), [client]) const visibleSections = useMemo(() => { @@ -257,8 +262,9 @@ const ClientForm: React.FC = ({ viewOnly, setModifiedFields, scripts, - scopes, - scopesLoading, + scriptsTruncated, + scopes: propScopes, + scopesLoading: propScopesLoading, onScopeSearch: handleScopeSearch, oidcConfiguration, } @@ -288,7 +294,16 @@ const ClientForm: React.FC = ({ return null } }, - [activeSection, viewOnly, scripts, scopes, scopesLoading, handleScopeSearch, oidcConfiguration], + [ + activeSection, + viewOnly, + scripts, + scriptsTruncated, + propScopes, + propScopesLoading, + handleScopeSearch, + oidcConfiguration, + ], ) const renderNavigation = useCallback(() => { diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts index 5ff79049a9..746ed6938e 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts @@ -1,6 +1,87 @@ -import type { ExtendedClient, ClientFormValues, ModifiedFields } from '../types' +import type { + ExtendedClient, + ClientFormValues, + ModifiedFields, + ClientScope, + AddFormValues, +} from '../types' import { EMPTY_CLIENT } from '../types' +interface ScopeEntry { + dn?: string + inum?: string + id?: string + displayName?: string + description?: string +} + +export function transformScopesResponse(entries: ScopeEntry[] | undefined | null): ClientScope[] { + return (entries || []).map( + (scope): ClientScope => ({ + dn: scope.dn || '', + inum: scope.inum, + id: scope.id, + displayName: scope.displayName || scope.id, + description: scope.description, + }), + ) +} + +/** + * Transforms simplified add form values into a full ClientFormValues payload. + * This adds all required default values for a new client. + */ +export function buildAddFormPayload(formValues: AddFormValues): ClientFormValues { + const payload = { + ...formValues, + applicationType: 'web' as const, + subjectType: 'public' as const, + tokenEndpointAuthMethod: 'client_secret_basic' as const, + responseTypes: [] as string[], + redirectUris: formValues.redirectUris.filter(Boolean), + postLogoutRedirectUris: formValues.postLogoutRedirectUris.filter(Boolean), + frontChannelLogoutSessionRequired: false, + backchannelUserCodeParameter: false, + trustedClient: false, + persistClientAuthorizations: false, + includeClaimsInIdToken: false, + rptAsJwt: false, + accessTokenAsJwt: false, + deletable: true, + customObjectClasses: ['top'], + contacts: [] as string[], + defaultAcrValues: [] as string[], + claims: [] as string[], + claimRedirectUris: [] as string[], + authorizedOrigins: [] as string[], + requestUris: [] as string[], + expirable: false, + attributes: { + runIntrospectionScriptBeforeJwtCreation: false, + keepClientAuthorizationAfterExpiration: false, + allowSpontaneousScopes: false, + backchannelLogoutSessionRequired: false, + backchannelLogoutUri: [] as string[], + rptClaimsScripts: [] as string[], + consentGatheringScripts: [] as string[], + spontaneousScopeScriptDns: [] as string[], + introspectionScripts: [] as string[], + postAuthnScripts: [] as string[], + ropcScripts: [] as string[], + updateTokenScriptDns: [] as string[], + additionalAudience: [] as string[], + spontaneousScopes: [] as string[], + jansAuthorizedAcr: [] as string[], + requirePar: false, + dpopBoundAccessToken: false, + jansDefaultPromptLogin: false, + minimumAcrLevelAutoresolve: false, + requirePkce: false, + }, + } + return payload as ClientFormValues +} + export function buildClientInitialValues(client: Partial): ClientFormValues { const merged = { ...EMPTY_CLIENT, @@ -172,6 +253,17 @@ export function formatDateForDisplay(dateString: string | undefined): string { } } +export function formatDateTime(dateString?: string, fallback = '--'): string { + if (!dateString) return fallback + try { + const date = new Date(dateString) + if (isNaN(date.getTime())) return dateString + return date.toLocaleString() + } catch { + return dateString + } +} + export function formatDateForInput(dateString: string | undefined): string { if (!dateString) return '' try { @@ -191,7 +283,12 @@ export function isValidUrl(url: string): boolean { } } -export function isValidUri(uri: string): boolean { +/** + * Checks if a string has a valid URI scheme prefix. + * Note: For form validation with optional fields and custom scheme support, + * use isValidUri from validations.ts instead. + */ +export function hasValidUriScheme(uri: string): boolean { if (!uri) return false const uriPattern = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ return uriPattern.test(uri) diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts index d2706e7ab7..a7a30fcc49 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts @@ -52,9 +52,9 @@ export const clientValidationSchema = Yup.object().shape({ jwksUri: urlSchema.nullable(), sectorIdentifierUri: urlSchema.nullable(), backchannelClientNotificationEndpoint: urlSchema.nullable(), - accessTokenLifetime: Yup.number().nullable().min(0, 'Must be a positive number'), - refreshTokenLifetime: Yup.number().nullable().min(0, 'Must be a positive number'), - defaultMaxAge: Yup.number().nullable().min(0, 'Must be a positive number'), + accessTokenLifetime: Yup.number().nullable().min(0, 'Must be a non-negative number'), + refreshTokenLifetime: Yup.number().nullable().min(0, 'Must be a non-negative number'), + defaultMaxAge: Yup.number().nullable().min(0, 'Must be a non-negative number'), expirationDate: Yup.string() .nullable() .when('expirable', { @@ -74,7 +74,11 @@ export function validateRedirectUri(uri: string): string | null { if (uri.startsWith('http://localhost') || uri.startsWith('http://127.0.0.1')) { return null } - return 'Must be a valid URL' + const customSchemeRegex = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.+$/ + if (customSchemeRegex.test(uri)) { + return null + } + return 'Must be a valid URL or custom scheme URI (e.g., myapp://callback)' } } @@ -84,10 +88,10 @@ export function validateEmail(email: string): string | null { return emailRegex.test(email) ? null : 'Must be a valid email address' } -export function validatePositiveNumber(value: number | undefined | null): string | null { +export function validateNonNegativeNumber(value: number | undefined | null): string | null { if (value === undefined || value === null) return null if (typeof value !== 'number') return 'Must be a number' - if (value < 0) return 'Must be a positive number' + if (value < 0) return 'Must be a non-negative number' return null } @@ -116,7 +120,7 @@ export function validateTokenLifetime( maxSeconds = 315360000, ): string | null { if (value === undefined || value === null) return null - if (value < 0) return 'Must be a positive number' + if (value < 0) return 'Must be a non-negative number' if (value > maxSeconds) return `Value exceeds maximum allowed (${maxSeconds} seconds)` return null } diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts index a840caa4d2..21e3b7edca 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientActions.ts @@ -16,50 +16,62 @@ export function useClientActions() { const logClientCreation = useCallback( async (client: ExtendedClient, message: string, modifiedFields?: ModifiedFields) => { - await logAuditUserAction({ - token, - userinfo, - action: CREATE, - resource: OIDC, - message, - modifiedFields, - performedOn: client.inum, - client_id, - payload: client, - }) + try { + await logAuditUserAction({ + token, + userinfo, + action: CREATE, + resource: OIDC, + message, + modifiedFields, + performedOn: client.inum, + client_id, + payload: client, + }) + } catch (error) { + console.error('Failed to log client creation audit:', error) + } }, [token, userinfo, client_id], ) const logClientUpdate = useCallback( async (client: ExtendedClient, message: string, modifiedFields?: ModifiedFields) => { - await logAuditUserAction({ - token, - userinfo, - action: UPDATE, - resource: OIDC, - message, - modifiedFields, - performedOn: client.inum, - client_id, - payload: client, - }) + try { + await logAuditUserAction({ + token, + userinfo, + action: UPDATE, + resource: OIDC, + message, + modifiedFields, + performedOn: client.inum, + client_id, + payload: client, + }) + } catch (error) { + console.error('Failed to log client update audit:', error) + } }, [token, userinfo, client_id], ) const logClientDeletion = useCallback( async (client: ExtendedClient, message: string) => { - await logAuditUserAction({ - token, - userinfo, - action: DELETION, - resource: OIDC, - message, - performedOn: client.inum, - client_id, - payload: { inum: client.inum, clientName: client.clientName }, - }) + try { + await logAuditUserAction({ + token, + userinfo, + action: DELETION, + resource: OIDC, + message, + performedOn: client.inum, + client_id, + payload: { inum: client.inum, clientName: client.clientName }, + }) + } catch (error) { + console.error('Failed to log client deletion audit:', error) + } }, [token, userinfo, client_id], ) diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts index 325ac9efa8..064e62908b 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientApi.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo } from 'react' -import { useQueryClient } from '@tanstack/react-query' +import { useQueryClient, QueryClient } from '@tanstack/react-query' import { useDispatch } from 'react-redux' +import { Dispatch } from 'redux' import { updateToast } from 'Redux/features/toastSlice' import { triggerWebhook } from 'Plugins/admin/redux/features/WebhookSlice' import { @@ -12,6 +13,42 @@ import { } from 'JansConfigApi' import type { Client, ClientListOptions } from '../types' +interface MutationHandlerOptions { + dispatch: Dispatch + queryClient: QueryClient + errorMessageFallback: string + triggerWebhookOnSuccess?: boolean + onSuccess?: () => void + onError?: (error: Error) => void +} + +function createMutationHandlers(options: MutationHandlerOptions) { + const { + dispatch, + queryClient, + errorMessageFallback, + triggerWebhookOnSuccess = true, + onSuccess, + onError, + } = options + + const handleSuccess = (data: T) => { + dispatch(updateToast(true, 'success')) + queryClient.invalidateQueries({ queryKey: getGetOauthOpenidClientsQueryKey() }) + if (triggerWebhookOnSuccess && data) { + dispatch(triggerWebhook({ createdFeatureValue: data })) + } + onSuccess?.() + } + + const handleError = (error: Error) => { + dispatch(updateToast(true, 'error', error?.message || errorMessageFallback)) + onError?.(error) + } + + return { handleSuccess, handleError } +} + export function useClientList(params: ClientListOptions) { const queryOptions = useMemo( () => ({ @@ -54,25 +91,16 @@ export function useCreateClient(onSuccess?: () => void, onError?: (error: Error) const queryClient = useQueryClient() const dispatch = useDispatch() - const handleSuccess = useCallback( - (data: Client) => { - dispatch(updateToast(true, 'success')) - queryClient.invalidateQueries({ - queryKey: getGetOauthOpenidClientsQueryKey(), - }) - dispatch(triggerWebhook({ createdFeatureValue: data })) - onSuccess?.() - }, - [dispatch, queryClient, onSuccess], - ) - - const handleError = useCallback( - (error: Error) => { - const errorMessage = error?.message || 'Failed to create client' - dispatch(updateToast(true, 'error', errorMessage)) - onError?.(error) - }, - [dispatch, onError], + const { handleSuccess, handleError } = useMemo( + () => + createMutationHandlers({ + dispatch, + queryClient, + errorMessageFallback: 'Failed to create client', + onSuccess, + onError, + }), + [dispatch, queryClient, onSuccess, onError], ) return usePostOauthOpenidClient({ @@ -87,25 +115,16 @@ export function useUpdateClient(onSuccess?: () => void, onError?: (error: Error) const queryClient = useQueryClient() const dispatch = useDispatch() - const handleSuccess = useCallback( - (data: Client) => { - dispatch(updateToast(true, 'success')) - queryClient.invalidateQueries({ - queryKey: getGetOauthOpenidClientsQueryKey(), - }) - dispatch(triggerWebhook({ createdFeatureValue: data })) - onSuccess?.() - }, - [dispatch, queryClient, onSuccess], - ) - - const handleError = useCallback( - (error: Error) => { - const errorMessage = error?.message || 'Failed to update client' - dispatch(updateToast(true, 'error', errorMessage)) - onError?.(error) - }, - [dispatch, onError], + const { handleSuccess, handleError } = useMemo( + () => + createMutationHandlers({ + dispatch, + queryClient, + errorMessageFallback: 'Failed to update client', + onSuccess, + onError, + }), + [dispatch, queryClient, onSuccess, onError], ) return usePutOauthOpenidClient({ @@ -120,21 +139,17 @@ export function useDeleteClient(onSuccess?: () => void, onError?: (error: Error) const queryClient = useQueryClient() const dispatch = useDispatch() - const handleSuccess = useCallback(() => { - dispatch(updateToast(true, 'success')) - queryClient.invalidateQueries({ - queryKey: getGetOauthOpenidClientsQueryKey(), - }) - onSuccess?.() - }, [dispatch, queryClient, onSuccess]) - - const handleError = useCallback( - (error: Error) => { - const errorMessage = error?.message || 'Failed to delete client' - dispatch(updateToast(true, 'error', errorMessage)) - onError?.(error) - }, - [dispatch, onError], + const { handleSuccess, handleError } = useMemo( + () => + createMutationHandlers({ + dispatch, + queryClient, + errorMessageFallback: 'Failed to delete client', + triggerWebhookOnSuccess: false, + onSuccess, + onError, + }), + [dispatch, queryClient, onSuccess, onError], ) return useDeleteOauthOpenidClientByInum({ @@ -150,13 +165,7 @@ export function useInvalidateClientQueries() { return useCallback(() => { queryClient.invalidateQueries({ - predicate: (query) => { - const queryKey = query.queryKey[0] as string - return ( - queryKey === getGetOauthOpenidClientsQueryKey()[0] || - queryKey === 'getOauthOpenidClientsByInum' - ) - }, + queryKey: getGetOauthOpenidClientsQueryKey(), }) }, [queryClient]) } diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/ActiveTokensSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/ActiveTokensSection.tsx index c4afbffb1b..461f7f50d9 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/ActiveTokensSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/ActiveTokensSection.tsx @@ -161,6 +161,13 @@ const ActiveTokensSection: React.FC> = [revokeTokenMutation, dispatch, t], ) + const escapeCSVField = (value: string): string => { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"` + } + return value + } + const convertToCSV = (data: TokenEntity[]): string => { if (data.length === 0) return '' @@ -180,7 +187,7 @@ const ActiveTokensSection: React.FC> = const value = row[key as keyof TokenEntity] if (value === undefined || value === null) return '' if (typeof value === 'boolean') return value.toString() - return String(value).replace(/,/g, ';') + return escapeCSVField(String(value)) }) .join(','), ) diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx index 7c82fc4d3f..b93dacdc54 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx @@ -6,6 +6,49 @@ import getThemeColor from 'Context/theme/config' import type { SectionProps } from '../types' import { TOKEN_ENDPOINT_AUTH_METHODS } from '../helper/constants' +interface AlgorithmSelectProps { + label: string + name: string + value: string + options: string[] + onChange: (value: string) => void + disabled: boolean + fieldStyle: object + placeholder: string +} + +const AlgorithmSelect: React.FC = ({ + label, + name, + value, + options, + onChange, + disabled, + fieldStyle, + placeholder, +}) => ( + onChange(e.target.value)} + disabled={disabled} + sx={fieldStyle} + SelectProps={{ native: true }} + InputLabelProps={{ shrink: true }} + > + + {options.map((opt) => ( + + ))} + +) + const AuthenticationSection: React.FC = ({ formik, viewOnly = false, @@ -198,90 +241,60 @@ const AuthenticationSection: React.FC = ({ {t('titles.id_token')} - + options={signingAlgorithms} + onChange={(value) => handleFieldChange( 'idTokenSignedResponseAlg', t('fields.id_token_signed_response_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {signingAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionAlgorithms} + onChange={(value) => handleFieldChange( 'idTokenEncryptedResponseAlg', t('fields.id_token_encrypted_response_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionEncodings} + onChange={(value) => handleFieldChange( 'idTokenEncryptedResponseEnc', t('fields.id_token_encrypted_response_enc'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionEncodings.map((enc) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> @@ -290,32 +303,22 @@ const AuthenticationSection: React.FC = ({ {t('titles.access_token')} - + options={accessTokenSigningAlgs} + onChange={(value) => handleFieldChange( 'accessTokenSigningAlg', t('fields.access_token_signing_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {accessTokenSigningAlgs.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> @@ -324,90 +327,60 @@ const AuthenticationSection: React.FC = ({ {t('titles.userinfo')} - + options={signingAlgorithms} + onChange={(value) => handleFieldChange( 'userInfoSignedResponseAlg', t('fields.userinfo_signed_response_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {signingAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionAlgorithms} + onChange={(value) => handleFieldChange( 'userInfoEncryptedResponseAlg', t('fields.userinfo_encrypted_response_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionEncodings} + onChange={(value) => handleFieldChange( 'userInfoEncryptedResponseEnc', t('fields.userinfo_encrypted_response_enc'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionEncodings.map((enc) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> @@ -416,90 +389,60 @@ const AuthenticationSection: React.FC = ({ {t('titles.request_object')} - + options={signingAlgorithms} + onChange={(value) => handleFieldChange( 'requestObjectSigningAlg', t('fields.request_object_signing_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {signingAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionAlgorithms} + onChange={(value) => handleFieldChange( 'requestObjectEncryptionAlg', t('fields.request_object_encryption_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionEncodings} + onChange={(value) => handleFieldChange( 'requestObjectEncryptionEnc', t('fields.request_object_encryption_enc'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionEncodings.map((enc) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> @@ -508,90 +451,60 @@ const AuthenticationSection: React.FC = ({ {t('titles.introspection')} - + options={signingAlgorithms} + onChange={(value) => handleAttributeChange( 'introspectionSignedResponseAlg', t('fields.introspection_signed_response_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {signingAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionAlgorithms} + onChange={(value) => handleAttributeChange( 'introspectionEncryptedResponseAlg', t('fields.introspection_encrypted_response_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionEncodings} + onChange={(value) => handleAttributeChange( 'introspectionEncryptedResponseEnc', t('fields.introspection_encrypted_response_enc'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionEncodings.map((enc) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> @@ -600,90 +513,60 @@ const AuthenticationSection: React.FC = ({ {t('titles.jarm')} - + options={signingAlgorithms} + onChange={(value) => handleAttributeChange( 'jansAuthSignedRespAlg', t('fields.jans_auth_signed_resp_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {signingAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionAlgorithms} + onChange={(value) => handleAttributeChange( 'jansAuthEncRespAlg', t('fields.jans_auth_enc_resp_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionEncodings} + onChange={(value) => handleAttributeChange( 'jansAuthEncRespEnc', t('fields.jans_auth_enc_resp_enc'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionEncodings.map((enc) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> @@ -692,90 +575,60 @@ const AuthenticationSection: React.FC = ({ {t('titles.tx_token')} - + options={signingAlgorithms} + onChange={(value) => handleAttributeChange( 'txTokenSignedResponseAlg', t('fields.tx_token_signed_response_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {signingAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionAlgorithms} + onChange={(value) => handleAttributeChange( 'txTokenEncryptedResponseAlg', t('fields.tx_token_encrypted_response_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={encryptionEncodings} + onChange={(value) => handleAttributeChange( 'txTokenEncryptedResponseEnc', t('fields.tx_token_encrypted_response_enc'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {encryptionEncodings.map((enc) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> @@ -784,32 +637,22 @@ const AuthenticationSection: React.FC = ({ {t('titles.logout')} - + options={signingAlgorithms} + onChange={(value) => handleAttributeChange( 'logoutStatusJwtSignedResponseAlg', t('fields.logout_status_jwt_signed_response_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {signingAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx index d06420f087..8af2504d27 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx @@ -56,17 +56,25 @@ const BasicInfoSection: React.FC = ({ setShowSecret((prev) => !prev) }, []) - const handleCopyClientId = useCallback(() => { + const handleCopyClientId = useCallback(async () => { if (formik.values.inum) { - navigator.clipboard.writeText(formik.values.inum) - dispatch(updateToast(true, 'success', t('messages.client_id_copied'))) + try { + await navigator.clipboard.writeText(formik.values.inum) + dispatch(updateToast(true, 'success', t('messages.client_id_copied'))) + } catch { + dispatch(updateToast(true, 'error', t('messages.copy_failed'))) + } } }, [formik.values.inum, dispatch, t]) - const handleCopyClientSecret = useCallback(() => { + const handleCopyClientSecret = useCallback(async () => { if (formik.values.clientSecret) { - navigator.clipboard.writeText(formik.values.clientSecret) - dispatch(updateToast(true, 'success', t('messages.client_secret_copied'))) + try { + await navigator.clipboard.writeText(formik.values.clientSecret) + dispatch(updateToast(true, 'success', t('messages.client_secret_copied'))) + } catch { + dispatch(updateToast(true, 'error', t('messages.copy_failed'))) + } } }, [formik.values.clientSecret, dispatch, t]) @@ -416,7 +424,7 @@ const BasicInfoSection: React.FC = ({ value.map((option, index) => ( = ({ const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) const [editorValues, setEditorValues] = useState>({}) + const [jsonErrors, setJsonErrors] = useState>({}) const handleFieldChange = useCallback( (fieldName: string, fieldLabel: string, value: unknown) => { @@ -100,39 +101,54 @@ const LocalizationSection: React.FC = ({ try { const parsed = newValue.trim() ? JSON.parse(newValue) : {} handleFieldChange(fieldName, fieldLabel, parsed) - } catch {} + setJsonErrors((prev) => ({ ...prev, [fieldName]: false })) + } catch { + setJsonErrors((prev) => ({ ...prev, [fieldName]: true })) + } }, [handleFieldChange], ) const renderJsonEditor = useCallback( - (label: string, fieldName: string, formikValue: Record | undefined) => ( - - {label} - - handleEditorChange(fieldName, label, newValue)} - name={`${fieldName}-editor`} - width="100%" - height="100px" - fontSize={13} - showPrintMargin={false} - showGutter={true} - highlightActiveLine={!viewOnly} - readOnly={viewOnly} - setOptions={{ - useWorker: false, - showLineNumbers: true, - tabSize: 2, + (label: string, fieldName: string, formikValue: Record | undefined) => { + const hasError = jsonErrors[fieldName] + return ( + + {label} + - - {t('placeholders.localized_json_format')} - - ), + > + handleEditorChange(fieldName, label, newValue)} + name={`${fieldName}-editor`} + width="100%" + height="100px" + fontSize={13} + showPrintMargin={false} + showGutter={true} + highlightActiveLine={!viewOnly} + readOnly={viewOnly} + setOptions={{ + useWorker: false, + showLineNumbers: true, + tabSize: 2, + }} + /> + + + {hasError ? t('messages.invalid_json_error') : t('placeholders.localized_json_format')} + + + ) + }, [ selectedTheme, viewOnly, @@ -142,6 +158,7 @@ const LocalizationSection: React.FC = ({ t, getEditorValue, handleEditorChange, + jsonErrors, ], ) diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/ScopesGrantsSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/ScopesGrantsSection.tsx index abe69e11e5..e6423e0e1a 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/ScopesGrantsSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/ScopesGrantsSection.tsx @@ -242,7 +242,6 @@ const ScopesGrantsSection: React.FC = ({ handleGrantTypeChange(grant.value, e.target.checked)} - disabled={viewOnly} sx={switchStyle} /> } @@ -279,7 +278,6 @@ const ScopesGrantsSection: React.FC = ({ handleResponseTypeChange(type.value, e.target.checked)} - disabled={viewOnly} sx={switchStyle} /> } diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/ScriptsSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/ScriptsSection.tsx index 005706ec48..c867aa5b84 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/ScriptsSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/ScriptsSection.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useContext, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { + Alert, Box, Grid, TextField, @@ -21,6 +22,7 @@ const ScriptsSection: React.FC = ({ viewOnly = false, setModifiedFields, scripts = [], + scriptsTruncated = false, }) => { const { t } = useTranslation() const theme = useContext(ThemeContext) @@ -171,6 +173,11 @@ const ScriptsSection: React.FC = ({ return ( + {scriptsTruncated && ( + + {t('messages.scripts_truncated_warning')} + + )} {t('titles.client_scripts')} @@ -311,7 +318,7 @@ const ScriptsSection: React.FC = ({ value.map((option, index) => ( = ({ formik, @@ -85,15 +86,6 @@ const SystemInfoSection: React.FC = ({ [themeColors], ) - const formatDate = useCallback((dateString: string | undefined) => { - if (!dateString) return '-' - try { - return new Date(dateString).toLocaleString() - } catch { - return dateString - } - }, []) - return ( @@ -129,7 +121,7 @@ const SystemInfoSection: React.FC = ({ size="small" label={t('fields.client_id_issued_at')} name="clientIdIssuedAt" - value={formatDate(formik.values.clientIdIssuedAt)} + value={formatDateTime(formik.values.clientIdIssuedAt, '-')} disabled sx={fieldStyle} /> @@ -141,7 +133,7 @@ const SystemInfoSection: React.FC = ({ size="small" label={t('fields.client_secret_expires_at')} name="clientSecretExpiresAt" - value={formatDate(formik.values.clientSecretExpiresAt)} + value={formatDateTime(formik.values.clientSecretExpiresAt, '-')} disabled sx={fieldStyle} /> @@ -153,7 +145,7 @@ const SystemInfoSection: React.FC = ({ size="small" label={t('fields.last_access_time')} name="lastAccessTime" - value={formatDate(formik.values.lastAccessTime)} + value={formatDateTime(formik.values.lastAccessTime, '-')} disabled sx={fieldStyle} /> @@ -165,7 +157,7 @@ const SystemInfoSection: React.FC = ({ size="small" label={t('fields.last_logon_time')} name="lastLogonTime" - value={formatDate(formik.values.lastLogonTime)} + value={formatDateTime(formik.values.lastLogonTime, '-')} disabled sx={fieldStyle} /> @@ -210,7 +202,7 @@ const SystemInfoSection: React.FC = ({ handleFieldChange( 'ttl', t('fields.ttl'), - e.target.value ? parseInt(e.target.value) : null, + e.target.value ? parseInt(e.target.value, 10) : null, ) } disabled={viewOnly} diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx index c7f97e74f2..d8ba196d4b 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx @@ -116,7 +116,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo handleFieldChange( 'accessTokenLifetime', t('fields.access_token_lifetime'), - e.target.value ? parseInt(e.target.value) : null, + e.target.value ? parseInt(e.target.value, 10) : null, ) } disabled={viewOnly} @@ -137,7 +137,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo handleFieldChange( 'refreshTokenLifetime', t('fields.refresh_token_lifetime'), - e.target.value ? parseInt(e.target.value) : null, + e.target.value ? parseInt(e.target.value, 10) : null, ) } disabled={viewOnly} @@ -158,7 +158,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo handleFieldChange( 'defaultMaxAge', t('fields.default_max_age'), - e.target.value ? parseInt(e.target.value) : null, + e.target.value ? parseInt(e.target.value, 10) : null, ) } disabled={viewOnly} @@ -179,7 +179,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo handleAttributeChange( 'idTokenLifetime', t('fields.id_token_lifetime'), - e.target.value ? parseInt(e.target.value) : null, + e.target.value ? parseInt(e.target.value, 10) : null, ) } disabled={viewOnly} @@ -200,7 +200,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo handleAttributeChange( 'txTokenLifetime', t('fields.tx_token_lifetime'), - e.target.value ? parseInt(e.target.value) : null, + e.target.value ? parseInt(e.target.value, 10) : null, ) } disabled={viewOnly} @@ -221,7 +221,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo handleAttributeChange( 'requestedLifetime', t('fields.requested_lifetime'), - e.target.value ? parseInt(e.target.value) : null, + e.target.value ? parseInt(e.target.value, 10) : null, ) } disabled={viewOnly} @@ -372,7 +372,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo handleAttributeChange( 'parLifetime', t('fields.par_lifetime'), - e.target.value ? parseInt(e.target.value) : null, + e.target.value ? parseInt(e.target.value, 10) : null, ) } disabled={viewOnly} @@ -529,7 +529,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo handleAttributeChange( 'minimumAcrLevel', t('fields.minimum_acr_level'), - e.target.value ? parseInt(e.target.value) : null, + e.target.value ? parseInt(e.target.value, 10) : null, ) } disabled={viewOnly} diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/UrisSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/UrisSection.tsx index 7d0ce9f7be..7257c92e32 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/UrisSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/UrisSection.tsx @@ -112,7 +112,7 @@ const UrisSection: React.FC = ({ formik, viewOnly = false, setModi {(value || []).map((uri, index) => ( - + ))} {(!value || value.length === 0) && ( @@ -138,7 +138,7 @@ const UrisSection: React.FC = ({ formik, viewOnly = false, setModi value.map((option, index) => ( isEdit?: boolean @@ -65,6 +76,7 @@ export interface SectionProps { viewOnly?: boolean setModifiedFields: React.Dispatch> scripts?: ClientScript[] + scriptsTruncated?: boolean scopes?: ClientScope[] scopesLoading?: boolean onScopeSearch?: (pattern: string) => void From 48fcf39b700be86a8754e191be8c32d0eb99e1df Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Thu, 11 Dec 2025 18:29:22 +0100 Subject: [PATCH 08/18] fix(admin-ui): fix code review #2491 --- admin-ui/app/locales/en/translation.json | 4 +- admin-ui/app/locales/es/translation.json | 24 ++----- admin-ui/app/locales/fr/translation.json | 9 +-- admin-ui/app/locales/pt/translation.json | 9 +-- .../components/Clients/ClientListPage.tsx | 14 ++-- .../Clients/components/ClientAddForm.tsx | 46 +++++++++---- .../components/Clients/helper/utils.ts | 34 ++++++++-- .../components/Clients/helper/validations.ts | 28 ++++---- .../sections/AuthenticationSection.tsx | 66 ++++++++----------- .../Clients/sections/BasicInfoSection.tsx | 28 ++++---- .../Clients/sections/LocalizationSection.tsx | 9 ++- .../components/Clients/types/formTypes.ts | 20 +----- 12 files changed, 146 insertions(+), 145 deletions(-) diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index 3d5c8ae478..3881fc1841 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -692,7 +692,6 @@ "software_statement": "Software Statement", "software_version": "Software Version", "spontaneous_scope_scripts": "Spontaneous Scope Scripts", - "spontaneous_scopes": "Spontaneous Scopes", "tos_uri": "Terms of Service URI", "update_token_scripts": "Update Token Scripts", "userinfo_encrypted_response_alg": "UserInfo Encryption Algorithm", @@ -938,7 +937,7 @@ "view_script_details": "View custom script details", "add_new_user": "Add User", "error_in_saving": "Error in saving.", - "error_loading_data": "Failed to load data", + "error_loading_data": "An error occurred while loading data. Please try again.", "try_again_later": "Please try again later", "loading_attribute": "Loading attribute...", "loading_attributes": "Loading attributes", @@ -1003,7 +1002,6 @@ "status_degraded": "Degraded", "status_unknown": "Unknown", "no_mau_data": "No data available for the selected period.", - "error_loading_data": "An error occurred while loading data. Please try again.", "mau_loading": "Loading statistics...", "vs_previous_period": "vs previous period" }, diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index b11af7031a..bc39c98bf2 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -348,7 +348,7 @@ "login_uris": "URIs de Login", "logo_uri": "URI del Logo", "logout_redirect_uris": "URIs de Redirección de Logout", - "logout_status_jwt_signed_response_alg": "Alg de Respuesta Firmada JWT de Estado de Logout", + "logout_status_jwt_signed_response_alg": "Algoritmo de Firma JWT del Estado de Cierre de Sesión", "level": "Nivel", "script": "Script", "local_primary_key": "Clave Primaria Local", @@ -445,7 +445,7 @@ "spontaneous_client_scopes": "Ámbitos de Cliente Espontáneos", "spontaneous_client_id": "ID de Cliente Espontáneo", "spontaneous_scope_script_dns": "DNs del Script de Ámbito Espontáneo", - "spontaneous_scopes": "Ámbitos Espontáneos", + "spontaneous_scopes": "Alcances Espontáneos", "spontaneousScopes": "Ámbitos espontáneos", "spontaneousScopesREGEX": "Expresión regular de validación de ámbitos espontáneos", "updateTokenScriptDns": "Actualizar Token", @@ -686,7 +686,6 @@ "software_statement": "Declaración de Software", "software_version": "Versión del Software", "spontaneous_scope_scripts": "Scripts de Alcance Espontáneo", - "spontaneous_scopes": "Alcances Espontáneos", "tos_uri": "URI de Términos de Servicio", "update_token_scripts": "Scripts de Actualización de Token", "userinfo_encrypted_response_alg": "Algoritmo de Cifrado UserInfo", @@ -713,7 +712,6 @@ "id_token_token_binding_cnf": "Confirmación de Enlace de Token de ID", "evidence": "Evidencia", "authorization_details_types": "Tipos de Detalles de Autorización", - "logout_status_jwt_signed_response_alg": "Algoritmo de Firma JWT del Estado de Cierre de Sesión", "client_name_localized": "Nombre del Cliente (Localizado)", "logo_uri_localized": "URI del Logo (Localizado)", "client_uri_localized": "URI del Cliente (Localizado)", @@ -925,7 +923,7 @@ "view_script_details": "Ver detalles del script personalizado", "add_new_user": "Agregar Usuario", "error_in_saving": "Error al guardar.", - "error_loading_data": "Error al cargar los datos", + "error_loading_data": "Ocurrió un error al cargar los datos. Por favor, inténtelo de nuevo.", "try_again_later": "Por favor intente de nuevo más tarde", "loading_attributes": "Cargando atributos", "error_processiong_request": "Error al procesar la solicitud.", @@ -996,7 +994,6 @@ "status_degraded": "Degradado", "status_unknown": "Desconocido", "no_mau_data": "No hay datos disponibles para el período seleccionado.", - "error_loading_data": "Ocurrió un error al cargar los datos. Por favor, inténtelo de nuevo.", "mau_loading": "Cargando estadísticas...", "vs_previous_period": "vs período anterior" }, @@ -1101,10 +1098,10 @@ }, "titles": { "actions": "Acciones", - "id_token": "Token de ID", - "access_token": "Token de Acceso", - "userinfo": "Información de Usuario", - "request_object": "Objeto de Solicitud", + "id_token": "id_token", + "access_token": "Token de acceso", + "userinfo": "Userinfo", + "request_object": "Objeto de petición", "introspection": "Introspección", "jarm": "JARM", "token_endpoint_auth": "Autenticación del Endpoint de Token", @@ -1189,11 +1186,7 @@ "CIBA": "CIBA", "PAR": "PAR", "UMA": "UMA", - "id_token": "id_token", - "access_token": "Token de acceso", - "userinfo": "Userinfo", "JARM": "JARM", - "request_object": "Objeto de petición", "agama": "Agama", "ssa_management": "Gestión de SSA", "change_backend_bind_password": "Cambiar contraseña de Bind Backend", @@ -1215,8 +1208,6 @@ "client_details": "Detalles del Cliente", "contacts": "Contactos", "edit_openid_connect_client": "Editar Cliente OpenID Connect", - "introspection": "Introspección", - "jarm": "JARM", "logout_uris": "URIs de Cierre de Sesión", "openid_connect_clients": "Clientes OpenID Connect", "other_advanced": "Otras Configuraciones Avanzadas", @@ -1224,7 +1215,6 @@ "par": "PAR", "redirect_uris": "URIs de Redirección", "scopes_and_grants": "Alcances y Concesiones", - "token_endpoint_auth": "Autenticación del Endpoint de Token", "token_settings": "Configuración de Tokens", "tx_token": "Token TX", "acr_security": "ACR y Seguridad", diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 7d1e731958..80af953ef6 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -471,7 +471,7 @@ "login_uris": "URI de connexion", "logo_uri": "URI du logo", "logout_redirect_uris": "Déconnexion Redirection URIs", - "logout_status_jwt_signed_response_alg": "Alg de Réponse Signée JWT de Statut de Déconnexion", + "logout_status_jwt_signed_response_alg": "Algorithme de Signature JWT du Statut de Déconnexion", "level": "Niveau", "local_primary_key": "Clé primaire locale", "max_connections": "Nombre maximal de connexions", @@ -564,7 +564,7 @@ "spontaneous_client_scopes": "Champs d'application des clients spontanés", "spontaneous_client_id": "Identifiant client spontané", "spontaneous_scope_script_dns": "DNS de script de portée spontanée", - "spontaneous_scopes": "Portées spontanées", + "spontaneous_scopes": "Scopes Spontanés", "spontaneousScopes": "Portées spontanées", "spontaneousScopesREGEX": "Regex de validation spontanée de la portée", "ssl_trust_store_file": "Fichier de magasin de confiance SSL", @@ -721,7 +721,6 @@ "software_statement": "Déclaration de Logiciel", "software_version": "Version du Logiciel", "spontaneous_scope_scripts": "Scripts de Scope Spontané", - "spontaneous_scopes": "Scopes Spontanés", "tos_uri": "URI des Conditions d'Utilisation", "update_token_scripts": "Scripts de Mise à Jour de Jeton", "userinfo_encrypted_response_alg": "Algorithme de Chiffrement UserInfo", @@ -748,7 +747,6 @@ "id_token_token_binding_cnf": "Confirmation de Liaison du Jeton d'ID", "evidence": "Preuve", "authorization_details_types": "Types de Détails d'Autorisation", - "logout_status_jwt_signed_response_alg": "Algorithme de Signature JWT du Statut de Déconnexion", "client_name_localized": "Nom du Client (Localisé)", "logo_uri_localized": "URI du Logo (Localisé)", "client_uri_localized": "URI du Client (Localisé)", @@ -1118,8 +1116,6 @@ "client_details": "Détails du Client", "contacts": "Contacts", "edit_openid_connect_client": "Modifier le Client OpenID Connect", - "introspection": "Introspection", - "jarm": "JARM", "logout_uris": "URIs de Déconnexion", "openid_connect_clients": "Clients OpenID Connect", "other_advanced": "Autres Paramètres Avancés", @@ -1127,7 +1123,6 @@ "par": "PAR", "redirect_uris": "URIs de Redirection", "scopes_and_grants": "Scopes et Autorisations", - "token_endpoint_auth": "Authentification du Point de Terminaison de Jeton", "token_settings": "Paramètres des Jetons", "tx_token": "Token TX", "acr_security": "ACR et Sécurité", diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index b694dd6dea..174d39fb7d 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -483,7 +483,7 @@ "login_uris": "URIs de login", "logo_uri": "URI do logotipo", "logout_redirect_uris": "Uris de redirecionamento de logout", - "logout_status_jwt_signed_response_alg": "Alg de Resposta Assinada JWT de Status de Logout", + "logout_status_jwt_signed_response_alg": "Algoritmo de Assinatura JWT do Status de Logout", "level": "Nível", "local_primary_key": "Chave Primária Local", "max_connections": "Conexões máximas", @@ -577,7 +577,7 @@ "spontaneous_client_scopes": "Âmbitos de cliente espontâneos", "spontaneous_client_id": "ID de cliente espontâneo", "spontaneous_scope_script_dns": "Dns de script de escopo espontâneo", - "spontaneous_scopes": "Âmbitos Espontâneos", + "spontaneous_scopes": "Escopos Espontâneos", "ssl_trust_store_file": "Arquivo SSL Trust Store", "ssl_trust_store_file_path": "sslTrustStoreFilePath", "ssl_trust_store_format": "Formato SSL Trust Store", @@ -716,7 +716,6 @@ "software_statement": "Declaração de Software", "software_version": "Versão do Software", "spontaneous_scope_scripts": "Scripts de Escopo Espontâneo", - "spontaneous_scopes": "Escopos Espontâneos", "tos_uri": "URI dos Termos de Serviço", "update_token_scripts": "Scripts de Atualização de Token", "userinfo_encrypted_response_alg": "Algoritmo de Criptografia UserInfo", @@ -743,7 +742,6 @@ "id_token_token_binding_cnf": "Confirmação de Vinculação do Token de ID", "evidence": "Evidência", "authorization_details_types": "Tipos de Detalhes de Autorização", - "logout_status_jwt_signed_response_alg": "Algoritmo de Assinatura JWT do Status de Logout", "client_name_localized": "Nome do Cliente (Localizado)", "logo_uri_localized": "URI do Logo (Localizado)", "client_uri_localized": "URI do Cliente (Localizado)", @@ -1112,8 +1110,6 @@ "client_details": "Detalhes do Cliente", "contacts": "Contatos", "edit_openid_connect_client": "Editar Cliente OpenID Connect", - "introspection": "Introspecção", - "jarm": "JARM", "logout_uris": "URIs de Logout", "openid_connect_clients": "Clientes OpenID Connect", "other_advanced": "Outras Configurações Avançadas", @@ -1121,7 +1117,6 @@ "par": "PAR", "redirect_uris": "URIs de Redirecionamento", "scopes_and_grants": "Escopos e Concessões", - "token_endpoint_auth": "Autenticação do Endpoint de Token", "token_settings": "Configurações de Tokens", "tx_token": "Token TX", "acr_security": "ACR e Segurança", diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx index 9833fe8a6d..95eb8ebc25 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx @@ -291,13 +291,19 @@ const ClientListPage: React.FC = () => { setPageNumber(0) setStartIndex(0) if (scopeInumFilter) { - navigate(location.pathname, { replace: true }) + const params = new URLSearchParams(location.search) + params.delete('scopeInum') + const newSearch = params.toString() + navigate(`${location.pathname}${newSearch ? `?${newSearch}` : ''}`, { replace: true }) } - }, [scopeInumFilter, navigate, location.pathname]) + }, [scopeInumFilter, navigate, location.pathname, location.search]) const handleClearScopeFilter = useCallback((): void => { - navigate(location.pathname, { replace: true }) - }, [navigate, location.pathname]) + const params = new URLSearchParams(location.search) + params.delete('scopeInum') + const newSearch = params.toString() + navigate(`${location.pathname}${newSearch ? `?${newSearch}` : ''}`, { replace: true }) + }, [navigate, location.pathname, location.search]) // Get scope display name for the filter indicator const scopeFilterDisplayName = useMemo(() => { diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx index 8cdf236599..b70f57fd4d 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx @@ -33,8 +33,13 @@ import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' import { adminUiFeatures } from 'Plugins/admin/helper/utils' import { useGetOauthScopes } from 'JansConfigApi' -import { GRANT_TYPES, DEFAULT_SCOPE_SEARCH_LIMIT, SECRET_GENERATION } from '../helper/constants' -import { buildAddFormPayload, buildClientPayload, transformScopesResponse } from '../helper/utils' +import { GRANT_TYPES, DEFAULT_SCOPE_SEARCH_LIMIT } from '../helper/constants' +import { + buildAddFormPayload, + buildClientPayload, + transformScopesResponse, + generateClientSecret, +} from '../helper/utils' import { uriValidation } from '../helper/validations' import type { AddFormValues, ClientFormValues, ClientScope, ModifiedFields } from '../types' @@ -105,13 +110,6 @@ const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => setShowSecret((prev) => !prev) }, []) - const generateSecret = useCallback((): string => { - const { LENGTH, CHARSET } = SECRET_GENERATION - const array = new Uint8Array(LENGTH) - crypto.getRandomValues(array) - return Array.from(array, (byte) => CHARSET[byte % CHARSET.length]).join('') - }, []) - const handleCopyToClipboard = useCallback((text: string) => { navigator.clipboard.writeText(text).catch(() => { // Clipboard API may fail in non-secure contexts or due to permissions @@ -123,6 +121,20 @@ const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => setCommitModalOpen(true) }, []) + const fieldLabelMap = useMemo( + () => ({ + clientName: t('fields.client_name'), + clientSecret: t('fields.client_secret'), + disabled: t('fields.status'), + description: t('fields.description'), + scopes: t('fields.scopes'), + grantTypes: t('fields.grant_types'), + redirectUris: t('fields.redirect_uris'), + postLogoutRedirectUris: t('fields.post_logout_redirect_uris'), + }), + [t], + ) + const handleCommitAccept = useCallback( (message: string) => { if (formValues) { @@ -131,14 +143,15 @@ const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => const modifiedFields: ModifiedFields = {} Object.entries(formValues).forEach(([key, value]) => { if (value !== undefined && value !== '' && (!Array.isArray(value) || value.length > 0)) { - modifiedFields[key] = value + const label = fieldLabelMap[key as keyof typeof fieldLabelMap] || key + modifiedFields[label] = value } }) onSubmit(payload, message, modifiedFields) } setCommitModalOpen(false) }, - [formValues, onSubmit], + [formValues, onSubmit, fieldLabelMap], ) const handleCommitClose = useCallback(() => { @@ -248,9 +261,14 @@ const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => { - const newSecret = generateSecret() - formik.setFieldValue('clientSecret', newSecret) - setShowSecret(true) + try { + const newSecret = generateClientSecret() + formik.setFieldValue('clientSecret', newSecret) + setShowSecret(true) + } catch { + // Error generating secret - crypto API unavailable + // User should ensure HTTPS context + } }} sx={{ 'border': '1px solid', diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts index 746ed6938e..d58e4b8c44 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts @@ -6,6 +6,7 @@ import type { AddFormValues, } from '../types' import { EMPTY_CLIENT } from '../types' +import { SECRET_GENERATION } from './constants' interface ScopeEntry { dn?: string @@ -159,6 +160,9 @@ export function buildClientPayload(values: ClientFormValues): ExtendedClient { } } + // Deep clone to ensure clean serializable object for API submission. + // Note: This strips undefined values and converts Date objects to ISO strings, + // which is the desired behavior for JSON API payloads. return JSON.parse(JSON.stringify(payload)) } @@ -247,6 +251,7 @@ export function formatDateForDisplay(dateString: string | undefined): string { if (!dateString) return '' try { const date = new Date(dateString) + if (isNaN(date.getTime())) return dateString return date.toLocaleString() } catch { return dateString @@ -268,6 +273,7 @@ export function formatDateForInput(dateString: string | undefined): string { if (!dateString) return '' try { const date = new Date(dateString) + if (isNaN(date.getTime())) return '' return date.toISOString().slice(0, 16) } catch { return '' @@ -299,11 +305,10 @@ export function downloadClientAsJson(client: ExtendedClient): void { const blob = new Blob([jsonData], { type: 'application/json' }) const link = document.createElement('a') link.href = URL.createObjectURL(blob) - link.download = client.displayName - ? `${client.displayName}.json` - : client.clientName - ? `${client.clientName}.json` - : 'client-data.json' + // Sanitize filename to avoid invalid characters on various platforms + const rawName = client.displayName || client.clientName || 'client-data' + const safeName = rawName.replace(/[^a-zA-Z0-9._-]+/g, '_') + link.download = `${safeName}.json` document.body.appendChild(link) link.click() document.body.removeChild(link) @@ -314,6 +319,25 @@ export function generateClientSecretPlaceholder(): string { return '••••••••••••••••' } +/** + * Generates a cryptographically secure random client secret. + * Uses the Web Crypto API (crypto.getRandomValues) which is available + * in all modern browsers and secure contexts. + * + * @throws {Error} If crypto.getRandomValues is not available (e.g., non-secure context) + * @returns {string} A random string of the configured length using the configured charset + */ +export function generateClientSecret(): string { + if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') { + throw new Error('Secure random generation not available. Ensure the page is served over HTTPS.') + } + + const { LENGTH, CHARSET } = SECRET_GENERATION + const array = new Uint8Array(LENGTH) + crypto.getRandomValues(array) + return Array.from(array, (byte) => CHARSET[byte % CHARSET.length]).join('') +} + export function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) return text return `${text.substring(0, maxLength)}...` diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts index a7a30fcc49..7f46146fd7 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts @@ -32,15 +32,15 @@ export const clientValidationSchema = Yup.object().shape({ displayName: Yup.string().nullable(), redirectUris: Yup.array() .of(Yup.string()) - .when('grantTypes', { - is: (grantTypes: string[]) => - grantTypes?.includes('authorization_code') || grantTypes?.includes('implicit'), - then: (schema) => - schema.min( - 1, - 'At least one redirect URI is required for authorization_code or implicit grants', - ), - otherwise: (schema) => schema, + .when('grantTypes', (grantTypes: string[] = [], schema) => { + const needsRedirect = + grantTypes.includes('authorization_code') || grantTypes.includes('implicit') + return needsRedirect + ? schema.min( + 1, + 'At least one redirect URI is required for authorization_code or implicit grants', + ) + : schema }), postLogoutRedirectUris: uriArraySchema.nullable(), frontChannelLogoutUri: urlSchema.nullable(), @@ -57,11 +57,11 @@ export const clientValidationSchema = Yup.object().shape({ defaultMaxAge: Yup.number().nullable().min(0, 'Must be a non-negative number'), expirationDate: Yup.string() .nullable() - .when('expirable', { - is: true, - then: (schema) => schema.required('Expiration date is required when client is set to expire'), - otherwise: (schema) => schema, - }), + .when('expirable', (expirable: boolean, schema) => + expirable + ? schema.required('Expiration date is required when client is set to expire') + : schema, + ), contacts: Yup.array().of(Yup.string().email('Must be a valid email')).nullable(), }) diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx index b93dacdc54..c74a713328 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx @@ -6,11 +6,13 @@ import getThemeColor from 'Context/theme/config' import type { SectionProps } from '../types' import { TOKEN_ENDPOINT_AUTH_METHODS } from '../helper/constants' +type SelectOption = string | { value: string; label: string } + interface AlgorithmSelectProps { label: string name: string value: string - options: string[] + options: SelectOption[] onChange: (value: string) => void disabled: boolean fieldStyle: object @@ -41,11 +43,15 @@ const AlgorithmSelect: React.FC = ({ InputLabelProps={{ shrink: true }} > - {options.map((opt) => ( - - ))} + {options.map((opt) => { + const optValue = typeof opt === 'string' ? opt : opt.value + const optLabel = typeof opt === 'string' ? opt : opt.label + return ( + + ) + })} ) @@ -148,61 +154,41 @@ const AuthenticationSection: React.FC = ({ {t('titles.token_endpoint_auth')} - + options={TOKEN_ENDPOINT_AUTH_METHODS} + onChange={(value) => handleFieldChange( 'tokenEndpointAuthMethod', t('fields.token_endpoint_auth_method'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {TOKEN_ENDPOINT_AUTH_METHODS.map((option) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> - + options={signingAlgorithms} + onChange={(value) => handleFieldChange( 'tokenEndpointAuthSigningAlg', t('fields.token_endpoint_auth_signing_alg'), - e.target.value, + value, ) } disabled={viewOnly} - sx={fieldStyle} - SelectProps={{ native: true }} - InputLabelProps={{ shrink: true }} - > - - {signingAlgorithms.map((alg) => ( - - ))} - + fieldStyle={fieldStyle} + placeholder={`${t('actions.choose')}...`} + /> diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx index 8af2504d27..cb1131bea7 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx @@ -27,7 +27,8 @@ import getThemeColor from 'Context/theme/config' import { useDispatch } from 'react-redux' import { updateToast } from 'Redux/features/toastSlice' import type { SectionProps } from '../types' -import { APPLICATION_TYPES, SUBJECT_TYPES, SECRET_GENERATION } from '../helper/constants' +import { APPLICATION_TYPES, SUBJECT_TYPES } from '../helper/constants' +import { generateClientSecret } from '../helper/utils' const BasicInfoSection: React.FC = ({ formik, @@ -78,18 +79,21 @@ const BasicInfoSection: React.FC = ({ } }, [formik.values.clientSecret, dispatch, t]) - const generateSecret = useCallback((): string => { - const { LENGTH, CHARSET } = SECRET_GENERATION - const array = new Uint8Array(LENGTH) - crypto.getRandomValues(array) - return Array.from(array, (byte) => CHARSET[byte % CHARSET.length]).join('') - }, []) - const handleGenerateSecret = useCallback(() => { - const newSecret = generateSecret() - handleFieldChange('clientSecret', t('fields.client_secret'), newSecret) - setShowSecret(true) - }, [generateSecret, handleFieldChange, t]) + try { + const newSecret = generateClientSecret() + handleFieldChange('clientSecret', t('fields.client_secret'), newSecret) + setShowSecret(true) + } catch (error) { + dispatch( + updateToast( + true, + 'error', + error instanceof Error ? error.message : t('messages.secret_generation_failed'), + ), + ) + } + }, [handleFieldChange, t, dispatch]) const fieldStyle = useMemo( () => ({ diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx index fce3f0c03f..9a14b797e3 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx @@ -64,13 +64,15 @@ const LocalizationSection: React.FC = ({ [themeColors], ) + const borderColor = themeColors?.lightBackground || '#e0e0e0' + const editorContainerStyle = useMemo( () => ({ borderRadius: '4px', - border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, + border: `1px solid ${borderColor}`, overflow: 'hidden', }), - [themeColors], + [borderColor], ) const helperTextStyle = useMemo( @@ -118,7 +120,7 @@ const LocalizationSection: React.FC = ({ = ({ viewOnly, labelStyle, editorContainerStyle, + borderColor, helperTextStyle, t, getEditorValue, diff --git a/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts b/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts index e14eb2a502..8e251adcea 100644 --- a/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts +++ b/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts @@ -131,22 +131,4 @@ export interface UmaResourceForTab { iconUri?: string } -export interface AuthState { - token?: { - access_token: string - } - config?: { - clientId: string - } - userinfo?: { - inum: string - name: string - } - location?: { - IPv4?: string - } -} - -export interface RootState { - authReducer: AuthState -} +export type { RootState } from 'Redux/sagas/types/audit' From 3f1c72cc47e827b931d79407e054673f344e8a9a Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Thu, 11 Dec 2025 18:58:53 +0100 Subject: [PATCH 09/18] fix(admin-ui): fix code review #2491 --- .../components/Clients/ClientListPage.tsx | 20 +++++--- .../components/Clients/helper/constants.ts | 1 + .../components/Clients/helper/utils.ts | 46 +------------------ .../components/Clients/helper/validations.ts | 5 +- .../components/Clients/types/formTypes.ts | 2 +- 5 files changed, 21 insertions(+), 53 deletions(-) diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx index 95eb8ebc25..da1f1cb145 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx @@ -51,7 +51,7 @@ import type { Client, ClientTableRow } from './types' import { useClientActions } from './hooks' import ClientDetailView from './components/ClientDetailView' import { formatGrantTypeLabel, truncateText } from './helper/utils' -import { CLIENT_ROUTES, DEFAULT_PAGE_SIZE } from './helper/constants' +import { CLIENT_ROUTES, DEFAULT_PAGE_SIZE, SCOPES_FETCH_LIMIT } from './helper/constants' interface DetailPanelProps { rowData: ClientTableRow @@ -183,7 +183,10 @@ const ClientListPage: React.FC = () => { }, }) - const { data: scopesData } = useGetOauthScopes({ limit: 200 }, { query: { staleTime: 60000 } }) + const { data: scopesData } = useGetOauthScopes( + { limit: SCOPES_FETCH_LIMIT }, + { query: { staleTime: 60000 } }, + ) const scopesList = useMemo(() => { return (scopesData?.entries || []) as Array<{ dn?: string; id?: string; displayName?: string }> @@ -224,8 +227,8 @@ const ClientListPage: React.FC = () => { const allClients = (clientsResponse?.entries || []) as ClientTableRow[] if (scopeInumFilter) { return allClients.filter((client) => { - const clientScopes = client.scopes || [] - return clientScopes.some((scope) => { + const scopes = client.scopes || [] + return scopes.some((scope) => { if (typeof scope === 'string') { return scope.includes(scopeInumFilter) } @@ -373,13 +376,15 @@ const ClientListPage: React.FC = () => { try { await deleteClient.mutateAsync({ inum: selectedClient.inum }) - await logClientDeletion(selectedClient, message) toggleDeleteModal() + logClientDeletion(selectedClient, message).catch((err) => { + console.error('Failed to log client deletion:', err) + }) } catch (error) { console.error('Error deleting client:', error) } }, - [selectedClient, deleteClient, logClientDeletion, toggleDeleteModal], + [selectedClient, deleteClient, toggleDeleteModal, logClientDeletion], ) const onPageChangeClick = useCallback( @@ -415,7 +420,7 @@ const ClientListPage: React.FC = () => { [handleClientDelete], ) - const DeleteIcon = () => + const DeleteIcon = useCallback(() => , []) const detailPanel = useCallback( (props: DetailPanelProps): React.ReactElement => ( @@ -588,6 +593,7 @@ const ClientListPage: React.FC = () => { handleGoToClientAddPage, handleDeleteClick, ViewIcon, + DeleteIcon, ]) const tableComponents = useMemo( diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts b/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts index ec49c4334a..afd4e4c19f 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/constants.ts @@ -134,6 +134,7 @@ export const CLIENT_ROUTES = { export const DEFAULT_PAGE_SIZE = 10 export const DEFAULT_SCOPE_SEARCH_LIMIT = 100 +export const SCOPES_FETCH_LIMIT = 200 export const TAB_LABELS = { basic: 'titles.basic_info', diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts index d58e4b8c44..6249e4e033 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/utils.ts @@ -28,56 +28,14 @@ export function transformScopesResponse(entries: ScopeEntry[] | undefined | null ) } -/** - * Transforms simplified add form values into a full ClientFormValues payload. - * This adds all required default values for a new client. - */ export function buildAddFormPayload(formValues: AddFormValues): ClientFormValues { const payload = { + ...(EMPTY_CLIENT as ExtendedClient), ...formValues, - applicationType: 'web' as const, - subjectType: 'public' as const, - tokenEndpointAuthMethod: 'client_secret_basic' as const, - responseTypes: [] as string[], redirectUris: formValues.redirectUris.filter(Boolean), postLogoutRedirectUris: formValues.postLogoutRedirectUris.filter(Boolean), - frontChannelLogoutSessionRequired: false, - backchannelUserCodeParameter: false, - trustedClient: false, - persistClientAuthorizations: false, - includeClaimsInIdToken: false, - rptAsJwt: false, - accessTokenAsJwt: false, - deletable: true, - customObjectClasses: ['top'], - contacts: [] as string[], - defaultAcrValues: [] as string[], - claims: [] as string[], - claimRedirectUris: [] as string[], - authorizedOrigins: [] as string[], - requestUris: [] as string[], - expirable: false, attributes: { - runIntrospectionScriptBeforeJwtCreation: false, - keepClientAuthorizationAfterExpiration: false, - allowSpontaneousScopes: false, - backchannelLogoutSessionRequired: false, - backchannelLogoutUri: [] as string[], - rptClaimsScripts: [] as string[], - consentGatheringScripts: [] as string[], - spontaneousScopeScriptDns: [] as string[], - introspectionScripts: [] as string[], - postAuthnScripts: [] as string[], - ropcScripts: [] as string[], - updateTokenScriptDns: [] as string[], - additionalAudience: [] as string[], - spontaneousScopes: [] as string[], - jansAuthorizedAcr: [] as string[], - requirePar: false, - dpopBoundAccessToken: false, - jansDefaultPromptLogin: false, - minimumAcrLevelAutoresolve: false, - requirePkce: false, + ...EMPTY_CLIENT.attributes, }, } return payload as ClientFormValues diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts index 7f46146fd7..74965f9332 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts @@ -31,7 +31,7 @@ export const clientValidationSchema = Yup.object().shape({ clientName: Yup.string().nullable(), displayName: Yup.string().nullable(), redirectUris: Yup.array() - .of(Yup.string()) + .of(uriValidation) .when('grantTypes', (grantTypes: string[] = [], schema) => { const needsRedirect = grantTypes.includes('authorization_code') || grantTypes.includes('implicit') @@ -108,6 +108,9 @@ export function validateRequiredArray( export function validateExpirationDate(expirationDate: string | undefined | null): string | null { if (!expirationDate) return null const expDate = new Date(expirationDate) + if (Number.isNaN(expDate.getTime())) { + return 'Expiration date must be a valid date' + } const now = new Date() if (expDate <= now) { return 'Expiration date must be in the future' diff --git a/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts b/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts index 8e251adcea..a3c0e9d05d 100644 --- a/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts +++ b/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts @@ -63,7 +63,7 @@ export interface ScopesGrantsTabProps extends TabPanelProps { } export interface AdvancedTabProps extends TabPanelProps { - scripts: Array<{ dn: string; name: string; scriptType?: string; enabled?: boolean }> + scripts: ClientScript[] umaResources?: UmaResourceForTab[] isEdit?: boolean clientInum?: string From 370667ff8b77e6d8394cc768b4d2490ac947c88e Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Thu, 11 Dec 2025 19:27:42 +0100 Subject: [PATCH 10/18] fix(admin-ui): fix code review #2491 --- .../auth-server/components/Clients/helper/validations.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts index 74965f9332..3437fdd7d8 100644 --- a/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts +++ b/admin-ui/plugins/auth-server/components/Clients/helper/validations.ts @@ -32,9 +32,9 @@ export const clientValidationSchema = Yup.object().shape({ displayName: Yup.string().nullable(), redirectUris: Yup.array() .of(uriValidation) - .when('grantTypes', (grantTypes: string[] = [], schema) => { - const needsRedirect = - grantTypes.includes('authorization_code') || grantTypes.includes('implicit') + .when('grantTypes', ([grantTypes], schema) => { + const grants = grantTypes || [] + const needsRedirect = grants.includes('authorization_code') || grants.includes('implicit') return needsRedirect ? schema.min( 1, @@ -57,7 +57,7 @@ export const clientValidationSchema = Yup.object().shape({ defaultMaxAge: Yup.number().nullable().min(0, 'Must be a non-negative number'), expirationDate: Yup.string() .nullable() - .when('expirable', (expirable: boolean, schema) => + .when('expirable', ([expirable], schema) => expirable ? schema.required('Expiration date is required when client is set to expire') : schema, From 2b297abbe86ce89f365fed04f9f1097b20bd27e5 Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Mon, 15 Dec 2025 14:35:46 +0100 Subject: [PATCH 11/18] fix(admin-ui): apply code formatting --- admin-ui/app/context/theme/config.ts | 2 +- .../components/Clients/ClientForm.test.tsx | 15 +- .../components/Clients/ClientDetailPage.tsx | 16 +- .../components/Clients/ClientEditPage.tsx | 32 -- .../components/Clients/ClientListPage.tsx | 37 +-- .../Clients/components/ClientAddForm.tsx | 54 +--- .../Clients/components/ClientForm.tsx | 275 ++++-------------- .../components/Clients/helper/validations.ts | 15 + .../components/Clients/hooks/index.ts | 3 + .../Clients/hooks/useClientScopes.ts | 40 +++ .../components/Clients/hooks/useDebounce.ts | 17 ++ .../components/Clients/hooks/usePageSize.ts | 22 ++ .../sections/AuthenticationSection.tsx | 19 +- .../Clients/sections/BasicInfoSection.tsx | 32 +- .../Clients/sections/CibaSection.tsx | 19 +- .../Clients/sections/LocalizationSection.tsx | 6 +- .../Clients/sections/ScopesGrantsSection.tsx | 19 +- .../Clients/sections/ScriptsSection.tsx | 19 +- .../Clients/sections/SystemInfoSection.tsx | 19 +- .../Clients/sections/TokensSection.tsx | 19 +- .../Clients/sections/UrisSection.tsx | 19 +- .../components/Clients/types/formTypes.ts | 3 - 22 files changed, 329 insertions(+), 373 deletions(-) create mode 100644 admin-ui/plugins/auth-server/components/Clients/hooks/useClientScopes.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/hooks/useDebounce.ts create mode 100644 admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts diff --git a/admin-ui/app/context/theme/config.ts b/admin-ui/app/context/theme/config.ts index fd92421795..a0e476f6fe 100644 --- a/admin-ui/app/context/theme/config.ts +++ b/admin-ui/app/context/theme/config.ts @@ -4,7 +4,7 @@ export const themeConfig = { darkBlack: { background: customColors.darkGray, lightBackground: customColors.darkBlueLightBackground, - fontColor: customColors.darkGray, + fontColor: customColors.white, borderColor: customColors.lightGray, menu: { background: customColors.darkBlueMenuBackground, diff --git a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx index 5d7045dde2..ec9dec259b 100644 --- a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx +++ b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx @@ -51,6 +51,14 @@ jest.mock('JansConfigApi', () => ({ })), })) +jest.mock('Plugins/auth-server/components/Clients/hooks', () => ({ + useClientScopes: jest.fn(() => ({ + scopes: [], + scopesLoading: false, + handleScopeSearch: jest.fn(), + })), +})) + const permissions = [ 'https://jans.io/oauth/config/openid/clients.readonly', 'https://jans.io/oauth/config/openid/clients.write', @@ -107,12 +115,7 @@ describe('ClientForm', () => { it('renders form with client name', () => { render( - + , ) diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx index 0c2a659036..9f3a9ea161 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientDetailPage.tsx @@ -3,10 +3,8 @@ import { useParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' import SetTitle from 'Utils/SetTitle' -import { useGetOauthScopes } from 'JansConfigApi' import { useClientActions, useClientById } from './hooks' import ClientForm from './components/ClientForm' -import { transformScopesResponse } from './helper/utils' const ClientDetailPage: React.FC = () => { const { t } = useTranslation() @@ -15,17 +13,7 @@ const ClientDetailPage: React.FC = () => { const { data: client, isLoading: clientLoading } = useClientById(id || '', Boolean(id)) - const { data: scopesResponse, isLoading: scopesLoading } = useGetOauthScopes( - { limit: 200 }, - { query: { staleTime: 60000 } }, - ) - - const scopes = useMemo( - () => transformScopesResponse(scopesResponse?.entries), - [scopesResponse?.entries], - ) - - const isLoading = clientLoading || scopesLoading + const isLoading = clientLoading SetTitle(t('titles.client_detail')) @@ -41,8 +29,6 @@ const ClientDetailPage: React.FC = () => { isEdit={true} viewOnly={true} onCancel={navigateToClientList} - scopes={scopes} - scopesLoading={scopesLoading} onSubmit={() => {}} /> )} diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx index 766ee9e52b..ac2035b161 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientEditPage.tsx @@ -5,46 +5,17 @@ import { useDispatch } from 'react-redux' import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' import SetTitle from 'Utils/SetTitle' import { updateToast } from 'Redux/features/toastSlice' -import { useGetOauthScopes } from 'JansConfigApi' import { useClientActions, useClientById, useUpdateClient } from './hooks' import ClientForm from './components/ClientForm' import type { ClientFormValues, ModifiedFields } from './types' -import { transformScopesResponse } from './helper/utils' const ClientEditPage: React.FC = () => { const { t } = useTranslation() const { id } = useParams<{ id: string }>() const dispatch = useDispatch() const { logClientUpdate, navigateToClientList } = useClientActions() - const [scopeSearchPattern, setScopeSearchPattern] = useState('') - const { data: client, isLoading: clientLoading } = useClientById(id || '', Boolean(id)) - const scopeQueryParams = useMemo( - () => ({ - limit: 200, - pattern: scopeSearchPattern || undefined, - }), - [scopeSearchPattern], - ) - - const { data: scopesResponse, isLoading: scopesLoading } = useGetOauthScopes(scopeQueryParams, { - query: { - refetchOnMount: 'always' as const, - refetchOnWindowFocus: false, - staleTime: 30000, - }, - }) - - const scopes = useMemo( - () => transformScopesResponse(scopesResponse?.entries), - [scopesResponse?.entries], - ) - - const handleScopeSearch = useCallback((pattern: string) => { - setScopeSearchPattern(pattern) - }, []) - const handleSuccess = useCallback(() => { navigateToClientList() }, [navigateToClientList]) @@ -87,9 +58,6 @@ const ClientEditPage: React.FC = () => { viewOnly={false} onSubmit={handleSubmit} onCancel={navigateToClientList} - scopes={scopes} - scopesLoading={scopesLoading} - onScopeSearch={handleScopeSearch} /> )} diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx index da1f1cb145..33e073b7f4 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx @@ -48,7 +48,7 @@ import { useGetOauthScopes, } from 'JansConfigApi' import type { Client, ClientTableRow } from './types' -import { useClientActions } from './hooks' +import { useClientActions, usePageSize, useDebounce } from './hooks' import ClientDetailView from './components/ClientDetailView' import { formatGrantTypeLabel, truncateText } from './helper/utils' import { CLIENT_ROUTES, DEFAULT_PAGE_SIZE, SCOPES_FETCH_LIMIT } from './helper/constants' @@ -105,14 +105,7 @@ const ClientListPage: React.FC = () => { const { logClientDeletion, navigateToClientAdd, navigateToClientEdit, navigateToClientView } = useClientActions() - const getInitialPageSize = (): number => { - const stored = localStorage.getItem('paggingSize') - if (!stored) return DEFAULT_PAGE_SIZE - const parsed = parseInt(stored, 10) - return Number.isNaN(parsed) ? DEFAULT_PAGE_SIZE : parsed - } - - const [limit, setLimit] = useState(getInitialPageSize()) + const [limit, setLimit] = usePageSize() const [pageNumber, setPageNumber] = useState(0) const [searchInput, setSearchInput] = useState('') const [pattern, setPattern] = useState(null) @@ -121,7 +114,7 @@ const ClientListPage: React.FC = () => { const [sortOrder, setSortOrder] = useState<'ascending' | 'descending'>('ascending') const [selectedClient, setSelectedClient] = useState(null) const [deleteModalOpen, setDeleteModalOpen] = useState(false) - const debounceTimerRef = useRef(null) + const debouncedPattern = useDebounce(searchInput, 500) const clientsQueryParams = useMemo( () => ({ @@ -274,10 +267,6 @@ const ClientListPage: React.FC = () => { const handlePatternKeyDown = useCallback( (event: React.KeyboardEvent): void => { if (event.key === 'Enter') { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - debounceTimerRef.current = null - } setPattern(searchInput || null) setPageNumber(0) setStartIndex(0) @@ -611,22 +600,12 @@ const ClientListPage: React.FC = () => { }, [authorizeHelper, clientScopes]) useEffect(() => { - debounceTimerRef.current = setTimeout(() => { - setPattern(searchInput || null) - if (searchInput !== (pattern || '')) { - setPageNumber(0) - setStartIndex(0) - } - debounceTimerRef.current = null - }, 500) - - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - debounceTimerRef.current = null - } + setPattern(debouncedPattern || null) + if (searchInput !== (pattern || '')) { + setPageNumber(0) + setStartIndex(0) } - }, [searchInput, pattern]) + }, [debouncedPattern]) return ( diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx index b70f57fd4d..a36a655eac 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useContext, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Formik, Form, Field, FieldArray } from 'formik' -import * as Yup from 'yup' import { Box, Grid, @@ -33,14 +32,10 @@ import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' import { adminUiFeatures } from 'Plugins/admin/helper/utils' import { useGetOauthScopes } from 'JansConfigApi' -import { GRANT_TYPES, DEFAULT_SCOPE_SEARCH_LIMIT } from '../helper/constants' -import { - buildAddFormPayload, - buildClientPayload, - transformScopesResponse, - generateClientSecret, -} from '../helper/utils' -import { uriValidation } from '../helper/validations' +import { GRANT_TYPES } from '../helper/constants' +import { useClientScopes } from '../hooks' +import { buildAddFormPayload, buildClientPayload, generateClientSecret } from '../helper/utils' +import { clientAddValidationSchema } from '../helper/validations' import type { AddFormValues, ClientFormValues, ClientScope, ModifiedFields } from '../types' interface ClientAddFormProps { @@ -59,21 +54,6 @@ const initialValues: AddFormValues = { postLogoutRedirectUris: [''], } -const validationSchema = Yup.object().shape({ - clientName: Yup.string().required('Client name is required'), - clientSecret: Yup.string().required('Client secret is required'), - description: Yup.string(), - scopes: Yup.array().of(Yup.string()), - grantTypes: Yup.array().of(Yup.string()), - redirectUris: Yup.array() - .of(uriValidation) - .test('has-valid-uri', 'At least one valid redirect URI is required', (value) => { - if (!value || value.length === 0) return false - return value.some((uri) => uri && uri.trim() !== '') - }), - postLogoutRedirectUris: Yup.array().of(uriValidation), -}) - const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => { const { t } = useTranslation() const theme = useContext(ThemeContext) @@ -83,28 +63,8 @@ const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => const [showSecret, setShowSecret] = useState(false) const [commitModalOpen, setCommitModalOpen] = useState(false) const [formValues, setFormValues] = useState(null) - const [scopeSearchPattern, setScopeSearchPattern] = useState('') - - const scopeQueryParams = useMemo( - () => ({ - limit: DEFAULT_SCOPE_SEARCH_LIMIT, - pattern: scopeSearchPattern || undefined, - }), - [scopeSearchPattern], - ) - const { data: scopesResponse, isLoading: scopesLoading } = useGetOauthScopes(scopeQueryParams, { - query: { - refetchOnMount: 'always' as const, - refetchOnWindowFocus: false, - staleTime: 30000, - }, - }) - - const scopes = useMemo( - () => transformScopesResponse(scopesResponse?.entries), - [scopesResponse?.entries], - ) + const { scopes, scopesLoading, handleScopeSearch } = useClientScopes() const handleToggleSecret = useCallback(() => { setShowSecret((prev) => !prev) @@ -207,7 +167,7 @@ const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => {(formik) => ( @@ -345,7 +305,7 @@ const ClientAddForm: React.FC = ({ onSubmit, onCancel }) => newValue.map((v) => (typeof v === 'string' ? v : v.dn)), ) }} - onInputChange={(_, value) => setScopeSearchPattern(value)} + onInputChange={(_, value) => handleScopeSearch(value)} loading={scopesLoading} renderInput={(params) => ( = { @@ -88,22 +69,13 @@ const ClientForm: React.FC = ({ viewOnly = false, onSubmit, onCancel, - scopes: propScopes = [], - scopesLoading: propScopesLoading = false, - onScopeSearch: propOnScopeSearch, }) => { const { t } = useTranslation() - const muiTheme = useTheme() - const isMobile = useMediaQuery(muiTheme.breakpoints.down('md')) const theme = useContext(ThemeContext) const selectedTheme = theme?.state?.theme || 'darkBlue' const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) const [activeSection, setActiveSection] = useState('basic') - const [navCollapsed, setNavCollapsed] = useState(() => { - const stored = localStorage.getItem(NAV_COLLAPSED_KEY) - return stored === 'true' - }) const [modifiedFields, setModifiedFields] = useState({}) const [commitModalOpen, setCommitModalOpen] = useState(false) const [formValues, setFormValues] = useState(null) @@ -116,16 +88,9 @@ const ClientForm: React.FC = ({ }, }) - const oidcConfiguration = useMemo(() => propertiesData || {}, [propertiesData]) + const oidcConfiguration = useMemo(() => (propertiesData || {}) as any, [propertiesData]) - const handleScopeSearch = useCallback( - (pattern: string) => { - if (propOnScopeSearch) { - propOnScopeSearch(pattern) - } - }, - [propOnScopeSearch], - ) + const { scopes, scopesLoading, handleScopeSearch } = useClientScopes() const { data: scriptsResponse } = useGetConfigScripts( { limit: SCRIPTS_FETCH_LIMIT }, @@ -172,14 +137,6 @@ const ClientForm: React.FC = ({ return SECTIONS.filter((s) => s.id !== 'activeTokens') }, [isEdit]) - const handleNavCollapse = useCallback(() => { - setNavCollapsed((prev) => { - const newValue = !prev - localStorage.setItem(NAV_COLLAPSED_KEY, String(newValue)) - return newValue - }) - }, []) - const handleSectionChange = useCallback((section: ClientSection) => { setActiveSection(section) }, []) @@ -208,51 +165,31 @@ const ClientForm: React.FC = ({ downloadClientAsJson(values) }, []) - const buttonStyle = useMemo( - () => ({ - ...applicationStyle.buttonStyle, - mr: 1, - }), - [], - ) - const operations = useMemo(() => { return Object.keys(modifiedFields).map((key) => ({ path: key, - value: modifiedFields[key], + value: modifiedFields[key] as any, })) }, [modifiedFields]) - const navPanelStyle = useMemo( - () => ({ - width: navCollapsed ? 64 : 240, - minWidth: navCollapsed ? 64 : 240, - transition: 'width 0.2s ease, min-width 0.2s ease', - borderRight: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, - backgroundColor: themeColors?.lightBackground || '#fafafa', - display: 'flex', - flexDirection: 'column' as const, - }), - [navCollapsed, themeColors], - ) - - const listItemStyle = useMemo( + const tabsSx = useMemo( () => ({ - 'py': 1.5, - 'px': navCollapsed ? 2 : 2, - 'justifyContent': navCollapsed ? 'center' : 'flex-start', - '&.Mui-selected': { - backgroundColor: `${themeColors?.background}15`, - borderRight: `3px solid ${themeColors?.background}`, + '& .MuiTab-root.Mui-selected': { + color: themeColors?.background, + fontWeight: 600, + background: themeColors?.background, + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + backgroundClip: 'text', }, - '&.Mui-selected:hover': { - backgroundColor: `${themeColors?.background}20`, - }, - '&:hover': { - backgroundColor: `${themeColors?.background}10`, + '& .MuiTabs-indicator': { + background: themeColors?.background, + height: 3, + borderRadius: '2px', + boxShadow: `0 2px 4px ${themeColors?.background}`, }, }), - [navCollapsed, themeColors], + [themeColors], ) const renderSectionContent = useCallback( @@ -263,8 +200,8 @@ const ClientForm: React.FC = ({ setModifiedFields, scripts, scriptsTruncated, - scopes: propScopes, - scopesLoading: propScopesLoading, + scopes, + scopesLoading, onScopeSearch: handleScopeSearch, oidcConfiguration, } @@ -299,114 +236,50 @@ const ClientForm: React.FC = ({ viewOnly, scripts, scriptsTruncated, - propScopes, - propScopesLoading, + scopes, + scopesLoading, handleScopeSearch, oidcConfiguration, ], ) - const renderNavigation = useCallback(() => { - if (isMobile) { - return ( - - {t('fields.section')} - - - ) - } + const activeTabIndex = useMemo( + () => visibleSections.findIndex((s) => s.id === activeSection), + [visibleSections, activeSection], + ) + const handleTabChange = useCallback( + (_: React.SyntheticEvent, newValue: number) => { + const section = visibleSections[newValue] + if (section) { + handleSectionChange(section.id as ClientSection) + } + }, + [visibleSections, handleSectionChange], + ) + + const renderNavigation = useCallback(() => { return ( - - + + {visibleSections.map((section) => ( - - handleSectionChange(section.id as ClientSection)} - sx={listItemStyle} - > - - {SECTION_ICONS[section.icon]} - - {!navCollapsed && ( - - )} - - + icon={SECTION_ICONS[section.icon] as React.ReactElement} + label={t(section.labelKey)} + iconPosition="start" + /> ))} - - - - {navCollapsed ? : } - - - + + ) - }, [ - isMobile, - activeSection, - navCollapsed, - themeColors, - t, - handleSectionChange, - handleNavCollapse, - navPanelStyle, - listItemStyle, - visibleSections, - ]) + }, [activeTabIndex, handleTabChange, tabsSx, visibleSections, t]) return ( @@ -458,38 +331,12 @@ const ClientForm: React.FC = ({ )} - {isMobile ? ( - - {renderNavigation()} - - - {renderSectionContent(formik)} - - - - ) : ( - - {renderNavigation()} - - - {renderSectionContent(formik)} - - - - )} + {renderNavigation()} + + + {renderSectionContent(formik)} + + 10) return 'ACR level must be between -1 and 10' return null } + +export const clientAddValidationSchema = Yup.object().shape({ + clientName: Yup.string().required('Client name is required'), + clientSecret: Yup.string().required('Client secret is required'), + description: Yup.string(), + scopes: Yup.array().of(Yup.string()), + grantTypes: Yup.array().of(Yup.string()), + redirectUris: Yup.array() + .of(uriValidation) + .test('has-valid-uri', 'At least one valid redirect URI is required', (value) => { + if (!value || value.length === 0) return false + return value.some((uri) => uri && uri.trim() !== '') + }), + postLogoutRedirectUris: Yup.array().of(uriValidation), +}) diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/index.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/index.ts index 4cc9463846..dfe66d4a94 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/index.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/index.ts @@ -7,3 +7,6 @@ export { useDeleteClient, useInvalidateClientQueries, } from './useClientApi' +export { useClientScopes } from './useClientScopes' +export { usePageSize } from './usePageSize' +export { useDebounce } from './useDebounce' diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientScopes.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientScopes.ts new file mode 100644 index 0000000000..f4aeac1fc7 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientScopes.ts @@ -0,0 +1,40 @@ +import { useMemo, useState, useCallback } from 'react' +import { useGetOauthScopes } from 'JansConfigApi' +import { transformScopesResponse } from '../helper/utils' +import { DEFAULT_SCOPE_SEARCH_LIMIT } from '../helper/constants' + +export const useClientScopes = (initialLimit = DEFAULT_SCOPE_SEARCH_LIMIT) => { + const [scopeSearchPattern, setScopeSearchPattern] = useState('') + + const scopeQueryParams = useMemo( + () => ({ + limit: initialLimit, + pattern: scopeSearchPattern || undefined, + }), + [initialLimit, scopeSearchPattern], + ) + + const { data: scopesResponse, isLoading: scopesLoading } = useGetOauthScopes(scopeQueryParams, { + query: { + refetchOnMount: 'always' as const, + refetchOnWindowFocus: false, + staleTime: 30000, + }, + }) + + const scopes = useMemo( + () => transformScopesResponse(scopesResponse?.entries), + [scopesResponse?.entries], + ) + + const handleScopeSearch = useCallback((pattern: string) => { + setScopeSearchPattern(pattern) + }, []) + + return { + scopes, + scopesLoading, + handleScopeSearch, + scopeSearchPattern, + } +} diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useDebounce.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useDebounce.ts new file mode 100644 index 0000000000..d048cc001f --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react' + +export const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts new file mode 100644 index 0000000000..ed38b11d13 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts @@ -0,0 +1,22 @@ +import { useState, useCallback } from 'react' +import { DEFAULT_PAGE_SIZE } from '../helper/constants' + +const STORAGE_KEY = 'paggingSize' + +export const usePageSize = (defaultSize = DEFAULT_PAGE_SIZE) => { + const getInitialPageSize = (): number => { + const stored = localStorage.getItem(STORAGE_KEY) + if (!stored) return defaultSize + const parsed = parseInt(stored, 10) + return Number.isNaN(parsed) ? defaultSize : parsed + } + + const [pageSize, setPageSizeState] = useState(getInitialPageSize()) + + const setPageSize = useCallback((size: number) => { + setPageSizeState(size) + localStorage.setItem(STORAGE_KEY, String(size)) + }, []) + + return [pageSize, setPageSize] as const +} diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx index c74a713328..80885f0f9e 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/AuthenticationSection.tsx @@ -96,11 +96,22 @@ const AuthenticationSection: React.FC = ({ borderColor: themeColors?.background, }, }, + ...(selectedTheme === 'darkBlack' && { + '& .MuiInputBase-input': { + color: `${themeColors?.backgroundBlack} !important`, + }, + '& .MuiInputBase-input.Mui-disabled': { + WebkitTextFillColor: `${themeColors?.backgroundBlack} !important`, + }, + '& label': { + color: `${themeColors?.backgroundBlack} !important`, + }, + }), '& .MuiInputLabel-root.Mui-focused': { color: themeColors?.background, }, }), - [viewOnly, themeColors], + [viewOnly, themeColors, selectedTheme], ) const sectionStyle = useMemo( @@ -109,7 +120,6 @@ const AuthenticationSection: React.FC = ({ p: 2, borderRadius: 1, border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, - backgroundColor: themeColors?.lightBackground || '#fafafa', }), [themeColors], ) @@ -118,7 +128,10 @@ const AuthenticationSection: React.FC = ({ () => ({ mb: 2, fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, - color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + color: + selectedTheme === 'darkBlack' + ? `${themeColors?.backgroundBlack} !important` + : themeColors?.fontColor || '#333', fontSize: '0.95rem', }), [themeColors, selectedTheme], diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx index cb1131bea7..b3c58c4852 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/BasicInfoSection.tsx @@ -103,11 +103,22 @@ const BasicInfoSection: React.FC = ({ borderColor: themeColors?.background, }, }, + ...(selectedTheme === 'darkBlack' && { + '& .MuiInputBase-input': { + color: `${themeColors?.backgroundBlack} !important`, + }, + '& .MuiInputBase-input.Mui-disabled': { + WebkitTextFillColor: `${themeColors?.backgroundBlack} !important`, + }, + '& label': { + color: `${themeColors?.backgroundBlack} !important`, + }, + }), '& .MuiInputLabel-root.Mui-focused': { color: themeColors?.background, }, }), - [viewOnly, themeColors], + [viewOnly, themeColors, selectedTheme], ) const sectionStyle = useMemo( @@ -116,7 +127,6 @@ const BasicInfoSection: React.FC = ({ p: 2, borderRadius: 1, border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, - backgroundColor: themeColors?.lightBackground || '#fafafa', }), [themeColors], ) @@ -145,7 +155,23 @@ const BasicInfoSection: React.FC = ({ name="inum" value={formik.values.inum || ''} disabled - sx={fieldStyle} + sx={{ + ...fieldStyle, + '& .MuiInputBase-input': { + fontWeight: 700, + fontSize: '1rem', + ...(selectedTheme === 'darkBlack' && { + color: `${themeColors?.backgroundBlack} !important`, + }), + }, + '& .MuiInputBase-input.Mui-disabled': { + fontWeight: 700, + fontSize: '1rem', + ...(selectedTheme === 'darkBlack' && { + WebkitTextFillColor: `${themeColors?.backgroundBlack} !important`, + }), + }, + }} InputProps={{ endAdornment: formik.values.inum && ( diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/CibaSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/CibaSection.tsx index 357216b31c..e42a0131b6 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/CibaSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/CibaSection.tsx @@ -36,11 +36,22 @@ const CibaSection: React.FC = ({ borderColor: themeColors?.background, }, }, + ...(selectedTheme === 'darkBlack' && { + '& .MuiInputBase-input': { + color: `${themeColors?.backgroundBlack} !important`, + }, + '& .MuiInputBase-input.Mui-disabled': { + WebkitTextFillColor: `${themeColors?.backgroundBlack} !important`, + }, + '& label': { + color: `${themeColors?.backgroundBlack} !important`, + }, + }), '& .MuiInputLabel-root.Mui-focused': { color: themeColors?.background, }, }), - [viewOnly, themeColors], + [viewOnly, themeColors, selectedTheme], ) const sectionStyle = useMemo( @@ -49,7 +60,6 @@ const CibaSection: React.FC = ({ p: 2, borderRadius: 1, border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, - backgroundColor: themeColors?.lightBackground || '#fafafa', }), [themeColors], ) @@ -58,7 +68,10 @@ const CibaSection: React.FC = ({ () => ({ mb: 2, fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, - color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + color: + selectedTheme === 'darkBlack' + ? `${themeColors?.backgroundBlack} !important` + : themeColors?.fontColor || '#333', fontSize: '0.95rem', }), [themeColors, selectedTheme], diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx index 9a14b797e3..95e7a0b133 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx @@ -39,7 +39,6 @@ const LocalizationSection: React.FC = ({ p: 2, borderRadius: 1, border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, - backgroundColor: themeColors?.lightBackground || '#fafafa', }), [themeColors], ) @@ -48,7 +47,10 @@ const LocalizationSection: React.FC = ({ () => ({ mb: 2, fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, - color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + color: + selectedTheme === 'darkBlack' + ? `${themeColors?.backgroundBlack} !important` + : themeColors?.fontColor || '#333', fontSize: '0.95rem', }), [themeColors, selectedTheme], diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/ScopesGrantsSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/ScopesGrantsSection.tsx index e6423e0e1a..b08e86fd7c 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/ScopesGrantsSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/ScopesGrantsSection.tsx @@ -109,7 +109,6 @@ const ScopesGrantsSection: React.FC = ({ p: 2, borderRadius: 1, border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, - backgroundColor: themeColors?.lightBackground || '#fafafa', }), [themeColors], ) @@ -118,7 +117,10 @@ const ScopesGrantsSection: React.FC = ({ () => ({ mb: 2, fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, - color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + color: + selectedTheme === 'darkBlack' + ? `${themeColors?.backgroundBlack} !important` + : themeColors?.fontColor || '#333', fontSize: '0.95rem', }), [themeColors, selectedTheme], @@ -144,11 +146,22 @@ const ScopesGrantsSection: React.FC = ({ borderColor: themeColors?.background, }, }, + ...(selectedTheme === 'darkBlack' && { + '& .MuiInputBase-input': { + color: `${themeColors?.backgroundBlack} !important`, + }, + '& .MuiInputBase-input.Mui-disabled': { + WebkitTextFillColor: `${themeColors?.backgroundBlack} !important`, + }, + '& label': { + color: `${themeColors?.backgroundBlack} !important`, + }, + }), '& .MuiInputLabel-root.Mui-focused': { color: themeColors?.background, }, }), - [themeColors], + [themeColors, selectedTheme], ) const switchStyle = useMemo( diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/ScriptsSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/ScriptsSection.tsx index c867aa5b84..d8e2b258f6 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/ScriptsSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/ScriptsSection.tsx @@ -48,11 +48,22 @@ const ScriptsSection: React.FC = ({ borderColor: themeColors?.background, }, }, + ...(selectedTheme === 'darkBlack' && { + '& .MuiInputBase-input': { + color: `${themeColors?.backgroundBlack} !important`, + }, + '& .MuiInputBase-input.Mui-disabled': { + WebkitTextFillColor: `${themeColors?.backgroundBlack} !important`, + }, + '& label': { + color: `${themeColors?.backgroundBlack} !important`, + }, + }), '& .MuiInputLabel-root.Mui-focused': { color: themeColors?.background, }, }), - [viewOnly, themeColors], + [viewOnly, themeColors, selectedTheme], ) const sectionStyle = useMemo( @@ -61,7 +72,6 @@ const ScriptsSection: React.FC = ({ p: 2, borderRadius: 1, border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, - backgroundColor: themeColors?.lightBackground || '#fafafa', }), [themeColors], ) @@ -70,7 +80,10 @@ const ScriptsSection: React.FC = ({ () => ({ mb: 2, fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, - color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + color: + selectedTheme === 'darkBlack' + ? `${themeColors?.backgroundBlack} !important` + : themeColors?.fontColor || '#333', fontSize: '0.95rem', }), [themeColors, selectedTheme], diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/SystemInfoSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/SystemInfoSection.tsx index bf5bc2447a..82de3122c5 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/SystemInfoSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/SystemInfoSection.tsx @@ -41,8 +41,19 @@ const SystemInfoSection: React.FC = ({ '& .MuiOutlinedInput-root': { backgroundColor: themeColors?.lightBackground || '#f5f5f5', }, + ...(selectedTheme === 'darkBlack' && { + '& .MuiInputBase-input': { + color: `${themeColors?.backgroundBlack} !important`, + }, + '& .MuiInputBase-input.Mui-disabled': { + WebkitTextFillColor: `${themeColors?.backgroundBlack} !important`, + }, + '& label': { + color: `${themeColors?.backgroundBlack} !important`, + }, + }), }), - [themeColors], + [themeColors, selectedTheme], ) const sectionStyle = useMemo( @@ -51,7 +62,6 @@ const SystemInfoSection: React.FC = ({ p: 2, borderRadius: 1, border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, - backgroundColor: themeColors?.lightBackground || '#fafafa', }), [themeColors], ) @@ -60,7 +70,10 @@ const SystemInfoSection: React.FC = ({ () => ({ mb: 2, fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, - color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + color: + selectedTheme === 'darkBlack' + ? `${themeColors?.backgroundBlack} !important` + : themeColors?.fontColor || '#333', fontSize: '0.95rem', }), [themeColors, selectedTheme], diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx index d8ba196d4b..b260e7893b 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx @@ -51,11 +51,22 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo borderColor: themeColors?.background, }, }, + ...(selectedTheme === 'darkBlack' && { + '& .MuiInputBase-input': { + color: `${themeColors?.backgroundBlack} !important`, + }, + '& .MuiInputBase-input.Mui-disabled': { + WebkitTextFillColor: `${themeColors?.backgroundBlack} !important`, + }, + '& label': { + color: `${themeColors?.backgroundBlack} !important`, + }, + }), '& .MuiInputLabel-root.Mui-focused': { color: themeColors?.background, }, }), - [viewOnly, themeColors], + [viewOnly, themeColors, selectedTheme], ) const sectionStyle = useMemo( @@ -64,7 +75,6 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo p: 2, borderRadius: 1, border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, - backgroundColor: themeColors?.lightBackground || '#fafafa', }), [themeColors], ) @@ -73,7 +83,10 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo () => ({ mb: 2, fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, - color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + color: + selectedTheme === 'darkBlack' + ? `${themeColors?.backgroundBlack} !important` + : themeColors?.fontColor || '#333', fontSize: '0.95rem', }), [themeColors, selectedTheme], diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/UrisSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/UrisSection.tsx index 7257c92e32..7674570059 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/UrisSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/UrisSection.tsx @@ -50,11 +50,22 @@ const UrisSection: React.FC = ({ formik, viewOnly = false, setModi borderColor: themeColors?.background, }, }, + ...(selectedTheme === 'darkBlack' && { + '& .MuiInputBase-input': { + color: `${themeColors?.backgroundBlack} !important`, + }, + '& .MuiInputBase-input.Mui-disabled': { + WebkitTextFillColor: `${themeColors?.backgroundBlack} !important`, + }, + '& label': { + color: `${themeColors?.backgroundBlack} !important`, + }, + }), '& .MuiInputLabel-root.Mui-focused': { color: themeColors?.background, }, }), - [viewOnly, themeColors], + [viewOnly, themeColors, selectedTheme], ) const sectionStyle = useMemo( @@ -63,7 +74,6 @@ const UrisSection: React.FC = ({ formik, viewOnly = false, setModi p: 2, borderRadius: 1, border: `1px solid ${themeColors?.lightBackground || '#e0e0e0'}`, - backgroundColor: themeColors?.lightBackground || '#fafafa', }), [themeColors], ) @@ -72,7 +82,10 @@ const UrisSection: React.FC = ({ formik, viewOnly = false, setModi () => ({ mb: 2, fontWeight: selectedTheme === 'darkBlack' ? 700 : 600, - color: selectedTheme === 'darkBlack' ? '#000000' : themeColors?.fontColor || '#333', + color: + selectedTheme === 'darkBlack' + ? `${themeColors?.backgroundBlack} !important` + : themeColors?.fontColor || '#333', fontSize: '0.95rem', }), [themeColors, selectedTheme], diff --git a/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts b/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts index a3c0e9d05d..c8133e5501 100644 --- a/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts +++ b/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts @@ -36,9 +36,6 @@ export interface ClientFormProps { viewOnly?: boolean onSubmit: (values: ClientFormValues, message: string, modifiedFields: ModifiedFields) => void onCancel?: () => void - scopes?: ClientScope[] - scopesLoading?: boolean - onScopeSearch?: (pattern: string) => void } export interface TabPanelProps { From ee474aa6186a21d842aa540a1f44938a21726a60 Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Mon, 15 Dec 2025 15:16:52 +0100 Subject: [PATCH 12/18] fix(admin-ui): fix code review #2491 --- admin-ui/app/helpers/navigation.ts | 1 + admin-ui/plugins/auth-server/plugin-metadata.js | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/admin-ui/app/helpers/navigation.ts b/admin-ui/app/helpers/navigation.ts index 6d31b9714b..d9124c3f89 100644 --- a/admin-ui/app/helpers/navigation.ts +++ b/admin-ui/app/helpers/navigation.ts @@ -80,6 +80,7 @@ const ROUTES = { AUTH_SERVER_CLIENT_EDIT: (inum: string) => `${PLUGIN_BASE_PATHS.AUTH_SERVER}/client/edit/${encodeURIComponent(inum)}`, AUTH_SERVER_CLIENT_EDIT_TEMPLATE: `${PLUGIN_BASE_PATHS.AUTH_SERVER}/client/edit/:id`, + AUTH_SERVER_CLIENT_VIEW_TEMPLATE: `${PLUGIN_BASE_PATHS.AUTH_SERVER}/client/view/:id`, // Scopes AUTH_SERVER_SCOPES_LIST: `${PLUGIN_BASE_PATHS.AUTH_SERVER}/scopes`, diff --git a/admin-ui/plugins/auth-server/plugin-metadata.js b/admin-ui/plugins/auth-server/plugin-metadata.js index 9e2245339f..6c28715656 100644 --- a/admin-ui/plugins/auth-server/plugin-metadata.js +++ b/admin-ui/plugins/auth-server/plugin-metadata.js @@ -137,7 +137,7 @@ const pluginMetadata = { }, // { // title: 'menus.lock ', - // path: PLUGIN_BASE_APTH + '/lock', + // path: ROUTES.AUTH_SERVER_LOCK, // permission: MESSAGE_READ, // }, ], @@ -170,7 +170,7 @@ const pluginMetadata = { }, { component: ClientDetailPage, - path: PLUGIN_BASE_APTH + '/client/view/:id', + path: ROUTES.AUTH_SERVER_CLIENT_VIEW_TEMPLATE, permission: CLIENT_READ, resourceKey: ADMIN_UI_RESOURCES.Clients, }, From 77bfb39497144086e0147d711429a00cdd0144f2 Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Mon, 15 Dec 2025 16:03:20 +0100 Subject: [PATCH 13/18] fix(admin-ui): fix code review #2491 --- admin-ui/app/locales/en/translation.json | 2 +- admin-ui/app/locales/es/translation.json | 2 +- admin-ui/app/locales/fr/translation.json | 2 +- admin-ui/app/locales/pt/translation.json | 2 +- .../app/routes/Apps/Gluu/GluuCommitDialog.tsx | 2 +- .../components/Clients/ClientForm.test.tsx | 52 ++++++++++++++++++- .../components/Clients/ClientListPage.tsx | 20 ++++--- .../Clients/components/ClientAddForm.tsx | 1 - .../Clients/components/ClientForm.tsx | 6 ++- .../Clients/hooks/useClientScopes.ts | 13 +++-- 10 files changed, 83 insertions(+), 19 deletions(-) diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index f8309db5b1..771c2b94f4 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -1104,7 +1104,7 @@ "expires_before": "Expiration Before Date", "charMoreThan512": "characters over limit (maximum 512)", "charLessThan10": "characters required (minimum 10)", - "more": " more", + "more": "more", "add_claims": "Add claims", "add_email": "Add email", "search_clients": "Search clients...", diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index 5e29762063..b5354e926a 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -1090,7 +1090,7 @@ "expires_before": "Expira antes de la fecha", "charMoreThan512": "caracteres excedidos (máximo 512)", "charLessThan10": "se requieren más caracteres (mínimo 10)", - "more": " más", + "more": "más", "add_claims": "Agregar claims", "add_email": "Agregar correo electrónico", "search_clients": "Buscar clientes...", diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index b54513ef48..48bab9d803 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -1023,7 +1023,7 @@ "expires_before": "Expiration avant la date", "charMoreThan512": "caractères acima do limite (maximum 512)", "charLessThan10": "caractères nécessaires (minimum 10)", - "more": " plus", + "more": "plus", "add_claims": "Ajouter des claims", "add_email": "Ajouter un email", "search_clients": "Rechercher des clients...", diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index c494b4dcf7..4368554c5a 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -1016,7 +1016,7 @@ "expires_before": "Expira antes da data", "charMoreThan512": "caracteres acima do limite (máximo 512)", "charLessThan10": "caracteres necessários (mínimo 10)", - "more": " mais", + "more": "mais", "add_claims": "Adicionar claims", "add_email": "Adicionar email", "search_clients": "Pesquisar clientes...", diff --git a/admin-ui/app/routes/Apps/Gluu/GluuCommitDialog.tsx b/admin-ui/app/routes/Apps/Gluu/GluuCommitDialog.tsx index b100005787..058ae8bb9b 100644 --- a/admin-ui/app/routes/Apps/Gluu/GluuCommitDialog.tsx +++ b/admin-ui/app/routes/Apps/Gluu/GluuCommitDialog.tsx @@ -242,7 +242,7 @@ const GluuCommitDialog = ({ }} > {userMessage.length < 10 - ? `${10 - userMessage.length} ${userMessage.length ? t('placeholders.more') : ''} ${t('placeholders.charLessThan10')}` + ? `${10 - userMessage.length}${userMessage.length ? ` ${t('placeholders.more')}` : ''} ${t('placeholders.charLessThan10')}` : `${userMessage.length - 512} ${t('placeholders.charMoreThan512')}`} )} diff --git a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx index ec9dec259b..84bde0472c 100644 --- a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx +++ b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react' import { Provider } from 'react-redux' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { combineReducers, configureStore } from '@reduxjs/toolkit' @@ -16,6 +16,8 @@ const mockClient = { responseTypes: ['code'], tokenEndpointAuthMethod: 'client_secret_basic', description: 'Test client description', + redirectUris: ['https://example.com/callback'], + scopes: [], } jest.mock('@janssenproject/cedarling_wasm', () => ({ @@ -244,4 +246,52 @@ describe('ClientForm', () => { expect(screen.getByText('Token Endpoint Authentication')).toBeInTheDocument() }) }) + + it('calls onSubmit with payload when form is submitted', async () => { + render( + + + , + ) + + // Modify client name to make form dirty + const clientNameInputs = screen.getAllByDisplayValue(mockClient.clientName) + fireEvent.change(clientNameInputs[0], { target: { value: 'Updated Client Name' } }) + + // Click save button to open commit dialog + const saveButtons = screen.getAllByRole('button', { name: /save/i }) + fireEvent.click(saveButtons[0]) + + // Wait for commit dialog modal to appear (it renders in a portal) + await waitFor(() => { + const modal = document.querySelector('.modal') + expect(modal).toBeInTheDocument() + }) + + // Find and fill the commit message input within the modal + const modal = document.querySelector('.modal') as HTMLElement + const commitInput = within(modal).getByPlaceholderText(/reason/i) + fireEvent.change(commitInput, { target: { value: 'Test commit message for update' } }) + + // Click accept button within the modal + const acceptButton = within(modal).getByRole('button', { name: /accept/i }) + fireEvent.click(acceptButton) + + // Verify onSubmit was called with payload containing updated client name + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledTimes(1) + expect(mockOnSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + clientName: 'Updated Client Name', + }), + 'Test commit message for update', + expect.any(Object), + ) + }) + }) }) diff --git a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx index 33e073b7f4..3098122fd5 100644 --- a/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/ClientListPage.tsx @@ -385,11 +385,14 @@ const ClientListPage: React.FC = () => { [limit], ) - const onRowCountChangeClick = useCallback((count: number): void => { - setStartIndex(0) - setPageNumber(0) - setLimit(count) - }, []) + const onRowCountChangeClick = useCallback( + (count: number): void => { + setStartIndex(0) + setPageNumber(0) + setLimit(count) + }, + [setLimit], + ) const handleEditClick = useCallback( (data: ClientTableRow | ClientTableRow[]) => { @@ -600,12 +603,13 @@ const ClientListPage: React.FC = () => { }, [authorizeHelper, clientScopes]) useEffect(() => { - setPattern(debouncedPattern || null) - if (searchInput !== (pattern || '')) { + const newPattern = debouncedPattern || null + if (newPattern !== pattern) { + setPattern(newPattern) setPageNumber(0) setStartIndex(0) } - }, [debouncedPattern]) + }, [debouncedPattern, pattern]) return ( diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx index a36a655eac..72335421fa 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientAddForm.tsx @@ -31,7 +31,6 @@ import getThemeColor from 'Context/theme/config' import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' import { adminUiFeatures } from 'Plugins/admin/helper/utils' -import { useGetOauthScopes } from 'JansConfigApi' import { GRANT_TYPES } from '../helper/constants' import { useClientScopes } from '../hooks' import { buildAddFormPayload, buildClientPayload, generateClientSecret } from '../helper/utils' diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx index ae2deff77a..efcb42bc39 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx @@ -28,6 +28,7 @@ import type { ClientFormValues, ClientSection, ModifiedFields, + OidcConfiguration, SectionProps, } from '../types' import { useClientScopes } from '../hooks' @@ -88,7 +89,10 @@ const ClientForm: React.FC = ({ }, }) - const oidcConfiguration = useMemo(() => (propertiesData || {}) as any, [propertiesData]) + const oidcConfiguration = useMemo( + (): OidcConfiguration => (propertiesData as OidcConfiguration) || {}, + [propertiesData], + ) const { scopes, scopesLoading, handleScopeSearch } = useClientScopes() diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientScopes.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientScopes.ts index f4aeac1fc7..96cd54078d 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/useClientScopes.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/useClientScopes.ts @@ -2,19 +2,25 @@ import { useMemo, useState, useCallback } from 'react' import { useGetOauthScopes } from 'JansConfigApi' import { transformScopesResponse } from '../helper/utils' import { DEFAULT_SCOPE_SEARCH_LIMIT } from '../helper/constants' +import { useDebounce } from './useDebounce' export const useClientScopes = (initialLimit = DEFAULT_SCOPE_SEARCH_LIMIT) => { const [scopeSearchPattern, setScopeSearchPattern] = useState('') + const debouncedSearchPattern = useDebounce(scopeSearchPattern, 300) const scopeQueryParams = useMemo( () => ({ limit: initialLimit, - pattern: scopeSearchPattern || undefined, + pattern: debouncedSearchPattern || undefined, }), - [initialLimit, scopeSearchPattern], + [initialLimit, debouncedSearchPattern], ) - const { data: scopesResponse, isLoading: scopesLoading } = useGetOauthScopes(scopeQueryParams, { + const { + data: scopesResponse, + isLoading: scopesLoading, + error: scopesError, + } = useGetOauthScopes(scopeQueryParams, { query: { refetchOnMount: 'always' as const, refetchOnWindowFocus: false, @@ -34,6 +40,7 @@ export const useClientScopes = (initialLimit = DEFAULT_SCOPE_SEARCH_LIMIT) => { return { scopes, scopesLoading, + scopesError, handleScopeSearch, scopeSearchPattern, } From 02ea5e43ca7ac5cac869dfd6a5c6c1b88215de9f Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Mon, 22 Dec 2025 23:34:32 +0100 Subject: [PATCH 14/18] fix(admin-ui): apply code review #2516 --- .../components/Clients/ClientForm.test.tsx | 10 +++------- .../components/Clients/components/ClientForm.tsx | 2 +- .../components/Clients/hooks/usePageSize.ts | 1 + .../Clients/sections/LocalizationSection.tsx | 16 ++++++---------- .../Clients/sections/SystemInfoSection.tsx | 2 +- .../Clients/sections/TokensSection.tsx | 14 +++++++------- .../components/Clients/types/formTypes.ts | 7 ++++--- 7 files changed, 23 insertions(+), 29 deletions(-) diff --git a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx index 84bde0472c..195315a2a9 100644 --- a/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx +++ b/admin-ui/plugins/auth-server/__tests__/components/Clients/ClientForm.test.tsx @@ -107,7 +107,6 @@ const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( describe('ClientForm', () => { const mockOnSubmit = jest.fn() const mockOnCancel = jest.fn() - const mockOnScopeSearch = jest.fn() beforeEach(() => { jest.clearAllMocks() @@ -267,14 +266,11 @@ describe('ClientForm', () => { const saveButtons = screen.getAllByRole('button', { name: /save/i }) fireEvent.click(saveButtons[0]) - // Wait for commit dialog modal to appear (it renders in a portal) - await waitFor(() => { - const modal = document.querySelector('.modal') - expect(modal).toBeInTheDocument() - }) + // Wait for commit dialog modal to appear + const modal = await screen.findByRole('dialog') + expect(modal).toBeInTheDocument() // Find and fill the commit message input within the modal - const modal = document.querySelector('.modal') as HTMLElement const commitInput = within(modal).getByPlaceholderText(/reason/i) fireEvent.change(commitInput, { target: { value: 'Test commit message for update' } }) diff --git a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx index efcb42bc39..0579e3ca81 100644 --- a/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/components/ClientForm.tsx @@ -154,7 +154,7 @@ const ClientForm: React.FC = ({ (message: string) => { if (formValues) { const payload = buildClientPayload(formValues) - onSubmit(payload as ClientFormValues, message, modifiedFields) + onSubmit(payload, message, modifiedFields) } setCommitModalOpen(false) }, diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts index ed38b11d13..7996d3b283 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react' import { DEFAULT_PAGE_SIZE } from '../helper/constants' +// Note: Key spelling preserved for backward compatibility with existing user preferences const STORAGE_KEY = 'paggingSize' export const usePageSize = (defaultSize = DEFAULT_PAGE_SIZE) => { diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx index 95e7a0b133..62741d4caf 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/LocalizationSection.tsx @@ -9,6 +9,12 @@ import { ThemeContext } from 'Context/theme/themeContext' import getThemeColor from 'Context/theme/config' import type { SectionProps } from '../types' +const helperTextStyle = { + mt: 0.5, + fontSize: '0.75rem', + color: 'text.secondary', +} + const LocalizationSection: React.FC = ({ formik, viewOnly = false, @@ -77,15 +83,6 @@ const LocalizationSection: React.FC = ({ [borderColor], ) - const helperTextStyle = useMemo( - () => ({ - mt: 0.5, - fontSize: '0.75rem', - color: 'text.secondary', - }), - [], - ) - const getEditorValue = useCallback( (fieldName: string, formikValue: Record | undefined): string => { if (editorValues[fieldName] !== undefined) { @@ -159,7 +156,6 @@ const LocalizationSection: React.FC = ({ labelStyle, editorContainerStyle, borderColor, - helperTextStyle, t, getEditorValue, handleEditorChange, diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/SystemInfoSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/SystemInfoSection.tsx index 82de3122c5..18ce54abda 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/SystemInfoSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/SystemInfoSection.tsx @@ -264,7 +264,7 @@ const SystemInfoSection: React.FC = ({ value.map((option, index) => ( = ({ formik, viewOnly = false, setMo type="number" label={t('fields.access_token_lifetime')} name="accessTokenLifetime" - value={formik.values.accessTokenLifetime || ''} + value={formik.values.accessTokenLifetime ?? ''} onChange={(e) => handleFieldChange( 'accessTokenLifetime', @@ -145,7 +145,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo type="number" label={t('fields.refresh_token_lifetime')} name="refreshTokenLifetime" - value={formik.values.refreshTokenLifetime || ''} + value={formik.values.refreshTokenLifetime ?? ''} onChange={(e) => handleFieldChange( 'refreshTokenLifetime', @@ -166,7 +166,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo type="number" label={t('fields.default_max_age')} name="defaultMaxAge" - value={formik.values.defaultMaxAge || ''} + value={formik.values.defaultMaxAge ?? ''} onChange={(e) => handleFieldChange( 'defaultMaxAge', @@ -187,7 +187,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo type="number" label={t('fields.id_token_lifetime')} name="attributes.idTokenLifetime" - value={formik.values.attributes?.idTokenLifetime || ''} + value={formik.values.attributes?.idTokenLifetime ?? ''} onChange={(e) => handleAttributeChange( 'idTokenLifetime', @@ -208,7 +208,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo type="number" label={t('fields.tx_token_lifetime')} name="attributes.txTokenLifetime" - value={formik.values.attributes?.txTokenLifetime || ''} + value={formik.values.attributes?.txTokenLifetime ?? ''} onChange={(e) => handleAttributeChange( 'txTokenLifetime', @@ -229,7 +229,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo type="number" label={t('fields.requested_lifetime')} name="attributes.requestedLifetime" - value={formik.values.attributes?.requestedLifetime || ''} + value={formik.values.attributes?.requestedLifetime ?? ''} onChange={(e) => handleAttributeChange( 'requestedLifetime', @@ -380,7 +380,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo type="number" label={t('fields.par_lifetime')} name="attributes.parLifetime" - value={formik.values.attributes?.parLifetime || ''} + value={formik.values.attributes?.parLifetime ?? ''} onChange={(e) => handleAttributeChange( 'parLifetime', diff --git a/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts b/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts index c8133e5501..a71041f573 100644 --- a/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts +++ b/admin-ui/plugins/auth-server/components/Clients/types/formTypes.ts @@ -1,3 +1,4 @@ +import type { Dispatch, SetStateAction } from 'react' import type { FormikProps } from 'formik' import type { ExtendedClient, ModifiedFields, ClientScope, ClientScript } from './clientTypes' @@ -34,7 +35,7 @@ export interface ClientFormProps { client: Partial isEdit?: boolean viewOnly?: boolean - onSubmit: (values: ClientFormValues, message: string, modifiedFields: ModifiedFields) => void + onSubmit: (payload: ExtendedClient, message: string, modifiedFields: ModifiedFields) => void onCancel?: () => void } @@ -42,7 +43,7 @@ export interface TabPanelProps { formik: FormikProps viewOnly?: boolean modifiedFields: ModifiedFields - setModifiedFields: React.Dispatch> + setModifiedFields: Dispatch> } export interface BasicInfoTabProps extends TabPanelProps { @@ -71,7 +72,7 @@ export interface UrisTabProps extends TabPanelProps {} export interface SectionProps { formik: FormikProps viewOnly?: boolean - setModifiedFields: React.Dispatch> + setModifiedFields: Dispatch> scripts?: ClientScript[] scriptsTruncated?: boolean scopes?: ClientScope[] From 22e48613e578c090e0ba3c990cee253f38e38dfd Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Tue, 23 Dec 2025 12:35:52 +0100 Subject: [PATCH 15/18] fix(admin-ui): fix code review #2491 --- .../components/Clients/hooks/usePageSize.ts | 22 ++++-- .../Clients/sections/TokensSection.tsx | 12 +-- .../components/Clients/types/formTypes.ts | 79 +++++++++---------- 3 files changed, 59 insertions(+), 54 deletions(-) diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts index 7996d3b283..1dfb32f790 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts @@ -5,19 +5,25 @@ import { DEFAULT_PAGE_SIZE } from '../helper/constants' const STORAGE_KEY = 'paggingSize' export const usePageSize = (defaultSize = DEFAULT_PAGE_SIZE) => { - const getInitialPageSize = (): number => { + const [pageSize, setPageSizeState] = useState(() => { const stored = localStorage.getItem(STORAGE_KEY) if (!stored) return defaultSize const parsed = parseInt(stored, 10) return Number.isNaN(parsed) ? defaultSize : parsed - } + }) - const [pageSize, setPageSizeState] = useState(getInitialPageSize()) - - const setPageSize = useCallback((size: number) => { - setPageSizeState(size) - localStorage.setItem(STORAGE_KEY, String(size)) - }, []) + const setPageSize = useCallback( + (size: number) => { + const validatedSize = size > 0 ? size : defaultSize + setPageSizeState(validatedSize) + try { + localStorage.setItem(STORAGE_KEY, String(validatedSize)) + } catch { + // Ignore localStorage errors (private browsing, quota exceeded, storage disabled) + } + }, + [defaultSize], + ) return [pageSize, setPageSize] as const } diff --git a/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx b/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx index 50576b446c..c62d1865ce 100644 --- a/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx +++ b/admin-ui/plugins/auth-server/components/Clients/sections/TokensSection.tsx @@ -515,7 +515,7 @@ const TokensSection: React.FC = ({ formik, viewOnly = false, setMo value.map((option, index) => ( = ({ formik, viewOnly = false, setMo value.map((option, index) => ( = ({ formik, viewOnly = false, setMo value.map((option, index) => ( = ({ formik, viewOnly = false, setMo value.map((option, index) => ( = ({ formik, viewOnly = false, setMo value.map((option, index) => ( = ({ formik, viewOnly = false, setMo value.map((option, index) => ( & { disabled: boolean description: string - scopes: string[] - grantTypes: string[] - redirectUris: string[] - postLogoutRedirectUris: string[] } export interface ClientFormProps { @@ -87,46 +90,42 @@ export interface SectionConfig { icon: string } -export interface OidcConfiguration { - idTokenSigningAlgValuesSupported?: string[] - idTokenEncryptionAlgValuesSupported?: string[] - idTokenEncryptionEncValuesSupported?: string[] - userinfoSigningAlgValuesSupported?: string[] - userinfoEncryptionAlgValuesSupported?: string[] - userinfoEncryptionEncValuesSupported?: string[] - requestObjectSigningAlgValuesSupported?: string[] - requestObjectEncryptionAlgValuesSupported?: string[] - requestObjectEncryptionEncValuesSupported?: string[] - tokenEndpointAuthMethodsSupported?: string[] - tokenEndpointAuthSigningAlgValuesSupported?: string[] - accessTokenSigningAlgValuesSupported?: string[] - authorizationSigningAlgValuesSupported?: string[] - authorizationEncryptionAlgValuesSupported?: string[] - authorizationEncryptionEncValuesSupported?: string[] - introspectionSigningAlgValuesSupported?: string[] - introspectionEncryptionAlgValuesSupported?: string[] - introspectionEncryptionEncValuesSupported?: string[] - txTokenSigningAlgValuesSupported?: string[] - txTokenEncryptionAlgValuesSupported?: string[] - txTokenEncryptionEncValuesSupported?: string[] +export type OidcConfiguration = Pick< + AppConfiguration, + | 'idTokenSigningAlgValuesSupported' + | 'idTokenEncryptionAlgValuesSupported' + | 'idTokenEncryptionEncValuesSupported' + | 'userInfoSigningAlgValuesSupported' + | 'userInfoEncryptionAlgValuesSupported' + | 'userInfoEncryptionEncValuesSupported' + | 'requestObjectSigningAlgValuesSupported' + | 'requestObjectEncryptionAlgValuesSupported' + | 'requestObjectEncryptionEncValuesSupported' + | 'tokenEndpointAuthMethodsSupported' + | 'tokenEndpointAuthSigningAlgValuesSupported' + | 'accessTokenSigningAlgValuesSupported' + | 'authorizationSigningAlgValuesSupported' + | 'authorizationEncryptionAlgValuesSupported' + | 'authorizationEncryptionEncValuesSupported' + | 'introspectionSigningAlgValuesSupported' + | 'introspectionEncryptionAlgValuesSupported' + | 'introspectionEncryptionEncValuesSupported' + | 'txTokenSigningAlgValuesSupported' + | 'txTokenEncryptionAlgValuesSupported' + | 'txTokenEncryptionEncValuesSupported' + | 'subjectTypesSupported' +> & { backchannelAuthenticationRequestSigningAlgValuesSupported?: string[] responseTypesSupported?: string[] grantTypesSupported?: string[] acrValuesSupported?: string[] - subjectTypesSupported?: string[] scopesSupported?: string[] claimsSupported?: string[] } -export interface UmaResourceForTab { - dn?: string - inum?: string - id?: string - name?: string - description?: string - scopes?: string[] - scopeExpression?: string - iconUri?: string -} +export type UmaResourceForTab = Pick< + UmaResource, + 'dn' | 'inum' | 'id' | 'name' | 'description' | 'scopes' | 'scopeExpression' | 'iconUri' +> export type { RootState } from 'Redux/sagas/types/audit' From feff1dea596b572517ec486588b7f858d2e68258 Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Tue, 23 Dec 2025 12:42:15 +0100 Subject: [PATCH 16/18] fix(admin-ui): fix code review #2491 --- .../plugins/auth-server/components/Clients/hooks/usePageSize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts index 1dfb32f790..0bbad9f2fa 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts @@ -14,7 +14,7 @@ export const usePageSize = (defaultSize = DEFAULT_PAGE_SIZE) => { const setPageSize = useCallback( (size: number) => { - const validatedSize = size > 0 ? size : defaultSize + const validatedSize = Number.isFinite(size) && size > 0 ? size : defaultSize setPageSizeState(validatedSize) try { localStorage.setItem(STORAGE_KEY, String(validatedSize)) From a3c99652dddd9d1907896b7a2840ffb8fd24add1 Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Tue, 23 Dec 2025 12:52:39 +0100 Subject: [PATCH 17/18] fix(admin-ui): fix code review #2491 --- .../auth-server/components/Clients/hooks/usePageSize.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts index 0bbad9f2fa..da3968a13a 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts @@ -4,7 +4,9 @@ import { DEFAULT_PAGE_SIZE } from '../helper/constants' // Note: Key spelling preserved for backward compatibility with existing user preferences const STORAGE_KEY = 'paggingSize' -export const usePageSize = (defaultSize = DEFAULT_PAGE_SIZE) => { +type SetPageSize = (size: number) => void + +export const usePageSize = (defaultSize = DEFAULT_PAGE_SIZE): readonly [number, SetPageSize] => { const [pageSize, setPageSizeState] = useState(() => { const stored = localStorage.getItem(STORAGE_KEY) if (!stored) return defaultSize @@ -14,7 +16,7 @@ export const usePageSize = (defaultSize = DEFAULT_PAGE_SIZE) => { const setPageSize = useCallback( (size: number) => { - const validatedSize = Number.isFinite(size) && size > 0 ? size : defaultSize + const validatedSize = Number.isFinite(size) && size > 0 ? Math.round(size) : defaultSize setPageSizeState(validatedSize) try { localStorage.setItem(STORAGE_KEY, String(validatedSize)) From f5b0e9bc4c57953642de5db2002d8dc65940bf30 Mon Sep 17 00:00:00 2001 From: Mougang Thomas Gasmyr Date: Tue, 23 Dec 2025 12:59:35 +0100 Subject: [PATCH 18/18] fix(admin-ui): fix code review #2491 --- .../components/Clients/hooks/usePageSize.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts index da3968a13a..1e76c94bd9 100644 --- a/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts +++ b/admin-ui/plugins/auth-server/components/Clients/hooks/usePageSize.ts @@ -7,16 +7,24 @@ const STORAGE_KEY = 'paggingSize' type SetPageSize = (size: number) => void export const usePageSize = (defaultSize = DEFAULT_PAGE_SIZE): readonly [number, SetPageSize] => { + const safeDefault = + Number.isFinite(defaultSize) && defaultSize > 0 ? Math.round(defaultSize) : DEFAULT_PAGE_SIZE + const [pageSize, setPageSizeState] = useState(() => { - const stored = localStorage.getItem(STORAGE_KEY) - if (!stored) return defaultSize - const parsed = parseInt(stored, 10) - return Number.isNaN(parsed) ? defaultSize : parsed + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (!stored) return safeDefault + const parsed = parseInt(stored, 10) + return Number.isNaN(parsed) ? safeDefault : parsed + } catch { + // localStorage unavailable (security restrictions, disabled, etc.) + return safeDefault + } }) const setPageSize = useCallback( (size: number) => { - const validatedSize = Number.isFinite(size) && size > 0 ? Math.round(size) : defaultSize + const validatedSize = Number.isFinite(size) && size > 0 ? Math.round(size) : safeDefault setPageSizeState(validatedSize) try { localStorage.setItem(STORAGE_KEY, String(validatedSize)) @@ -24,7 +32,7 @@ export const usePageSize = (defaultSize = DEFAULT_PAGE_SIZE): readonly [number, // Ignore localStorage errors (private browsing, quota exceeded, storage disabled) } }, - [defaultSize], + [safeDefault], ) return [pageSize, setPageSize] as const