This is an automated email from the ASF dual-hosted git repository.

mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new a2eb2c59a33 NIFI-15855 - Connector wizard - secret and boolean 
property handling (#11248)
a2eb2c59a33 is described below

commit a2eb2c59a33fdb9f33aba98ec2a65f786a0ab869
Author: Rob Fellows <[email protected]>
AuthorDate: Fri May 15 11:08:12 2026 -0400

    NIFI-15855 - Connector wizard - secret and boolean property handling 
(#11248)
    
    * NIFI-15855 - Connector wizard - secret and boolean property handling
    
    * Address review feedback:
---
 .../connector-property-input.component.html        |  18 +-
 .../connector-property-input.component.spec.ts     | 372 ++++++++++++++++++++-
 .../connector-property-input.component.ts          | 165 ++++++++-
 3 files changed, 536 insertions(+), 19 deletions(-)

diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
index b9583944126..7b87d9f6d2a 100644
--- 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
@@ -18,12 +18,14 @@
 @if (prop) {
     @switch (prop.type) {
         @case ('BOOLEAN') {
-            <mat-checkbox [formControl]="formControl" 
data-qa="property-input-boolean">
-                <span class="text-sm">{{ prop.name }}</span>
+            <div class="flex flex-col">
+                <mat-slide-toggle [formControl]="formControl" 
labelPosition="before" data-qa="property-input-boolean">
+                    <span class="text-sm">{{ prop.name }}</span>
+                </mat-slide-toggle>
                 @if (prop.description) {
                     <span class="text-xs tertiary-color block">{{ 
prop.description }}</span>
                 }
-            </mat-checkbox>
+            </div>
         }
         @default {
             @if (shouldUseAssetUpload()) {
@@ -79,14 +81,18 @@
                         [searchPlaceholder]="'Search'"
                         [allowClear]="!prop.required"
                         [hint]="prop.description || ''"
-                        [showHint]="!!prop.description"
+                        [showHint]="!!prop.description && !hasSecretsError()"
                         [validationError]="getValidationErrorMessage()"
+                        [loadError]="hasSecretsError()"
+                        [loadErrorMessage]="'Failed to load secrets'"
                         data-qa="property-input-select">
                     </searchable-select>
-                    @if (isDynamicValuesLoading()) {
+                    @if (isDynamicValuesLoading() || isSecretsLoading()) {
                         <div class="flex items-center gap-2 mt-1" 
data-qa="property-input-loading">
                             <mat-progress-spinner diameter="14" 
mode="indeterminate"></mat-progress-spinner>
-                            <span class="text-xs tertiary-color">Loading 
values...</span>
+                            <span class="text-xs tertiary-color">
+                                {{ isSecretsLoading() ? 'Loading secrets...' : 
'Loading values...' }}
+                            </span>
                         </div>
                     }
                 </div>
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
index b7bdddd9ba6..bbbf71841ae 100644
--- 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
@@ -26,8 +26,10 @@ import { ConnectorPropertyInput } from 
'./connector-property-input.component';
 import {
     AllowableValue,
     AssetInfo,
+    buildSecretKey,
     ConnectorPropertyDescriptor,
     PropertyAllowableValuesState,
+    Secret,
     UploadProgressInfo
 } from '../../types';
 
@@ -65,6 +67,18 @@ function makeProgress(overrides: Partial<UploadProgressInfo> 
= {}): UploadProgre
     };
 }
 
+function makeSecret(overrides: Partial<Secret> = {}): Secret {
+    return {
+        name: 'my-secret',
+        fullyQualifiedName: 'group-a.my-secret',
+        providerId: 'provider-1',
+        providerName: 'Vault',
+        groupName: 'group-a',
+        description: 'A secret',
+        ...overrides
+    };
+}
+
 /**
  * Host fixture that owns the parent FormControl and reactively passes signal 
inputs
  * to ConnectorPropertyInput. Use setters on the returned harness to drive 
updates.
@@ -79,6 +93,9 @@ function makeProgress(overrides: Partial<UploadProgressInfo> 
= {}): UploadProgre
             [dynamicAllowableValuesState]="dynamicAllowableValuesState()"
             [currentAssets]="currentAssets()"
             [assetUploadProgress]="assetUploadProgress()"
+            [availableSecrets]="availableSecrets()"
+            [secretsLoading]="secretsLoading()"
+            [secretsError]="secretsError()"
             (requestAllowableValues)="onRequestAllowableValues()"
             (assetFilesSelected)="onAssetFilesSelected($event)"
             (assetDeleteRequested)="onAssetDeleteRequested($event)"
@@ -87,11 +104,14 @@ function makeProgress(overrides: 
Partial<UploadProgressInfo> = {}): UploadProgre
     `
 })
 class HostComponent {
-    control = new FormControl<string | string[] | null>(null);
+    control = new FormControl<string | string[] | boolean | null>(null);
     property: WritableSignal<ConnectorPropertyDescriptor> = signal(makeProp());
     dynamicAllowableValuesState: WritableSignal<PropertyAllowableValuesState | 
null> = signal(null);
     currentAssets: WritableSignal<AssetInfo[]> = signal([]);
     assetUploadProgress: WritableSignal<UploadProgressInfo[]> = signal([]);
+    availableSecrets: WritableSignal<Secret[] | null> = signal(null);
+    secretsLoading: WritableSignal<boolean> = signal(false);
+    secretsError: WritableSignal<string | null> = signal(null);
     requestSpy = vi.fn();
     assetFilesSelectedSpy = vi.fn();
     assetDeleteRequestedSpy = vi.fn();
@@ -127,9 +147,12 @@ async function setup(
     options: {
         property?: ConnectorPropertyDescriptor;
         dynamicState?: PropertyAllowableValuesState | null;
-        initialValue?: string | string[] | null;
+        initialValue?: string | string[] | boolean | null;
         currentAssets?: AssetInfo[];
         assetUploadProgress?: UploadProgressInfo[];
+        availableSecrets?: Secret[] | null;
+        secretsLoading?: boolean;
+        secretsError?: string | null;
     } = {}
 ) {
     await TestBed.configureTestingModule({
@@ -154,6 +177,15 @@ async function setup(
     if (options.assetUploadProgress !== undefined) {
         host.assetUploadProgress.set(options.assetUploadProgress);
     }
+    if (options.availableSecrets !== undefined) {
+        host.availableSecrets.set(options.availableSecrets);
+    }
+    if (options.secretsLoading !== undefined) {
+        host.secretsLoading.set(options.secretsLoading);
+    }
+    if (options.secretsError !== undefined) {
+        host.secretsError.set(options.secretsError);
+    }
 
     fixture.detectChanges();
     await fixture.whenStable();
@@ -330,13 +362,90 @@ describe('ConnectorPropertyInput', () => {
     });
 
     describe('boolean rendering', () => {
-        it('renders a checkbox for BOOLEAN properties', async () => {
+        it('renders a mat-slide-toggle (not a checkbox) for BOOLEAN 
properties', async () => {
             const { fixture } = await setup({
                 property: makeProp({ type: 'BOOLEAN' })
             });
 
-            const checkbox = 
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
-            expect(checkbox).toBeTruthy();
+            const toggle = 
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
+            const checkbox = 
fixture.debugElement.query(By.css('mat-checkbox'));
+
+            expect(toggle).toBeTruthy();
+            
expect(toggle.nativeElement.tagName.toLowerCase()).toBe('mat-slide-toggle');
+            expect(checkbox).toBeNull();
+        });
+
+        it('reflects the form value in the toggle checked state', async () => {
+            const { fixture, host } = await setup({
+                property: makeProp({ type: 'BOOLEAN' }),
+                initialValue: true
+            });
+
+            const toggle = 
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
+            expect(toggle.componentInstance.checked).toBe(true);
+
+            host.control.setValue(false);
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+
+            expect(toggle.componentInstance.checked).toBe(false);
+        });
+
+        it('propagates the form value through the [formControl] binding 
(parent <-> toggle round-trip)', async () => {
+            const { fixture, host } = await setup({
+                property: makeProp({ type: 'BOOLEAN' })
+            });
+
+            const toggleDebug = 
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
+
+            // Parent -> child
+            host.control.setValue(true);
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+            expect(toggleDebug.componentInstance.checked).toBe(true);
+
+            // Toggle -> parent: invoke the underlying input element's click,
+            // which is the public surface MatSlideToggle exposes for 
user-driven changes.
+            const input: HTMLInputElement | null = (toggleDebug.nativeElement 
as HTMLElement).querySelector(
+                'button[role="switch"], input[type="checkbox"]'
+            );
+            expect(input).not.toBeNull();
+            input!.click();
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+
+            expect(host.control.value).toBe(false);
+        });
+
+        it('coerces string "true" to true via writeValue', async () => {
+            const { fixture, host } = await setup({
+                property: makeProp({ type: 'BOOLEAN' })
+            });
+
+            host.control.setValue('true' as unknown as boolean);
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+
+            const toggle = 
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
+            expect(toggle.componentInstance.checked).toBe(true);
+        });
+
+        it('coerces non-true values to false via writeValue', async () => {
+            const { fixture, host } = await setup({
+                property: makeProp({ type: 'BOOLEAN' })
+            });
+
+            host.control.setValue('false' as unknown as boolean);
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+
+            const toggle = 
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
+            expect(toggle.componentInstance.checked).toBe(false);
         });
     });
 
@@ -489,6 +598,259 @@ describe('ConnectorPropertyInput', () => {
         });
     });
 
+    describe('SECRET rendering', () => {
+        it('renders a searchable-select for a SECRET property even when no 
secrets are loaded yet', async () => {
+            const { fixture, inputComponent } = await setup({
+                property: makeProp({ type: 'SECRET' })
+            });
+
+            const select = 
fixture.debugElement.query(By.css('[data-qa="property-input-select"]'));
+            expect(select).toBeTruthy();
+            expect(inputComponent.shouldUseSelect()).toBe(true);
+        });
+
+        it('builds options from availableSecrets using the composite key as 
the option value', async () => {
+            const secret = makeSecret({
+                name: 'Prod DB Password',
+                fullyQualifiedName: 'group-a.prod-db',
+                providerId: 'vault-1',
+                providerName: 'Vault',
+                groupName: 'group-a',
+                description: 'Production DB password'
+            });
+            const { inputComponent } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                availableSecrets: [secret]
+            });
+
+            expect(inputComponent.selectOptions).toEqual([
+                {
+                    value: buildSecretKey('vault-1', 'Vault', 
'group-a.prod-db'),
+                    label: 'Prod DB Password',
+                    description: 'Production DB password',
+                    group: 'Vault'
+                }
+            ]);
+        });
+
+        it('uses the bare provider name as the group when a provider owns a 
single group', async () => {
+            const secrets = [
+                makeSecret({ name: 'secret-1', providerName: 'Vault', 
groupName: 'group-a' }),
+                makeSecret({
+                    name: 'secret-2',
+                    fullyQualifiedName: 'group-a.secret-2',
+                    providerName: 'Vault',
+                    groupName: 'group-a'
+                })
+            ];
+            const { inputComponent } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                availableSecrets: secrets
+            });
+
+            expect(inputComponent.selectOptions.map((o) => 
o.group)).toEqual(['Vault', 'Vault']);
+        });
+
+        it('formats the group as "Provider - Group" when a provider owns 
multiple groups', async () => {
+            const secrets = [
+                makeSecret({ name: 'secret-1', providerName: 'Vault', 
groupName: 'group-a' }),
+                makeSecret({
+                    name: 'secret-2',
+                    fullyQualifiedName: 'group-b.secret-2',
+                    providerName: 'Vault',
+                    groupName: 'group-b'
+                })
+            ];
+            const { inputComponent } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                availableSecrets: secrets
+            });
+
+            expect(inputComponent.selectOptions.map((o) => 
o.group)).toEqual(['Vault - group-a', 'Vault - group-b']);
+        });
+
+        it('appends a disabled "(no longer available)" option when the saved 
value is missing from the loaded secrets', async () => {
+            const orphanKey = buildSecretKey('old-provider', 'OldVault', 
'group-x.gone');
+            const { inputComponent } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                initialValue: orphanKey,
+                availableSecrets: [makeSecret()]
+            });
+
+            const orphan = inputComponent.selectOptions.find((o) => o.value 
=== orphanKey);
+            expect(orphan).toBeTruthy();
+            expect(orphan?.disabled).toBe(true);
+            expect(orphan?.label).toBe('group-x.gone (no longer available)');
+            expect(orphan?.group).toBe('OldVault');
+        });
+
+        it('renders the saved value as an orphan placeholder while secrets are 
still loading (no "(no longer available)" suffix)', async () => {
+            const orphanKey = buildSecretKey('provider-1', 'Vault', 
'group-a.secret-1');
+            const { inputComponent } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                initialValue: orphanKey,
+                availableSecrets: null,
+                secretsLoading: true
+            });
+
+            const orphan = inputComponent.selectOptions.find((o) => o.value 
=== orphanKey);
+            expect(orphan).toBeTruthy();
+            expect(orphan?.disabled).toBe(true);
+            expect(orphan?.label).toBe('group-a.secret-1');
+            expect(orphan?.group).toBe('Vault');
+        });
+
+        it('shows the "(no longer available)" suffix once loading completes 
and the secret is absent', async () => {
+            const orphanKey = buildSecretKey('provider-1', 'Vault', 
'group-a.secret-1');
+            const { fixture, host, inputComponent } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                initialValue: orphanKey,
+                availableSecrets: null,
+                secretsLoading: true
+            });
+
+            let orphan = inputComponent.selectOptions.find((o) => o.value === 
orphanKey);
+            expect(orphan?.label).toBe('group-a.secret-1');
+
+            host.secretsLoading.set(false);
+            host.availableSecrets.set([]);
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+
+            orphan = inputComponent.selectOptions.find((o) => o.value === 
orphanKey);
+            expect(orphan?.label).toBe('group-a.secret-1 (no longer 
available)');
+        });
+
+        it('rewrites the form value to the current composite key after a 
provider rename', async () => {
+            const savedKey = buildSecretKey('provider-1', 'OldVault', 
'group-a.secret-1');
+            const renamedSecret = makeSecret({
+                providerId: 'provider-1',
+                providerName: 'NewVault',
+                fullyQualifiedName: 'group-a.secret-1',
+                groupName: 'group-a',
+                name: 'secret-1'
+            });
+            const expectedKey = buildSecretKey('provider-1', 'NewVault', 
'group-a.secret-1');
+
+            const { fixture, host } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                initialValue: savedKey,
+                availableSecrets: [renamedSecret]
+            });
+
+            // afterNextRender defers the setValue to the next render; flush 
via detectChanges + whenStable.
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+
+            expect(host.control.value).toBe(expectedKey);
+        });
+
+        it('flips the searchable-select into loadError mode and suppresses the 
description hint when secretsError is set', async () => {
+            const { fixture, inputComponent } = await setup({
+                property: makeProp({ type: 'SECRET', description: 'A 
description' }),
+                secretsError: 'Backend rejected the secrets request'
+            });
+
+            const select = 
fixture.debugElement.query(By.css('[data-qa="property-input-select"]'));
+            expect(select.componentInstance.loadError()).toBe(true);
+            expect(select.componentInstance.loadErrorMessage()).toBe('Failed 
to load secrets');
+            expect(select.componentInstance.showHint()).toBe(false);
+            expect(inputComponent.hasSecretsError()).toBe(true);
+        });
+
+        it('does not flip into loadError mode when secretsError is the empty 
string', async () => {
+            const { fixture } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                secretsError: ''
+            });
+
+            const select = 
fixture.debugElement.query(By.css('[data-qa="property-input-select"]'));
+            expect(select.componentInstance.loadError()).toBe(false);
+            expect(select.componentInstance.loadErrorMessage()).toBe('Failed 
to load secrets');
+        });
+
+        it('shows the inline loading spinner with "Loading secrets..." while 
secretsLoading is true', async () => {
+            const { fixture } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                secretsLoading: true
+            });
+
+            const spinner = 
fixture.debugElement.query(By.css('[data-qa="property-input-loading"]'));
+            expect(spinner).toBeTruthy();
+            expect((spinner.nativeElement as 
HTMLElement).textContent).toContain('Loading secrets...');
+        });
+
+        it('hides the loading spinner once secretsLoading flips back to 
false', async () => {
+            const { fixture, host } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                secretsLoading: true
+            });
+
+            
expect(fixture.debugElement.query(By.css('[data-qa="property-input-loading"]'))).toBeTruthy();
+
+            host.secretsLoading.set(false);
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+
+            
expect(fixture.debugElement.query(By.css('[data-qa="property-input-loading"]'))).toBeNull();
+        });
+
+        it('returns the right placeholder text for each SECRET state', async 
() => {
+            const { inputComponent, host, fixture } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                secretsLoading: true
+            });
+            expect(inputComponent.getSelectPlaceholder()).toBe('Loading 
secrets...');
+
+            host.secretsLoading.set(false);
+            host.secretsError.set('boom');
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+            expect(inputComponent.getSelectPlaceholder()).toBe('Failed to load 
secrets');
+
+            host.secretsError.set(null);
+            host.availableSecrets.set([]);
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+            expect(inputComponent.getSelectPlaceholder()).toBe('No secrets 
available');
+
+            host.availableSecrets.set([makeSecret()]);
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+            expect(inputComponent.getSelectPlaceholder()).toBe('Select a 
secret');
+        });
+
+        it('removes the orphan option from selectOptions after the user 
selects a real secret', async () => {
+            const orphanKey = buildSecretKey('old-provider', 'OldVault', 
'group-x.gone');
+            const realSecret = makeSecret();
+            const realKey = buildSecretKey(
+                realSecret.providerId,
+                realSecret.providerName,
+                realSecret.fullyQualifiedName
+            );
+            const { fixture, host, inputComponent } = await setup({
+                property: makeProp({ type: 'SECRET' }),
+                initialValue: orphanKey,
+                availableSecrets: [realSecret]
+            });
+
+            expect(inputComponent.selectOptions.some((o) => o.value === 
orphanKey && o.disabled)).toBe(true);
+
+            host.control.setValue(realKey);
+            fixture.detectChanges();
+            await fixture.whenStable();
+            fixture.detectChanges(false);
+
+            expect(inputComponent.selectOptions.some((o) => o.value === 
orphanKey)).toBe(false);
+        });
+    });
+
     describe('asset rendering', () => {
         it('renders an asset-upload (single) for an ASSET property and hides 
the default text input', async () => {
             const { fixture, inputComponent } = await setup({
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
index 27d7c161cda..682256ff96d 100644
--- 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
@@ -15,17 +15,31 @@
  * limitations under the License.
  */
 
-import { Component, DestroyRef, DoCheck, effect, inject, input, OnInit, output 
} from '@angular/core';
+import {
+    afterNextRender,
+    Component,
+    DestroyRef,
+    DoCheck,
+    effect,
+    inject,
+    Injector,
+    input,
+    OnInit,
+    output,
+    runInInjectionContext
+} from '@angular/core';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { ControlValueAccessor, FormControl, NgControl, ReactiveFormsModule } 
from '@angular/forms';
 import { MatError, MatFormField, MatHint, MatLabel } from 
'@angular/material/form-field';
 import { MatInput } from '@angular/material/input';
-import { MatCheckbox } from '@angular/material/checkbox';
 import { MatProgressSpinner } from '@angular/material/progress-spinner';
+import { MatSlideToggle } from '@angular/material/slide-toggle';
 import {
     AllowableValue,
     AssetInfo,
+    buildSecretKey,
     ConnectorPropertyDescriptor,
+    parseSecretKey,
     PropertyAllowableValuesState,
     SearchableSelectOption,
     Secret,
@@ -37,12 +51,13 @@ import { AssetUpload } from 
'../asset-upload/asset-upload.component';
 /**
  * Form control for a single connector property.
  * Renders different input types based on the property descriptor:
- * STRING/INTEGER/DOUBLE/FLOAT -> text input, BOOLEAN -> checkbox,
+ * STRING/INTEGER/DOUBLE/FLOAT -> text input, BOOLEAN -> slide toggle,
  * STRING_LIST without allowable values -> textarea (comma-separated),
  * allowable values (static or fetched) -> searchable-select
  * (multi-select when the property type is STRING_LIST),
- * ASSET / ASSET_LIST -> asset-upload (drop zone + uploaded list + progress).
- * SECRET handling is deferred to a follow-up PR.
+ * ASSET / ASSET_LIST -> asset-upload (drop zone + uploaded list + progress),
+ * SECRET -> searchable-select populated from availableSecrets (composite key
+ * value, provider/group grouping, orphan handling, provider-rename auto-fix).
  *
  * Uses an internal FormControl bound to the actual input elements so that
  * mat-form-field can detect error state. Validation state is synced from
@@ -58,8 +73,8 @@ import { AssetUpload } from 
'../asset-upload/asset-upload.component';
         MatLabel,
         MatHint,
         MatInput,
-        MatCheckbox,
         MatProgressSpinner,
+        MatSlideToggle,
         SearchableSelect,
         AssetUpload
     ],
@@ -68,6 +83,7 @@ import { AssetUpload } from 
'../asset-upload/asset-upload.component';
 export class ConnectorPropertyInput implements ControlValueAccessor, DoCheck, 
OnInit {
     private ngControl = inject(NgControl, { optional: true, self: true });
     private destroyRef = inject(DestroyRef);
+    private injector = inject(Injector);
 
     readonly property = input.required<ConnectorPropertyDescriptor>();
     readonly dynamicAllowableValuesState = input<PropertyAllowableValuesState 
| null>(null);
@@ -114,6 +130,9 @@ export class ConnectorPropertyInput implements 
ControlValueAccessor, DoCheck, On
         effect(() => {
             const prop = this.property();
             this.dynamicAllowableValuesState();
+            this.availableSecrets();
+            this.secretsLoading();
+            this.secretsError();
             this.selectOptions = this.computeSelectOptions();
 
             const currentName = prop?.name ?? null;
@@ -123,6 +142,29 @@ export class ConnectorPropertyInput implements 
ControlValueAccessor, DoCheck, On
             }
             this.lastSeenPropertyName = currentName;
         });
+
+        // Reconcile form value with current secret identity after secrets 
load.
+        // If the saved composite key matches a current secret by providerId + 
fullyQualifiedName
+        // but the providerName has changed, rewrite the form value to the 
current key.
+        // afterNextRender defers the setValue past the current 
change-detection cycle.
+        // Multiple rapid re-runs are safe: the rewrite is idempotent.
+        effect(() => {
+            const secrets = this.availableSecrets();
+            if (this.property()?.type !== 'SECRET' || !secrets) return;
+            const currentValue = this.formControl.value as string | null;
+            if (!currentValue) return;
+            const parsed = parseSecretKey(currentValue);
+            const matching = secrets.find(
+                (s) => s.providerId === parsed.providerId && 
s.fullyQualifiedName === parsed.fullyQualifiedName
+            );
+            if (!matching) return;
+            const expected = buildSecretKey(matching.providerId, 
matching.providerName, matching.fullyQualifiedName);
+            if (currentValue !== expected) {
+                runInInjectionContext(this.injector, () => {
+                    afterNextRender(() => this.formControl.setValue(expected, 
{ emitEvent: true }));
+                });
+            }
+        });
     }
 
     ngOnInit(): void {
@@ -136,6 +178,9 @@ export class ConnectorPropertyInput implements 
ControlValueAccessor, DoCheck, On
             normalized = value === true || value === 'true';
         }
         this.formControl.setValue(normalized, { emitEvent: false });
+        if (this.property()?.type === 'SECRET') {
+            this.selectOptions = this.computeSelectOptions();
+        }
     }
 
     registerOnChange(fn: (value: unknown) => void): void {
@@ -143,6 +188,9 @@ export class ConnectorPropertyInput implements 
ControlValueAccessor, DoCheck, On
             this.valueChangesSubscribed = true;
             
this.formControl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((value)
 => {
                 fn(value);
+                if (this.property()?.type === 'SECRET') {
+                    this.selectOptions = this.computeSelectOptions();
+                }
             });
         }
     }
@@ -276,6 +324,12 @@ export class ConnectorPropertyInput implements 
ControlValueAccessor, DoCheck, On
             return false;
         }
 
+        // SECRET always uses the select. Loading shows an inline spinner; 
load errors
+        // disable the select via the searchable-select loadError input.
+        if (prop.type === 'SECRET') {
+            return true;
+        }
+
         if (this.isDynamicValuesFetchFailed() || 
this.isDynamicValuesFetchEmpty()) {
             return false;
         }
@@ -339,10 +393,38 @@ export class ConnectorPropertyInput implements 
ControlValueAccessor, DoCheck, On
         return this.property()?.type === 'STRING_LIST' && 
this.shouldUseSelect();
     }
 
+    /**
+     * True when a SECRET property's secrets list is currently being loaded.
+     */
+    isSecretsLoading(): boolean {
+        return this.property()?.type === 'SECRET' && this.secretsLoading();
+    }
+
+    /**
+     * True when a SECRET property's secrets fetch reported an error.
+     */
+    hasSecretsError(): boolean {
+        return this.property()?.type === 'SECRET' && !!this.secretsError();
+    }
+
     /**
      * Placeholder text for the searchable-select dropdown.
      */
     getSelectPlaceholder(): string {
+        const prop = this.property();
+        if (prop?.type === 'SECRET') {
+            if (this.secretsLoading()) {
+                return 'Loading secrets...';
+            }
+            if (this.secretsError()) {
+                return 'Failed to load secrets';
+            }
+            const list = this.availableSecrets();
+            if (list && list.length === 0) {
+                return 'No secrets available';
+            }
+            return 'Select a secret';
+        }
         if (this.isDynamicValuesLoading()) {
             return 'Loading values...';
         }
@@ -402,8 +484,18 @@ export class ConnectorPropertyInput implements 
ControlValueAccessor, DoCheck, On
     }
 
     /**
-     * Compute SearchableSelectOptions from whichever source is available:
-     * dynamic values when successfully fetched, otherwise static values.
+     * Pure computation of SearchableSelectOptions from whichever source is 
available.
+     * Has no side effects; all reactive updates (provider-rename rewrite, 
orphan
+     * clearance on value change) are handled by separate effects and 
subscriptions.
+     *
+     * - SECRET: options come from availableSecrets, valued by a composite key
+     *   (providerId::providerName::fullyQualifiedName) and grouped by provider
+     *   (or "Provider - Group" when a provider owns multiple groups). Saved
+     *   values that no longer match an available secret are surfaced as 
disabled
+     *   options; the "(no longer available)" suffix is suppressed while 
secrets
+     *   are still loading so as not to alarm the user prematurely.
+     * - Otherwise: dynamic values when successfully fetched, falling back to
+     *   the descriptor's static allowableValues.
      */
     private computeSelectOptions(): SearchableSelectOption<string>[] {
         const prop = this.property();
@@ -411,6 +503,10 @@ export class ConnectorPropertyInput implements 
ControlValueAccessor, DoCheck, On
             return [];
         }
 
+        if (prop.type === 'SECRET') {
+            return this.computeSecretSelectOptions();
+        }
+
         let allowableValues: AllowableValue[] = [];
 
         const state = this.dynamicAllowableValuesState();
@@ -426,6 +522,59 @@ export class ConnectorPropertyInput implements 
ControlValueAccessor, DoCheck, On
         }));
     }
 
+    private computeSecretSelectOptions(): SearchableSelectOption<string>[] {
+        const options: SearchableSelectOption<string>[] = [];
+        const secrets: Secret[] | null = this.availableSecrets();
+
+        if (secrets && secrets.length > 0) {
+            const providerGroups = new Map<string, Set<string>>();
+            for (const s of secrets) {
+                if (!providerGroups.has(s.providerName)) {
+                    providerGroups.set(s.providerName, new Set());
+                }
+                providerGroups.get(s.providerName)!.add(s.groupName);
+            }
+
+            for (const s of secrets) {
+                const groupCount = providerGroups.get(s.providerName)?.size ?? 
1;
+                const group = groupCount > 1 ? `${s.providerName} - 
${s.groupName}` : s.providerName;
+                options.push({
+                    value: buildSecretKey(s.providerId, s.providerName, 
s.fullyQualifiedName),
+                    label: s.name,
+                    description: s.description,
+                    group
+                });
+            }
+        }
+
+        const currentValue = this.formControl.value as string | null;
+        if (currentValue && secrets) {
+            const parsed = parseSecretKey(currentValue);
+            const matching = secrets.find(
+                (s) => s.providerId === parsed.providerId && 
s.fullyQualifiedName === parsed.fullyQualifiedName
+            );
+            if (!matching) {
+                options.push({
+                    value: currentValue,
+                    label: `${parsed.fullyQualifiedName} (no longer 
available)`,
+                    disabled: true,
+                    group: parsed.providerName
+                });
+            }
+        } else if (currentValue) {
+            const parsed = parseSecretKey(currentValue);
+            const loadCompleted = !this.secretsLoading() && 
!this.secretsError();
+            options.push({
+                value: currentValue,
+                label: loadCompleted ? `${parsed.fullyQualifiedName} (no 
longer available)` : parsed.fullyQualifiedName,
+                disabled: true,
+                group: parsed.providerName
+            });
+        }
+
+        return options;
+    }
+
     private syncValidationState(): void {
         if (!this.parentControl) {
             return;


Reply via email to