/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ import Component from '@glimmer/component'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; import { service } from '@ember/service'; import keys from 'core/utils/keys'; import type CapabilitiesService from 'vault/services/capabilities'; import type Router from 'vault/router'; import type NamespaceService from 'vault/services/namespace'; import type AuthService from 'vault/vault/services/auth'; import type Store from '@ember-data/store'; import errorMessage from 'vault/utils/error-message'; interface NamespaceOption { path: string; label: string; } /** * @module NamespacePicker * @description component is used to display a dropdown listing all namespaces that the current user has access to. * The user can select a namespace from the dropdown to navigate directly to that namespace. * The "Manage" button directs the user to the namespace management page. * The "Refresh List" button refreshes the list of namespaces in the dropdown. * * @example * */ export default class NamespacePicker extends Component { @service declare auth: AuthService; @service declare capabilities: CapabilitiesService; @service declare namespace: NamespaceService; @service declare router: Router; @service declare store: Store; // Load 200 namespaces in the namespace picker at a time @tracked batchSize = 200; @tracked canManageNamespaces = false; // Show/hide manage namespaces button @tracked errorLoadingNamespaces = ''; @tracked hasNamespaces = false; @tracked searchInput = ''; @tracked searchInputHelpText = "Enter a full path in the search bar and hit the 'Enter' ↵ key to navigate faster."; constructor(owner: unknown, args: Record) { super(owner, args); this.loadOptions(); this.fetchManageCapability(); } get allNamespaces(): NamespaceOption[] { return this.getOptions(this.namespace?.accessibleNamespaces); } get selectedNamespace(): NamespaceOption | null { return this.getSelected(this.allNamespaces, this.namespace?.path) ?? null; } private matchesPath(option: NamespaceOption, currentPath: string): boolean { return option?.path === currentPath; } private getSelected(options: NamespaceOption[], currentPath: string): NamespaceOption | undefined { return options.find((option) => this.matchesPath(option, currentPath)); } private getOptions(accessibleNamespaces: string[]): NamespaceOption[] { /* Each namespace option has 2 properties: { path and label } * - path: full namespace path (used to navigate to the namespace) * - label: text displayed inside the namespace picker dropdown (if root, then path is "", else label = path) * * Example: * | path | label | * | ---- | ----- | * | '' | 'root' | * | 'parent' | 'parent' | * | 'parent/child' | 'parent/child' | */ const options = (accessibleNamespaces || []).map((ns: string) => ({ path: ns, label: ns })); // Add the user's root namespace because `sys/internal/ui/namespaces` does not include it. const userRootNamespace = this.auth.authData?.userRootNamespace; if (!options?.find((o) => o.path === userRootNamespace)) { // the 'root' namespace is technically an empty string so we manually add the 'root' label. const label = userRootNamespace === '' ? 'root' : userRootNamespace; options.unshift({ path: userRootNamespace, label }); } // If there are no namespaces returned by the internal endpoint, add the current namespace // to the list of options. This is a fallback for when the user has access to a single namespace. if (options.length === 0) { // 'path' defined in the namespace service is the full namespace path options.push({ path: this.namespace.path, label: this.namespace.path }); } return options; } get hasSearchInput(): boolean { return this.searchInput?.trim().length > 0; } get namespaceCount(): number { return this.namespaceOptions.length; } get namespaceLabel(): string { return this.searchInput === '' ? 'All namespaces' : 'Matching namespaces'; } get namespaceOptions(): NamespaceOption[] { if (this.searchInput.trim() === '') { return this.allNamespaces || []; } else { const filtered = this.allNamespaces.filter((ns) => ns.label.toLowerCase().includes(this.searchInput.toLowerCase()) ); return filtered || []; } } get noNamespacesMessage(): string { const noNamespacesMessage = 'No namespaces found.'; const noMatchingNamespacesHelpText = 'No matching namespaces found. Try searching for a different namespace.'; return this.hasSearchInput ? noMatchingNamespacesHelpText : noNamespacesMessage; } get showNoNamespacesMessage(): boolean { const hasError = this.errorLoadingNamespaces !== ''; return this.namespaceCount === 0 && !hasError; } get visibleNamespaceOptions(): NamespaceOption[] { return this.namespaceOptions.slice(0, this.batchSize); } @action adjustElementWidth(element: HTMLElement): void { // Hide the element so that it doesn't affect the layout element.style.display = 'none'; let maxWidth = 240; // Default minimum width // Calculate the maximum width of the visible namespace options // The namespace is displayed as an HDS::checkmark button, so we need to find the width of the checkmark element this.visibleNamespaceOptions.forEach((namespace: NamespaceOption) => { const checkmarkElement = document.querySelector(`[data-test-button="${namespace.label}"]`); const width = (checkmarkElement as HTMLElement).offsetWidth; if (width > maxWidth) { maxWidth = width; } }); // Set the width of the target element element.style.width = `${maxWidth}px`; // Show the element once the width is set element.style.display = ''; } @action async fetchManageCapability(): Promise { // The namespace picker options are from `sys/internal/ui/namespaces` which all users have permissions to request. // The UI view for managing namespaces (i.e. CRUD actions) calls `sys/namespaces` and DOES require LIST permissions. // This is the capability check to hide/show the button that navigates to that route. const { canList } = await this.capabilities.fetchPathCapabilities('sys/namespaces'); this.canManageNamespaces = canList; } @action focusSearchInput(element: HTMLInputElement): void { // On mount, cursor should default to the search input field element.focus(); } @action async loadOptions(): Promise { try { await this.namespace?.findNamespacesForUser?.perform(); this.errorLoadingNamespaces = ''; } catch (error) { this.errorLoadingNamespaces = errorMessage(error); } } @action loadMore(): void { // Increase the batch size to load more items this.batchSize += 200; } @action setupScrollListener(element: HTMLElement): void { element.addEventListener('scroll', this.onScroll); } @action onScroll(event: Event): void { const element = event.target as HTMLElement; // Check if the user has scrolled to the bottom if (element.scrollTop + element.clientHeight >= element.scrollHeight) { this.loadMore(); } } @action async onChange(selected: NamespaceOption): Promise { this.searchInput = ''; this.router.transitionTo('vault.cluster.dashboard', { queryParams: { namespace: selected.path } }); } @action async onKeyDown(event: KeyboardEvent): Promise { if (event.key === keys.ENTER && this.searchInput?.trim()) { const matchingNamespace = this.allNamespaces.find((ns) => ns.label === this.searchInput.trim()); if (matchingNamespace) { this.searchInput = ''; this.router.transitionTo('vault.cluster.dashboard', { queryParams: { namespace: matchingNamespace.path }, }); } } } @action onSearchInput(event: Event): void { const target = event.target as HTMLInputElement; this.searchInput = target.value; } @action async refreshList(): Promise { this.searchInput = ''; await this.loadOptions(); } @action toggleNamespacePicker() { // Reset the search input when the dropdown is toggled this.searchInput = ''; } }