diff --git a/admin-ui/app/constants/ui.ts b/admin-ui/app/constants/ui.ts index 31e361e782..c37c2442e9 100644 --- a/admin-ui/app/constants/ui.ts +++ b/admin-ui/app/constants/ui.ts @@ -25,3 +25,48 @@ export const GRADIENT_POSITION = { } as const export const ELLIPSE_SIZE = '200% 160%' + +export const CEDARLING_CONFIG_SPACING = { + ALERT_TO_INPUT: 30, + LABEL_MB: 7, + INPUT_HEIGHT: 52, + INPUT_TO_RADIO: 44, + RADIO_LABEL_MB: 8, + HELPER_MT: 10, + BUTTONS_MT: 50, + ALERT_PADDING_TOP: 22, + ALERT_PADDING_BOTTOM: 20, + ALERT_PADDING_LEFT: 56, + ALERT_PADDING_RIGHT: 24, + ALERT_ICON_LEFT: 25, + ALERT_ICON_TOP: 22, + ALERT_TITLE_MB: 8, + RADIO_GROUP_GAP: 25, + INPUT_PADDING_VERTICAL: 14, + INPUT_PADDING_HORIZONTAL: 21, + BUTTON_OFFSET_TOP: 4, + TOOLTIP_MAX_WIDTH: 320, + ICON_SIZE_MD: 24, +} as const + +export const MAPPING_SPACING = { + PAGE_PADDING_TOP: 53, + ALERT_TO_CARD: 24, + CARD_PADDING: 33, + CARD_HEADER_HEIGHT: 73, + CARD_BORDER_RADIUS: 16, + CARD_MARGIN_BOTTOM: 16, + PERMISSION_ROW_GAP: 20, + PERMISSION_ITEM_GAP: 30, + CHECKBOX_SIZE: 22, + CHECKBOX_LABEL_GAP: 9, + CHECKBOX_BORDER_RADIUS: 5, + CHECKBOX_BORDER_WIDTH: 2, + CHECK_ICON_SIZE: 14, + INFO_ALERT_PADDING_VERTICAL: 16, + INFO_ALERT_PADDING_HORIZONTAL: 24, + INFO_ALERT_GAP: 16, + INFO_ALERT_BORDER_RADIUS: 6, + INFO_ICON_SIZE: 24, + CONTENT_PADDING_TOP: 27, +} as const diff --git a/admin-ui/app/context/theme/config.ts b/admin-ui/app/context/theme/config.ts index 30518a3d7f..509d2afe70 100644 --- a/admin-ui/app/context/theme/config.ts +++ b/admin-ui/app/context/theme/config.ts @@ -10,6 +10,7 @@ const createLightTheme = () => { background, lightBackground: customColors.whiteSmoke, fontColor: text, + textMuted: customColors.textSecondary, borderColor: border, inputBackground: customColors.lightInputBg, menu: { @@ -25,6 +26,19 @@ const createLightTheme = () => { dashboard: { supportCard: customColors.white, }, + card: { + background: customColors.white, + border: customColors.lightBorder, + }, + infoAlert: { + background: customColors.cedarInfoBgLight, + border: customColors.cedarInfoBorderLight, + text: customColors.cedarInfoTextLight, + icon: customColors.cedarInfoTextLight, + }, + checkbox: { + uncheckedBorder: customColors.sidebarHoverBg, + }, } } @@ -37,6 +51,7 @@ const createDarkTheme = () => { background, lightBackground: customColors.primaryDark, fontColor: text, + textMuted: customColors.textMutedDark, borderColor: border, inputBackground: customColors.darkInputBg, menu: { @@ -52,6 +67,19 @@ const createDarkTheme = () => { dashboard: { supportCard: customColors.darkCardBg, }, + card: { + background: customColors.cedarCardBgDark, + border: customColors.cedarCardBorderDark, + }, + infoAlert: { + background: customColors.cedarCardBgDark, + border: customColors.cedarCardBorderDark, + text: customColors.cedarTextSecondaryDark, + icon: customColors.cedarTextTertiaryDark, + }, + checkbox: { + uncheckedBorder: customColors.cedarCardBorderDark, + }, } } diff --git a/admin-ui/app/context/theme/constants.ts b/admin-ui/app/context/theme/constants.ts index a83c234e85..0703410757 100644 --- a/admin-ui/app/context/theme/constants.ts +++ b/admin-ui/app/context/theme/constants.ts @@ -1,6 +1,6 @@ export const THEME_LIGHT = 'light' export const THEME_DARK = 'dark' -export const DEFAULT_THEME = THEME_LIGHT +export const DEFAULT_THEME = THEME_DARK export const THEME_VALUES = [THEME_LIGHT, THEME_DARK] as const diff --git a/admin-ui/app/customColors.ts b/admin-ui/app/customColors.ts index 647a409a4b..1ec17c1b2c 100644 --- a/admin-ui/app/customColors.ts +++ b/admin-ui/app/customColors.ts @@ -40,6 +40,14 @@ export const customColors = { buttonLightBg: '#f4f6f8', darkBorderGradientBase: '#00d5e6', ribbonShadowColor: '#1a237e', + // Cedarling configuration specific (synced with Figma light & dark themes) + cedarCardBgDark: '#10375e', + cedarCardBorderDark: '#224f7c', + cedarTextSecondaryDark: '#c9dbec', + cedarTextTertiaryDark: '#72a1d1', + cedarInfoBgLight: '#e5f6fd', + cedarInfoBorderLight: '#a6d3e6', + cedarInfoTextLight: '#4f8196', } as const /** diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index 029ca64504..18e565ad3e 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -817,6 +817,7 @@ "no_configuration_loaded": "No configuration loaded", "insufficient_permissions_to_modify": "You do not have permission to modify this configuration", "action_commit_question": "Audit log: Want to apply changes made on this page?", + "action_commit_question_title": "Confirm Changes", "licenseAuditLog": "Do you really want to reset the existing license?", "action_deletion_question": "Do you really want to delete this item?", "action_deletion_for": "Deletion confirmation for", @@ -856,6 +857,8 @@ "partial_update_failure": "Some configurations failed to update. Please review and try again.", "min_characters": "Minimum {{count}} characters", "field_required": "This field is required", + "remote": "Remote", + "default": "Default", "default_policy_store_is_used": "Admin UI is already using default policy-store for access control.", "insufficient_token_read_permission": "Access to token data is not granted.", "try_again_later": "Please try again later", @@ -883,9 +886,13 @@ "mapping_added_successfully": "Mapping added successfully", "error_adding_mapping": "Failed to add mapping", "error_loading_mapping": "Failed to load role-permission mappings", + "no_role_mappings_found": "No role mappings found", "permissions_count_one": "{{count}} permission", "permissions_count_other": "{{count}} permissions", + "permissions_count": "{{count, plural, one {{count}} permission} other {{count}} permissions}}", "no_permissions_assigned": "No permissions assigned", + "out_of": "out of", + "permission_label": "Permission", "see_configurations": "See Configuration", "manage_configurations": "Manage Configurations", "fetching_project_details": "Fetching project details...", @@ -1881,9 +1888,16 @@ "point3": "3. Make the required modifications in the policies for Admin UI access control and save the changes.", "point4": "4. Make sure the hostname of the OpenID Configuration Endpoint matches with the hostname of your OpenId Provider.", "point5": "5. Copy the Policy Store URL.", - "point6": "6. Open Cedarling Policy Store configuration screen on Admin UI and add the copied Policy Store URL in Admin UI Remote Policy Store field. Set Policy Retrieval Point field to Remote to use the remote Policy Store URL for the GUI access control.", + "point6": "6. Open Cedarling Policy Store configuration screen on Admin UI and add the copied Policy Store URL in Admin UI Remote Policy Store field. Set Policy Retrieval Point field to Remote to use the remote Policy Store URL for the Admin UI access control.", "useRemotePolicyStore": "It is recommended to set it to Default for production. If set to Default, it will use the Admin-UI storage for Cedarling authorization. Enable Default mode and use the refresh button to store or update GitHub policies on the Admin-UI Server.", - "updateRemotePolicyStoreOnServer": "Click here to update the default policy-store JSON with the version available from the configured remote URL." + "updateRemotePolicyStoreOnServer": "Click here to update the default policy-store JSON with the version available from the configured remote URL.", + "policyUrlDisabledWhenDefault": "Policy URL is disabled when Default is selected.", + "policyStoreUrlInvalidError": "Policy Store URL must be a valid HTTP or HTTPS URL.", + "agamaLabPolicyDesigner": "Agama Lab's Policy Designer", + "gluuFlexAdminUiPolicyStoreDisplay": "Gluu Flex Admin UI Policy Store", + "auditPolicyStoreUrlUpdated": "Policy Store URL configuration updated", + "auditSyncRoleToScopesMappings": "Sync role to scopes mappings", + "auditSetPolicyStoreAsDefault": "Set policy store as default" }, "mappings": { "note_prefix": "Configure", diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index b51f2c32c6..0b777c49ec 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -819,6 +819,7 @@ "insufficient_permissions_to_modify": "No tiene permiso para modificar esta configuración", "insufficient_token_read_permission": "El acceso a los datos del token no está concedido.", "action_commit_question": "Registro de auditoría: ¿Desea aplicar los cambios realizados en esta página?", + "action_commit_question_title": "Confirmar cambios", "licenseAuditLog": "¿Realmente desea restablecer la licencia existente?", "action_deletion_question": "¿Realmente desea eliminar este elemento?", "action_deletion_for": "Confirmación de eliminación para", @@ -858,6 +859,8 @@ "partial_update_failure": "Algunas configuraciones no se pudieron actualizar. Por favor, revise e intente de nuevo.", "min_characters": "Mínimo {{count}} caracteres", "field_required": "Este campo es obligatorio", + "remote": "Remoto", + "default": "Predeterminado", "try_again_later": "Por favor intente de nuevo más tarde", "loading_attributes": "Cargando atributos", "error_processiong_request": "Error al procesar la solicitud.", @@ -881,9 +884,13 @@ "mapping_added_successfully": "Mapeo agregado correctamente", "error_adding_mapping": "Error al agregar el mapeo", "error_loading_mapping": "Error al cargar los mapeos de roles y permisos", + "no_role_mappings_found": "No se encontraron mapeos de roles", "permissions_count_one": "{{count}} permiso", "permissions_count_other": "{{count}} permisos", + "permissions_count": "{{count, plural, one {{count}} permiso} other {{count}} permisos}}", "no_permissions_assigned": "No hay permisos asignados", + "out_of": "de", + "permission_label": "Permiso", "see_configurations": "Ver Configuración", "manage_configurations": "Gestionar Configuraciones", "fetching_project_details": "Obteniendo detalles del proyecto...", @@ -1871,9 +1878,16 @@ "point3": "3. Realizar las modificaciones necesarias en las políticas para el control de acceso de la Admin UI y guardar los cambios.", "point4": "4. Asegúrese de que el nombre de host del Endpoint de Configuración de OpenID coincida con el nombre de host de su Proveedor OpenID.", "point5": "5. Copiar la URL del Almacén de Políticas.", - "point6": "6. Abra la pantalla de configuración del Almacén de Políticas de Cedarling en la Admin UI y agregue la URL del Almacén de Políticas copiada en el campo Almacén de Políticas Remoto de la Admin UI. Configure el campo Punto de Recuperación de Políticas como Remoto para usar la URL del Almacén de Políticas remoto para el control de acceso de la interfaz gráfica.", + "point6": "6. Abra la pantalla de configuración del Almacén de Políticas de Cedarling en la Admin UI y agregue la URL del Almacén de Políticas copiada en el campo Almacén de Políticas Remoto de la Admin UI. Configure el campo Punto de Recuperación de Políticas como Remoto para usar la URL del Almacén de Políticas remoto para el control de acceso de la Admin UI.", "useRemotePolicyStore": "Se recomienda establecerlo en Predeterminado para producción. Si se establece en Predeterminado, utilizará el almacenamiento de la interfaz de administración para la autorización de Cedarling. Active el modo Predeterminado y utilice el botón de actualización para guardar o actualizar las políticas de GitHub en el servidor de la interfaz de administración.", - "updateRemotePolicyStoreOnServer": "Haga clic aquí para actualizar el JSON del almacén de políticas predeterminado con la versión disponible en la URL remota configurada." + "updateRemotePolicyStoreOnServer": "Haga clic aquí para actualizar el JSON del almacén de políticas predeterminado con la versión disponible en la URL remota configurada.", + "policyUrlDisabledWhenDefault": "La URL de la política está deshabilitada cuando se selecciona Predeterminado.", + "policyStoreUrlInvalidError": "La URL del almacén de políticas debe ser una URL HTTP o HTTPS válida.", + "agamaLabPolicyDesigner": "Diseñador de políticas de Agama Lab", + "gluuFlexAdminUiPolicyStoreDisplay": "Almacén de políticas de la interfaz de administración de Gluu Flex", + "auditPolicyStoreUrlUpdated": "Configuración de la URL del almacén de políticas actualizada", + "auditSyncRoleToScopesMappings": "Sincronizar rol con asignaciones de ámbitos", + "auditSetPolicyStoreAsDefault": "Establecer almacén de políticas predeterminado" }, "mappings": { "note_prefix": "Configurar", diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 750cefadde..61f0680be3 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -707,6 +707,8 @@ "partial_update_failure": "Certaines configurations n'ont pas pu être mises à jour. Veuillez vérifier et réessayer.", "min_characters": "Minimum {{count}} caractères", "field_required": "Ce champ est obligatoire", + "remote": "Distant", + "default": "Par défaut", "credentials": "Credentials", "view_configuration": "Afficher la configuration", "new_role": "Nouveau rôle", @@ -766,6 +768,7 @@ "invalid_json_error": "Valeur JSON invalide.", "invalid_url_error": "URL invalide ou URL non autorisée.", "action_commit_question": "Journal d'audit : vous souhaitez appliquer les modifications apportées sur cette page ?", + "action_commit_question_title": "Confirmer les modifications", "licenseAuditLog": "Voulez-vous vraiment réinitialiser la licence existante ?", "action_deletion_question": "Voulez-vous vraiment supprimer cet élément ?", "action_deletion_for": "Confirmation de suppression pour", @@ -815,9 +818,13 @@ "mapping_added_successfully": "Mappage ajouté avec succès", "error_adding_mapping": "Échec de l'ajout du mappage", "error_loading_mapping": "Échec du chargement des mappages rôle-permission", + "no_role_mappings_found": "Aucun mappage de rôles trouvé", "permissions_count_one": "{{count}} permission", "permissions_count_other": "{{count}} permissions", + "permissions_count": "{{count, plural, one {{count}} permission} other {{count}} permissions}}", "no_permissions_assigned": "Aucune permission attribuée", + "out_of": "sur", + "permission_label": "Permission", "see_configurations": "Voir configuration", "manage_configurations": "Gérer les configurations", "fetching_project_details": "Récupération des détails du projet...", @@ -1732,9 +1739,16 @@ "point3": "3. Effectuer les modifications nécessaires dans les politiques pour le contrôle d’accès de l’Admin UI et enregistrer les changements.", "point4": "4. Assurez-vous que le nom d’hôte du point de terminaison de configuration OpenID correspond au nom d’hôte de votre fournisseur OpenID.", "point5": "5. Copier l’URL du magasin de politiques.", - "point6": "6. Ouvrez l’écran de configuration du magasin de politiques Cedarling dans l’Admin UI et ajoutez l’URL du magasin de politiques copiée dans le champ Magasin de politiques distant de l’Admin UI. Définissez le champ Point de récupération des politiques sur Distant afin d’utiliser l’URL du magasin de politiques distant pour le contrôle d’accès de l’interface graphique.", + "point6": "6. Ouvrez l’écran de configuration du magasin de politiques Cedarling dans l’Admin UI et ajoutez l’URL du magasin de politiques copiée dans le champ Magasin de politiques distant de l’Admin UI. Définissez le champ Point de récupération des politiques sur Distant afin d’utiliser l’URL du magasin de politiques distant pour le contrôle d’accès de l’Admin UI.", "useRemotePolicyStore": "Il est recommandé de définir cette option sur « Par défaut » en production. Si cette option est définie, le stockage de l’interface d’administration sera utilisé pour l’autorisation Cedarling. Activez le mode « Par défaut » et utilisez le bouton d’actualisation pour enregistrer ou mettre à jour les politiques GitHub sur le serveur de l’interface d’administration.", - "updateRemotePolicyStoreOnServer": "Cliquez ici pour mettre à jour le fichier JSON du magasin de politiques par défaut avec la version disponible à partir de l'URL distante configurée." + "updateRemotePolicyStoreOnServer": "Cliquez ici pour mettre à jour le fichier JSON du magasin de politiques par défaut avec la version disponible à partir de l'URL distante configurée.", + "policyUrlDisabledWhenDefault": "L'URL de la stratégie est désactivée lorsque Par défaut est sélectionné.", + "policyStoreUrlInvalidError": "L'URL du magasin de politiques doit être une URL HTTP ou HTTPS valide.", + "agamaLabPolicyDesigner": "Concepteur de stratégies Agama Lab", + "gluuFlexAdminUiPolicyStoreDisplay": "Gluu Flex Admin UI Policy Store", + "auditPolicyStoreUrlUpdated": "Configuration de l'URL du magasin de politiques mise à jour", + "auditSyncRoleToScopesMappings": "Synchroniser les rôles avec les mappages de portées", + "auditSetPolicyStoreAsDefault": "Définir le magasin de politiques par défaut" }, "mappings": { "note_prefix": "Configure", diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index 7ec65eeb8a..ab251faef9 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -702,6 +702,8 @@ "partial_update_failure": "Algumas configurações não puderam ser atualizadas. Por favor, revise e tente novamente.", "min_characters": "Mínimo {{count}} caracteres", "field_required": "Este campo é obrigatório", + "remote": "Remoto", + "default": "Padrão", "credentials": "Credenciais", "view_configuration": "Ver Configuração", "add_idp": "Adicionar IDP SAML", @@ -761,6 +763,7 @@ "insufficient_permissions_to_modify": "Você não tem permissão para modificar esta configuração", "insufficient_token_read_permission": "O acesso aos dados do token não foi concedido.", "action_commit_question": "Registro de auditoria: deseja aplicar as alterações feitas nesta página?", + "action_commit_question_title": "Confirmar alterações", "licenseAuditLog": "Você realmente deseja redefinir a licença existente?", "action_deletion_question": "Tem certeza que quer deletar este item?", "action_deletion_for": "Confirmação de exclusão para", @@ -810,9 +813,12 @@ "mapping_added_successfully": "Mapeamento adicionado com sucesso", "error_adding_mapping": "Falha ao adicionar o mapeamento", "error_loading_mapping": "Falha ao carregar os mapeamentos de função e permissão", + "no_role_mappings_found": "Nenhum mapeamento de funções encontrado", "permissions_count_one": "{{count}} permissão", "permissions_count_other": "{{count}} permissões", "no_permissions_assigned": "Nenhuma permissão atribuída", + "out_of": "de", + "permission_label": "Permissão", "see_configurations": "Voir configuration", "manage_configurations": "Gerenciar configurações", "fetching_project_details": "Buscando detalhes do projeto...", @@ -1725,9 +1731,16 @@ "point3": "3. Fazer as modificações necessárias nas políticas para o controle de acesso da Admin UI e salvar as alterações.", "point4": "4. Certifique-se de que o nome do host do Endpoint de Configuração do OpenID corresponda ao nome do host do seu Provedor OpenID.", "point5": "5. Copiar a URL do Repositório de Políticas.", - "point6": "6. Abra a tela de configuração do Repositório de Políticas Cedarling na Admin UI e adicione a URL do Repositório de Políticas copiada no campo Repositório de Políticas Remoto da Admin UI. Defina o campo Ponto de Recuperação de Políticas como Remoto para usar a URL do Repositório de Políticas remoto para o controle de acesso da interface gráfica.", + "point6": "6. Abra a tela de configuração do Repositório de Políticas Cedarling na Admin UI e adicione a URL do Repositório de Políticas copiada no campo Repositório de Políticas Remoto da Admin UI. Defina o campo Ponto de Recuperação de Políticas como Remoto para usar a URL do Repositório de Políticas remoto para o controle de acesso da Admin UI.", "useRemotePolicyStore": "Recomenda-se definir como Padrão para a produção. Se definido como Padrão, será utilizado o armazenamento da interface administrativa para a autorização do Cedarling. Ative o modo Padrão e utilize o botão de atualização para armazenar ou atualizar as políticas do GitHub no servidor da interface administrativa.", - "updateRemotePolicyStoreOnServer": "Clique aqui para atualizar o JSON do repositório de políticas padrão com a versão disponível no URL remoto configurado." + "updateRemotePolicyStoreOnServer": "Clique aqui para atualizar o JSON do repositório de políticas padrão com a versão disponível no URL remoto configurado.", + "policyUrlDisabledWhenDefault": "O URL da política está desativado quando Padrão está selecionado.", + "policyStoreUrlInvalidError": "O URL do repositório de políticas deve ser um URL HTTP ou HTTPS válido.", + "agamaLabPolicyDesigner": "Designer de Políticas do Agama Lab", + "gluuFlexAdminUiPolicyStoreDisplay": "Armazenamento de Políticas da UI Administrativa Gluu Flex", + "auditPolicyStoreUrlUpdated": "Configuração do URL do repositório de políticas atualizada", + "auditSyncRoleToScopesMappings": "Sincronizar função com mapeamentos de escopos", + "auditSetPolicyStoreAsDefault": "Definir repositório de políticas padrão" }, "mappings": { "note_prefix": "Configurar", diff --git a/admin-ui/app/redux/hooks.ts b/admin-ui/app/redux/hooks.ts new file mode 100644 index 0000000000..4a455509cb --- /dev/null +++ b/admin-ui/app/redux/hooks.ts @@ -0,0 +1,11 @@ +// Typed Redux hooks: canonical AppDispatch and useAppDispatch/useAppSelector + +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' +import type { RootState } from './types' +import store from './store' + +export type AppDispatch = typeof store.dispatch +export const useAppDispatch = () => useDispatch() +export const useAppSelector: TypedUseSelectorHook = useSelector + +export type { RootState } diff --git a/admin-ui/app/redux/reducers/ReducerRegistry.ts b/admin-ui/app/redux/reducers/ReducerRegistry.ts index 7783cad310..0618e057c1 100644 --- a/admin-ui/app/redux/reducers/ReducerRegistry.ts +++ b/admin-ui/app/redux/reducers/ReducerRegistry.ts @@ -1,26 +1,43 @@ +import type { Reducer, UnknownAction } from '@reduxjs/toolkit' + +// Looser type than redux/types ReducerMap: plugin keys are dynamic, not limited to keyof RootState. +type InternalReducerMap = Record> +type ChangeListener = (reducers: InternalReducerMap) => void + class ReducerRegistry { - private _emitChange: ((reducers: { [key: string]: any }) => void) | null - private _reducers: { [key: string]: any } + private _emitChange: ChangeListener | null + private _reducers: InternalReducerMap constructor() { this._emitChange = null this._reducers = {} } - getReducers(): { [key: string]: any } { + getReducers(): InternalReducerMap { return { ...this._reducers } } - register(name: string, reducer: any): void { - this._reducers = { ...this._reducers, [name]: reducer } + register(name: string, reducer: Reducer): void { + this._reducers = { + ...this._reducers, + [name]: reducer as Reducer, + } if (this._emitChange) { this._emitChange(this.getReducers()) } } - setChangeListener(listener: (reducers: { [key: string]: any }) => void): void { + setChangeListener(listener: ChangeListener): void { this._emitChange = listener } + + hasReducer(name: string): boolean { + return Object.prototype.hasOwnProperty.call(this._reducers, name) + } + + getReducer(name: string): Reducer | undefined { + return this._reducers[name] + } } const reducerRegistry = new ReducerRegistry() diff --git a/admin-ui/app/redux/types/index.ts b/admin-ui/app/redux/types/index.ts new file mode 100644 index 0000000000..2a8a2dcf2f --- /dev/null +++ b/admin-ui/app/redux/types/index.ts @@ -0,0 +1,558 @@ +import type { Reducer, UnknownAction } from '@reduxjs/toolkit' +import type { ProfileDetails } from 'Routes/Apps/Profile/types' + +// Core app state types + +// Auth +export interface BackendStatus { + active: boolean + errorMessage: string | null + statusCode: number | null +} + +export interface UserInfo { + inum?: string + user_name?: string + name?: string + given_name?: string + family_name?: string + [key: string]: string | string[] | number | boolean | undefined | null +} + +export interface AuthConfig { + clientId?: string + [key: string]: unknown +} + +export interface AuthLocation { + IPv4?: string + [key: string]: unknown +} + +export interface AuthState { + isAuthenticated: boolean + userinfo: UserInfo | null + userinfo_jwt: string | null + idToken: string | null + jwtToken: string | null + issuer: string | null + permissions: string[] + location: AuthLocation + config: AuthConfig + codeChallenge: string | null + codeChallengeMethod: string + codeVerifier: string | null + backendStatus: BackendStatus + loadingConfig: boolean + authState?: unknown + userInum?: string | null + isUserInfoFetched: boolean + hasSession: boolean +} + +// Init State +export interface GenericItem { + [key: string]: string | number | boolean | string[] | number[] | boolean[] | null +} + +export interface InitState { + scripts: GenericItem[] + clients: GenericItem[] + scopes: GenericItem[] + attributes: GenericItem[] + totalClientsEntries: number + isTimeout: boolean + loadingScripts: boolean +} + +// Logout State (stateless) +export type LogoutState = Record + +// License State +export interface LicenseState { + isLicenseValid: boolean + islicenseCheckResultLoaded: boolean + isLicenseActivationResultLoaded: boolean + isLicenceAPIkeyValid: boolean + isLoading: boolean + isConfigValid: boolean | null + error: string + errorSSA: string + generatingTrialKey: boolean + isNoValidLicenseKeyFound: boolean + isUnderThresholdLimit: boolean + isValidatingFlow: boolean +} + +// License Details State +export interface LicenseDetailsItem { + companyName?: string + customerEmail?: string + customerFirstName?: string + customerLastName?: string + licenseActive?: boolean + licenseEnable?: boolean + licenseEnabled?: boolean + licenseKey?: string + licenseType?: string + maxActivations?: number + productCode?: string + productName?: string + validityPeriod?: string + licenseExpired?: boolean +} + +export interface LicenseDetailsState { + item: LicenseDetailsItem + loading: boolean +} + +// OIDC Discovery State +export interface OidcDiscoveryState { + configuration: Record + loading: boolean +} + +// MAU State +export interface MauStatItem { + month?: string + mau?: number + [key: string]: unknown +} + +export interface MauState { + stat: MauStatItem[] + loading: boolean + startMonth: string + endMonth: string +} + +// Health State +export type HealthStatus = 'Running' | 'Not present' | 'Down' + +export type HealthServiceKey = + | 'jans-lock' + | 'jans-auth' + | 'jans-config-api' + | 'jans-casa' + | 'jans-fido2' + | 'jans-scim' + | 'jans-link' + | 'keycloak' + +export type HealthStatusResponse = Partial> & { + [serviceName: string]: HealthStatus +} + +export interface HealthState { + serverStatus: HealthStatus | null + dbStatus: HealthStatus | null + health: HealthStatusResponse + loading: boolean +} + +// Attributes State +export interface AttributeItem { + displayName?: string + [key: string]: unknown +} + +export interface AttributesState { + items: AttributeItem[] + loading: boolean + initLoading: boolean +} + +// Toast State +export type ToastType = 'success' | 'error' | 'warning' | 'info' + +export interface ToastState { + showToast: boolean + message: string + type: ToastType +} + +// Profile Details State +export interface ProfileDetailsState { + profileDetails: ProfileDetails | null + loading: boolean +} + +// Cedar Permissions State +export interface CedarPermissionsState { + permissions: Record + loading: boolean + error: string | null + initialized: boolean | null + isInitializing: boolean + cedarFailedStatusAfterMaxTries: boolean | null + policyStoreJson: string +} + +// Session State (logout audit) +export interface SessionState { + logoutAuditInFlight: boolean + logoutAuditSucceeded: boolean | null +} + +// Lock State +export interface LockState { + lockDetail: Record + loading: boolean +} + +// Admin plugin state types + +// API Role +export interface ApiRoleItem { + inum?: string + role?: string + description?: string + deletable?: boolean + [key: string]: unknown +} + +export interface ApiRoleState { + items: ApiRoleItem[] + item?: ApiRoleItem + loading: boolean +} + +// API Permission State +export interface ApiPermissionItem { + inum?: string + permission?: string + description?: string + defaultPermissionInToken?: boolean + [key: string]: unknown +} + +export interface ApiPermissionState { + items: ApiPermissionItem[] + item?: ApiPermissionItem + loading: boolean + saveOperationFlag: boolean + errorInSaveOperationFlag: boolean +} + +// Mapping State (role-permission) +export interface MappingItem { + role?: string + permissions?: string[] + [key: string]: unknown +} + +export interface MappingState { + items: MappingItem[] + serverItems: MappingItem[] + loading: boolean +} + +// Webhook State +export interface WebhookEntry { + inum?: string + displayName?: string + url?: string + httpMethod?: string + httpHeaders?: Record + httpRequestBody?: string + jansEnabled?: boolean + [key: string]: unknown +} + +export interface AuiFeature { + inum?: string + displayName?: string + description?: string + [key: string]: unknown +} + +export interface TriggerPayload { + feature: string | null + payload: unknown +} + +export interface WebhookState { + webhooks: WebhookEntry[] + loading: boolean + saveOperationFlag: boolean + errorInSaveOperationFlag: boolean + totalItems: number + entriesCount: number + selectedWebhook: WebhookEntry | null + loadingFeatures: boolean + features: AuiFeature[] + webhookFeatures: AuiFeature[] + loadingWebhookFeatures: boolean + loadingWebhooks: boolean + featureWebhooks: WebhookEntry[] + webhookModal: boolean + triggerWebhookInProgress: boolean + triggerWebhookMessage: string + webhookTriggerErrors: unknown[] + triggerPayload: TriggerPayload + featureToTrigger: string + showErrorModal: boolean +} + +// Asset State +export interface AssetDocument { + inum?: string + displayName?: string + description?: string + document?: string + creationDate?: string + jansEnabled?: boolean + [key: string]: unknown +} + +export interface AssetState { + assets: AssetDocument[] + services: string[] + fileTypes: string[] + loading: boolean + saveOperationFlag: boolean + errorInSaveOperationFlag: boolean + totalItems: number + entriesCount: number + selectedAsset: AssetDocument | Record + loadingAssets: boolean + assetModal: boolean + showErrorModal: boolean +} + +// Custom Script State +export interface ScriptError { + type?: string + message?: string + stackTrace?: string +} + +export interface ScriptType { + value: string + name: string +} + +export interface ModuleProperty { + value1: string + value2: string + description?: string + hide?: boolean +} + +export interface ConfigurationProperty { + key?: string + value?: string + value1?: string + value2?: string + hide?: boolean +} + +export interface CustomScriptItem { + inum?: string + name?: string + description?: string + scriptType?: string + programmingLanguage?: string + level?: number + script?: string + aliases?: string[] + moduleProperties?: ModuleProperty[] + configurationProperties?: ConfigurationProperty[] + locationPath?: string + locationType?: string + enabled?: boolean + scriptError?: ScriptError + internal?: boolean + revision?: number +} + +export interface CustomScriptState { + items: CustomScriptItem[] + item?: CustomScriptItem + loading: boolean + view: boolean + saveOperationFlag: boolean + errorInSaveOperationFlag: boolean + totalItems: number + entriesCount: number + scriptTypes: ScriptType[] + hasFetchedScriptTypes: boolean + loadingScriptTypes: boolean +} + +// Auth server plugin state types + +// OIDC Client +export interface OidcClientItem { + inum?: string + clientName?: string + displayName?: string + [key: string]: unknown +} + +export interface OidcTokensState { + items: unknown[] + totalItems: number + entriesCount: number +} + +export interface OidcState { + items: OidcClientItem[] + item: OidcClientItem + view: boolean + loading: boolean + isTokenLoading: boolean + saveOperationFlag: boolean + errorInSaveOperationFlag: boolean + totalItems: number + entriesCount: number + tokens: OidcTokensState +} + +// Scope State +export interface ScopeItem { + inum?: string + id?: string + displayName?: string + description?: string + scopeType?: string + [key: string]: unknown +} + +export interface ScopeState { + items: ScopeItem[] + item: ScopeItem + loading: boolean + saveOperationFlag: boolean + errorInSaveOperationFlag: boolean + scopesByCreator: ScopeItem[] + totalItems: number + entriesCount: number + clientScopes: ScopeItem[] + loadingClientScopes: boolean + selectedClientScopes: ScopeItem[] +} + +// JSON Config State +export interface JsonConfigState { + configuration: Record + loading: boolean + saveError: boolean +} + +// UMA Resource State +export interface UmaResourceItem { + inum?: string + name?: string + [key: string]: unknown +} + +export interface UmaResourceState { + items: UmaResourceItem[] + item: UmaResourceItem + loading: boolean +} + +// Message State +export interface MessageState { + messages: unknown[] + loading: boolean + error: string | null +} + +// Auth Server Session State +export interface AuthServerSessionState { + sessions: unknown[] + loading: boolean + totalItems: number + entriesCount: number +} + +// SMTP plugin state types + +export type ConnectProtection = 'None' | 'STARTTLS' | 'SSL/TLS' + +export interface SmtpConfiguration { + host?: string + port?: number + connect_protection?: ConnectProtection + from_name?: string + from_email_address?: string + requires_authentication?: boolean + smtp_authentication_account_username?: string + smtp_authentication_account_password?: string + trust_host?: boolean + key_store?: string + key_store_password?: string + key_store_alias?: string + signing_algorithm?: string +} + +export interface SmtpState { + smtp: SmtpConfiguration + loading: boolean + testStatus: boolean | null + openModal: boolean + testButtonEnabled: boolean +} + +// Root state: core reducers (always present) + +export interface CoreAppState { + authReducer: AuthState + initReducer: InitState + logoutReducer: LogoutState + licenseReducer: LicenseState + licenseDetailsReducer: LicenseDetailsState + oidcDiscoveryReducer: OidcDiscoveryState + mauReducer: MauState + healthReducer: HealthState + attributesReducerRoot: AttributesState + toastReducer: ToastState + profileDetailsReducer: ProfileDetailsState + cedarPermissions: CedarPermissionsState + lockReducer: LockState + logoutAuditReducer: SessionState +} + +// Admin plugin reducers +export interface AdminPluginState { + apiRoleReducer: ApiRoleState + apiPermissionReducer: ApiPermissionState + mappingReducer: MappingState + webhookReducer: WebhookState + assetReducer: AssetState + customScriptReducer: CustomScriptState +} + +// Auth server plugin reducers +export interface AuthServerPluginState { + oidcReducer: OidcState + scopeReducer: ScopeState + jsonConfigReducer: JsonConfigState + UMAResourceReducer: UmaResourceState + messageReducer: MessageState + sessionReducer: AuthServerSessionState +} + +// SMTP plugin reducers +export interface SmtpPluginState { + smtpsReducer: SmtpState +} + +// RootState: core + optional plugin reducers (dynamically registered) +export interface RootState + extends CoreAppState, Partial {} + +// AppDispatch, useAppDispatch, useAppSelector: import from @/redux/hooks (canonical source using typeof store.dispatch) + +// Reducer registry types +export type ReducerMap = { + [K in keyof RootState]?: Reducer +} + +export type ReducerChangeListener = (reducers: ReducerMap) => void + +export type { AuthState as AuthReducerState } +export type SliceState = NonNullable diff --git a/admin-ui/app/routes/Apps/Gluu/GluuFormFooter.tsx b/admin-ui/app/routes/Apps/Gluu/GluuFormFooter.tsx index 0215dfb267..3cbfdd1931 100644 --- a/admin-ui/app/routes/Apps/Gluu/GluuFormFooter.tsx +++ b/admin-ui/app/routes/Apps/Gluu/GluuFormFooter.tsx @@ -1,25 +1,17 @@ -import { useContext, useMemo, useCallback, memo } from 'react' -import { Button, Divider } from 'Components' +import { useMemo, useCallback, memo } from 'react' import { useTranslation } from 'react-i18next' -import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' -import { ThemeContext } from 'Context/theme/themeContext' -import { DEFAULT_THEME } from '@/context/theme/constants' +import { useTheme } from '@/context/theme/themeContext' +import { THEME_DARK } from '@/context/theme/constants' import { Box } from '@mui/material' import { useAppNavigation, ROUTES } from '@/helpers/navigation' - -interface ButtonLabelProps { - isLoading: boolean - iconClass: string - label: string - loadingIconClass?: string -} +import { GluuButton } from '@/components' +import { useStyles, BUTTON_STYLES, getButtonColors } from './styles/GluuFormFooter.style' interface GluuFormFooterBaseProps { showBack?: boolean backButtonLabel?: string onBack?: () => void disableBack?: boolean - backIconClass?: string showCancel?: boolean cancelButtonLabel?: string onCancel?: () => void @@ -37,26 +29,26 @@ type GluuFormFooterProps = GluuFormFooterBaseProps & | { applyButtonType: 'button'; onApply: () => void } ) -const ButtonLabel = memo((props: ButtonLabelProps) => { - const { isLoading, iconClass, label, loadingIconClass = 'fa fa-spinner fa-spin' } = props - return ( - <> - - {label} - - ) -}) - -ButtonLabel.displayName = 'ButtonLabel' +const COMMON_BUTTON_STYLE = { + minHeight: BUTTON_STYLES.height, + padding: `${BUTTON_STYLES.paddingY}px ${BUTTON_STYLES.paddingX}px`, + borderRadius: BUTTON_STYLES.borderRadius, + fontSize: BUTTON_STYLES.fontSize, + fontWeight: BUTTON_STYLES.fontWeight, + letterSpacing: BUTTON_STYLES.letterSpacing, +} -const BUTTON_STYLE = { ...applicationStyle.buttonStyle, ...applicationStyle.buttonFlexIconStyles } +const SHARED_BUTTON_PROPS = { + useOpacityOnHover: true, + hoverOpacity: 0.85, + style: COMMON_BUTTON_STYLE, +} const GluuFormFooter = ({ showBack, backButtonLabel, onBack, disableBack = false, - backIconClass = 'fa fa-arrow-circle-left', showCancel, cancelButtonLabel, onCancel, @@ -70,12 +62,28 @@ const GluuFormFooter = ({ className = '', }: GluuFormFooterProps) => { const { t } = useTranslation() - const theme = useContext(ThemeContext) - const selectedTheme = useMemo(() => { - return theme?.state?.theme || DEFAULT_THEME - }, [theme?.state?.theme]) + const { state } = useTheme() + const isDark = state.theme === THEME_DARK const { navigateToRoute } = useAppNavigation() + const buttonStates = useMemo(() => { + const hasAnyButton = Boolean(showBack) || Boolean(showCancel) || Boolean(showApply) + const hasThreeButtons = Boolean(showBack) && Boolean(showCancel) && Boolean(showApply) + const hasRightGroup = hasThreeButtons || (!!showCancel && !showApply) + + return { + showBack: Boolean(showBack), + showCancel: Boolean(showCancel), + showApply: Boolean(showApply), + hasAnyButton, + hasThreeButtons, + hasRightGroup, + } + }, [showBack, showCancel, showApply]) + + const { classes } = useStyles({ hasRightGroup: buttonStates.hasRightGroup }) + const buttonColors = useMemo(() => getButtonColors(isDark), [isDark]) + const handleBackClick = useCallback(() => { if (onBack) { onBack() @@ -90,23 +98,6 @@ const GluuFormFooter = ({ } }, [onCancel]) - const buttonStates = useMemo(() => { - const hasAnyButton = Boolean(showBack) || Boolean(showCancel) || Boolean(showApply) - const hasAllThreeButtons = Boolean(showBack) && Boolean(showCancel) && Boolean(showApply) - const hasBackAndCancel = Boolean(showBack) && Boolean(showCancel) && !showApply - - return { - showBack: Boolean(showBack), - showCancel: Boolean(showCancel), - showApply: Boolean(showApply), - hasAnyButton, - hasAllThreeButtons, - hasBackAndCancel, - } - }, [showBack, showCancel, showApply]) - - const buttonColor = useMemo(() => `primary-${selectedTheme}`, [selectedTheme]) - const backLabel = useMemo(() => backButtonLabel || t('actions.back'), [backButtonLabel, t]) const cancelLabel = useMemo( () => cancelButtonLabel || t('actions.cancel'), @@ -114,108 +105,81 @@ const GluuFormFooter = ({ ) const applyLabel = useMemo(() => applyButtonLabel || t('actions.apply'), [applyButtonLabel, t]) - const buttonLayout = useMemo(() => { - if (!buttonStates.hasAnyButton) { - return { back: '', cancel: '', apply: '' } - } - - const layout = { - back: buttonStates.showBack ? 'd-flex' : '', - cancel: buttonStates.showCancel ? 'd-flex' : '', - apply: buttonStates.showApply ? 'd-flex' : '', - } - - if (buttonStates.showApply) { - layout.apply += ' ms-auto' - if (buttonStates.hasAllThreeButtons) { - layout.apply += ' me-0' - } - } else if (buttonStates.showCancel) { - layout.cancel += ' ms-auto' - } - - return layout - }, [buttonStates]) - if (!buttonStates.hasAnyButton) { return null } return ( - <> - - + + {buttonStates.showBack && ( - - )} - - {buttonStates.showApply && ( - - {applyButtonType === 'submit' ? ( - - ) : ( - - )} - + {backLabel} + )} - {buttonStates.showCancel && ( - + {applyLabel} + )} - + + {buttonStates.hasRightGroup && ( + + {buttonStates.hasThreeButtons && buttonStates.showApply && ( + + {applyLabel} + + )} + + {buttonStates.showCancel && ( + + {cancelLabel} + + )} + + )} + ) } diff --git a/admin-ui/app/routes/Apps/Gluu/GluuNavBar.tsx b/admin-ui/app/routes/Apps/Gluu/GluuNavBar.tsx index 3e873efc40..6188970f53 100755 --- a/admin-ui/app/routes/Apps/Gluu/GluuNavBar.tsx +++ b/admin-ui/app/routes/Apps/Gluu/GluuNavBar.tsx @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux' import { ErrorBoundary } from 'react-error-boundary' import Box from '@mui/material/Box' import { Nav, NavItem, Notifications, SidebarTrigger, ChevronIcon } from 'Components' +import GluuText from 'Routes/Apps/Gluu/GluuText' import { DropdownProfile } from 'Routes/components/Dropdowns/DropdownProfile' import type { UserInfo } from 'Redux/features/types/authTypes' import { LanguageMenu } from './LanguageMenu' @@ -96,9 +97,14 @@ const GluuNavBar = () => { )} -

+ {pageTitle} -

+
@@ -118,7 +124,9 @@ const GluuNavBar = () => { renderTrigger={(isOpen: boolean) => ( - {displayName} + + {displayName} + diff --git a/admin-ui/app/routes/Apps/Gluu/LanguageMenu.tsx b/admin-ui/app/routes/Apps/Gluu/LanguageMenu.tsx index bbd8ce589e..45d1353467 100644 --- a/admin-ui/app/routes/Apps/Gluu/LanguageMenu.tsx +++ b/admin-ui/app/routes/Apps/Gluu/LanguageMenu.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useContext, useMemo, useCallback, memo, useRef } f import { useTranslation } from 'react-i18next' import Box from '@mui/material/Box' import { GluuDropdown, type GluuDropdownOption, ChevronIcon } from 'Components' +import GluuText from 'Routes/Apps/Gluu/GluuText' import { ThemeContext } from 'Context/theme/themeContext' import { THEME_DARK, DEFAULT_THEME } from '@/context/theme/constants' import { useStyles } from './styles/LanguageMenu.style' @@ -88,7 +89,9 @@ const LanguageMenu = memo(({ userInfo }) => { ( - {lang.toUpperCase()} + + {lang.toUpperCase()} + diff --git a/admin-ui/app/routes/Apps/Gluu/ThemeDropdown.tsx b/admin-ui/app/routes/Apps/Gluu/ThemeDropdown.tsx index 69265b6b8a..21b49bc1ea 100644 --- a/admin-ui/app/routes/Apps/Gluu/ThemeDropdown.tsx +++ b/admin-ui/app/routes/Apps/Gluu/ThemeDropdown.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useCallback, memo } from 'react' import { useTranslation } from 'react-i18next' import Box from '@mui/material/Box' import { GluuDropdown, type GluuDropdownOption, ChevronIcon } from 'Components' +import GluuText from 'Routes/Apps/Gluu/GluuText' import { useTheme } from '@/context/theme/themeContext' import { THEME_LIGHT, THEME_DARK, isValidTheme, type ThemeValue } from '@/context/theme/constants' import { useStyles } from './styles/ThemeDropdown.style' @@ -73,11 +74,19 @@ export const ThemeDropdownComponent = memo(({ userI () => [ { value: THEME_LIGHT, - label: {t('themes.light')}, + label: ( + + {t('themes.light')} + + ), }, { value: THEME_DARK, - label: {t('themes.dark')}, + label: ( + + {t('themes.dark')} + + ), }, ], [classes, t], @@ -87,7 +96,9 @@ export const ThemeDropdownComponent = memo(({ userI ( - {t('themes.theme')} + + {t('themes.theme')} + diff --git a/admin-ui/app/routes/Apps/Gluu/styles/GluuFormFooter.style.ts b/admin-ui/app/routes/Apps/Gluu/styles/GluuFormFooter.style.ts new file mode 100644 index 0000000000..14fcda4feb --- /dev/null +++ b/admin-ui/app/routes/Apps/Gluu/styles/GluuFormFooter.style.ts @@ -0,0 +1,58 @@ +import { makeStyles } from 'tss-react/mui' +import customColors from '@/customColors' + +interface FormFooterStyleParams { + hasRightGroup: boolean +} + +export const useStyles = makeStyles()((_theme, { hasRightGroup }) => ({ + footerWrapper: { + display: 'flex', + alignItems: 'center', + justifyContent: hasRightGroup ? 'space-between' : 'flex-start', + gap: 12, + paddingTop: 16, + paddingBottom: 8, + }, + + leftGroup: { + display: 'flex', + alignItems: 'center', + gap: 12, + }, + + rightGroup: { + display: 'flex', + alignItems: 'center', + gap: 12, + }, +})) + +export const BUTTON_STYLES = { + height: 40, + paddingX: 28, + paddingY: 10, + borderRadius: 6, + fontSize: 14, + fontWeight: 700, + letterSpacing: '0.28px', +} + +export const getButtonColors = (isDark: boolean) => ({ + back: { + backgroundColor: customColors.statusActive, + textColor: customColors.white, + borderColor: customColors.statusActive, + }, + apply: { + backgroundColor: isDark ? customColors.white : customColors.primaryDark, + textColor: isDark ? customColors.primaryDark : customColors.white, + borderColor: isDark ? customColors.white : customColors.primaryDark, + }, + cancel: { + backgroundColor: 'transparent', + textColor: isDark ? customColors.white : customColors.primaryDark, + borderColor: isDark ? customColors.white : customColors.primaryDark, + outlined: true, + }, +}) diff --git a/admin-ui/app/routes/Apps/Profile/ProfilePage.tsx b/admin-ui/app/routes/Apps/Profile/ProfilePage.tsx index 40344d3dfc..4e06523ff1 100755 --- a/admin-ui/app/routes/Apps/Profile/ProfilePage.tsx +++ b/admin-ui/app/routes/Apps/Profile/ProfilePage.tsx @@ -2,12 +2,11 @@ import React, { useContext, useEffect, useCallback, useMemo, memo } from 'react' import { Container, Row, Col, Card, CardBody, Button, Badge, AvatarImage } from 'Components' import { ErrorBoundary } from 'react-error-boundary' import GluuErrorFallBack from '../Gluu/GluuErrorFallBack' -import { useDispatch, useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { ThemeContext } from '../../../context/theme/themeContext' import SetTitle from 'Utils/SetTitle' import styles from './styles' -import { Box, Divider, Skeleton } from '@mui/material' +import { Box, Divider } from '@mui/material' import { getProfileDetails } from 'Redux/features/ProfileDetailsSlice' import { randomAvatar } from '../../../utilities' import getThemeColor from '../../../context/theme/config' @@ -17,22 +16,43 @@ import { useCedarling } from '@/cedarling' import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' import { CEDAR_RESOURCE_SCOPES } from '@/cedarling/constants/resourceScopes' import { useAppNavigation, ROUTES } from '@/helpers/navigation' -import type { AppDispatch, ProfileRootState, ThemeContextValue, CustomAttribute } from './types' +import type { ThemeContextValue, CustomAttribute } from './types' +import { useAppDispatch, useAppSelector } from '@/redux/hooks' +import GluuLoader from '../Gluu/GluuLoader' const JANS_ADMIN_UI_ROLE_ATTR = 'jansAdminUIRole' -const SKELETON_WIDTH = '45%' const BADGE_PADDING = '4px 6px' -const SKELETON_HEIGHT = 40 +const USERS_RESOURCE_ID = ADMIN_UI_RESOURCES.Users -const skeletonCenterStyle = { +const FLEX_COLUMN_GAP2 = { display: 'flex', - justifyContent: 'center', + flexDirection: 'column' as const, + gap: 2, +} +const FLEX_COLUMN_GAP1 = { + display: 'flex', + flexDirection: 'column' as const, + gap: 1, +} +const FIELD_ROW_STYLE = { + display: 'flex', + justifyContent: 'space-between' as const, alignItems: 'center', -} as const + marginBottom: 1, +} +const ROLES_BADGES_BOX_STYLE = { + display: 'flex', + gap: '2px', + flexWrap: 'wrap' as const, + alignItems: 'end', + justifyContent: 'end', +} +const COL_PROPS = { xs: 10, md: 8, lg: 5 } +const USERS_SCOPES = CEDAR_RESOURCE_SCOPES[USERS_RESOURCE_ID] const ProfileDetails: React.FC = () => { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const theme = useContext(ThemeContext) as ThemeContextValue const selectedTheme = useMemo(() => theme?.state?.theme ?? DEFAULT_THEME, [theme?.state?.theme]) const themeColors = useMemo(() => getThemeColor(selectedTheme), [selectedTheme]) @@ -41,24 +61,25 @@ const ProfileDetails: React.FC = () => { SetTitle(t('titles.profile_detail')) - const { loading, profileDetails } = useSelector( - (state: ProfileRootState) => state.profileDetailsReducer, - ) - const authState = useSelector((state: ProfileRootState) => state.authReducer) - const { userinfo, token: authToken } = authState - const stateUserInum = (authState as { userInum?: string | null; hasSession?: boolean }).userInum - const hasSession = (authState as { hasSession?: boolean }).hasSession ?? false + const { loading, profileDetails } = useAppSelector((state) => state.profileDetailsReducer) + const authState = useAppSelector((state) => state.authReducer) + const authStateWithToken = authState as typeof authState & { + token?: { access_token?: string } | null + userInum?: string | null + hasSession?: boolean + } + const { userinfo, token: authToken } = authStateWithToken ?? {} + const stateUserInum = authStateWithToken?.userInum + const hasSession = authStateWithToken?.hasSession ?? false const userInum = useMemo(() => stateUserInum || userinfo?.inum, [stateUserInum, userinfo?.inum]) const apiAccessToken = authToken?.access_token ?? null const canMakeApiCall = hasSession || !!apiAccessToken const { authorizeHelper, hasCedarWritePermission } = useCedarling() - const usersResourceId = useMemo(() => ADMIN_UI_RESOURCES.Users, []) - const usersScopes = useMemo(() => CEDAR_RESOURCE_SCOPES[usersResourceId], [usersResourceId]) const canEditProfile = useMemo( - () => hasCedarWritePermission(usersResourceId), - [hasCedarWritePermission, usersResourceId], + () => hasCedarWritePermission(USERS_RESOURCE_ID), + [hasCedarWritePermission], ) const jansAdminUIRole = useMemo( @@ -69,6 +90,13 @@ const ProfileDetails: React.FC = () => { [profileDetails?.customAttributes], ) + const snValue = useMemo( + () => + profileDetails?.customAttributes?.find((att: CustomAttribute) => att?.name === 'sn') + ?.values?.[0], + [profileDetails?.customAttributes], + ) + const avatarSrc = useMemo(() => randomAvatar(), []) useEffect(() => { @@ -79,10 +107,10 @@ const ProfileDetails: React.FC = () => { }, [canMakeApiCall, dispatch, userInum]) useEffect(() => { - if (usersScopes && usersScopes.length > 0) { - authorizeHelper(usersScopes) + if (USERS_SCOPES?.length) { + authorizeHelper(USERS_SCOPES) } - }, [authorizeHelper, usersScopes]) + }, [authorizeHelper]) const navigateToUserManagement = useCallback((): void => { if (!profileDetails?.inum) return @@ -106,114 +134,87 @@ const ProfileDetails: React.FC = () => { }, [jansAdminUIRole?.values, selectedTheme, themeColors.fontColor]) const renderField = useCallback( - (labelKey: string, value: string | undefined, isLoading: boolean) => { - if (isLoading) { - return - } - return ( - - {t(labelKey)} - {value || '-'} - - ) - }, + (labelKey: string, value: string | undefined) => ( + + {t(labelKey)} + {value || '-'} + + ), [t], ) - const renderDisplayName = useMemo(() => { - if (loading) { - return ( - - - - ) - } - return ( + const renderDisplayName = useMemo( + () => ( {profileDetails?.displayName} - ) - }, [loading, profileDetails?.displayName]) + ), + [profileDetails?.displayName], + ) - const renderUserRolesField = useMemo(() => { - if (loading) { - return - } - return ( - + const renderUserRolesField = useMemo( + () => ( + {t('titles.roles')} - {roleBadges && ( - - {roleBadges} - - )} + {roleBadges && {roleBadges}} - ) - }, [loading, roleBadges, t]) + ), + [roleBadges, t], + ) + + const editButtonStyle = useMemo( + () => ({ + backgroundColor: 'transparent' as const, + color: customColors.primaryDark, + border: `1px solid ${themeColors.background}`, + }), + [themeColors.background], + ) return ( - - - - - - - - - - - - {renderDisplayName} - {renderField('fields.givenName', profileDetails?.givenName, loading)} - - {renderField( - 'fields.sn', - profileDetails?.customAttributes?.find( - (att: CustomAttribute) => att?.name === 'sn', - )?.values?.[0], - loading, - )} - - {renderField('fields.mail', profileDetails?.mail, loading)} - - {renderUserRolesField} - - {renderField('fields.status', profileDetails?.status, loading)} - - - {canEditProfile && ( - <> - {loading ? ( - - ) : ( - )} - - )} - - - - - - - + + + + + + + + )} + ) } diff --git a/admin-ui/app/routes/Apps/Profile/types.ts b/admin-ui/app/routes/Apps/Profile/types.ts index 0575f4ad6f..38393a307a 100644 --- a/admin-ui/app/routes/Apps/Profile/types.ts +++ b/admin-ui/app/routes/Apps/Profile/types.ts @@ -1,4 +1,3 @@ -import type { Dispatch, UnknownAction } from '@reduxjs/toolkit' import type { UserInfo } from 'Redux/features/types/authTypes' export interface CustomAttribute { @@ -40,8 +39,6 @@ export interface ProfileRootState { authReducer: AuthState } -export type AppDispatch = Dispatch - export interface ThemeContextValue { state: { theme: string diff --git a/admin-ui/app/routes/Dashboards/DashboardPage.tsx b/admin-ui/app/routes/Dashboards/DashboardPage.tsx index 4501aeeb7c..2a91765adc 100644 --- a/admin-ui/app/routes/Dashboards/DashboardPage.tsx +++ b/admin-ui/app/routes/Dashboards/DashboardPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch, useSelector } from 'react-redux' +import { useAppSelector, useAppDispatch } from '@/redux/hooks' import { useMediaQuery } from 'react-responsive' import type { Dayjs } from 'dayjs' import Grid from '@mui/material/Grid' @@ -13,7 +13,6 @@ import { useAppNavigation, ROUTES } from '@/helpers/navigation' import { ThemeContext } from 'Context/theme/themeContext' import { THEME_DARK, DEFAULT_THEME } from '@/context/theme/constants' import { auditLogoutLogs } from 'Redux/features/sessionSlice' -import type { AuthState } from 'Redux/features/types/authTypes' import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' import GluuPermissionModal from 'Routes/Apps/Gluu/GluuPermissionModal' import { formatDate } from 'Utils/Util' @@ -22,7 +21,6 @@ import SetTitle from 'Utils/SetTitle' import { useMauStats } from 'Plugins/admin/components/MAU/hooks' import { useHealthStatus } from 'Plugins/admin/components/Health/hooks' import { DEFAULT_STATUS } from '@/constants' -import type { CedarPermissionsState } from '@/cedarling/types' import type { MauDateRange } from 'Plugins/admin/components/MAU/types' import DashboardChart from './Chart/DashboardChart' import { CHART_LEGEND_CONFIG, STATUS_DETAILS } from './constants' @@ -41,15 +39,14 @@ import { DATE_FORMATS, } from '@/utils/dayjsUtils' -interface RootState { - authReducer: AuthState - cedarPermissions: CedarPermissionsState -} +const DASHBOARD_RESOURCE_ID = ADMIN_UI_RESOURCES.Dashboard +const DASHBOARD_SCOPES = CEDAR_RESOURCE_SCOPES[DASHBOARD_RESOURCE_ID] +const MOBILE_MEDIA_QUERY = { maxWidth: 767 } const DashboardPage = () => { const { t } = useTranslation() - const dispatch = useDispatch() - const isMobile = useMediaQuery({ maxWidth: 767 }) + const dispatch = useAppDispatch() + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) const themeContext = useContext(ThemeContext) const currentTheme = useMemo( @@ -90,35 +87,28 @@ const DashboardPage = () => { const debouncedStartDate = useDebounce(startDate, 400) const debouncedEndDate = useDebounce(endDate, 400) - const { isUserInfoFetched, hasSession } = useSelector((state: RootState) => state.authReducer) - const permissions = useSelector((state: RootState) => state.authReducer.permissions) + const { isUserInfoFetched, hasSession, permissions } = useAppSelector( + (state) => state.authReducer, + ) const { hasCedarReadPermission, authorizeHelper } = useCedarling() const { navigateToRoute } = useAppNavigation() - const cedarInitialized = useSelector((state: RootState) => state.cedarPermissions?.initialized) - const cedarIsInitializing = useSelector( - (state: RootState) => state.cedarPermissions?.isInitializing, - ) - - const dashboardResourceId = useMemo(() => ADMIN_UI_RESOURCES.Dashboard, []) - const dashboardScopes = useMemo( - () => CEDAR_RESOURCE_SCOPES[dashboardResourceId], - [dashboardResourceId], - ) + const cedarInitialized = useAppSelector((state) => state.cedarPermissions?.initialized) + const cedarIsInitializing = useAppSelector((state) => state.cedarPermissions?.isInitializing) const hasViewPermissions = useMemo(() => { if (!cedarInitialized || cedarIsInitializing) { return false } - return Boolean(hasCedarReadPermission(dashboardResourceId)) - }, [cedarInitialized, cedarIsInitializing, hasCedarReadPermission, dashboardResourceId]) + return Boolean(hasCedarReadPermission(DASHBOARD_RESOURCE_ID)) + }, [cedarInitialized, cedarIsInitializing, hasCedarReadPermission]) SetTitle(t('menus.dashboard')) const initPermissions = useCallback(async () => { if (!hasSession || !cedarInitialized) return - await authorizeHelper(dashboardScopes) - }, [hasSession, cedarInitialized, authorizeHelper, dashboardScopes]) + await authorizeHelper(DASHBOARD_SCOPES) + }, [hasSession, cedarInitialized, authorizeHelper]) useEffect(() => { if (hasSession && cedarInitialized && !cedarIsInitializing) { @@ -350,7 +340,7 @@ const DashboardPage = () => { {showModal} - +
diff --git a/admin-ui/app/routes/Dashboards/styles.ts b/admin-ui/app/routes/Dashboards/styles.ts index 688390ce99..59d3c4914b 100644 --- a/admin-ui/app/routes/Dashboards/styles.ts +++ b/admin-ui/app/routes/Dashboards/styles.ts @@ -255,6 +255,9 @@ const styles = makeStyles<{ themeColors: DashboardThemeColors; isDark: boolean } height: '70px', }, }, + topGridNoMargin: { + marginBottom: 0, + }, statusContainer: { width: '100%', color: themeColors.text, diff --git a/admin-ui/app/routes/License/LicenseDetailsPage.style.ts b/admin-ui/app/routes/License/LicenseDetailsPage.style.ts index 9c08b589a6..7c5f02af0f 100644 --- a/admin-ui/app/routes/License/LicenseDetailsPage.style.ts +++ b/admin-ui/app/routes/License/LicenseDetailsPage.style.ts @@ -60,6 +60,12 @@ export const useStyles = makeStyles()((theme, { isDark }) => { display: 'flex', justifyContent: 'flex-start', }, + resetButton: { + gap: `${SPACING.CARD_CONTENT_GAP}px`, + }, + refreshIcon: { + fontSize: fontSizes.md, + }, card: { backgroundColor: isDark ? customColors.darkCardBg : customColors.white, borderRadius: '16px', diff --git a/admin-ui/app/routes/License/LicenseDetailsPage.tsx b/admin-ui/app/routes/License/LicenseDetailsPage.tsx index d27995d36b..237e79970b 100644 --- a/admin-ui/app/routes/License/LicenseDetailsPage.tsx +++ b/admin-ui/app/routes/License/LicenseDetailsPage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState, useContext, useCallback } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch } from 'react-redux' import { useTranslation } from 'react-i18next' +import { useAppSelector } from '@/redux/hooks' import { ThemeContext } from '@/context/theme/themeContext' import { DEFAULT_THEME, THEME_DARK } from '@/context/theme/constants' import customColors from '@/customColors' @@ -18,34 +19,44 @@ import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' import { CEDAR_RESOURCE_SCOPES } from '@/cedarling/constants/resourceScopes' import GluuCommitDialog from '../Apps/Gluu/GluuCommitDialog' import type { LicenseField } from './types' -import type { LicenseDetailsState } from 'Redux/features/licenseDetailsSlice' import { useAppNavigation, ROUTES } from '@/helpers/navigation' import { useStyles } from './LicenseDetailsPage.style' const PLACEHOLDER = '_' +const LICENSE_RESOURCE_ID = ADMIN_UI_RESOURCES.License +const LICENSE_SCOPES = CEDAR_RESOURCE_SCOPES[LICENSE_RESOURCE_ID] + +const LICENSE_FIELD_CONFIG: ReadonlyArray<{ key: string; label: string }> = [ + { key: 'productName', label: 'fields.productName' }, + { key: 'productCode', label: 'fields.productCode' }, + { key: 'licenseType', label: 'fields.licenseType' }, + { key: 'licenseKey', label: 'fields.licenseKey' }, + { key: 'customerEmail', label: 'fields.customerEmail' }, + { key: 'customerName', label: 'fields.customerName' }, + { key: 'companyName', label: 'fields.companyName' }, + { key: 'validityPeriod', label: 'fields.validityPeriod' }, + { key: 'isLicenseActive', label: 'fields.isLicenseActive' }, + { key: 'isLicenseExpired', label: 'fields.isLicenseExpired' }, +] const LicenseDetailsPage = () => { - const { item, loading } = useSelector( - (state: { licenseDetailsReducer: LicenseDetailsState }) => state.licenseDetailsReducer, - ) + const { item, loading } = useAppSelector((state) => state.licenseDetailsReducer) const dispatch = useDispatch() const { t } = useTranslation() const { hasCedarWritePermission, authorizeHelper } = useCedarling() const [modal, setModal] = useState(false) const { navigateToRoute } = useAppNavigation() - const licenseResourceId = useMemo(() => ADMIN_UI_RESOURCES.License, []) - const licenseScopes = useMemo(() => CEDAR_RESOURCE_SCOPES[licenseResourceId], [licenseResourceId]) const canWriteLicense = useMemo( - () => hasCedarWritePermission(licenseResourceId), - [hasCedarWritePermission, licenseResourceId], + () => hasCedarWritePermission(LICENSE_RESOURCE_ID), + [hasCedarWritePermission], ) useEffect(() => { - if (licenseScopes && licenseScopes.length > 0) { - authorizeHelper(licenseScopes) + if (LICENSE_SCOPES?.length) { + authorizeHelper(LICENSE_SCOPES) } - }, [authorizeHelper, licenseScopes]) + }, [authorizeHelper]) useEffect(() => { dispatch(getLicenseDetails()) @@ -63,60 +74,24 @@ const LicenseDetailsPage = () => { const { classes } = useStyles({ isDark }) const licenseFields = useMemo( - () => [ - { - key: 'productName', - label: 'fields.productName', - value: loading ? PLACEHOLDER : item.productName, - }, - { - key: 'productCode', - label: 'fields.productCode', - value: loading ? PLACEHOLDER : item.productCode, - }, - { - key: 'licenseType', - label: 'fields.licenseType', - value: loading ? PLACEHOLDER : item.licenseType, - }, - { - key: 'licenseKey', - label: 'fields.licenseKey', - value: loading ? PLACEHOLDER : item.licenseKey, - }, - { - key: 'customerEmail', - label: 'fields.customerEmail', - value: loading ? PLACEHOLDER : item.customerEmail, - }, - { - key: 'customerName', - label: 'fields.customerName', - value: loading - ? PLACEHOLDER - : [item.customerFirstName, item.customerLastName].filter(Boolean).join(' '), - }, - { - key: 'companyName', - label: 'fields.companyName', - value: loading ? PLACEHOLDER : item.companyName, - }, - { - key: 'validityPeriod', - label: 'fields.validityPeriod', - value: loading ? PLACEHOLDER : item.validityPeriod ? formatDate(item.validityPeriod) : null, - }, - { - key: 'isLicenseActive', - label: 'fields.isLicenseActive', - value: loading ? PLACEHOLDER : item.licenseActive ? t('actions.yes') : t('actions.no'), - }, - { - key: 'isLicenseExpired', - label: 'fields.isLicenseExpired', - value: loading ? PLACEHOLDER : item.licenseExpired ? t('actions.yes') : t('actions.no'), - }, - ], + () => + LICENSE_FIELD_CONFIG.map(({ key, label }) => { + let value: string | null + if (loading) { + value = PLACEHOLDER + } else if (key === 'customerName') { + value = [item.customerFirstName, item.customerLastName].filter(Boolean).join(' ') || null + } else if (key === 'validityPeriod') { + value = item.validityPeriod ? formatDate(item.validityPeriod) : null + } else if (key === 'isLicenseActive') { + value = item.licenseActive ? t('actions.yes') : t('actions.no') + } else if (key === 'isLicenseExpired') { + value = item.licenseExpired ? t('actions.yes') : t('actions.no') + } else { + value = (item as Record)[key] ?? null + } + return { key, label, value } + }), [loading, item, t], ) @@ -163,14 +138,14 @@ const LicenseDetailsPage = () => { {canWriteLicense && (
- + {t('fields.resetLicense')}
diff --git a/admin-ui/app/utils/regex.ts b/admin-ui/app/utils/regex.ts index bd2ce7e78d..fd61cfe119 100644 --- a/admin-ui/app/utils/regex.ts +++ b/admin-ui/app/utils/regex.ts @@ -1,2 +1,9 @@ /** Trailing period at end of string - used to strip trailing dots from status messages etc. */ export const REGEX_TRAILING_PERIOD = /\.$/ + +/** Replace characters that are invalid in HTML id attributes (keep alphanumeric, underscore, hyphen). */ +export const REGEX_ID_SANITIZE_CHARS = /[^a-zA-Z0-9_-]/g +/** Collapse consecutive hyphens into one. */ +export const REGEX_ID_COLLAPSE_HYPHENS = /-+/g +/** Remove one or more leading/trailing hyphens. */ +export const REGEX_ID_TRIM_HYPHENS = /^-+|-+$/g diff --git a/admin-ui/app/utils/validation/index.ts b/admin-ui/app/utils/validation/index.ts index 5541d4b756..863739d64d 100644 --- a/admin-ui/app/utils/validation/index.ts +++ b/admin-ui/app/utils/validation/index.ts @@ -3,3 +3,4 @@ export { COMMIT_MESSAGE_MIN_LENGTH, COMMIT_MESSAGE_MAX_LENGTH, } from './commitMessage' +export { isValidUrl, isValidUrlAnyProtocol } from './url' diff --git a/admin-ui/app/utils/validation/url.ts b/admin-ui/app/utils/validation/url.ts new file mode 100644 index 0000000000..31669903a9 --- /dev/null +++ b/admin-ui/app/utils/validation/url.ts @@ -0,0 +1,21 @@ +export const isValidUrl = (url: string): boolean => { + const trimmed = url.trim() + if (!trimmed) return true + try { + const parsed = new URL(trimmed) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + +export const isValidUrlAnyProtocol = (url: string): boolean => { + const trimmed = url.trim() + if (!trimmed) return true + try { + new URL(trimmed) + return true + } catch { + return false + } +} diff --git a/admin-ui/plugins.config.json b/admin-ui/plugins.config.json index ad2250af11..e85321be2a 100644 --- a/admin-ui/plugins.config.json +++ b/admin-ui/plugins.config.json @@ -45,11 +45,6 @@ "key": "fido", "metadataFile": "./fido/plugin-metadata" }, - { - "order": 10, - "key": "saml", - "metadataFile": "./saml/plugin-metadata" - }, { "order": 11, "key": "jans-lock", diff --git a/admin-ui/plugins/admin/components/Cedarling/CedarlingConfigPage.style.ts b/admin-ui/plugins/admin/components/Cedarling/CedarlingConfigPage.style.ts new file mode 100644 index 0000000000..322da7ffcc --- /dev/null +++ b/admin-ui/plugins/admin/components/Cedarling/CedarlingConfigPage.style.ts @@ -0,0 +1,217 @@ +import { makeStyles } from 'tss-react/mui' +import type { Theme } from '@mui/material/styles' +import { alpha } from '@mui/material/styles' +import { BORDER_RADIUS, CEDARLING_CONFIG_SPACING, SPACING } from '@/constants' +import { fontFamily, fontWeights, fontSizes, lineHeights, letterSpacing } from '@/styles/fonts' +import customColors from '@/customColors' + +interface CedarlingConfigThemeColors { + cardBg: string + navbarBorder: string + text: string + alertText: string + infoBg: string + infoBorder: string + inputBg: string + placeholderText: string +} + +const sectionLabelBase = { + fontFamily, + fontWeight: fontWeights.semiBold, + fontSize: fontSizes.md, + lineHeight: lineHeights.normal, + letterSpacing: letterSpacing.normal, +} as const + +const useStyles = makeStyles<{ themeColors: CedarlingConfigThemeColors; isDark: boolean }>()(( + theme: Theme, + params, +) => { + const { themeColors, isDark } = params + + return { + configCard: { + backgroundColor: 'transparent', + padding: 0, + width: '100%', + boxSizing: 'border-box', + position: 'relative', + display: 'flex', + flexDirection: 'column', + overflow: 'visible', + }, + formContent: { + width: '100%', + display: 'flex', + flexDirection: 'column', + }, + alertWrapper: { + width: '100%', + marginBottom: CEDARLING_CONFIG_SPACING.ALERT_TO_INPUT, + }, + alertBox: { + backgroundColor: themeColors.infoBg, + border: `1px solid ${themeColors.infoBorder}`, + borderRadius: BORDER_RADIUS.DEFAULT, + padding: `${CEDARLING_CONFIG_SPACING.ALERT_PADDING_TOP}px ${CEDARLING_CONFIG_SPACING.ALERT_PADDING_RIGHT}px ${CEDARLING_CONFIG_SPACING.ALERT_PADDING_BOTTOM}px ${CEDARLING_CONFIG_SPACING.ALERT_PADDING_LEFT}px`, + position: 'relative', + width: '100%', + boxSizing: 'border-box', + }, + alertIcon: { + position: 'absolute', + left: CEDARLING_CONFIG_SPACING.ALERT_ICON_LEFT, + top: CEDARLING_CONFIG_SPACING.ALERT_ICON_TOP, + width: CEDARLING_CONFIG_SPACING.ICON_SIZE_MD, + height: CEDARLING_CONFIG_SPACING.ICON_SIZE_MD, + color: themeColors.alertText, + }, + alertStepTitle: { + fontFamily, + fontWeight: fontWeights.medium, + fontSize: fontSizes.base, + lineHeight: lineHeights.tight, + color: themeColors.alertText, + marginBottom: CEDARLING_CONFIG_SPACING.ALERT_TITLE_MB, + }, + alertBody: { + fontFamily, + fontWeight: fontWeights.medium, + fontSize: fontSizes.base, + lineHeight: lineHeights.tight, + color: themeColors.alertText, + }, + inputSection: { + 'marginBottom': CEDARLING_CONFIG_SPACING.INPUT_TO_RADIO, + '& .MuiFormHelperText-root': { + marginLeft: 0, + paddingLeft: 0, + fontSize: fontSizes.base, + lineHeight: lineHeights.tight, + }, + }, + fieldLabel: { + ...sectionLabelBase, + color: themeColors.text, + marginBottom: CEDARLING_CONFIG_SPACING.LABEL_MB, + }, + inputField: { + '& .MuiOutlinedInput-root': { + 'backgroundColor': themeColors.inputBg, + 'borderRadius': BORDER_RADIUS.SMALL, + 'height': CEDARLING_CONFIG_SPACING.INPUT_HEIGHT, + '& fieldset': { + borderColor: isDark ? 'transparent' : customColors.borderInput, + }, + '&:hover fieldset': { + borderColor: isDark ? 'transparent' : customColors.lightGray, + }, + '&.Mui-focused fieldset': { + borderColor: customColors.lightBlue, + }, + '&.Mui-disabled': { + '& .MuiInputBase-input': { + 'color': themeColors.text, + 'WebkitTextFillColor': themeColors.text, + '&::placeholder': { + color: themeColors.placeholderText, + opacity: 0.6, + }, + }, + }, + }, + '& .MuiInputBase-input': { + 'color': themeColors.text, + fontFamily, + 'fontSize': fontSizes.base, + 'padding': `${CEDARLING_CONFIG_SPACING.INPUT_PADDING_VERTICAL}px ${CEDARLING_CONFIG_SPACING.INPUT_PADDING_HORIZONTAL}px`, + '&::placeholder': { + color: themeColors.placeholderText, + opacity: 0.6, + }, + }, + }, + radioSection: { + marginBottom: CEDARLING_CONFIG_SPACING.HELPER_MT, + }, + radioLabel: { + ...sectionLabelBase, + color: themeColors.text, + marginBottom: CEDARLING_CONFIG_SPACING.RADIO_LABEL_MB, + }, + radio: { + 'color': themeColors.text, + '&.Mui-checked': { + 'color': customColors.statusActive, + '& .MuiSvgIcon-root': { + fill: customColors.statusActive, + stroke: customColors.statusActive, + strokeWidth: '1px', + }, + }, + }, + radioText: { + fontFamily, + fontWeight: fontWeights.medium, + fontSize: fontSizes.base, + lineHeight: lineHeights.relaxed, + color: isDark ? customColors.cedarTextSecondaryDark : customColors.textSecondary, + }, + helperText: { + fontFamily, + fontWeight: fontWeights.medium, + fontSize: fontSizes.base, + lineHeight: lineHeights.tight, + color: isDark ? customColors.cedarTextSecondaryDark : customColors.cedarInfoTextLight, + marginTop: CEDARLING_CONFIG_SPACING.HELPER_MT, + }, + inputHelperText: { + marginLeft: 0, + paddingLeft: 0, + }, + fieldRow: { + display: 'flex', + alignItems: 'center', + gap: SPACING.CARD_CONTENT_GAP, + width: '100%', + [theme.breakpoints.down('sm')]: { + flexDirection: 'column', + alignItems: 'stretch', + }, + }, + disabledPolicyTooltip: { + fontSize: fontSizes.base, + lineHeight: lineHeights.tight, + maxWidth: CEDARLING_CONFIG_SPACING.TOOLTIP_MAX_WIDTH, + }, + inputFieldWrapper: { + display: 'block', + flex: 1, + minWidth: 0, + }, + radioGroup: { + gap: `${CEDARLING_CONFIG_SPACING.RADIO_GROUP_GAP}px`, + }, + refreshIconButton: { + 'marginTop': 0, + 'color': customColors.logo, + '&:hover': { + backgroundColor: alpha(customColors.logo, 0.08), + }, + }, + alertLink: { + 'fontWeight': fontWeights.medium, + 'color': themeColors.text, + 'textDecoration': 'none', + '&:hover': { + textDecoration: 'underline', + }, + }, + buttonSection: { + marginTop: CEDARLING_CONFIG_SPACING.BUTTONS_MT, + }, + } +}) + +export { useStyles } diff --git a/admin-ui/plugins/admin/components/Cedarling/CedarlingConfigPage.tsx b/admin-ui/plugins/admin/components/Cedarling/CedarlingConfigPage.tsx index b9d02b9244..239062c8ff 100644 --- a/admin-ui/plugins/admin/components/Cedarling/CedarlingConfigPage.tsx +++ b/admin-ui/plugins/admin/components/Cedarling/CedarlingConfigPage.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import SetTitle from 'Utils/SetTitle' -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch } from 'react-redux' +import { useAppSelector } from '@/redux/hooks' import { useCedarling } from '@/cedarling' import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' import { CEDAR_RESOURCE_SCOPES } from '@/cedarling/constants/resourceScopes' @@ -22,40 +23,31 @@ import type { import { updateToast } from '@/redux/features/toastSlice' import { getErrorMessage, type ApiError } from 'Plugins/schema/utils/errorHandler' import { logAudit } from '@/utils/AuditLogger' -import type { RootState as AuditRootState } from '@/redux/sagas/types/audit' +import { isValidUrl } from '@/utils/validation' import { UPDATE } from '@/audit/UserActionType' import { Box, - Typography, - Paper, TextField, FormControlLabel, Radio, RadioGroup, IconButton, - Alert, - Stack, - FormControl, - FormLabel, Link, - ThemeProvider, - createTheme, } from '@mui/material' import { RefreshOutlined, InfoOutlined } from '@mui/icons-material' +import Tooltip from '@mui/material/Tooltip' import GluuTooltip from '@/routes/Apps/Gluu/GluuTooltip' import { ADMIN_UI_CEDARLING_CONFIG } from 'Plugins/admin/redux/audit/Resources' import { useQueryClient } from '@tanstack/react-query' -import customColors from '@/customColors' - -const isValidUrl = (url: string): boolean => { - if (!url.trim()) return true - try { - const parsed = new URL(url) - return parsed.protocol === 'http:' || parsed.protocol === 'https:' - } catch { - return false - } -} +import { GluuPageContent } from '@/components' +import GluuText from 'Routes/Apps/Gluu/GluuText' +import { useTheme } from 'Context/theme/themeContext' +import { themeConfig } from '@/context/theme/config' +import { THEME_DARK, DEFAULT_THEME } from '@/context/theme/constants' +import { useStyles } from './CedarlingConfigPage.style' + +const SECURITY_RESOURCE_ID = ADMIN_UI_RESOURCES.Security +const SECURITY_SCOPES = CEDAR_RESOURCE_SCOPES[SECURITY_RESOURCE_ID] ?? [] const CedarlingConfigPage: React.FC = () => { const { hasCedarReadPermission, hasCedarWritePermission, authorizeHelper } = useCedarling() @@ -67,67 +59,77 @@ const CedarlingConfigPage: React.FC = () => { const [isLoading, setIsLoading] = useState(false) const queryClient = useQueryClient() - const muiTheme = useMemo(() => { - const primaryColor = customColors.logo - return createTheme({ - palette: { - primary: { - main: primaryColor, - }, - }, - }) - }, []) + const { state: themeState } = useTheme() + const currentTheme = themeState?.theme || DEFAULT_THEME + const isDark = currentTheme === THEME_DARK + const theme = themeConfig[currentTheme] + + const cedarThemeColors = useMemo( + () => ({ + cardBg: 'transparent', + navbarBorder: theme.navbar.border, + text: theme.fontColor, + alertText: theme.infoAlert.text, + infoBg: theme.infoAlert.background, + infoBorder: theme.infoAlert.border, + inputBg: theme.inputBackground, + placeholderText: theme.textMuted, + }), + [theme], + ) + + const { classes } = useStyles({ themeColors: cedarThemeColors, isDark }) const editAdminuiConfMutation = useEditAdminuiConf() const syncRoleToScopesMappingsMutation = useSyncRoleToScopesMappings() const setRemotePolicyStoreAsDefaultMutation = useSetRemotePolicyStoreAsDefault() - const userinfo: AuditRootState['authReducer']['userinfo'] | undefined = useSelector( - (state: AuditRootState) => state.authReducer?.userinfo, - ) - const client_id: string | undefined = useSelector( - (state: AuditRootState) => state.authReducer?.config?.clientId, - ) + const userinfo = useAppSelector((state) => state.authReducer?.userinfo) + const client_id = useAppSelector((state) => state.authReducer?.config?.clientId) const [cedarlingPolicyStoreRetrievalPoint, setCedarlingPolicyStoreRetrievalPoint] = useState('remote') const dispatch = useDispatch() const urlError = useMemo(() => { + if (cedarlingPolicyStoreRetrievalPoint === 'default') return '' if (!urlTouched) return '' - if (!auiPolicyStoreUrl.trim()) return t('messages.is_required') - if (!isValidUrl(auiPolicyStoreUrl)) return t('messages.invalid_url_error') + if (!auiPolicyStoreUrl.trim()) return t('messages.field_required') + if (!isValidUrl(auiPolicyStoreUrl)) + return t('documentation.cedarlingConfig.policyStoreUrlInvalidError') return '' - }, [auiPolicyStoreUrl, urlTouched, t]) + }, [cedarlingPolicyStoreRetrievalPoint, auiPolicyStoreUrl, urlTouched, t]) const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault() setUrlTouched(true) - // when user tries to save default policy store with empty URL - if ( - auiConfig?.cedarlingPolicyStoreRetrievalPoint === 'default' && - cedarlingPolicyStoreRetrievalPoint === 'default' && - auiPolicyStoreUrl.trim() === '' - ) { - const errorMessage = `${t('messages.default_policy_store_is_used')}` - dispatch(updateToast(true, 'error', errorMessage)) - return - } - //when user tries to save remote policy store with empty URL - if (auiPolicyStoreUrl.trim() === '') { - const errorMessage = `${t('messages.error_in_saving')} field: ${t('fields.auiPolicyStoreUrl')} ${t('messages.is_required')}` - dispatch(updateToast(true, 'error', errorMessage)) - return - } - if (!isValidUrl(auiPolicyStoreUrl)) { - const errorMessage = `${t('messages.error_in_saving')} field: ${t('fields.auiPolicyStoreUrl')} ${t('messages.invalid_url_error')}` - dispatch(updateToast(true, 'error', errorMessage)) - return + const trimmedUrl = auiPolicyStoreUrl.trim() + if (cedarlingPolicyStoreRetrievalPoint === 'remote') { + if (!trimmedUrl) { + dispatch( + updateToast( + true, + 'error', + `${t('fields.auiPolicyStoreUrl')}: ${t('messages.field_required')}`, + ), + ) + return + } + if (!isValidUrl(trimmedUrl)) { + dispatch( + updateToast( + true, + 'error', + `${t('messages.error_in_saving')} ${t('fields.auiPolicyStoreUrl')}: ${t('documentation.cedarlingConfig.policyStoreUrlInvalidError')}`, + ), + ) + return + } } const requestData = { - auiPolicyStoreUrl, + auiPolicyStoreUrl: trimmedUrl, cedarlingPolicyStoreRetrievalPoint, } @@ -141,7 +143,7 @@ const CedarlingConfigPage: React.FC = () => { editAppConfigResponse?.cedarlingPolicyStoreRetrievalPoint || 'remote', ) - let userMessage: string = 'Policy Store URL configuration updated' + let userMessage: string = t('documentation.cedarlingConfig.auditPolicyStoreUrlUpdated') await logAudit({ userinfo: userinfo ?? undefined, action: UPDATE, @@ -153,7 +155,7 @@ const CedarlingConfigPage: React.FC = () => { await syncRoleToScopesMappingsMutation.mutateAsync() - userMessage = 'sync role to scopes mappings' + userMessage = t('documentation.cedarlingConfig.auditSyncRoleToScopesMappings') await logAudit({ userinfo: userinfo ?? undefined, action: UPDATE, @@ -200,14 +202,14 @@ const CedarlingConfigPage: React.FC = () => { dispatch(updateToast(true, 'success')) - const userMessage: string = 'Set policy store as default' + const userMessage: string = t('documentation.cedarlingConfig.auditSetPolicyStoreAsDefault') await logAudit({ userinfo: userinfo ?? undefined, action: UPDATE, resource: ADMIN_UI_CEDARLING_CONFIG, message: userMessage, client_id: client_id, - payload: {}, + payload: { auiPolicyStoreUrl: auiConfig?.auiPolicyStoreUrl ?? auiPolicyStoreUrl ?? '' }, }) } catch (error) { console.error('Error updating Cedarling configuration:', error) @@ -221,7 +223,15 @@ const CedarlingConfigPage: React.FC = () => { setIsLoading(false) } }, - [dispatch, setRemotePolicyStoreAsDefaultMutation, userinfo, client_id, t], + [ + dispatch, + setRemotePolicyStoreAsDefaultMutation, + userinfo, + client_id, + t, + auiConfig?.auiPolicyStoreUrl, + auiPolicyStoreUrl, + ], ) const handleInputChange = useCallback((e: React.ChangeEvent) => { @@ -233,22 +243,17 @@ const CedarlingConfigPage: React.FC = () => { }, []) const handleRadioChange = useCallback((e: React.ChangeEvent) => { - setCedarlingPolicyStoreRetrievalPoint( - e.target.value as AppConfigResponseCedarlingPolicyStoreRetrievalPoint, - ) + const value = e.target.value as AppConfigResponseCedarlingPolicyStoreRetrievalPoint + setCedarlingPolicyStoreRetrievalPoint(value) }, []) - const securityResourceId = useMemo(() => ADMIN_UI_RESOURCES.Security, []) - const securityScopes = useMemo(() => { - return CEDAR_RESOURCE_SCOPES[securityResourceId] || [] - }, [securityResourceId]) const canReadSecurity = useMemo( - () => hasCedarReadPermission(securityResourceId), - [hasCedarReadPermission, securityResourceId], + () => hasCedarReadPermission(SECURITY_RESOURCE_ID), + [hasCedarReadPermission], ) const canWriteSecurity = useMemo( - () => hasCedarWritePermission(securityResourceId), - [hasCedarWritePermission, securityResourceId], + () => hasCedarWritePermission(SECURITY_RESOURCE_ID), + [hasCedarWritePermission], ) const isRefreshButtonHidden = useMemo( @@ -261,188 +266,197 @@ const CedarlingConfigPage: React.FC = () => { [canWriteSecurity, isLoading], ) + const isPolicyUrlInputDisabled = useMemo( + () => cedarlingPolicyStoreRetrievalPoint === 'default' || !canWriteSecurity || isLoading, + [cedarlingPolicyStoreRetrievalPoint, canWriteSecurity, isLoading], + ) + useEffect(() => { - if (securityScopes && securityScopes.length > 0) { - authorizeHelper(securityScopes) + if (SECURITY_SCOPES.length > 0) { + authorizeHelper(SECURITY_SCOPES) } - }, [authorizeHelper, securityScopes]) + }, [authorizeHelper]) useEffect(() => { if (isSuccess && auiConfig) { + const retrievalPoint = auiConfig?.cedarlingPolicyStoreRetrievalPoint || 'remote' + setCedarlingPolicyStoreRetrievalPoint(retrievalPoint) setAuiPolicyStoreUrl(auiConfig?.auiPolicyStoreUrl || '') - setCedarlingPolicyStoreRetrievalPoint( - auiConfig?.cedarlingPolicyStoreRetrievalPoint || 'remote', - ) } }, [isSuccess, auiConfig]) return ( - - - - {t('documentation.cedarlingConfig.title')} - - - } - sx={{ - 'mb': 3, - '& .MuiAlert-message': { width: '100%' }, - }} - > - - {t('documentation.cedarlingConfig.steps')}{' '} - - - {t('documentation.cedarlingConfig.point1')}{' '} - - GluuFlexAdminUIPolicyStore - - . - - - {t('documentation.cedarlingConfig.point2')}{' '} - - Agama Lab's Policy Designer - - . - - - {t('documentation.cedarlingConfig.point3')}{' '} - - - {t('documentation.cedarlingConfig.point4')}{' '} - - - {t('documentation.cedarlingConfig.point5')}{' '} - - - {t('documentation.cedarlingConfig.point6')}{' '} - - - -
- - - + + + + + - {!isRefreshButtonHidden && ( - + {t('documentation.cedarlingConfig.steps')} + + + {t('documentation.cedarlingConfig.point1')}{' '} + - + . + + + {t('documentation.cedarlingConfig.point2')}{' '} + + {t('documentation.cedarlingConfig.agamaLabPolicyDesigner')} + + . + + + {t('documentation.cedarlingConfig.point3')} + + + {t('documentation.cedarlingConfig.point4')} + + + {t('documentation.cedarlingConfig.point5')} + + + {t('documentation.cedarlingConfig.point6')} + + + + + + + + {t('fields.auiPolicyStoreUrl')}: + + + {cedarlingPolicyStoreRetrievalPoint === 'default' ? ( + + + + + + ) : ( + + + + )} + {!isRefreshButtonHidden && ( + - - - - )} + + + + + )} + - - + + {t('fields.cedarlingPolicyStoreRetrievalPoint')}: + + - {t('fields.cedarlingPolicyStoreRetrievalPoint')} - - - - } - label={ - - Remote - - } - disabled={isInputDisabled} - /> - } - label={ - - Default - - } - disabled={isInputDisabled} - /> - - - + } + label={ + + {t('messages.remote')} + + } + disabled={isInputDisabled} + sx={{ marginRight: 0 }} + /> + } + label={ + + {t('messages.default')} + + } + disabled={isInputDisabled} + sx={{ marginRight: 0 }} + /> + + {t('documentation.cedarlingConfig.useRemotePolicyStore')} - - - - - -
+ +
+ + + + + + -
+
) diff --git a/admin-ui/plugins/admin/components/Health/HealthPage.style.ts b/admin-ui/plugins/admin/components/Health/HealthPage.style.ts index 1b3bf4f576..8ffb84d11e 100644 --- a/admin-ui/plugins/admin/components/Health/HealthPage.style.ts +++ b/admin-ui/plugins/admin/components/Health/HealthPage.style.ts @@ -3,6 +3,7 @@ import type { Theme } from '@mui/material/styles' import { SPACING, BORDER_RADIUS } from '@/constants' import { fontFamily, fontWeights, fontSizes, lineHeights } from '@/styles/fonts' import { getCardBorderStyle } from '@/styles/cardBorderStyles' +import customColors from '@/customColors' interface HealthPageThemeColors { cardBg: string @@ -103,6 +104,12 @@ const useStyles = makeStyles<{ themeColors: HealthPageThemeColors; isDark: boole alignItems: 'center', gap: SPACING.CARD_CONTENT_GAP, }, + errorMessage: { + color: customColors.accentRed, + }, + infoMessage: { + color: customColors.textSecondary, + }, errorIcon: { color: 'inherit', flexShrink: 0, @@ -131,6 +138,9 @@ const useStyles = makeStyles<{ themeColors: HealthPageThemeColors; isDark: boole serviceCardWrapper: { minWidth: 0, }, + refreshIcon: { + fontSize: 16, + }, } }) diff --git a/admin-ui/plugins/admin/components/Health/HealthPage.tsx b/admin-ui/plugins/admin/components/Health/HealthPage.tsx index 1900132f17..89103792fb 100644 --- a/admin-ui/plugins/admin/components/Health/HealthPage.tsx +++ b/admin-ui/plugins/admin/components/Health/HealthPage.tsx @@ -68,10 +68,7 @@ const HealthPage: React.FC = () => { textColor={healthThemeColors.refreshButtonText} useOpacityOnHover > - + {t('actions.refresh')}
@@ -79,14 +76,14 @@ const HealthPage: React.FC = () => {
{isError && ( -
+
{t('messages.error_fetching_health_status')}
)} {!isLoading && !isError && services.length === 0 && ( -
+
{t('messages.no_services_found')} diff --git a/admin-ui/plugins/admin/components/Health/hooks/useHealthStatus.ts b/admin-ui/plugins/admin/components/Health/hooks/useHealthStatus.ts index efbffce374..f39cb04b09 100644 --- a/admin-ui/plugins/admin/components/Health/hooks/useHealthStatus.ts +++ b/admin-ui/plugins/admin/components/Health/hooks/useHealthStatus.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react' -import { useSelector } from 'react-redux' import { useGetServiceStatus, type JsonNode } from 'JansConfigApi' -import type { RootState } from 'Redux/sagas/types/audit' +import { useAppSelector } from '@/redux/hooks' import type { ServiceHealth, ServiceStatusValue, ServiceStatusResponse } from '../types' import { HEALTH_CACHE_CONFIG, @@ -45,7 +44,7 @@ const transformServiceStatus = (data: JsonNode | undefined): ServiceHealth[] => } export const useHealthStatus = () => { - const hasSession = useSelector((state: RootState) => state.authReducer?.hasSession) + const hasSession = useAppSelector((state) => state.authReducer?.hasSession) const query = useGetServiceStatus(undefined, { query: { diff --git a/admin-ui/plugins/admin/components/Mapping/MappingItem.tsx b/admin-ui/plugins/admin/components/Mapping/MappingItem.tsx deleted file mode 100644 index 8f23f8cbb1..0000000000 --- a/admin-ui/plugins/admin/components/Mapping/MappingItem.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React from 'react' -import { Accordion, AccordionSummary, AccordionDetails, Typography, Chip, Box } from '@mui/material' -import { ExpandMore, SecurityOutlined } from '@mui/icons-material' -import { useTranslation } from 'react-i18next' -import type { MappingItemProps } from './types' -import customColors from '@/customColors' - -const MappingItem: React.FC = React.memo(function MappingItem({ candidate }) { - const { t } = useTranslation() - - const permissionsCount = candidate?.permissions?.length || 0 - - const permissionChips = candidate?.permissions?.map((permission, idx) => ( - - )) - - return ( - - } - sx={{ - 'borderRadius': '8px', - '&:hover': { - backgroundColor: 'action.hover', - }, - '& .MuiAccordionSummary-content': { - alignItems: 'center', - justifyContent: 'space-between', - }, - }} - > - - - - {candidate?.role} - - - - - - - {permissionChips} - - {permissionsCount === 0 && ( - - {t('messages.no_permissions_assigned')} - - )} - - - ) -}) - -export default MappingItem diff --git a/admin-ui/plugins/admin/components/Mapping/MappingPage.tsx b/admin-ui/plugins/admin/components/Mapping/MappingPage.tsx deleted file mode 100644 index a133f3c9b0..0000000000 --- a/admin-ui/plugins/admin/components/Mapping/MappingPage.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React, { useEffect, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { Typography, Alert, Box } from '@mui/material' -import { InfoOutlined } from '@mui/icons-material' -import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' -import { Card, CardBody } from 'Components' -import GluuViewWrapper from 'Routes/Apps/Gluu/GluuViewWrapper' -import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' -import MappingItem from './MappingItem' -import SetTitle from 'Utils/SetTitle' -import { useCedarling } from '@/cedarling' -import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' -import { CEDAR_RESOURCE_SCOPES } from '@/cedarling/constants/resourceScopes' -import { Link } from 'react-router-dom' -import { useMappingData } from './hooks' - -const MappingPage: React.FC = React.memo(function MappingPage() { - const { t } = useTranslation() - SetTitle(t('titles.mapping')) - const { hasCedarReadPermission, authorizeHelper } = useCedarling() - - const mappingResourceId = useMemo(() => ADMIN_UI_RESOURCES.Security, []) - const mappingScopes = useMemo( - () => CEDAR_RESOURCE_SCOPES[mappingResourceId] || [], - [mappingResourceId], - ) - const canReadMapping = useMemo( - () => hasCedarReadPermission(mappingResourceId), - [hasCedarReadPermission, mappingResourceId], - ) - - const { mapping, isLoading, isError } = useMappingData(canReadMapping) - - useEffect(() => { - if (mappingScopes && mappingScopes.length > 0) { - authorizeHelper(mappingScopes) - } - }, [authorizeHelper, mappingScopes]) - - const mappingList = useMemo( - () => - mapping.map((candidate, idx) => ( - - )), - [mapping], - ) - - return ( - - - - - - {t('titles.mapping')} - - - {t('messages.role_permission_mapping_description') || - 'Manage role-to-permission mappings for the Admin UI'} - - - - } - sx={{ - 'mb': 3, - '& .MuiAlert-message': { - width: '100%', - }, - }} - > - - {t('documentation.mappings.note_prefix')}{' '} - - Cedarling - {' '} - {t('documentation.mappings.note_suffix')} - - - - {isError && ( - - {t('messages.error_loading_mapping')} - - )} - - - {mappingList} - - - - - ) -}) - -export default MappingPage diff --git a/admin-ui/plugins/admin/components/Mapping/RolePermissionCard.tsx b/admin-ui/plugins/admin/components/Mapping/RolePermissionCard.tsx new file mode 100644 index 0000000000..ee8810430e --- /dev/null +++ b/admin-ui/plugins/admin/components/Mapping/RolePermissionCard.tsx @@ -0,0 +1,139 @@ +import React, { useState, useMemo, useCallback } from 'react' +import { Box, Collapse } from '@mui/material' +import { Check, ExpandMore } from '@mui/icons-material' +import { useTranslation } from 'react-i18next' +import { useTheme } from '@/context/theme/themeContext' +import { themeConfig } from '@/context/theme/config' +import { THEME_DARK } from '@/context/theme/constants' +import GluuText from 'Routes/Apps/Gluu/GluuText' +import { + REGEX_ID_SANITIZE_CHARS, + REGEX_ID_COLLAPSE_HYPHENS, + REGEX_ID_TRIM_HYPHENS, +} from '@/utils/regex' +import type { RolePermissionCardProps } from './types' +import { useStyles } from './styles/MappingPage.style' + +const CONTENT_ID_PREFIX = 'mapping-content-' +const CONTENT_ID_ROLE_FALLBACK = 'role' +const TOGGLE_KEYS = new Set(['Enter', ' ']) +const ARIA_ASSIGNED = 'assigned' + +interface ExtendedRolePermissionCardProps extends RolePermissionCardProps { + allPermissions: string[] + itemIndex?: number +} + +const PermissionCheckbox: React.FC<{ + permission: string + classes: ReturnType['classes'] +}> = React.memo(({ permission, classes }) => ( + + + + + + {permission} + + +)) + +PermissionCheckbox.displayName = 'PermissionCheckbox' + +const RolePermissionCard: React.FC = React.memo( + function RolePermissionCard({ candidate, allPermissions, itemIndex = 0 }) { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + + const { state } = useTheme() + const isDark = state.theme === THEME_DARK + const currentTheme = themeConfig[state.theme] + const { classes } = useStyles({ isDark, theme: currentTheme }) + + const rolePermissions = useMemo( + () => new Set(candidate?.permissions || []), + [candidate?.permissions], + ) + + const sortedPermissions = useMemo( + () => allPermissions.filter((p) => rolePermissions.has(p)).sort(), + [allPermissions, rolePermissions], + ) + + const handleToggle = useCallback(() => { + setIsExpanded((prev) => !prev) + }, []) + + const contentId = useMemo(() => { + const rolePart = + (candidate?.role ?? '') + .replace(REGEX_ID_SANITIZE_CHARS, '-') + .replace(REGEX_ID_COLLAPSE_HYPHENS, '-') + .replace(REGEX_ID_TRIM_HYPHENS, '') || CONTENT_ID_ROLE_FALLBACK + return `${CONTENT_ID_PREFIX}${itemIndex}-${rolePart}` + }, [candidate?.role, itemIndex]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (TOGGLE_KEYS.has(e.key)) { + e.preventDefault() + handleToggle() + } + }, + [handleToggle], + ) + + const permissionCheckboxes = useMemo(() => { + if (!isExpanded) return null + return sortedPermissions.map((permission) => ( + + )) + }, [isExpanded, sortedPermissions, classes]) + + return ( + + + + {candidate?.role} + + + + {t('messages.permissions_count', { count: rolePermissions.size })} + + + + + + + {sortedPermissions.length === 0 ? ( + + {t('messages.no_permissions_assigned')} + + ) : ( + {permissionCheckboxes} + )} + + + + ) + }, +) + +export default RolePermissionCard diff --git a/admin-ui/plugins/admin/components/Mapping/RolePermissionMappingPage.tsx b/admin-ui/plugins/admin/components/Mapping/RolePermissionMappingPage.tsx new file mode 100644 index 0000000000..2d49c656f5 --- /dev/null +++ b/admin-ui/plugins/admin/components/Mapping/RolePermissionMappingPage.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Alert, Box } from '@mui/material' +import { InfoOutlined } from '@mui/icons-material' +import { Link } from 'react-router-dom' +import { useTheme } from '@/context/theme/themeContext' +import { themeConfig } from '@/context/theme/config' +import { THEME_DARK } from '@/context/theme/constants' +import GluuText from 'Routes/Apps/Gluu/GluuText' +import GluuViewWrapper from 'Routes/Apps/Gluu/GluuViewWrapper' +import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' +import { GluuPageContent } from '@/components' +import RolePermissionCard from './RolePermissionCard' +import SetTitle from 'Utils/SetTitle' +import { ROUTES } from '@/helpers/navigation' +import { useCedarling } from '@/cedarling' +import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' +import { CEDAR_RESOURCE_SCOPES } from '@/cedarling/constants/resourceScopes' +import { useMappingData } from './hooks' +import { useStyles } from './styles/MappingPage.style' + +const MAPPING_RESOURCE_ID = ADMIN_UI_RESOURCES.Security +const MAPPING_SCOPES = CEDAR_RESOURCE_SCOPES[MAPPING_RESOURCE_ID] || [] + +const RolePermissionMappingPage: React.FC = React.memo(function RolePermissionMappingPage() { + const { t } = useTranslation() + SetTitle(t('titles.mapping')) + + const { state } = useTheme() + const isDark = state.theme === THEME_DARK + const currentTheme = themeConfig[state.theme] + const { classes } = useStyles({ isDark, theme: currentTheme }) + + const { hasCedarReadPermission, authorizeHelper } = useCedarling() + const canReadMapping = hasCedarReadPermission(MAPPING_RESOURCE_ID) + + const { mapping, permissions, isLoading, isError } = useMappingData(canReadMapping) + + const allPermissions = useMemo( + () => permissions.map((p) => p.permission).filter(Boolean) as string[], + [permissions], + ) + + useEffect(() => { + if (MAPPING_SCOPES.length > 0) { + authorizeHelper(MAPPING_SCOPES) + } + }, [authorizeHelper]) + + return ( + + + + + {t('messages.role_permission_mapping_description')} + + + + + + {t('documentation.mappings.note_prefix')}{' '} + + Cedarling + {' '} + {t('documentation.mappings.note_suffix')} + + + + {isError && ( + + {t('messages.error_loading_mapping')} + + )} + + + + {!isError && + (mapping.length === 0 ? ( + + {t('messages.no_role_mappings_found')} + + ) : ( + mapping.map((candidate, idx) => { + const roleKey = candidate?.role ?? 'role' + return ( + + ) + }) + ))} + + + + + + ) +}) + +export default RolePermissionMappingPage diff --git a/admin-ui/plugins/admin/components/Mapping/styles/MappingPage.style.ts b/admin-ui/plugins/admin/components/Mapping/styles/MappingPage.style.ts new file mode 100644 index 0000000000..c83f3953db --- /dev/null +++ b/admin-ui/plugins/admin/components/Mapping/styles/MappingPage.style.ts @@ -0,0 +1,213 @@ +import { makeStyles } from 'tss-react/mui' +import customColors, { hexToRgb } from '@/customColors' +import { fontFamily, fontWeights, fontSizes, letterSpacing, lineHeights } from '@/styles/fonts' +import { MAPPING_SPACING } from '@/constants/ui' + +interface ThemeColors { + fontColor: string + textMuted: string + card: { + background: string + border: string + } + infoAlert: { + background: string + border: string + text: string + icon: string + } + checkbox: { + uncheckedBorder: string + } +} + +interface MappingPageStyleParams { + isDark: boolean + theme: ThemeColors +} + +export const useStyles = makeStyles()((_theme, { isDark, theme }) => ({ + pageWrapper: { + display: 'flex', + flexDirection: 'column', + gap: MAPPING_SPACING.ALERT_TO_CARD, + }, + + pageDescription: { + fontFamily, + fontSize: fontSizes['2xl'], + fontWeight: fontWeights.semiBold, + lineHeight: 1.5, + letterSpacing: letterSpacing.normal, + color: theme.fontColor, + marginBottom: MAPPING_SPACING.ALERT_TO_CARD, + }, + + infoAlert: { + backgroundColor: theme.infoAlert.background, + border: `1px solid ${theme.infoAlert.border}`, + borderRadius: MAPPING_SPACING.INFO_ALERT_BORDER_RADIUS, + padding: `${MAPPING_SPACING.INFO_ALERT_PADDING_VERTICAL}px ${MAPPING_SPACING.INFO_ALERT_PADDING_HORIZONTAL}px`, + display: 'flex', + alignItems: 'center', + gap: MAPPING_SPACING.INFO_ALERT_GAP, + }, + + infoIcon: { + width: MAPPING_SPACING.INFO_ICON_SIZE, + height: MAPPING_SPACING.INFO_ICON_SIZE, + color: theme.infoAlert.icon, + flexShrink: 0, + }, + + infoText: { + fontFamily, + fontSize: fontSizes.base, + fontWeight: fontWeights.medium, + lineHeight: lineHeights.tight, + color: theme.infoAlert.text, + }, + + infoLink: { + fontFamily, + fontSize: fontSizes.base, + fontWeight: fontWeights.medium, + color: theme.fontColor, + textDecoration: 'underline', + textUnderlineOffset: '2px', + }, + + roleCard: { + backgroundColor: theme.card.background, + borderRadius: MAPPING_SPACING.CARD_BORDER_RADIUS, + boxShadow: `0px 4px 11px 0px rgba(${hexToRgb(customColors.black)}, 0.05)`, + marginBottom: MAPPING_SPACING.CARD_MARGIN_BOTTOM, + overflow: 'hidden', + }, + + roleCardHeader: { + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'space-between', + 'padding': `0 ${MAPPING_SPACING.CARD_PADDING}px`, + 'height': MAPPING_SPACING.CARD_HEADER_HEIGHT, + 'borderBottom': `1px solid ${theme.card.border}`, + 'cursor': 'pointer', + 'transition': 'background-color 0.2s ease', + '&:hover': { + backgroundColor: isDark + ? `rgba(${hexToRgb(customColors.white)}, 0.02)` + : `rgba(${hexToRgb(customColors.black)}, 0.02)`, + }, + }, + + roleTitle: { + fontFamily, + fontSize: fontSizes.xl, + fontWeight: fontWeights.medium, + lineHeight: lineHeights.tight, + color: theme.fontColor, + }, + + roleHeaderRight: { + display: 'flex', + alignItems: 'center', + gap: 8, + }, + + permissionCount: { + fontFamily, + fontSize: '15px', + fontWeight: fontWeights.medium, + lineHeight: lineHeights.relaxed, + color: theme.fontColor, + }, + + permissionCountHighlight: { + color: customColors.statusActive, + }, + + chevronIcon: { + width: 18, + height: 18, + color: theme.fontColor, + transition: 'transform 0.3s ease', + }, + + chevronIconOpen: { + transform: 'rotate(180deg)', + }, + + roleCardContent: { + padding: MAPPING_SPACING.CARD_PADDING, + paddingTop: MAPPING_SPACING.CONTENT_PADDING_TOP, + }, + + permissionsGrid: { + display: 'flex', + flexWrap: 'wrap', + gap: `${MAPPING_SPACING.PERMISSION_ROW_GAP}px ${MAPPING_SPACING.PERMISSION_ITEM_GAP}px`, + alignItems: 'center', + }, + + permissionItem: { + display: 'flex', + alignItems: 'center', + gap: MAPPING_SPACING.CHECKBOX_LABEL_GAP, + cursor: 'default', + }, + + checkbox: { + width: MAPPING_SPACING.CHECKBOX_SIZE, + height: MAPPING_SPACING.CHECKBOX_SIZE, + borderRadius: MAPPING_SPACING.CHECKBOX_BORDER_RADIUS, + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + + checkboxChecked: { + backgroundColor: isDark ? 'transparent' : customColors.white, + border: `${MAPPING_SPACING.CHECKBOX_BORDER_WIDTH}px solid ${customColors.statusActive}`, + }, + + checkboxUnchecked: { + backgroundColor: isDark ? 'transparent' : customColors.white, + border: `${MAPPING_SPACING.CHECKBOX_BORDER_WIDTH}px solid ${theme.checkbox.uncheckedBorder}`, + }, + + checkIcon: { + width: MAPPING_SPACING.CHECK_ICON_SIZE, + height: MAPPING_SPACING.CHECK_ICON_SIZE, + color: customColors.statusActive, + }, + + permissionLabel: { + fontFamily, + fontSize: fontSizes.base, + fontWeight: fontWeights.medium, + lineHeight: 'normal', + color: isDark ? customColors.white : customColors.black, + }, + + noPermissions: { + fontFamily, + fontSize: fontSizes.base, + fontWeight: fontWeights.medium, + color: theme.textMuted, + fontStyle: 'italic', + }, + + errorAlert: { + marginBottom: MAPPING_SPACING.CARD_MARGIN_BOTTOM, + }, + + infoEmptyState: { + marginBottom: MAPPING_SPACING.CARD_MARGIN_BOTTOM, + backgroundColor: theme.infoAlert.background, + border: `1px solid ${theme.infoAlert.border}`, + borderRadius: MAPPING_SPACING.INFO_ALERT_BORDER_RADIUS, + padding: `${MAPPING_SPACING.INFO_ALERT_PADDING_VERTICAL}px ${MAPPING_SPACING.INFO_ALERT_PADDING_HORIZONTAL}px`, + }, +})) diff --git a/admin-ui/plugins/admin/components/Mapping/types/index.ts b/admin-ui/plugins/admin/components/Mapping/types/index.ts index 2c5dc56d14..3e1369acde 100644 --- a/admin-ui/plugins/admin/components/Mapping/types/index.ts +++ b/admin-ui/plugins/admin/components/Mapping/types/index.ts @@ -2,7 +2,7 @@ import type { RolePermissionMapping, AdminRole, AdminPermission } from 'JansConf export type { RolePermissionMapping, AdminRole, AdminPermission } -export interface MappingItemProps { +export interface RolePermissionCardProps { candidate: RolePermissionMapping } diff --git a/admin-ui/plugins/admin/plugin-metadata.ts b/admin-ui/plugins/admin/plugin-metadata.ts index 39af72aeb3..09fd05b86d 100644 --- a/admin-ui/plugins/admin/plugin-metadata.ts +++ b/admin-ui/plugins/admin/plugin-metadata.ts @@ -1,5 +1,5 @@ import HealthPage from './components/Health/HealthPage' -import MappingPage from './components/Mapping/MappingPage' +import RolePermissionMappingPage from './components/Mapping/RolePermissionMappingPage' import SettingsPage from './components/Settings/SettingsPage' import MauPage from './components/MAU/MauPage' @@ -150,7 +150,7 @@ const pluginMetadata = { }, { - component: MappingPage, + component: RolePermissionMappingPage, path: ROUTES.ADMIN_MAPPING, permission: MAPPING_READ, },