diff --git a/TASKS.md b/TASKS.md index 3d77439..96288a0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,20 @@ ## Current -### `feat/secrets-ui` +### `feat/wizard-auth-selector` Status: completed DoD: -- separate `Secrets` page exists next to `API Keys` -- UI supports list/create/rotate/delete for workspace secrets on live backend endpoints -- UI shows read-only auth profile usage references for each secret -- no UI flow pretends to reveal plaintext after create/rotate when backend does not return it +- wizard step 2 uses auth selector instead of `${secrets.*}` placeholders +- upstream auth is serialized into `execution_config.auth_profile_ref` +- raw auth headers are removed from primary wizard UX +- quick-create secret and auth profile flow works against live backend endpoints - node checks, UI docker build, and workspace check pass ## Next -- `feat/wizard-auth-selector` +- `feat/manual-regression-pass` ## Backlog diff --git a/apps/ui/css/wizard.css b/apps/ui/css/wizard.css index ec10830..a074bd6 100644 --- a/apps/ui/css/wizard.css +++ b/apps/ui/css/wizard.css @@ -1358,6 +1358,11 @@ color: #58a6ff; border: 1px solid rgba(88, 166, 255, 0.2); } +.upstream-auth-badge.auth-basic { + background: rgba(240, 173, 78, 0.12); + color: #f0ad4e; + border: 1px solid rgba(240, 173, 78, 0.2); +} .upstream-auth-badge.auth-apikey { background: rgba(188, 140, 255, 0.1); color: #bc8cff; diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html index 416a8d2..232371c 100644 --- a/apps/ui/html/wizard/index.html +++ b/apps/ui/html/wizard/index.html @@ -239,6 +239,41 @@ + diff --git a/apps/ui/html/wizard/step2.html b/apps/ui/html/wizard/step2.html index 5f2c137..bc3c461 100644 --- a/apps/ui/html/wizard/step2.html +++ b/apps/ui/html/wizard/step2.html @@ -84,21 +84,88 @@
-
Root URL — no trailing slash. Supports ${secrets.*}.
+
Root URL without a trailing slash. Auth is configured separately below.
- + + +
Use secrets-backed auth profiles instead of embedding credential headers in the upstream definition.
+
+ + + + + +
+
- json / auth-headers + json / static-headers
-
-
These headers are sent on every request to this upstream. Use secrets for credentials.
+
Optional non-secret headers sent on every request to this upstream. Use auth profiles for credentials.
diff --git a/apps/ui/html/wizard/step5.html b/apps/ui/html/wizard/step5.html index 96168f9..c7fba9e 100644 --- a/apps/ui/html/wizard/step5.html +++ b/apps/ui/html/wizard/step5.html @@ -5,7 +5,7 @@ Step 5 of 5

Mapping and execution

-

Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 3.

+

Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 2.

diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index 79b1452..08795de 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -558,14 +558,44 @@ var TRANSLATIONS = { 'wizard.step2.change': 'Change', 'wizard.step2.auth_none': 'No auth', 'wizard.step2.auth_bearer': 'Bearer token', + 'wizard.step2.auth_basic': 'Basic auth', 'wizard.step2.auth_apikey': 'API key', 'wizard.step2.register_new': 'Register new upstream', 'wizard.step2.name': 'Name', 'wizard.step2.base_url': 'Base URL', 'wizard.step2.unique_hint': 'Unique identifier for this upstream.', - 'wizard.step2.base_url_hint': 'Root URL — no trailing slash. Supports ${secrets.*}.', - 'wizard.step2.auth_headers': 'Auth headers', - 'wizard.step2.auth_headers_hint': 'These headers are sent on every request to this upstream. Use secrets for credentials.', + 'wizard.step2.base_url_hint': 'Root URL without a trailing slash. Auth is configured separately below.', + 'wizard.step2.auth_selector': 'Upstream auth', + 'wizard.step2.auth_selector_hint': 'Use secrets-backed auth profiles instead of embedding credential headers in the upstream definition.', + 'wizard.step2.auth_mode.none': 'No auth', + 'wizard.step2.auth_mode.existing': 'Use existing auth profile', + 'wizard.step2.auth_mode.create': 'Create auth profile now', + 'wizard.step2.auth_profile': 'Auth profile', + 'wizard.step2.auth_profile_hint': 'Selected profile will be resolved at runtime and attached through `execution_config.auth_profile_ref`.', + 'wizard.step2.auth_profile_empty': 'No auth profiles yet. Create one below or on the Secrets page.', + 'wizard.step2.create_profile_title': 'Create auth profile', + 'wizard.step2.create_profile_subtitle': 'Create a reusable profile backed by encrypted workspace secrets.', + 'wizard.step2.profile_name': 'Profile name', + 'wizard.step2.profile_kind': 'Auth kind', + 'wizard.step2.auth_kind.bearer': 'Bearer token', + 'wizard.step2.auth_kind.basic': 'Basic auth', + 'wizard.step2.auth_kind.api_key_header': 'API key header', + 'wizard.step2.auth_kind.api_key_query': 'API key query', + 'wizard.step2.header_name': 'Header name', + 'wizard.step2.query_param': 'Query param', + 'wizard.step2.username_secret': 'Username secret', + 'wizard.step2.password_secret': 'Password secret', + 'wizard.step2.secret_value': 'Secret', + 'wizard.step2.quick_secret': 'Quick create secret', + 'wizard.step2.manage_secrets': 'Open secrets page', + 'wizard.step2.static_headers': 'Static headers (optional)', + 'wizard.step2.static_headers_hint': 'Optional non-secret headers sent on every request to this upstream. Use auth profiles for credentials.', + 'wizard.step2.quick_secret_title': 'Quick create secret', + 'wizard.step2.quick_secret_name': 'Secret name', + 'wizard.step2.quick_secret_kind': 'Secret kind', + 'wizard.step2.quick_secret_value': 'Secret value', + 'wizard.step2.quick_secret_hint': 'The plaintext is encrypted immediately and will not be returned again.', + 'wizard.step2.quick_secret_submit': 'Create secret', 'wizard.step2.save_upstream': 'Save upstream', 'wizard.step2.endpoint_title': 'Endpoint', 'wizard.step2.endpoint_subtitle': 'Path and HTTP method for this specific operation', @@ -657,7 +687,7 @@ var TRANSLATIONS = { 'wizard.step4.protocol_agnostic_title': 'Schemas are protocol-agnostic', 'wizard.step4.protocol_agnostic_body': 'These schemas describe the MCP contract, not the upstream wire format. Field mapping in Step 5 translates between the two. You can upload a sample JSON response to auto-generate the output schema.', 'wizard.step5.title': 'Mapping and execution', - 'wizard.step5.subtitle': 'Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 3.', + 'wizard.step5.subtitle': 'Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 2.', 'wizard.step5.input_request': 'Input → Request', 'wizard.step5.output_response': 'Response → Output', 'wizard.step5.execution': 'Execution', @@ -706,6 +736,21 @@ var TRANSLATIONS = { 'wizard.error.tool_name': 'Tool name is required', 'wizard.error.parser_unavailable': 'YAML parser is not available', 'wizard.error.select_upstream': 'Select or register an upstream first', + 'wizard.error.auth_profile_required': 'Select an auth profile or switch upstream auth to No auth.', + 'wizard.error.auth_profile_name': 'Auth profile name is required.', + 'wizard.error.secret_required': 'Select the secret required by this auth profile.', + 'wizard.error.basic_secrets_required': 'Select both username and password secrets.', + 'wizard.error.header_name_required': 'Header name is required for this auth profile.', + 'wizard.error.query_param_required': 'Query parameter name is required for this auth profile.', + 'wizard.error.secret_name_required': 'Secret name is required.', + 'wizard.error.secret_value_required': 'Secret value is required.', + 'wizard.error.save_upstream_first': 'Save the upstream after creating a new auth profile before continuing.', + 'wizard.toast.auth_profile_created_title': 'Auth profile created', + 'wizard.toast.auth_profile_created_body': 'New upstream auth profile is ready and selected.', + 'wizard.toast.auth_profile_error_title': 'Auth profile creation failed', + 'wizard.toast.quick_secret_created_title': 'Secret created', + 'wizard.toast.quick_secret_created_body': 'The new secret is now available in the auth selector.', + 'wizard.toast.quick_secret_error_title': 'Secret creation failed', 'wizard.error.save': 'Failed to save operation', 'wizard.error.save_title': 'Save failed', 'wizard.save.created': 'Draft created', @@ -1443,9 +1488,38 @@ var TRANSLATIONS = { 'wizard.step2.name': 'Имя', 'wizard.step2.base_url': 'Base URL', 'wizard.step2.unique_hint': 'Уникальный идентификатор этого upstream-а.', - 'wizard.step2.base_url_hint': 'Корневой URL без завершающего слеша. Поддерживает ${secrets.*}.', - 'wizard.step2.auth_headers': 'Auth headers', - 'wizard.step2.auth_headers_hint': 'Эти заголовки отправляются с каждым запросом в этот upstream. Используйте secrets для учетных данных.', + 'wizard.step2.base_url_hint': 'Корневой URL без завершающего слеша. Auth настраивается отдельно ниже.', + 'wizard.step2.auth_selector': 'Upstream auth', + 'wizard.step2.auth_selector_hint': 'Используйте secrets-backed auth profiles вместо встраивания credential headers в определение upstream-а.', + 'wizard.step2.auth_mode.none': 'Без авторизации', + 'wizard.step2.auth_mode.existing': 'Использовать существующий auth profile', + 'wizard.step2.auth_mode.create': 'Создать auth profile сейчас', + 'wizard.step2.auth_profile': 'Auth profile', + 'wizard.step2.auth_profile_hint': 'Выбранный профиль будет резолвиться в runtime и подключаться через `execution_config.auth_profile_ref`.', + 'wizard.step2.auth_profile_empty': 'Auth profiles пока нет. Создайте его ниже или на странице Secrets.', + 'wizard.step2.create_profile_title': 'Создать auth profile', + 'wizard.step2.create_profile_subtitle': 'Создайте переиспользуемый профиль на базе зашифрованных workspace secrets.', + 'wizard.step2.profile_name': 'Имя профиля', + 'wizard.step2.profile_kind': 'Тип auth', + 'wizard.step2.auth_kind.bearer': 'Bearer token', + 'wizard.step2.auth_kind.basic': 'Basic auth', + 'wizard.step2.auth_kind.api_key_header': 'API key header', + 'wizard.step2.auth_kind.api_key_query': 'API key query', + 'wizard.step2.header_name': 'Имя заголовка', + 'wizard.step2.query_param': 'Query param', + 'wizard.step2.username_secret': 'Секрет логина', + 'wizard.step2.password_secret': 'Секрет пароля', + 'wizard.step2.secret_value': 'Секрет', + 'wizard.step2.quick_secret': 'Быстро создать секрет', + 'wizard.step2.manage_secrets': 'Открыть страницу Secrets', + 'wizard.step2.static_headers': 'Статические заголовки (опционально)', + 'wizard.step2.static_headers_hint': 'Необязательные не-секретные заголовки для каждого запроса к этому upstream-у. Для credential flows используйте auth profiles.', + 'wizard.step2.quick_secret_title': 'Быстро создать секрет', + 'wizard.step2.quick_secret_name': 'Имя секрета', + 'wizard.step2.quick_secret_kind': 'Тип секрета', + 'wizard.step2.quick_secret_value': 'Значение секрета', + 'wizard.step2.quick_secret_hint': 'Plaintext сразу шифруется и больше не возвращается.', + 'wizard.step2.quick_secret_submit': 'Создать секрет', 'wizard.step2.save_upstream': 'Сохранить upstream', 'wizard.step2.endpoint_title': 'Endpoint', 'wizard.step2.endpoint_subtitle': 'Путь и HTTP-метод для конкретной операции', @@ -1537,7 +1611,7 @@ var TRANSLATIONS = { 'wizard.step4.protocol_agnostic_title': 'Схемы не зависят от протокола', 'wizard.step4.protocol_agnostic_body': 'Эти схемы описывают MCP-контракт, а не wire-format upstream-а. Маппинг на шаге 5 переводит одно в другое. Можно загрузить sample JSON-ответа, чтобы автоматически сгенерировать output schema.', 'wizard.step5.title': 'Mapping и исполнение', - 'wizard.step5.subtitle': 'Определите, как аргументы MCP-инструмента мапятся в поля upstream-запроса и как ответ upstream-а мапится обратно в MCP output. Затем задайте execution-параметры — auth настраивается на уровне upstream на шаге 3.', + 'wizard.step5.subtitle': 'Определите, как аргументы MCP-инструмента мапятся в поля upstream-запроса и как ответ upstream-а мапится обратно в MCP output. Затем задайте execution-параметры — auth настраивается на уровне upstream на шаге 2.', 'wizard.step5.input_request': 'Input → Request', 'wizard.step5.output_response': 'Response → Output', 'wizard.step5.execution': 'Исполнение', @@ -1586,6 +1660,21 @@ var TRANSLATIONS = { 'wizard.error.tool_name': 'Имя инструмента обязательно', 'wizard.error.parser_unavailable': 'YAML parser недоступен', 'wizard.error.select_upstream': 'Сначала выберите или зарегистрируйте upstream', + 'wizard.error.auth_profile_required': 'Выберите auth profile или переключите upstream auth в режим «Без авторизации».', + 'wizard.error.auth_profile_name': 'Нужно указать имя auth profile.', + 'wizard.error.secret_required': 'Выберите секрет, необходимый для этого auth profile.', + 'wizard.error.basic_secrets_required': 'Выберите секреты и для логина, и для пароля.', + 'wizard.error.header_name_required': 'Для этого auth profile нужно указать имя заголовка.', + 'wizard.error.query_param_required': 'Для этого auth profile нужно указать имя query-параметра.', + 'wizard.error.secret_name_required': 'Нужно указать имя секрета.', + 'wizard.error.secret_value_required': 'Нужно указать значение секрета.', + 'wizard.error.save_upstream_first': 'Сначала сохраните upstream после создания нового auth profile.', + 'wizard.toast.auth_profile_created_title': 'Auth profile создан', + 'wizard.toast.auth_profile_created_body': 'Новый upstream auth profile готов и уже выбран.', + 'wizard.toast.auth_profile_error_title': 'Не удалось создать auth profile', + 'wizard.toast.quick_secret_created_title': 'Секрет создан', + 'wizard.toast.quick_secret_created_body': 'Новый секрет уже доступен в auth selector.', + 'wizard.toast.quick_secret_error_title': 'Не удалось создать секрет', 'wizard.error.save': 'Не удалось сохранить операцию', 'wizard.error.save_title': 'Не удалось сохранить', 'wizard.save.created': 'Черновик создан', diff --git a/apps/ui/js/wizard.js b/apps/ui/js/wizard.js index d038afd..4b5e5f0 100644 --- a/apps/ui/js/wizard.js +++ b/apps/ui/js/wizard.js @@ -276,9 +276,44 @@ document.addEventListener('DOMContentLoaded', async function() { await loadProtocolCapabilities(); await loadWizardPanels([1, 2, 3, 4, 5]); + await loadWizardAuthResources(); bindProtocolCards(); bindWizardLiveActions(); bindStreamingConfigControls(); + updateUpstreamAuthUi(); + + var quickSecretModal = document.getElementById('quick-secret-modal'); + var quickSecretClose = document.getElementById('quick-secret-close-btn'); + var quickSecretCancel = document.getElementById('quick-secret-cancel-btn'); + var quickSecretSubmit = document.getElementById('quick-secret-submit-btn'); + if (quickSecretClose) quickSecretClose.addEventListener('click', closeQuickSecretModal); + if (quickSecretCancel) quickSecretCancel.addEventListener('click', closeQuickSecretModal); + if (quickSecretModal) { + quickSecretModal.addEventListener('click', function(event) { + if (event.target === quickSecretModal) closeQuickSecretModal(); + }); + } + if (quickSecretSubmit) { + quickSecretSubmit.addEventListener('click', async function() { + var button = quickSecretSubmit; + var original = button.textContent; + button.disabled = true; + button.textContent = tKey('wizard.step2.quick_secret_submit'); + try { + await submitQuickSecret(); + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error( + error.message || tKey('wizard.toast.quick_secret_error_title'), + tKey('wizard.toast.quick_secret_error_title') + ); + } + } finally { + button.disabled = false; + button.textContent = original; + } + }); + } var params = new URLSearchParams(window.location.search); if (params.get('mode') === 'edit' && params.get('operationId')) { @@ -338,46 +373,170 @@ function selectGqlType(btn) { if (defaults[type]) editor.value = defaults[type]; } -/* ── Upstream selector (Step 3) ── */ +/* ── Upstream selector (Step 2) ── */ var upstreams = [ - { id: 'acme-api', name: 'acme-api', url: 'https://api.acme.com', authHeaders: '{\n \"Authorization\": \"Bearer ${secrets.ACME_API_TOKEN}\"\n}' }, - { id: 'stripe', name: 'stripe-payments', url: 'https://api.stripe.com', authHeaders: '{\n \"Authorization\": \"Bearer ${secrets.STRIPE_API_KEY}\"\n}' }, - { id: 'internal-crm', name: 'internal-crm', url: 'https://crm.internal.acme.io', authHeaders: '{\n}' }, - { id: 'sendgrid', name: 'sendgrid-mail', url: 'https://api.sendgrid.com', authHeaders: '{\n \"Authorization\": \"Bearer ${secrets.SENDGRID_API_KEY}\"\n}' }, - { id: 'twilio', name: 'twilio-sms', url: 'https://api.twilio.com', authHeaders: '{\n \"Authorization\": \"Bearer ${secrets.TWILIO_API_KEY}\"\n}' }, + { id: 'open-meteo', name: 'Open Meteo', url: 'https://api.open-meteo.com', staticHeaders: '{}', authProfileId: null, authProfileName: '', authKind: null }, + { id: 'countries-graphql', name: 'Countries GraphQL', url: 'https://countries.trevorblades.com', staticHeaders: '{}', authProfileId: null, authProfileName: '', authKind: null }, + { id: 'grpcb', name: 'grpcb.in', url: 'https://grpcb.in:443', staticHeaders: '{}', authProfileId: null, authProfileName: '', authKind: null } ]; +var wizardSecrets = []; +var wizardAuthProfiles = []; var selectedUpstreamId = null; var dropdownOpen = false; var editingUpstreamId = null; -function inferUpstreamAuth(headersText) { - var parsed = parseStructuredText(headersText || '{}'); - var keys = parsed && typeof parsed === 'object' ? Object.keys(parsed) : []; - if (!keys.length) { +function authProfileById(authProfileId) { + return wizardAuthProfiles.find(function(item) { return item.id === authProfileId; }) || null; +} + +function syncUpstreamAuthMetadata(upstream) { + if (!upstream) return upstream; + if (!upstream.authProfileId) { + upstream.authProfileName = ''; + upstream.authKind = null; + return upstream; + } + + var profile = authProfileById(upstream.authProfileId); + if (profile) { + upstream.authProfileName = profile.name; + upstream.authKind = profile.kind; + } + return upstream; +} + +function inferUpstreamAuth(upstream) { + syncUpstreamAuthMetadata(upstream); + if (!upstream || !upstream.authProfileId || !upstream.authKind) { return { kind: 'none', label: tKey('wizard.step2.auth_none') }; } - - var hasAuthorization = keys.some(function(key) { - return String(key).toLowerCase() === 'authorization'; - }); - - if (hasAuthorization) { + if (upstream.authKind === 'bearer') { return { kind: 'bearer', label: tKey('wizard.step2.auth_bearer') }; } - + if (upstream.authKind === 'basic') { + return { kind: 'basic', label: tKey('wizard.step2.auth_basic') }; + } return { kind: 'apikey', label: tKey('wizard.step2.auth_apikey') }; } function upstreamMeta(upstream) { - var auth = inferUpstreamAuth(upstream && upstream.authHeaders); + var auth = inferUpstreamAuth(upstream); return { kind: auth.kind, label: auth.label, }; } +async function loadWizardAuthResources() { + if (!wizardWorkspaceId || !window.CrankApi) { + wizardSecrets = []; + wizardAuthProfiles = []; + renderAuthProfileOptions(); + return; + } + + try { + var results = await Promise.all([ + window.CrankApi.listSecrets(wizardWorkspaceId), + window.CrankApi.listAuthProfiles(wizardWorkspaceId), + ]); + wizardSecrets = (results[0] && results[0].items) || []; + wizardAuthProfiles = (results[1] && results[1].items) || []; + } catch (_error) { + wizardSecrets = []; + wizardAuthProfiles = []; + } + + upstreams.forEach(syncUpstreamAuthMetadata); + renderAuthProfileOptions(); +} + +function renderAuthProfileOptions() { + var profileSelect = document.getElementById('new-upstream-auth-profile'); + var secretSelect = document.getElementById('new-auth-profile-secret-id'); + var usernameSelect = document.getElementById('new-auth-profile-username-secret'); + var passwordSelect = document.getElementById('new-auth-profile-password-secret'); + var profileHint = document.getElementById('new-upstream-auth-profile-hint'); + + if (profileSelect) { + var previousProfile = profileSelect.value; + profileSelect.innerHTML = ''; + if (!wizardAuthProfiles.length) { + var emptyProfile = document.createElement('option'); + emptyProfile.value = ''; + emptyProfile.textContent = tKey('wizard.step2.auth_profile_empty'); + profileSelect.appendChild(emptyProfile); + if (profileHint) profileHint.textContent = tKey('wizard.step2.auth_profile_empty'); + } else { + var placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = tKey('wizard.step2.auth_profile'); + profileSelect.appendChild(placeholder); + wizardAuthProfiles.forEach(function(profile) { + var option = document.createElement('option'); + option.value = profile.id; + option.textContent = profile.name + ' · ' + profile.kind; + profileSelect.appendChild(option); + }); + if (previousProfile) { + profileSelect.value = previousProfile; + } + if (profileHint) profileHint.textContent = tKey('wizard.step2.auth_profile_hint'); + } + } + + [secretSelect, usernameSelect, passwordSelect].forEach(function(select) { + if (!select) return; + var previous = select.value; + select.innerHTML = ''; + var placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = tKey('wizard.step2.secret_value'); + select.appendChild(placeholder); + wizardSecrets.forEach(function(secret) { + var option = document.createElement('option'); + option.value = secret.id; + option.textContent = secret.name + ' · ' + secret.kind; + select.appendChild(option); + }); + if (previous) { + select.value = previous; + } + }); +} + +function updateAuthProfileCreateUi() { + var kind = textValue('new-auth-profile-kind') || 'bearer'; + var headerGroup = document.getElementById('auth-profile-header-name-group'); + var queryGroup = document.getElementById('auth-profile-query-param-group'); + var basicRow = document.getElementById('auth-profile-basic-secret-row'); + var singleSecretGroup = document.getElementById('auth-profile-single-secret-group'); + + if (headerGroup) { + headerGroup.style.display = (kind === 'bearer' || kind === 'api_key_header') ? '' : 'none'; + } + if (queryGroup) { + queryGroup.style.display = kind === 'api_key_query' ? '' : 'none'; + } + if (basicRow) { + basicRow.style.display = kind === 'basic' ? '' : 'none'; + } + if (singleSecretGroup) { + singleSecretGroup.style.display = kind === 'basic' ? 'none' : ''; + } +} + +function updateUpstreamAuthUi() { + var mode = textValue('new-upstream-auth-mode') || 'none'; + var existingGroup = document.getElementById('upstream-auth-existing-group'); + var createGroup = document.getElementById('upstream-auth-create-group'); + if (existingGroup) existingGroup.style.display = mode === 'existing' ? '' : 'none'; + if (createGroup) createGroup.style.display = mode === 'create' ? '' : 'none'; + updateAuthProfileCreateUi(); +} + function renderDropdownList(filter) { var list = document.getElementById('upstream-dropdown-list'); if (!list) return; @@ -522,7 +681,17 @@ function startNewUpstream() { form.style.display = ''; setValue('new-upstream-name', ''); setValue('new-upstream-url', ''); - setValue('new-upstream-auth-headers', '{\n}'); + setValue('new-upstream-static-headers', '{\n}'); + setValue('new-upstream-auth-mode', 'none'); + setValue('new-upstream-auth-profile', ''); + setValue('new-auth-profile-name', ''); + setValue('new-auth-profile-kind', 'bearer'); + setValue('new-auth-profile-header-name', 'Authorization'); + setValue('new-auth-profile-query-param', 'api_key'); + setValue('new-auth-profile-secret-id', ''); + setValue('new-auth-profile-username-secret', ''); + setValue('new-auth-profile-password-secret', ''); + updateUpstreamAuthUi(); var first = form.querySelector('input'); if (first) setTimeout(function() { first.focus(); }, 50); } @@ -545,46 +714,151 @@ function beginEditSelectedUpstream(e) { form.style.display = ''; setValue('new-upstream-name', upstream.name); setValue('new-upstream-url', upstream.url); - setValue('new-upstream-auth-headers', upstream.authHeaders || '{\n}'); + setValue('new-upstream-static-headers', upstream.staticHeaders || '{\n}'); + setValue('new-upstream-auth-profile', upstream.authProfileId || ''); + setValue('new-upstream-auth-mode', upstream.authProfileId ? 'existing' : 'none'); + updateUpstreamAuthUi(); } } -function saveNewUpstream(e) { - e.stopPropagation(); - var nameEl = document.getElementById('new-upstream-name'); - var urlEl = document.getElementById('new-upstream-url'); - var name = nameEl ? nameEl.value.trim() : ''; - var url = urlEl ? urlEl.value.trim() : ''; - if (!name) { if (nameEl) nameEl.focus(); return; } - if (!url) { if (urlEl) urlEl.focus(); return; } - - var authHeadersEl = document.getElementById('new-upstream-auth-headers'); - var authHeaders = authHeadersEl ? authHeadersEl.value : '{\n}'; - var nextRecord = { - id: editingUpstreamId || ('custom-' + Date.now()), - name: name, - url: url, - authHeaders: authHeaders - }; - - if (editingUpstreamId) { - upstreams = upstreams.map(function(item) { - return item.id === editingUpstreamId ? nextRecord : item; - }); - } else { - upstreams.push(nextRecord); +function buildAuthProfileConfig(kind) { + if (kind === 'bearer') { + var bearerHeader = textValue('new-auth-profile-header-name') || 'Authorization'; + var bearerSecretId = textValue('new-auth-profile-secret-id'); + if (!bearerHeader) throw new Error(tKey('wizard.error.header_name_required')); + if (!bearerSecretId) throw new Error(tKey('wizard.error.secret_required')); + return { + bearer: { + header_name: bearerHeader, + secret_id: bearerSecretId, + } + }; } - // Reset form - if (nameEl) nameEl.value = ''; - if (urlEl) urlEl.value = ''; - if (authHeadersEl) authHeadersEl.value = '{\n}'; - var form = document.getElementById('upstream-new-form'); - if (form) form.style.display = 'none'; - editingUpstreamId = null; + if (kind === 'basic') { + var usernameSecretId = textValue('new-auth-profile-username-secret'); + var passwordSecretId = textValue('new-auth-profile-password-secret'); + if (!usernameSecretId || !passwordSecretId) throw new Error(tKey('wizard.error.basic_secrets_required')); + return { + basic: { + username_secret_id: usernameSecretId, + password_secret_id: passwordSecretId, + } + }; + } - // Select the new upstream - pickUpstream(nextRecord.id); + if (kind === 'api_key_header') { + var headerName = textValue('new-auth-profile-header-name'); + var headerSecretId = textValue('new-auth-profile-secret-id'); + if (!headerName) throw new Error(tKey('wizard.error.header_name_required')); + if (!headerSecretId) throw new Error(tKey('wizard.error.secret_required')); + return { + api_key_header: { + header_name: headerName, + secret_id: headerSecretId, + } + }; + } + + var queryParam = textValue('new-auth-profile-query-param'); + var querySecretId = textValue('new-auth-profile-secret-id'); + if (!queryParam) throw new Error(tKey('wizard.error.query_param_required')); + if (!querySecretId) throw new Error(tKey('wizard.error.secret_required')); + return { + api_key_query: { + param_name: queryParam, + secret_id: querySecretId, + } + }; +} + +async function createAuthProfileFromForm() { + var name = textValue('new-auth-profile-name'); + var kind = textValue('new-auth-profile-kind') || 'bearer'; + if (!name) throw new Error(tKey('wizard.error.auth_profile_name')); + + var profile = await window.CrankApi.createAuthProfile(wizardWorkspaceId, { + name: name, + kind: kind, + config: buildAuthProfileConfig(kind), + }); + wizardAuthProfiles.push(profile); + renderAuthProfileOptions(); + if (window.CrankUi) { + window.CrankUi.success( + tKey('wizard.toast.auth_profile_created_body'), + tKey('wizard.toast.auth_profile_created_title') + ); + } + return profile; +} + +async function saveNewUpstream(e) { + e.stopPropagation(); + try { + var nameEl = document.getElementById('new-upstream-name'); + var urlEl = document.getElementById('new-upstream-url'); + var name = nameEl ? nameEl.value.trim() : ''; + var url = urlEl ? urlEl.value.trim() : ''; + if (!name) { if (nameEl) nameEl.focus(); return; } + if (!url) { if (urlEl) urlEl.focus(); return; } + + var staticHeadersEl = document.getElementById('new-upstream-static-headers'); + var staticHeaders = staticHeadersEl ? staticHeadersEl.value : '{\n}'; + parseHeaderMap(staticHeaders); + + var authMode = textValue('new-upstream-auth-mode') || 'none'; + var authProfileId = null; + var authProfileName = ''; + var authKind = null; + + if (authMode === 'existing') { + authProfileId = textValue('new-upstream-auth-profile'); + if (!authProfileId) throw new Error(tKey('wizard.error.auth_profile_required')); + var existingProfile = authProfileById(authProfileId); + authProfileName = existingProfile ? existingProfile.name : ''; + authKind = existingProfile ? existingProfile.kind : null; + } else if (authMode === 'create') { + var createdProfile = await createAuthProfileFromForm(); + authProfileId = createdProfile.id; + authProfileName = createdProfile.name; + authKind = createdProfile.kind; + setValue('new-upstream-auth-profile', createdProfile.id); + setValue('new-upstream-auth-mode', 'existing'); + } + var nextRecord = { + id: editingUpstreamId || ('custom-' + Date.now()), + name: name, + url: url, + staticHeaders: staticHeaders, + authProfileId: authProfileId, + authProfileName: authProfileName, + authKind: authKind, + }; + + if (editingUpstreamId) { + upstreams = upstreams.map(function(item) { + return item.id === editingUpstreamId ? nextRecord : item; + }); + } else { + upstreams.push(nextRecord); + } + + if (nameEl) nameEl.value = ''; + if (urlEl) urlEl.value = ''; + if (staticHeadersEl) staticHeadersEl.value = '{\n}'; + var form = document.getElementById('upstream-new-form'); + if (form) form.style.display = 'none'; + editingUpstreamId = null; + pickUpstream(nextRecord.id); + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error( + error.message || tKey('wizard.toast.auth_profile_error_title'), + tKey('wizard.toast.auth_profile_error_title') + ); + } + } } function cancelNewUpstream(e) { @@ -596,6 +870,58 @@ function cancelNewUpstream(e) { editingUpstreamId = null; } +function openQuickSecretModal(e) { + if (e) e.preventDefault(); + var modal = document.getElementById('quick-secret-modal'); + if (!modal) return; + setValue('quick-secret-name', ''); + setValue('quick-secret-value', ''); + setValue('quick-secret-kind', 'token'); + modal.classList.add('open'); + setTimeout(function() { + var input = document.getElementById('quick-secret-name'); + if (input) input.focus(); + }, 30); +} + +function closeQuickSecretModal() { + var modal = document.getElementById('quick-secret-modal'); + if (modal) modal.classList.remove('open'); +} + +async function submitQuickSecret() { + var name = textValue('quick-secret-name'); + var value = textValue('quick-secret-value'); + var kind = textValue('quick-secret-kind') || 'token'; + if (!name) throw new Error(tKey('wizard.error.secret_name_required')); + if (!value) throw new Error(tKey('wizard.error.secret_value_required')); + + var secret = await window.CrankApi.createSecret(wizardWorkspaceId, { + name: name, + kind: kind, + value: value, + }); + wizardSecrets.push(secret); + renderAuthProfileOptions(); + var primarySelect = document.getElementById('new-auth-profile-secret-id'); + var usernameSelect = document.getElementById('new-auth-profile-username-secret'); + var passwordSelect = document.getElementById('new-auth-profile-password-secret'); + if (primarySelect && primarySelect.offsetParent !== null) { + primarySelect.value = secret.id; + } else if (usernameSelect && !usernameSelect.value) { + usernameSelect.value = secret.id; + } else if (passwordSelect) { + passwordSelect.value = secret.id; + } + closeQuickSecretModal(); + if (window.CrankUi) { + window.CrankUi.success( + tKey('wizard.toast.quick_secret_created_body'), + tKey('wizard.toast.quick_secret_created_title') + ); + } +} + function escapeHtml(str) { return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } @@ -1154,10 +1480,17 @@ function parseExecutionConfig(text) { var value = parseStructuredText(text); var retry = value.retry || value.retry_policy || null; var headers = value.headers && typeof value.headers === 'object' ? value.headers : {}; + var selectedUpstream = currentUpstream(); + if (selectedUpstream && selectedUpstream.authMode === 'create') { + throw new Error(tKey('wizard.error.save_upstream_first')); + } + var authProfileRef = selectedUpstream && selectedUpstream.authProfileId + ? selectedUpstream.authProfileId + : (value.auth && value.auth.profile ? value.auth.profile : null); var config = { timeout_ms: Number(value.timeout_ms || 10000), retry_policy: retry && retry.max_attempts ? { max_attempts: Number(retry.max_attempts) } : null, - auth_profile_ref: value.auth && value.auth.profile ? value.auth.profile : null, + auth_profile_ref: authProfileRef, headers: headers, protocol_options: null, }; @@ -1185,12 +1518,18 @@ function currentUpstream() { var customUrl = textValue('new-upstream-url'); if (!customUrl) return null; - var authHeaders = textValue('new-upstream-auth-headers'); + var authMode = textValue('new-upstream-auth-mode') || 'none'; + var authProfileId = authMode === 'existing' ? textValue('new-upstream-auth-profile') : null; + var authProfile = authProfileById(authProfileId); return { id: 'custom', name: customName || 'custom-upstream', url: customUrl, - authHeaders: authHeaders, + staticHeaders: textValue('new-upstream-static-headers') || '{\n}', + authMode: authMode, + authProfileId: authProfileId || null, + authProfileName: authProfile ? authProfile.name : '', + authKind: authProfile ? authProfile.kind : null, }; } @@ -1230,7 +1569,7 @@ function buildTarget() { base_url: upstream.url, method: activeMethod ? activeMethod.dataset.method : 'POST', path_template: pathTemplate, - static_headers: parseHeaderMap(upstream.authHeaders), + static_headers: parseHeaderMap(upstream.staticHeaders), }; } @@ -1634,8 +1973,10 @@ function selectProtocol(protocol) { updateWizardProtocolVisibility(); } -function prefillUpstream(baseUrl, headers) { - var known = upstreams.find(function(item) { return item.url === baseUrl; }); +function prefillUpstream(baseUrl, headers, authProfileRef) { + var known = upstreams.find(function(item) { + return item.url === baseUrl && (item.authProfileId || null) === (authProfileRef || null); + }); if (known) { pickUpstream(known.id); return; @@ -1648,7 +1989,10 @@ function prefillUpstream(baseUrl, headers) { if (form) form.style.display = ''; setValue('new-upstream-name', 'imported-upstream'); setValue('new-upstream-url', baseUrl); - setValue('new-upstream-auth-headers', headers && Object.keys(headers).length ? JSON.stringify(headers, null, 2) : '{\n}'); + setValue('new-upstream-static-headers', headers && Object.keys(headers).length ? JSON.stringify(headers, null, 2) : '{\n}'); + setValue('new-upstream-auth-mode', authProfileRef ? 'existing' : 'none'); + setValue('new-upstream-auth-profile', authProfileRef || ''); + updateUpstreamAuthUi(); var valueEl = document.getElementById('upstream-combobox-value'); if (valueEl) { @@ -1725,7 +2069,7 @@ function prefillWizardFromEdit(detail, versionDocument) { var target = versionDocument.target || {}; if (target.kind === 'rest') { - prefillUpstream(target.base_url, target.static_headers); + prefillUpstream(target.base_url, target.static_headers, versionDocument.execution_config ? versionDocument.execution_config.auth_profile_ref : null); setValue('endpoint-path', target.path_template); document.querySelectorAll('.method-card').forEach(function(card) { card.classList.toggle('active', card.dataset.method === target.method); @@ -1733,14 +2077,14 @@ function prefillWizardFromEdit(detail, versionDocument) { } else if (target.kind === 'graphql') { var endpoint = target.endpoint || ''; var parsedEndpoint = new URL(endpoint, window.location.origin); - prefillUpstream(parsedEndpoint.origin, {}); + prefillUpstream(parsedEndpoint.origin, {}, versionDocument.execution_config ? versionDocument.execution_config.auth_profile_ref : null); setValue('endpoint-path', parsedEndpoint.pathname + parsedEndpoint.search); setValue('gql-query-editor', target.query_template); document.querySelectorAll('.gql-type-card').forEach(function(card) { card.classList.toggle('active', card.dataset.gqlType === target.operation_type); }); } else if (target.kind === 'grpc') { - prefillUpstream(target.server_addr, {}); + prefillUpstream(target.server_addr, {}, versionDocument.execution_config ? versionDocument.execution_config.auth_profile_ref : null); var route = '/' + (target.package ? target.package + '.' : '') + target.service + '/' + target.method; setValue('grpc-manual-route', route); setValue('grpc-manual-req-type', ''); diff --git a/docs/secrets-auth-plan.md b/docs/secrets-auth-plan.md index 4827322..7e071c5 100644 --- a/docs/secrets-auth-plan.md +++ b/docs/secrets-auth-plan.md @@ -247,5 +247,6 @@ DoD: 2. `feat/auth-profile-secret-resolution` 3. `feat/secrets-ui` 4. `feat/wizard-auth-selector` +5. `feat/manual-regression-pass` -Сейчас `feat/secrets-ui` уже закрыт: оператор может управлять encrypted secrets и видеть, какие `AuthProfile` на них ссылаются. Следующий шаг - убрать raw `Auth headers` из wizard primary flow и заменить их на нормальный auth selector и quick-create path для `Secret`/`AuthProfile`. +Сейчас `feat/wizard-auth-selector` уже закрыт: wizard использует auth selector на шаге 2, сериализует `auth_profile_ref` в `execution_config`, умеет quick-create для `Secret` и `AuthProfile`, а raw `Auth headers` убраны из primary UX. Следующий шаг - ручной regression pass по REST / GraphQL / gRPC / WebSocket / SOAP и проверка связки `Secrets -> Auth Profiles -> Test Run / MCP call`.