From 4c1b646ab6b5f390a605a67ec9748d0187d6085c Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Tue, 29 Sep 2026 17:27:35 -0600 Subject: [PATCH 01/15] feat(presentation-mode): mask API keys and tokens for screensharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screensharing the admin UI puts live API keys on someone else's monitor. One toggle in the top bar now covers every credential the UI renders, with a per-field eye to reveal one on demand. Display and clipboard are deliberately separate paths: df-try-it renders displaySnippet and copies snippet, df-mcp-connect renders maskIfPresenting(x) and copies x. A masked curl command still pastes into Postman and works. The value leaves the DOM rather than being blurred — a CSS blur stays selectable and recoverable from a screenshot. Service-config secrets key off the field name, not the schema type: oidc.client_secret ships as `text` and mcp.oauth_client_secret as `string`, so both render as plain visible inputs. `_id` is excluded so client_id stays readable during OAuth setup. This is not a security boundary — the key still arrives in the API response and is visible in devtools. It addresses screenshare exposure. Covered by 34 unit tests (registered in jest.config.ci.js) and e2e/presentation-mode.spec.ts, which asserts the clipboard still carries the exact unmasked command and sweeps the leaking routes, failing loudly if a swept route has no credential left to hide. --- e2e/presentation-mode.spec.ts | 262 ++++++++++++++++++ jest.config.ci.js | 4 + .../df-app-details.component.html | 2 +- .../df-app-details.component.ts | 2 + .../df-manage-apps-table.component.html | 6 +- .../df-manage-apps-table.component.ts | 2 + .../df-mcp-access-editor.component.html | 2 +- .../df-mcp-access-editor.component.ts | 2 + .../df-mcp-connect.component.html | 10 +- .../df-mcp-connect.component.ts | 20 +- .../df-artifact-card.component.html | 5 +- .../df-artifact-card.component.ts | 2 + .../df-celebration-dialog.component.html | 2 +- .../df-celebration-dialog.component.ts | 2 + .../df-dynamic-field.component.html | 14 +- .../df-dynamic-field.component.ts | 44 ++- .../df-secret/df-secret.component.spec.ts | 106 +++++++ .../df-secret/df-secret.component.ts | 133 +++++++++ .../df-theme-toggle.component.html | 11 + .../df-theme-toggle.component.ts | 33 ++- .../df-try-it/df-try-it.component.html | 2 +- .../df-try-it/df-try-it.component.ts | 19 +- .../services/df-presentation.service.spec.ts | 51 ++++ .../services/df-presentation.service.ts | 53 ++++ src/app/shared/utilities/mask.spec.ts | 82 ++++++ src/app/shared/utilities/mask.ts | 59 ++++ 26 files changed, 910 insertions(+), 20 deletions(-) create mode 100644 e2e/presentation-mode.spec.ts create mode 100644 src/app/shared/components/df-secret/df-secret.component.spec.ts create mode 100644 src/app/shared/components/df-secret/df-secret.component.ts create mode 100644 src/app/shared/services/df-presentation.service.spec.ts create mode 100644 src/app/shared/services/df-presentation.service.ts create mode 100644 src/app/shared/utilities/mask.spec.ts create mode 100644 src/app/shared/utilities/mask.ts diff --git a/e2e/presentation-mode.spec.ts b/e2e/presentation-mode.spec.ts new file mode 100644 index 000000000..d77ed5ef9 --- /dev/null +++ b/e2e/presentation-mode.spec.ts @@ -0,0 +1,262 @@ +import { test, expect, Page } from '@playwright/test'; +import { loginAsAdmin } from './fixtures/admin-login'; +import { DfApi, E2E_SERVICE_PREFIX } from './fixtures/df-api'; + +/** + * Presentation mode: one toggle masks every API key and token in the UI so the + * admin interface can be screenshared without putting live credentials on + * someone else's monitor. + * + * The invariant these tests exist to defend is the awkward one: what is on + * SCREEN must be masked while what lands on the CLIPBOARD must stay real, so a + * curl command still pastes into Postman and works. + */ + +const HEX_KEY = /\b[0-9a-f]{32,64}\b/; +const MASK = '••••••••••••'; +/** A DreamFactory API key (hex) or a session JWT — either may appear in a + * generated snippet depending on which identity is selected. */ +const CREDENTIAL = + /\b(?:[0-9a-f]{32,64}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/; + +test.use({ permissions: ['clipboard-read', 'clipboard-write'] }); + +async function setPresentation(page: Page, on: boolean) { + const toggle = page.getByTestId('presentation-toggle'); + await expect(toggle).toBeVisible({ timeout: 10_000 }); + const isOn = (await toggle.getAttribute('aria-pressed')) === 'true'; + if (isOn !== on) { + await toggle.click(); + } + await expect(toggle).toHaveAttribute('aria-pressed', String(on)); +} + +async function gotoApiKeys(page: Page) { + await page.goto('/dreamfactory/dist/#/api-connections/api-keys'); + await expect(page.locator('table tbody tr').first()).toBeVisible({ + timeout: 15_000, + }); +} + +const OIDC_SERVICE = `${E2E_SERVICE_PREFIX}presentation_oidc`; + +test.describe('presentation mode', () => { + test.beforeAll(async ({ request }) => { + const api = await DfApi.login(request); + await api.deleteByNamePrefix(); + await api.createServices([ + { + name: OIDC_SERVICE, + label: 'E2E presentation mode oidc', + type: 'oidc', + is_active: true, + config: { + client_id: 'e2e-client-id', + client_secret: 'e2e-client-secret-value', + redirect_url: 'https://example.com/cb', + auth_endpoint: 'https://example.com/auth', + token_endpoint: 'https://example.com/token', + scopes: 'openid profile', + }, + }, + ]); + }); + + test.afterAll(async ({ request }) => { + const api = await DfApi.login(request); + await api.deleteByNamePrefix(); + }); + + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('the toggle is reachable from every page and starts off', async ({ + page, + }) => { + const toggle = page.getByTestId('presentation-toggle'); + await expect(toggle).toBeVisible({ timeout: 15_000 }); + await expect(toggle).toHaveAttribute('aria-pressed', 'false'); + }); + + test('API keys disappear from the DOM, not just from view', async ({ + page, + }) => { + await gotoApiKeys(page); + + // Baseline: the page really is leaking keys before the toggle. + const before = await page.locator('table').innerText(); + expect(before, 'page must show real keys with the toggle off').toMatch( + HEX_KEY + ); + + await setPresentation(page, true); + + const after = await page.locator('table').innerText(); + expect(after, 'no API key may survive on screen').not.toMatch(HEX_KEY); + expect(after).toContain(MASK); + + // innerHTML too: a CSS-only blur would leave the key selectable and + // recoverable, which is not good enough for a screenshare. + const html = await page.locator('table').innerHTML(); + expect(html, 'the key must be absent from the markup').not.toMatch(HEX_KEY); + }); + + test('the per-field eye reveals one key without navigating away', async ({ + page, + }) => { + await gotoApiKeys(page); + + // Capture a real key first, with masking off, to compare against. + const rowText = await page.locator('table tbody tr').first().innerText(); + const realKey = rowText.match(HEX_KEY)?.[0]; + expect(realKey, 'needed a real key to compare against').toBeTruthy(); + + await setPresentation(page, true); + + const row = page.locator('table tbody tr').first(); + const eye = page.getByTestId('secret-eye').first(); + await expect(eye).toBeVisible(); + + await eye.click(); + await expect(row).toContainText(realKey as string); + // Table rows navigate on click; the eye must not take the list with it. + expect(page.url()).toContain('/api-connections/api-keys'); + + await eye.click(); + await expect(row).not.toContainText(realKey as string); + }); + + test('curl snippets mask the credentials but copy the real command', async ({ + page, + }) => { + await page.goto('/dreamfactory/dist/#/api-connections/api-docs/db'); + // The request builder (and with it the export snippets) only renders once + // an endpoint is picked from the left-hand operation list. + const op = page.locator('.docs-op').first(); + await expect(op).toBeVisible({ timeout: 25_000 }); + await op.click(); + + const snippet = page.locator('.try-it__code').first(); + const copy = page.locator('.try-it__copy').first(); + await expect(snippet).toBeVisible({ timeout: 20_000 }); + await expect(snippet).toContainText('curl'); + + // The real command, captured before anything is masked. + const realSnippet = await snippet.innerText(); + const credential = realSnippet.match(CREDENTIAL)?.[0]; + expect( + credential, + 'the snippet must carry a credential for this test to mean anything' + ).toBeTruthy(); + + await setPresentation(page, true); + + const shown = await snippet.innerText(); + expect( + shown, + 'the credential must not be readable on screen' + ).not.toContain(credential as string); + expect(shown, 'the command itself must stay legible').toContain('curl'); + expect(shown).toContain(MASK); + + await copy.click(); + const clip = await page.evaluate(() => navigator.clipboard.readText()); + // The whole point: what you paste into Postman is the untouched command. + expect( + clip, + 'the clipboard must carry the REAL command, masking is display-only' + ).toBe(realSnippet); + expect(clip).toContain(credential as string); + }); + + test('service-config secrets are masked even when the schema calls them text', async ({ + page, + }) => { + // oidc.client_secret ships as schema type `text`, so nothing but the field + // name marks it a credential. That is the case worth pinning down. + await page.goto('/dreamfactory/dist/#/api-security/authentication'); + const row = page.locator('table tbody tr', { hasText: OIDC_SERVICE }); + await expect(row.first()).toBeVisible({ timeout: 25_000 }); + await row.first().click(); + + const secret = page.locator('input[type="text"]').first(); + await expect(secret).toBeVisible({ timeout: 15_000 }); + // Baseline: with the toggle off the secret field is an ordinary text input. + expect(await page.locator('input[type="password"]').count()).toBe(0); + + await setPresentation(page, true); + + expect( + await page.locator('input[type="password"]').count(), + 'the credential fields must render as password inputs' + ).toBeGreaterThan(0); + expect( + await page.getByTestId('config-secret-eye').count(), + 'each masked credential field needs its own eye' + ).toBeGreaterThan(0); + + // client_id is not a secret; hiding it would make OAuth setup painful. + await expect( + page.locator('input[type="text"]').filter({ hasNotText: '' }).first() + ).toBeVisible(); + }); + + // A net rather than a single assertion: every route that renders a credential + // today must stop doing so with the toggle on. A route with nothing to leak + // fails loudly rather than passing quietly, so this cannot rot into a no-op. + // Only routes that demonstrably render a credential on this instance. The + // MCP section leaks keys too, but only once an MCP server has a created key, + // which this fixture does not set up — covered by the mcp-access-key unit and + // component tests instead. + const SWEEP_ROUTES = ['/api-connections/api-keys', '/api-security/api-keys']; + + for (const route of SWEEP_ROUTES) { + test(`no credential survives on ${route}`, async ({ page }) => { + await setPresentation(page, false); + await page.goto(`/dreamfactory/dist/#${route}`); + await page.waitForLoadState('networkidle'); + const before = await page.locator('body').innerText(); + expect( + before, + `${route} has no credential to hide — this guard is not testing anything` + ).toMatch(CREDENTIAL); + + await setPresentation(page, true); + await page.goto(`/dreamfactory/dist/#${route}`); + await page.waitForLoadState('networkidle'); + + const after = await page.locator('body').innerText(); + expect(after, `${route} still shows a credential`).not.toMatch( + CREDENTIAL + ); + }); + } + + test('the setting survives a reload, so a demo does not start exposed', async ({ + page, + }) => { + await gotoApiKeys(page); + await setPresentation(page, true); + + await page.reload(); + await expect(page.locator('table tbody tr').first()).toBeVisible({ + timeout: 15_000, + }); + + await expect(page.getByTestId('presentation-toggle')).toHaveAttribute( + 'aria-pressed', + 'true' + ); + expect(await page.locator('table').innerText()).not.toMatch(HEX_KEY); + }); + + test('turning it off puts the keys back', async ({ page }) => { + await gotoApiKeys(page); + await setPresentation(page, true); + expect(await page.locator('table').innerText()).not.toMatch(HEX_KEY); + + await setPresentation(page, false); + expect(await page.locator('table').innerText()).toMatch(HEX_KEY); + }); +}); diff --git a/jest.config.ci.js b/jest.config.ci.js index f007653be..0626b693d 100644 --- a/jest.config.ci.js +++ b/jest.config.ci.js @@ -60,5 +60,9 @@ module.exports = { '/src/app/shared/components/df-trial-banner/df-trial-banner.component.spec.ts', '/src/app/shared/components/df-trial-expired/df-trial-expired.component.spec.ts', '/src/app/shared/components/df-engagement-banner/df-engagement-banner.component.spec.ts', + // 2026-09-29 presentation mode: mask API keys and tokens for screensharing. + '/src/app/shared/utilities/mask.spec.ts', + '/src/app/shared/services/df-presentation.service.spec.ts', + '/src/app/shared/components/df-secret/df-secret.component.spec.ts', ], }; diff --git a/src/app/adf-apps/df-app-details/df-app-details.component.html b/src/app/adf-apps/df-app-details/df-app-details.component.html index 984099621..670b4268e 100644 --- a/src/app/adf-apps/df-app-details/df-app-details.component.html +++ b/src/app/adf-apps/df-app-details/df-app-details.component.html @@ -104,7 +104,7 @@ }} - {{ editApp.apiKey }} + diff --git a/src/app/adf-mcp/df-mcp-access/df-mcp-access-editor.component.ts b/src/app/adf-mcp/df-mcp-access/df-mcp-access-editor.component.ts index db60a784d..d21aa48aa 100644 --- a/src/app/adf-mcp/df-mcp-access/df-mcp-access-editor.component.ts +++ b/src/app/adf-mcp/df-mcp-access/df-mcp-access-editor.component.ts @@ -42,6 +42,7 @@ import { DfMcpAccessApiService, McpRole } from '../mcp-access-api.service'; import { McpServiceKind } from '../mcp-catalog'; import { McpBackendService } from '../mcp-effective'; import { McpEditorStore } from '../mcp-store'; +import { DfSecretComponent } from 'src/app/shared/components/df-secret/df-secret.component'; export interface McpAccessEditorData { store: McpEditorStore; @@ -68,6 +69,7 @@ export const ROLE_ROUTE = '/api-connections/role-based-access'; selector: 'df-mcp-access-editor', standalone: true, imports: [ + DfSecretComponent, CommonModule, FormsModule, MatButtonModule, diff --git a/src/app/adf-mcp/df-mcp-connect/df-mcp-connect.component.html b/src/app/adf-mcp/df-mcp-connect/df-mcp-connect.component.html index a2a05f294..db3a6125c 100644 --- a/src/app/adf-mcp/df-mcp-connect/df-mcp-connect.component.html +++ b/src/app/adf-mcp/df-mcp-connect/df-mcp-connect.component.html @@ -303,7 +303,7 @@

Claude — add a custom connector

Claude Code

Run in your terminal:

-
{{ claudeCodeSnippet }}
+
{{ maskIfPresenting(claudeCodeSnippet) }}

Check reachability: - {{ genericCurl }} + {{ maskIfPresenting(genericCurl) }} diff --git a/src/app/adf-mcp/df-mcp-connect/df-mcp-connect.component.ts b/src/app/adf-mcp/df-mcp-connect/df-mcp-connect.component.ts index 8b9cdef23..5e5de9679 100644 --- a/src/app/adf-mcp/df-mcp-connect/df-mcp-connect.component.ts +++ b/src/app/adf-mcp/df-mcp-connect/df-mcp-connect.component.ts @@ -20,6 +20,8 @@ import { RouterModule } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { DfSnackbarService } from 'src/app/shared/services/df-snackbar.service'; +import { DfPresentationService } from 'src/app/shared/services/df-presentation.service'; +import { maskSecretsIn } from 'src/app/shared/utilities/mask'; import { McpEditorStore } from '../mcp-store'; import { McpTab } from '../df-mcp-details/df-mcp-details.component'; @@ -93,7 +95,19 @@ export class DfMcpConnectComponent implements OnInit, OnChanges, OnDestroy { private autoDismissTimer: ReturnType | null = null; - constructor(private snackbarService: DfSnackbarService) {} + constructor( + private snackbarService: DfSnackbarService, + public presentation: DfPresentationService + ) {} + + /** Display-only masking for the client snippets. The copy buttons are wired + * to the raw getters on purpose, so what lands in Claude Code / Cursor / + * Postman is still the real key. */ + maskIfPresenting(text: string): string { + return this.presentation.on + ? maskSecretsIn(text, [this.store.createdApiKey]) + : text; + } ngOnInit(): void { this.restoreClientChoice(); @@ -224,7 +238,9 @@ export class DfMcpConnectComponent implements OnInit, OnChanges, OnDestroy { /* -------------------------------- OAuth -------------------------------- */ get secretDisplay(): string { - if (this.secretRevealed) return this.store.cfg.oauthClientSecret || '—'; + if (this.secretRevealed && !this.presentation.on) { + return this.store.cfg.oauthClientSecret || '—'; + } return this.store.cfg.oauthClientSecret ? '••••••••••••••••' : '—'; } diff --git a/src/app/shared/components/df-artifact-card/df-artifact-card.component.html b/src/app/shared/components/df-artifact-card/df-artifact-card.component.html index a805a609e..1dca6ed18 100644 --- a/src/app/shared/components/df-artifact-card/df-artifact-card.component.html +++ b/src/app/shared/components/df-artifact-card/df-artifact-card.component.html @@ -64,9 +64,8 @@

{{ t('title') }}

{{ t('authHeaderLabel') }}
- {{ headerName }}: {{ activeKey }} + {{ headerName }}: +
- {{ data.apiKey }} + void; @@ -74,11 +84,41 @@ export class DfDynamicFieldComponent implements OnInit, DoCheck, AfterViewInit { constructor( @Optional() @Self() public controlDir: NgControl, private activedRoute: ActivatedRoute, - private themeService: DfThemeService + private themeService: DfThemeService, + public presentation: DfPresentationService ) { controlDir.valueAccessor = this; } + /** True when this config field holds a credential that presentation mode + * should cover. Service config schemas are inconsistent about marking + * secrets as `password` — oidc.client_secret ships as `text` and + * mcp.oauth_client_secret as `string` — so the field name decides. */ + get isMaskedSecret(): boolean { + if (this.secretRevealed || !this.presentation.on) { + return false; + } + return ( + ['string', 'text', 'password'].includes(this.schema?.type) && + isSecretFieldName(this.schema?.name) + ); + } + + /** The eye only appears on a field that is actually covered right now, or on + * one this component is covering and the user has since revealed. */ + get showSecretEye(): boolean { + return ( + this.presentation.on && + ['string', 'text', 'password'].includes(this.schema?.type) && + isSecretFieldName(this.schema?.name) + ); + } + + toggleSecretReveal(event?: Event): void { + event?.stopPropagation(); + this.secretRevealed = !this.secretRevealed; + } + eventList: string[] = []; filteredEventList: Observable; isDarkMode = this.themeService.darkMode$; diff --git a/src/app/shared/components/df-secret/df-secret.component.spec.ts b/src/app/shared/components/df-secret/df-secret.component.spec.ts new file mode 100644 index 000000000..a2c901062 --- /dev/null +++ b/src/app/shared/components/df-secret/df-secret.component.spec.ts @@ -0,0 +1,106 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DfSecretComponent } from './df-secret.component'; +import { DfPresentationService } from '../../services/df-presentation.service'; +import { MASK } from '../../utilities/mask'; + +const KEY = '6498a8ad1beb4f0e9c3d2a1b8f7e6d5c4b3a29180716253443526170819a0b1c'; + +describe('DfSecretComponent', () => { + let fixture: ComponentFixture; + let component: DfSecretComponent; + let presentation: DfPresentationService; + + const rendered = () => fixture.nativeElement.textContent as string; + const eye = () => + fixture.nativeElement.querySelector('[data-testid="secret-eye"]'); + + beforeEach(async () => { + localStorage.clear(); + document.body.className = ''; + await TestBed.configureTestingModule({ + imports: [DfSecretComponent], + }).compileComponents(); + presentation = TestBed.inject(DfPresentationService); + fixture = TestBed.createComponent(DfSecretComponent); + component = fixture.componentInstance; + component.value = KEY; + fixture.detectChanges(); + }); + + it('shows the real value while presentation mode is off', () => { + expect(rendered()).toContain(KEY); + expect(eye()).toBeNull(); + }); + + it('hides the value — not just visually — once presentation mode is on', () => { + presentation.set(true); + fixture.detectChanges(); + expect(rendered()).not.toContain(KEY); + expect(rendered()).toContain(MASK); + // The point of the feature: the key is absent from the DOM, so it cannot + // be read off a screenshare or recovered by selecting the text. + expect(fixture.nativeElement.innerHTML).not.toContain(KEY); + }); + + it('offers an eye only while masking, and reveals on click', () => { + presentation.set(true); + fixture.detectChanges(); + expect(eye()).not.toBeNull(); + + eye().click(); + fixture.detectChanges(); + expect(rendered()).toContain(KEY); + + eye().click(); + fixture.detectChanges(); + expect(rendered()).not.toContain(KEY); + }); + + it('re-hides a revealed field when presentation mode is switched on again', () => { + presentation.set(true); + fixture.detectChanges(); + eye().click(); + fixture.detectChanges(); + expect(rendered()).toContain(KEY); + + presentation.set(false); + fixture.detectChanges(); + presentation.set(true); + fixture.detectChanges(); + + expect(component.revealed).toBe(false); + expect(rendered()).not.toContain(KEY); + }); + + it('renders nothing for an absent value instead of a mask', () => { + // setInput, not a bare assignment: the component is OnPush, and this is + // how Angular delivers the binding in real use. + fixture.componentRef.setInput('value', null); + presentation.set(true); + fixture.detectChanges(); + expect(rendered().trim()).toBe(''); + }); + + it('stringifies a non-string value handed over by a table cell', () => { + fixture.componentRef.setInput('value', 12345); + fixture.detectChanges(); + expect(rendered()).toContain('12345'); + }); + + it('stops the click from reaching a clickable table row', () => { + presentation.set(true); + fixture.detectChanges(); + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + const stop = jest.spyOn(event, 'stopPropagation'); + eye().dispatchEvent(event); + fixture.detectChanges(); + expect(stop).toHaveBeenCalled(); + expect(component.revealed).toBe(true); + }); + + it('drops its subscription on destroy', () => { + const sub = (component as unknown as { sub: { closed: boolean } }).sub; + fixture.destroy(); + expect(sub.closed).toBe(true); + }); +}); diff --git a/src/app/shared/components/df-secret/df-secret.component.ts b/src/app/shared/components/df-secret/df-secret.component.ts new file mode 100644 index 000000000..9862fa1a2 --- /dev/null +++ b/src/app/shared/components/df-secret/df-secret.component.ts @@ -0,0 +1,133 @@ +import { + Component, + Input, + OnDestroy, + ChangeDetectionStrategy, + inject, +} from '@angular/core'; +import { AsyncPipe, NgIf } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; +import { Subscription } from 'rxjs'; +import { DfPresentationService } from '../../services/df-presentation.service'; +import { maskSecret } from '../../utilities/mask'; + +/** + * Renders a secret (API key, token) that hides itself while presentation mode + * is on, with a per-field eye to reveal it again. + * + * The real value is never written to the DOM while masked, so it cannot be + * read off a screenshare, out of a screenshot, or by selecting the text. + * Copy buttons at the call sites read the value from the model rather than + * the DOM, so they keep handing Postman the real key either way. + */ +@Component({ + selector: 'df-secret', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIf, AsyncPipe, FontAwesomeModule], + template: ` + + {{ displayFor(state.on && !revealed) }} + + + `, + styles: [ + ` + :host { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + } + .df-secret__value { + overflow-wrap: anywhere; + } + .df-secret__value--masked { + /* The mask is one token, not text: wrapping it mid-dots reads as a + rendering bug in a narrow table column. */ + white-space: nowrap; + letter-spacing: 2px; + user-select: none; + color: var(--df-text-muted); + } + .df-secret__eye { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--df-text-muted); + cursor: pointer; + } + .df-secret__eye:hover { + color: var(--df-text); + } + `, + ], +}) +export class DfSecretComponent implements OnDestroy { + /** Accepts whatever a table cell accessor hands over, not just strings. */ + @Input() value: unknown; + + get text(): string { + return this.value === null || this.value === undefined + ? '' + : String(this.value); + } + + readonly faEye = faEye; + readonly faEyeSlash = faEyeSlash; + + revealed = false; + + presentation = inject(DfPresentationService); + private sub: Subscription; + + constructor() { + // Flipping presentation mode ON must re-hide anything already revealed — + // otherwise the switch silently fails to cover the one field you looked at. + this.sub = this.presentation.on$.subscribe(on => { + if (on) { + this.revealed = false; + } + }); + } + + /** An empty field stays empty: a mask on nothing would imply a secret that + * is not there. */ + displayFor(masked: boolean | null): string { + return masked ? maskSecret(this.text) : this.text; + } + + /** stopPropagation because a secret often sits in a clickable table row — + * revealing a key must not also navigate away from the list. */ + toggleReveal(event?: Event): void { + event?.stopPropagation(); + this.revealed = !this.revealed; + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } +} diff --git a/src/app/shared/components/df-theme-toggle/df-theme-toggle.component.html b/src/app/shared/components/df-theme-toggle/df-theme-toggle.component.html index 65b5a2a31..28670d8f0 100644 --- a/src/app/shared/components/df-theme-toggle/df-theme-toggle.component.html +++ b/src/app/shared/components/df-theme-toggle/df-theme-toggle.component.html @@ -10,3 +10,14 @@ aria-label="Toggle phosphor mode"> >_ + diff --git a/src/app/shared/components/df-theme-toggle/df-theme-toggle.component.ts b/src/app/shared/components/df-theme-toggle/df-theme-toggle.component.ts index cbd144caf..f2eeb23c2 100644 --- a/src/app/shared/components/df-theme-toggle/df-theme-toggle.component.ts +++ b/src/app/shared/components/df-theme-toggle/df-theme-toggle.component.ts @@ -1,6 +1,9 @@ import { Component, inject } from '@angular/core'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faEyeSlash } from '@fortawesome/free-solid-svg-icons'; import { DfThemeService } from '../../services/df-theme.service'; +import { DfPresentationService } from '../../services/df-presentation.service'; import { BehaviorSubject } from 'rxjs'; import { AsyncPipe } from '@angular/common'; import { TranslocoService } from '@ngneat/transloco'; @@ -32,6 +35,28 @@ import { TranslocoService } from '@ngneat/transloco'; border-color: var(--df-border); color: var(--df-text); } + .presentation-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--df-text-muted); + cursor: pointer; + } + .presentation-toggle:hover { + border-color: var(--df-border); + color: var(--df-text); + } + .presentation-toggle.on { + border-color: var(--df-border); + background: var(--df-warn-bg, rgba(255, 176, 32, 0.16)); + color: var(--df-warn-text, #b26a00); + } :host-context(.phosphor-theme) .phosphor-toggle { color: #33ff66; border-color: rgba(51, 255, 102, 0.4); @@ -40,13 +65,19 @@ import { TranslocoService } from '@ngneat/transloco'; `, ], standalone: true, - imports: [MatSlideToggleModule, AsyncPipe], + imports: [MatSlideToggleModule, AsyncPipe, FontAwesomeModule], }) export class DfThemeToggleComponent { isDarkMode$: BehaviorSubject = new BehaviorSubject(true); themeService = inject(DfThemeService); + presentation = inject(DfPresentationService); + readonly faEyeSlash = faEyeSlash; private transloco = inject(TranslocoService); + togglePresentation(): void { + this.presentation.toggle(); + } + toggle() { this.isDarkMode$.subscribe(isDarkMode => { this.themeService.setThemeMode(!isDarkMode); diff --git a/src/app/shared/components/df-try-it/df-try-it.component.html b/src/app/shared/components/df-try-it/df-try-it.component.html index 625bea9c4..b7a6a37a3 100644 --- a/src/app/shared/components/df-try-it/df-try-it.component.html +++ b/src/app/shared/components/df-try-it/df-try-it.component.html @@ -251,7 +251,7 @@ content_copy{{ t('copy') }}
-
{{ snippet }}
+
{{ displaySnippet }}
diff --git a/src/app/shared/components/df-try-it/df-try-it.component.ts b/src/app/shared/components/df-try-it/df-try-it.component.ts index 4edad8beb..86607be58 100644 --- a/src/app/shared/components/df-try-it/df-try-it.component.ts +++ b/src/app/shared/components/df-try-it/df-try-it.component.ts @@ -31,6 +31,8 @@ import { API_KEY_HEADER, SESSION_TOKEN_HEADER, } from 'src/app/shared/constants/http-headers'; +import { maskSecretsIn } from 'src/app/shared/utilities/mask'; +import { DfPresentationService } from 'src/app/shared/services/df-presentation.service'; export type TryItMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -201,7 +203,8 @@ export class DfTryItComponent implements OnInit { constructor( private http: HttpClient, private userData: DfUserDataService, - private zone: NgZone + private zone: NgZone, + public presentation: DfPresentationService ) {} ngOnInit(): void { @@ -740,6 +743,20 @@ export class DfTryItComponent implements OnInit { ].join('\n'); } + /** What the snippet looks like on screen. The command stays readable; only + * the credentials are covered, so a screenshare can still show the shape of + * the call. copySnippet() deliberately copies `snippet`, not this — the + * clipboard must carry the real key for Postman. */ + get displaySnippet(): string { + if (!this.presentation.on) { + return this.snippet; + } + return maskSecretsIn(this.snippet, [ + this.selectedIdentity?.apiKey, + this.userData.token, + ]); + } + copySnippet(): void { void navigator.clipboard?.writeText(this.snippet); } diff --git a/src/app/shared/services/df-presentation.service.spec.ts b/src/app/shared/services/df-presentation.service.spec.ts new file mode 100644 index 000000000..52f0185d5 --- /dev/null +++ b/src/app/shared/services/df-presentation.service.spec.ts @@ -0,0 +1,51 @@ +import { TestBed } from '@angular/core/testing'; +import { DfPresentationService } from './df-presentation.service'; + +describe('DfPresentationService', () => { + let service: DfPresentationService; + + beforeEach(() => { + localStorage.clear(); + document.body.className = ''; + TestBed.configureTestingModule({}); + service = TestBed.inject(DfPresentationService); + }); + + it('defaults to off, so nothing is hidden until asked', () => { + expect(service.on).toBe(false); + expect(document.body.classList.contains('presentation-mode')).toBe(false); + }); + + it('toggles, persists, and drives the body class', () => { + service.toggle(); + expect(service.on).toBe(true); + expect(localStorage.getItem('isPresentationMode')).toBe('true'); + expect(document.body.classList.contains('presentation-mode')).toBe(true); + + service.toggle(); + expect(service.on).toBe(false); + expect(localStorage.getItem('isPresentationMode')).toBe('false'); + expect(document.body.classList.contains('presentation-mode')).toBe(false); + }); + + it('emits to subscribers', () => { + const seen: boolean[] = []; + service.on$.subscribe(v => seen.push(v)); + service.set(true); + service.set(false); + expect(seen).toEqual([false, true, false]); + }); + + it('restores a stored ON state', () => { + localStorage.setItem('isPresentationMode', 'true'); + service.loadInitial(); + expect(service.on).toBe(true); + expect(document.body.classList.contains('presentation-mode')).toBe(true); + }); + + it('stays off on a corrupt stored value rather than throwing', () => { + localStorage.setItem('isPresentationMode', 'not-json'); + expect(() => service.loadInitial()).not.toThrow(); + expect(service.on).toBe(false); + }); +}); diff --git a/src/app/shared/services/df-presentation.service.ts b/src/app/shared/services/df-presentation.service.ts new file mode 100644 index 000000000..8edf11734 --- /dev/null +++ b/src/app/shared/services/df-presentation.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +const STORAGE_KEY = 'isPresentationMode'; + +/** + * Presentation mode: one switch that masks every API key and session token + * rendered in the admin UI, for screensharing and demos. + * + * Mirrors DfThemeService — BehaviorSubject + localStorage + a single body + * class, so global styles (and third-party DOM such as Swagger UI, which we + * cannot template) can key off `.presentation-mode`. + */ +@Injectable({ + providedIn: 'root', +}) +export class DfPresentationService { + on$ = new BehaviorSubject(false); + + constructor() { + this.loadInitial(); + } + + get on(): boolean { + return this.on$.value; + } + + set(on: boolean): void { + this.on$.next(on); + localStorage.setItem(STORAGE_KEY, JSON.stringify(on)); + this.applyBodyClass(); + } + + toggle(): void { + this.set(!this.on$.value); + } + + loadInitial(): void { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + this.on$.next(JSON.parse(stored) === true); + } catch { + /* corrupt value — stay off, which is the safe-for-work default */ + } + } + this.applyBodyClass(); + } + + private applyBodyClass(): void { + document.body.classList.toggle('presentation-mode', this.on$.value); + } +} diff --git a/src/app/shared/utilities/mask.spec.ts b/src/app/shared/utilities/mask.spec.ts new file mode 100644 index 000000000..d436b2bbf --- /dev/null +++ b/src/app/shared/utilities/mask.spec.ts @@ -0,0 +1,82 @@ +import { MASK, maskSecret, maskSecretsIn, isSecretFieldName } from './mask'; + +const KEY = '6498a8ad1beb4f0e9c3d2a1b8f7e6d5c4b3a29180716253443526170819a0b1c'; +const JWT = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJmMzc3NjgyMzNlMGQifQ.s1gn4tur3_v4lu3'; + +describe('maskSecret', () => { + it('returns a fixed-width mask that does not leak the real length', () => { + expect(maskSecret(KEY)).toBe(MASK); + expect(maskSecret('short')).toBe(MASK); + expect(maskSecret(KEY).length).toBe(maskSecret('short').length); + }); + + it('leaves an absent value empty rather than masking nothing', () => { + expect(maskSecret('')).toBe(''); + expect(maskSecret(null)).toBe(''); + expect(maskSecret(undefined)).toBe(''); + }); +}); + +describe('maskSecretsIn', () => { + const curl = `curl -X GET 'http://localhost/api/v2/db/_table/contact' \\\n -H 'X-DreamFactory-API-Key: ${KEY}' \\\n -H 'X-DreamFactory-Session-Token: ${JWT}'`; + + it('covers the key and the session token but keeps the command readable', () => { + const out = maskSecretsIn(curl, [KEY, JWT]); + expect(out).not.toContain(KEY); + expect(out).not.toContain(JWT); + expect(out).toContain('curl -X GET'); + expect(out).toContain('X-DreamFactory-API-Key'); + expect(out).toContain('/api/v2/db/_table/contact'); + }); + + it('catches an API key it was not told about', () => { + expect(maskSecretsIn(`-H 'X-Key: ${KEY}'`)).not.toContain(KEY); + }); + + it('catches a JWT it was not told about', () => { + expect(maskSecretsIn(`Bearer ${JWT}`)).not.toContain(JWT); + }); + + it('masks every occurrence, not just the first', () => { + const out = maskSecretsIn(`${KEY} and again ${KEY}`, [KEY]); + expect(out).not.toContain(KEY); + expect(out.split(MASK).length - 1).toBe(2); + }); + + it('leaves text with no secrets untouched', () => { + const plain = "curl -X GET 'http://localhost/api/v2/db/_table/contact'"; + expect(maskSecretsIn(plain)).toBe(plain); + }); + + it('ignores short known values so a stray word is not blanked out', () => { + // 'db' would otherwise replace every occurrence of those two letters. + expect(maskSecretsIn('db table in db', ['db'])).toBe('db table in db'); + }); +}); + +describe('isSecretFieldName', () => { + it.each([ + 'client_secret', + 'oauth_client_secret', + 'api_key', + 'password', + 'auth_token', + 'AWS_SECRET_ACCESS_KEY', + ])('treats %s as a secret', name => { + expect(isSecretFieldName(name)).toBe(true); + }); + + it.each(['client_id', 'id', 'host', 'port', 'database', 'username'])( + 'leaves %s visible', + name => { + expect(isSecretFieldName(name)).toBe(false); + } + ); + + it('handles an absent name', () => { + expect(isSecretFieldName(null)).toBe(false); + expect(isSecretFieldName(undefined)).toBe(false); + expect(isSecretFieldName('')).toBe(false); + }); +}); diff --git a/src/app/shared/utilities/mask.ts b/src/app/shared/utilities/mask.ts new file mode 100644 index 000000000..eb1a07466 --- /dev/null +++ b/src/app/shared/utilities/mask.ts @@ -0,0 +1,59 @@ +/** + * Secret masking for presentation mode. + * + * Display-only: every call site keeps the real value in the model and hands + * the clipboard the real value, so copy-to-Postman is unaffected. Nothing + * here is a security boundary — the key still arrives in the API response + * and is visible in devtools. It exists so screensharing the admin UI does + * not put live credentials on someone else's monitor. + */ + +/** Fixed width on purpose: a length-preserving mask leaks the secret's length. */ +export const MASK = '••••••••••••'; + +export function maskSecret(value: string | null | undefined): string { + return value ? MASK : ''; +} + +/** + * Mask secrets embedded in a larger block of text — the curl / Python / JS + * snippets, where the key sits inside an otherwise readable command that we + * still want legible on screen. + * + * Known values are replaced first (exact, no false positives), then two + * catch-all patterns cover a header we did not anticipate: DreamFactory API + * keys (32+ hex) and JWT session tokens. + */ +export function maskSecretsIn( + text: string, + known: (string | null | undefined)[] = [] +): string { + let out = text; + known + .filter((v): v is string => !!v && v.length >= 8) + .forEach(v => { + out = out.split(v).join(MASK); + }); + return out + .replace(/\b[0-9a-f]{32,}\b/gi, MASK) + .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, MASK); +} + +/** + * Whether a service-config field name denotes a credential. + * + * Config schemas are not trustworthy here: oidc.client_secret ships as `text` + * and mcp.oauth_client_secret as `string`, so both render as plain visible + * inputs. `_id` is excluded because a client_id is not a secret and hiding it + * makes OAuth setup needlessly painful. + */ +export function isSecretFieldName(name: string | null | undefined): boolean { + if (!name) { + return false; + } + const n = name.toLowerCase(); + if (n.endsWith('_id') || n === 'id') { + return false; + } + return /key|secret|password|passwd|token|credential/.test(n); +} From 0c45a9a0d1182117e817d5ccdf41e1634b435d15 Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Mon, 5 Oct 2026 11:35:20 -0600 Subject: [PATCH 02/15] fix(presentation-mode): keep unmasked API keys on one line in the keys table overflow-wrap: anywhere let the auto-layout table shrink the API key column to a single character, stacking the 64-char key vertically. Show it on one line, capped at 180px with an ellipsis instead. --- .../df-manage-apps/df-manage-apps-table.component.scss | 7 +++++++ src/app/shared/components/df-secret/df-secret.component.ts | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.scss b/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.scss index 70cb1263c..7f9219d57 100644 --- a/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.scss +++ b/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.scss @@ -3,6 +3,13 @@ text-overflow: ellipsis; } +// One line, ellipsized: overflow-wrap on the value otherwise lets the auto +// table squeeze the column to one character and stack the key vertically. +.mat-column-apiKey df-secret { + max-width: 180px; + white-space: nowrap; +} + // Numeric metric columns stay narrow so the meter has room to breathe. .mat-column-tokens, .mat-column-spend { diff --git a/src/app/shared/components/df-secret/df-secret.component.ts b/src/app/shared/components/df-secret/df-secret.component.ts index 9862fa1a2..1ce83243a 100644 --- a/src/app/shared/components/df-secret/df-secret.component.ts +++ b/src/app/shared/components/df-secret/df-secret.component.ts @@ -57,6 +57,11 @@ import { maskSecret } from '../../utilities/mask'; } .df-secret__value { overflow-wrap: anywhere; + /* Lets a host that sets white-space: nowrap and a max-width (a table + column) truncate the key with an ellipsis instead of overflowing. */ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .df-secret__value--masked { /* The mask is one token, not text: wrapping it mid-dots reads as a From 92b017209e1cefb2f1f744c46d97cae3958be22c Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Mon, 5 Oct 2026 11:03:52 -0600 Subject: [PATCH 03/15] feat(pickers): searchable selects with endpoint type filter Shared df-select-search header + dfSearch pipe. Role-Based Access service and component pickers get type-to-search; the component picker also gets All/Schema/Table/Function/Procedure toggles. Wildcards stay selectable while filtering. Users/Admins app+role autocompletes now narrow as you type; Event Scripts pickers and the API Docs operation nav are searchable. Saved payloads are unchanged. Closes #534 --- .../df-api-docs/df-api-docs.component.html | 40 ++-- .../df-api-docs/df-api-docs.component.scss | 11 + .../df-api-docs/df-api-docs.component.ts | 4 + .../df-script-details.component.html | 24 ++- .../df-script-details.component.ts | 6 + .../df-roles-access.component.html | 15 +- .../df-roles-access.component.ts | 25 +++ .../df-select-search.component.ts | 189 ++++++++++++++++++ .../df-select-search/df-select-search.spec.ts | 42 ++++ .../df-user-app-roles.component.html | 12 +- .../df-user-app-roles.component.ts | 2 + src/assets/i18n/en.json | 7 + 12 files changed, 356 insertions(+), 21 deletions(-) create mode 100644 src/app/shared/components/df-select-search/df-select-search.component.ts create mode 100644 src/app/shared/components/df-select-search/df-select-search.spec.ts diff --git a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.html b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.html index 504715bed..8bbfee00e 100644 --- a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.html +++ b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.html @@ -68,20 +68,34 @@
diff --git a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.scss b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.scss index 382f8dde8..56e442e28 100644 --- a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.scss +++ b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.scss @@ -72,6 +72,17 @@ max-height: calc(100vh - var(--df-space-8)); overflow-y: auto; + &__search { + width: 100%; + box-sizing: border-box; + font: inherit; + color: inherit; + background: transparent; + border: 1px solid rgba(127, 127, 127, 0.4); + border-radius: 4px; + padding: var(--df-space-1) var(--df-space-2); + } + &__group { margin: var(--df-space-3) 0 var(--df-space-1); padding: 0 var(--df-space-2); diff --git a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.ts b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.ts index 64492730e..9d02b4ba8 100644 --- a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.ts +++ b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.ts @@ -1,3 +1,4 @@ +import { DfSearchPipe } from 'src/app/shared/components/df-select-search/df-select-search.component'; import { Component, ElementRef, @@ -129,6 +130,7 @@ const KNOWN_METHODS: TryItMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; styleUrls: ['./df-api-docs.component.scss'], standalone: true, imports: [ + DfSearchPipe, MatButtonModule, MatFormFieldModule, MatInputModule, @@ -181,6 +183,8 @@ export class DfApiDocsComponent implements OnInit, OnDestroy { // Three-column model. loading = true; groups: DocGroup[] = []; + /** Left-nav filter text (matches operation paths). */ + opQuery = ''; selectedOp: DocOperation | null = null; // Request builder (right column), computed once per selected operation so the diff --git a/src/app/adf-event-scripts/df-script-details/df-script-details.component.html b/src/app/adf-event-scripts/df-script-details/df-script-details.component.html index 56d5a4bff..8ff93ef3e 100644 --- a/src/app/adf-event-scripts/df-script-details/df-script-details.component.html +++ b/src/app/adf-event-scripts/df-script-details/df-script-details.component.html @@ -8,8 +8,12 @@ + {{ service }} @@ -23,8 +27,12 @@ + {{ item }} @@ -48,8 +56,12 @@ + {{ item }} @@ -77,8 +89,12 @@ Name + {{ item }} diff --git a/src/app/adf-event-scripts/df-script-details/df-script-details.component.ts b/src/app/adf-event-scripts/df-script-details/df-script-details.component.ts index 3d2d595f8..bdd7c2851 100644 --- a/src/app/adf-event-scripts/df-script-details/df-script-details.component.ts +++ b/src/app/adf-event-scripts/df-script-details/df-script-details.component.ts @@ -1,3 +1,7 @@ +import { + DfSearchPipe, + DfSelectSearchComponent, +} from 'src/app/shared/components/df-select-search/df-select-search.component'; import { Component, Inject, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { UntilDestroy } from '@ngneat/until-destroy'; @@ -39,6 +43,8 @@ import { DfLinkServiceComponent } from 'src/app/shared/components/df-link-servic templateUrl: './df-script-details.component.html', standalone: true, imports: [ + DfSelectSearchComponent, + DfSearchPipe, DfAceEditorComponent, MatSlideToggleModule, TranslocoPipe, diff --git a/src/app/adf-roles/df-roles-access/df-roles-access.component.html b/src/app/adf-roles/df-roles-access/df-roles-access.component.html index 798011135..29bf0d342 100644 --- a/src/app/adf-roles/df-roles-access/df-roles-access.component.html +++ b/src/app/adf-roles/df-roles-access/df-roles-access.component.html @@ -31,9 +31,14 @@ panelWidth="null" (selectionChange)="getComponents(getFormArrayIndex(i))" required> + All {{ option.name }} @@ -64,11 +69,17 @@ }} + {{ option }}(); + private noTypes: SelectSearchType[] = []; + getComponentTypes(index: number): SelectSearchType[] { + const components = this.getComponentArray(index); + if (!components.length) return this.noTypes; + let types = this.componentTypes.get(components); + if (!types) { + types = ENDPOINT_TYPES.filter(t => + components.some(c => c.startsWith(t.prefix)) + ); + this.componentTypes.set(components, types); + } + return types; + } + getFormArrayIndex(visibleIndex: number): number { let visibleCount = 0; for (let i = 0; i < this.visible.length; i++) { diff --git a/src/app/shared/components/df-select-search/df-select-search.component.ts b/src/app/shared/components/df-select-search/df-select-search.component.ts new file mode 100644 index 000000000..50276b3ca --- /dev/null +++ b/src/app/shared/components/df-select-search/df-select-search.component.ts @@ -0,0 +1,189 @@ +import { NgFor, NgIf } from '@angular/common'; +import { + Component, + ElementRef, + Input, + OnInit, + Pipe, + PipeTransform, + ViewChild, + inject, +} from '@angular/core'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatSelect } from '@angular/material/select'; +import { TranslocoPipe } from '@ngneat/transloco'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; + +export interface SelectSearchType { + labelKey: string; + prefix: string; +} + +/** Role-access component prefixes, offered as type filters on endpoint lists. */ +export const ENDPOINT_TYPES: SelectSearchType[] = [ + { labelKey: 'selectSearch.schema', prefix: '_schema/' }, + { labelKey: 'selectSearch.table', prefix: '_table/' }, + { labelKey: 'selectSearch.function', prefix: '_func/' }, + { labelKey: 'selectSearch.procedure', prefix: '_proc/' }, +]; + +/** Wildcards (`*`, the service root `''`, `_table/`, `_table/*`) never get filtered out. */ +const WILDCARD = /^([^/]*\/)?\*?$/; + +/** + * Narrows an option list by a case-insensitive substring and an optional + * prefix (type). `key` reads a label off object items. + * *ngFor="let o of options | dfSearch: search.query : search.type : 'name'" + */ +@Pipe({ name: 'dfSearch', standalone: true }) +export class DfSearchPipe implements PipeTransform { + transform(items: T[] | null, query = '', type = '', key?: string): T[] { + const q = (query || '').trim().toLowerCase(); + if (!items || (!q && !type)) return items || []; + return items.filter(item => { + const label = String( + (key ? (item as Record)?.[key] : item) ?? '' + ); + return ( + WILDCARD.test(label) || + (label.startsWith(type) && label.toLowerCase().includes(q)) + ); + }); + } +} + +/** + * Search box (plus optional type toggles) for the top of a single-select + * `mat-select` panel. Arrow keys, Enter and Tab fall through to the select; + * Esc clears the query first, then closes. Pair it with `DfSearchPipe`: + * + * + * + * + */ +// ponytail: single-select only. In `multiple` mode mat-select drops selected +// options that are filtered out of the DOM; keep them rendered before using it there. +@UntilDestroy() +@Component({ + selector: 'df-select-search', + standalone: true, + imports: [NgIf, NgFor, MatButtonToggleModule, TranslocoPipe], + template: ` + + `, + styles: [ + ` + .df-select-search { + position: sticky; + top: -8px; + z-index: 1; + margin-top: -8px; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 8px; + background: var(--mat-select-panel-background-color, inherit); + border-bottom: 1px solid rgba(127, 127, 127, 0.25); + } + input { + font: inherit; + color: inherit; + background: transparent; + border: 1px solid rgba(127, 127, 127, 0.4); + border-radius: 4px; + padding: 6px 8px; + } + mat-button-toggle-group { + flex-wrap: wrap; + font-size: 12px; + } + :host ::ng-deep .mat-button-toggle-label-content { + padding: 0 8px; + line-height: 28px; + } + `, + ], +}) +export class DfSelectSearchComponent implements OnInit { + private _types?: SelectSearchType[]; + @Input() set types(types: SelectSearchType[] | undefined) { + this._types = types; + // A different service may not have the type that was picked before. + if (!types?.some(t => t.prefix === this.type)) this.type = ''; + } + get types(): SelectSearchType[] | undefined { + return this._types; + } + @ViewChild('input', { static: true }) input: ElementRef; + + query = ''; + type = ''; + private select = inject(MatSelect); + + ngOnInit(): void { + this.select.openedChange + .pipe(untilDestroyed(this)) + .subscribe((open: boolean) => { + if (open) this.input.nativeElement.focus(); + else this.query = ''; // show the chosen value again on the trigger + }); + } + + setQuery(query: string): void { + this.query = query; + this.activateFirst(); + } + + setType(type: string): void { + this.type = type; + this.activateFirst(); + this.input.nativeElement.focus(); + } + + onKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && this.query) { + this.setQuery(''); + event.stopPropagation(); + } else if ( + !['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key) + ) { + // Keep typing (space, Home/End, letters) out of mat-select's typeahead. + event.stopPropagation(); + } + } + + // Wait for the filtered options to render, then highlight the first real + // match (not a pinned wildcard) so typing + Enter picks what was searched. + private activateFirst(): void { + setTimeout(() => { + const q = this.query.trim().toLowerCase(); + const options = this.select.options.toArray(); + const match = options.findIndex( + o => !!q && o.viewValue.toLowerCase().includes(q) + ); + this.select._keyManager?.setActiveItem(Math.max(match, 0)); + }); + } +} diff --git a/src/app/shared/components/df-select-search/df-select-search.spec.ts b/src/app/shared/components/df-select-search/df-select-search.spec.ts new file mode 100644 index 000000000..78af376af --- /dev/null +++ b/src/app/shared/components/df-select-search/df-select-search.spec.ts @@ -0,0 +1,42 @@ +import { DfSearchPipe } from './df-select-search.component'; + +describe('DfSearchPipe', () => { + const pipe = new DfSearchPipe(); + const components = [ + '', + '*', + '_schema/', + '_schema/*', + '_schema/orders', + '_table/', + '_table/*', + '_table/orders', + '_table/orders/*', + '_table/users', + '_func/', + '_func/order_total', + ]; + + it('returns the list untouched when there is no filter', () => { + expect(pipe.transform(components)).toBe(components); + }); + + it('filters by type and case-insensitive substring, keeping wildcards', () => { + expect(pipe.transform(components, 'ORD', '_table/')).toEqual([ + '', + '*', + '_schema/', + '_schema/*', + '_table/', + '_table/*', + '_table/orders', + '_table/orders/*', + '_func/', + ]); + }); + + it('reads labels off objects by key', () => { + const roles = [{ name: 'Admin' }, { name: 'reader' }]; + expect(pipe.transform(roles, 'adm', '', 'name')).toEqual([roles[0]]); + }); +}); diff --git a/src/app/shared/components/df-user-app-roles/df-user-app-roles.component.html b/src/app/shared/components/df-user-app-roles/df-user-app-roles.component.html index 8cfd095d6..73816807a 100644 --- a/src/app/shared/components/df-user-app-roles/df-user-app-roles.component.html +++ b/src/app/shared/components/df-user-app-roles/df-user-app-roles.component.html @@ -30,7 +30,11 @@ [matAutocomplete]="auto" /> {{ app.name }} @@ -54,7 +58,11 @@ [matAutocomplete]="auto" /> {{ role.name }} diff --git a/src/app/shared/components/df-user-app-roles/df-user-app-roles.component.ts b/src/app/shared/components/df-user-app-roles/df-user-app-roles.component.ts index 74dc3b63d..75fa5ced9 100644 --- a/src/app/shared/components/df-user-app-roles/df-user-app-roles.component.ts +++ b/src/app/shared/components/df-user-app-roles/df-user-app-roles.component.ts @@ -1,3 +1,4 @@ +import { DfSearchPipe } from 'src/app/shared/components/df-select-search/df-select-search.component'; import { Component, Input, OnInit } from '@angular/core'; import { FormArray, @@ -29,6 +30,7 @@ import { UntilDestroy } from '@ngneat/until-destroy'; styleUrls: ['./df-user-app-roles.component.scss'], standalone: true, imports: [ + DfSearchPipe, FormsModule, ReactiveFormsModule, MatAutocompleteModule, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index c3d0663ca..51515a69f 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -44,6 +44,13 @@ "sortedAsc": "Sorted ascending", "sortedDesc": "Sorted descending", "search": "Search", + "selectSearch": { + "all": "All", + "schema": "Schema", + "table": "Table", + "function": "Function", + "procedure": "Procedure" + }, "commandPalette": { "placeholder": "Search or jump to anything", "noResults": "Nothing matches that.", From 72eff1831616d9d19afcb46ab9b0df0d8915356a Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Mon, 5 Oct 2026 11:21:49 -0600 Subject: [PATCH 04/15] feat(pickers): type dropdown from component prefixes, drop other-type wildcards Replace the Schema/Table/Function/Procedure toggles with a compact Type select derived from each component's first path segment, so file and system services get types too. Within a type only that type's own wildcards are kept; the query matches the part after the prefix. --- .../df-roles-access.component.ts | 12 ++- .../df-select-search.component.ts | 84 +++++++++++-------- .../df-select-search/df-select-search.spec.ts | 46 +++++++--- src/assets/i18n/en.json | 1 + 4 files changed, 89 insertions(+), 54 deletions(-) diff --git a/src/app/adf-roles/df-roles-access/df-roles-access.component.ts b/src/app/adf-roles/df-roles-access/df-roles-access.component.ts index e815de734..3528f3cb0 100644 --- a/src/app/adf-roles/df-roles-access/df-roles-access.component.ts +++ b/src/app/adf-roles/df-roles-access/df-roles-access.component.ts @@ -36,7 +36,7 @@ import { CommonModule } from '@angular/common'; import { DfSearchPipe, DfSelectSearchComponent, - ENDPOINT_TYPES, + componentTypes, SelectSearchType, } from 'src/app/shared/components/df-select-search/df-select-search.component'; @@ -216,17 +216,15 @@ export class DfRolesAccessComponent implements OnInit { /** Type filters for the component picker, only for types this service has. * Memoized per component list so the template binding stays stable. */ - private componentTypes = new WeakMap(); + private typeCache = new WeakMap(); private noTypes: SelectSearchType[] = []; getComponentTypes(index: number): SelectSearchType[] { const components = this.getComponentArray(index); if (!components.length) return this.noTypes; - let types = this.componentTypes.get(components); + let types = this.typeCache.get(components); if (!types) { - types = ENDPOINT_TYPES.filter(t => - components.some(c => c.startsWith(t.prefix)) - ); - this.componentTypes.set(components, types); + types = componentTypes(components); + this.typeCache.set(components, types); } return types; } diff --git a/src/app/shared/components/df-select-search/df-select-search.component.ts b/src/app/shared/components/df-select-search/df-select-search.component.ts index 50276b3ca..10529977a 100644 --- a/src/app/shared/components/df-select-search/df-select-search.component.ts +++ b/src/app/shared/components/df-select-search/df-select-search.component.ts @@ -9,30 +9,44 @@ import { ViewChild, inject, } from '@angular/core'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatSelect } from '@angular/material/select'; import { TranslocoPipe } from '@ngneat/transloco'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; export interface SelectSearchType { - labelKey: string; + /** First path segment plus slash, e.g. `_table/`. */ prefix: string; + /** Raw segment, shown when there is no friendly label. */ + label: string; + labelKey?: string; } -/** Role-access component prefixes, offered as type filters on endpoint lists. */ -export const ENDPOINT_TYPES: SelectSearchType[] = [ - { labelKey: 'selectSearch.schema', prefix: '_schema/' }, - { labelKey: 'selectSearch.table', prefix: '_table/' }, - { labelKey: 'selectSearch.function', prefix: '_func/' }, - { labelKey: 'selectSearch.procedure', prefix: '_proc/' }, -]; +const TYPE_LABEL_KEYS: Record = { + _schema: 'selectSearch.schema', + _table: 'selectSearch.table', + _func: 'selectSearch.function', + _proc: 'selectSearch.procedure', +}; -/** Wildcards (`*`, the service root `''`, `_table/`, `_table/*`) never get filtered out. */ -const WILDCARD = /^([^/]*\/)?\*?$/; +/** + * Types for a component list: one per distinct first path segment + * (`_table/orders/` -> `_table/`), so DB, file and system services all work. + */ +export function componentTypes(components: string[]): SelectSearchType[] { + const segments = new Set( + components.filter(c => c.includes('/')).map(c => c.split('/')[0]) + ); + return [...segments].map(label => ({ + prefix: `${label}/`, + label, + labelKey: TYPE_LABEL_KEYS[label], + })); +} /** * Narrows an option list by a case-insensitive substring and an optional - * prefix (type). `key` reads a label off object items. + * prefix (type). `key` reads a label off object items. Wildcards stay listed + * whatever the query: `''` and `*` with no type, `` and `*` within one. * *ngFor="let o of options | dfSearch: search.query : search.type : 'name'" */ @Pipe({ name: 'dfSearch', standalone: true }) @@ -44,16 +58,15 @@ export class DfSearchPipe implements PipeTransform { const label = String( (key ? (item as Record)?.[key] : item) ?? '' ); - return ( - WILDCARD.test(label) || - (label.startsWith(type) && label.toLowerCase().includes(q)) - ); + if (!label.startsWith(type)) return false; + const rest = label.slice(type.length); + return rest === '' || rest === '*' || rest.toLowerCase().includes(q); }); } } /** - * Search box (plus optional type toggles) for the top of a single-select + * Search box (plus optional type dropdown) for the top of a single-select * `mat-select` panel. Arrow keys, Enter and Tab fall through to the select; * Esc clears the query first, then closes. Pair it with `DfSearchPipe`: * @@ -67,21 +80,23 @@ export class DfSearchPipe implements PipeTransform { @Component({ selector: 'df-select-search', standalone: true, - imports: [NgIf, NgFor, MatButtonToggleModule, TranslocoPipe], + imports: [NgIf, NgFor, TranslocoPipe], template: `