feat: add wizard auth selector

This commit is contained in:
a.tolmachev
2026-04-07 11:38:00 +03:00
parent 341496e314
commit 6711214bb1
8 changed files with 626 additions and 85 deletions
+6 -6
View File
@@ -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
+5
View File
@@ -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;
+35
View File
@@ -239,6 +239,41 @@
<script src="../../js/streaming-form.js"></script>
<script src="../../js/stream-test-run.js"></script>
<div class="modal-overlay" id="quick-secret-modal">
<div class="modal">
<div class="modal-header">
<span class="modal-title" data-i18n="wizard.step2.quick_secret_title">Quick create secret</span>
<button class="modal-close" id="quick-secret-close-btn" type="button">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
</svg>
</button>
</div>
<div class="modal-body" style="display:grid; gap:14px;">
<div class="field-group">
<label class="field-label" data-i18n="wizard.step2.quick_secret_name">Secret name</label>
<input class="field-input" id="quick-secret-name" type="text" autocomplete="off">
</div>
<div class="field-group">
<label class="field-label" data-i18n="wizard.step2.quick_secret_kind">Secret kind</label>
<select class="field-select" id="quick-secret-kind">
<option value="token" data-i18n="secrets.kind.token">Token</option>
<option value="header" data-i18n="secrets.kind.header">Header value</option>
<option value="generic" data-i18n="secrets.kind.generic">Generic JSON</option>
</select>
</div>
<div class="field-group">
<label class="field-label" data-i18n="wizard.step2.quick_secret_value">Secret value</label>
<input class="field-input" id="quick-secret-value" type="text" autocomplete="off">
<div class="field-hint" data-i18n="wizard.step2.quick_secret_hint">The plaintext is encrypted immediately and will not be returned again.</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" id="quick-secret-cancel-btn" type="button" data-i18n="btn.cancel">Cancel</button>
<button class="btn-primary" id="quick-secret-submit-btn" type="button" data-i18n="wizard.step2.quick_secret_submit">Create secret</button>
</div>
</div>
</div>
<script src="../../js/wizard.js"></script>
<script src="../../js/nav.js"></script>
+73 -6
View File
@@ -84,21 +84,88 @@
<div class="form-group">
<label class="form-label"><span data-i18n="wizard.step2.base_url">Base URL</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
<input class="form-input input-mono" id="new-upstream-url" type="text" placeholder="https://api.example.com" autocomplete="off" spellcheck="false">
<div class="form-hint" data-i18n="wizard.step2.base_url_hint">Root URL — no trailing slash. Supports ${secrets.*}.</div>
<div class="form-hint" data-i18n="wizard.step2.base_url_hint">Root URL without a trailing slash. Auth is configured separately below.</div>
</div>
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.step2.auth_headers">Auth headers</label>
<label class="form-label" data-i18n="wizard.step2.auth_selector">Upstream auth</label>
<select class="form-select" id="new-upstream-auth-mode" onchange="updateUpstreamAuthUi()">
<option value="none" data-i18n="wizard.step2.auth_mode.none">No auth</option>
<option value="existing" data-i18n="wizard.step2.auth_mode.existing">Use existing auth profile</option>
<option value="create" data-i18n="wizard.step2.auth_mode.create">Create auth profile now</option>
</select>
<div class="form-hint" data-i18n="wizard.step2.auth_selector_hint">Use secrets-backed auth profiles instead of embedding credential headers in the upstream definition.</div>
</div>
<div class="form-group" id="upstream-auth-existing-group" style="display:none;">
<label class="form-label" data-i18n="wizard.step2.auth_profile">Auth profile</label>
<select class="form-select" id="new-upstream-auth-profile"></select>
<div class="form-hint" id="new-upstream-auth-profile-hint" data-i18n="wizard.step2.auth_profile_hint">Selected profile will be resolved at runtime and attached through `execution_config.auth_profile_ref`.</div>
</div>
<div class="config-card" id="upstream-auth-create-group" style="display:none; margin-top: 6px;">
<div class="config-card-header">
<div>
<div class="config-card-title" data-i18n="wizard.step2.create_profile_title">Create auth profile</div>
<div class="config-card-subtitle" data-i18n="wizard.step2.create_profile_subtitle">Create a reusable profile backed by encrypted workspace secrets.</div>
</div>
</div>
<div class="config-card-body" style="gap: 14px;">
<div class="form-group">
<label class="form-label"><span data-i18n="wizard.step2.profile_name">Profile name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
<input class="form-input" id="new-auth-profile-name" type="text" autocomplete="off" spellcheck="false">
</div>
<div class="form-row" style="grid-template-columns: 1fr 1fr;">
<div class="form-group">
<label class="form-label"><span data-i18n="wizard.step2.profile_kind">Auth kind</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
<select class="form-select" id="new-auth-profile-kind" onchange="updateAuthProfileCreateUi()">
<option value="bearer" data-i18n="wizard.step2.auth_kind.bearer">Bearer token</option>
<option value="basic" data-i18n="wizard.step2.auth_kind.basic">Basic auth</option>
<option value="api_key_header" data-i18n="wizard.step2.auth_kind.api_key_header">API key header</option>
<option value="api_key_query" data-i18n="wizard.step2.auth_kind.api_key_query">API key query</option>
</select>
</div>
<div class="form-group" id="auth-profile-header-name-group">
<label class="form-label" data-i18n="wizard.step2.header_name">Header name</label>
<input class="form-input input-mono" id="new-auth-profile-header-name" type="text" value="Authorization" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="form-group" id="auth-profile-query-param-group" style="display:none;">
<label class="form-label" data-i18n="wizard.step2.query_param">Query param</label>
<input class="form-input input-mono" id="new-auth-profile-query-param" type="text" value="api_key" autocomplete="off" spellcheck="false">
</div>
<div class="form-row" id="auth-profile-basic-secret-row" style="display:none; grid-template-columns: 1fr 1fr;">
<div class="form-group">
<label class="form-label" data-i18n="wizard.step2.username_secret">Username secret</label>
<select class="form-select" id="new-auth-profile-username-secret"></select>
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.step2.password_secret">Password secret</label>
<select class="form-select" id="new-auth-profile-password-secret"></select>
</div>
</div>
<div class="form-group" id="auth-profile-single-secret-group">
<label class="form-label" data-i18n="wizard.step2.secret_value">Secret</label>
<select class="form-select" id="new-auth-profile-secret-id"></select>
</div>
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<button class="btn-ghost-sm" onclick="openQuickSecretModal(event)" data-i18n="wizard.step2.quick_secret">Quick create secret</button>
<a class="btn-ghost-sm" href="/secrets" target="_blank" rel="noopener" data-i18n="wizard.step2.manage_secrets">Open secrets page</a>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.step2.static_headers">Static headers (optional)</label>
<div class="code-block" style="border-radius: 6px;">
<div class="code-toolbar">
<div class="code-dots"><span></span><span></span><span></span></div>
<span class="code-toolbar-label">json / auth-headers</span>
<span class="code-toolbar-label">json / static-headers</span>
</div>
<textarea class="form-textarea code-textarea" id="new-upstream-auth-headers" rows="7">{
"Authorization": "${secrets.API_KEY}"
<textarea class="form-textarea code-textarea" id="new-upstream-static-headers" rows="5">{
}</textarea>
</div>
<div class="form-hint" data-i18n="wizard.step2.auth_headers_hint">These headers are sent on every request to this upstream. Use secrets for credentials.</div>
<div class="form-hint" data-i18n="wizard.step2.static_headers_hint">Optional non-secret headers sent on every request to this upstream. Use auth profiles for credentials.</div>
</div>
<div style="display:flex; gap:8px; margin-top:4px;">
<button class="btn-primary-sm" onclick="saveNewUpstream(event)" data-i18n="wizard.step2.save_upstream">Save upstream</button>
+1 -1
View File
@@ -5,7 +5,7 @@
<span data-i18n-html="wizard.step_of">Step 5 of 5</span>
</div>
<h1 class="step-panel-title" data-i18n="wizard.step5.title">Mapping and execution</h1>
<p class="step-panel-subtitle" data-i18n="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.</p>
<p class="step-panel-subtitle" data-i18n="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.</p>
</div>
<div class="section-divider" style="margin-bottom: 16px;">
+97 -8
View File
@@ -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': 'Черновик создан',
+407 -63
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
@@ -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', '');
+2 -1
View File
@@ -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`.