Files
portal/app/resources/js/views/settings/ApiTab.vue
T
Дмитрий 5f209a2fcc fix(ui): косметика UI-аудита — даты дд.мм.гггг, инлайн-валидация, формат денег, aria, тосты, статус-метки, админка
Раунд 2 минор-фиксы (Playwright-аудит):
- RuDateField (новый): даты дд.мм.гггг через ru date-picker вместо нативного
  <input type=date> (показывал мм/дд/гггг на en-локали) — Отчёты + Сделки.
- BalanceCapacityIndicator: разделитель тысяч «1 000 ₽», эмодзи→mdi.
- dealsApiMapper/DealDetailBody: статус-смена в активности русскими метками
  (было «viewed → new» сырыми слагами).
- ProfileTab: инлайн-валидация Имя/Фамилия (под полем, как в Реквизитах).
- RequisitesTab: проверка формата телефона на клиенте.
- ApiTab: eye-toggle с aria-label (показать/скрыть ключ и секрет).
- DashboardView: «3 / 0» → скрываем «/ N» и «лимит тарифа» при лимите 0.
- KanbanView: тост-подтверждение при смене статуса (+ цветной фейл-тост).
- NotificationsTab: убран жаргон «users.notification_preferences в БД».
- Админка: TenantsTable «ИНН не указан» вместо пустого «ИНН »; PricingTiers
  epoch-дата «1970»→«начала» + ru-формат цены; Incidents empty-state «Инцидентов
  нет»; SupplierIntegration/PdSubjectRequests — window.confirm/alert → v-dialog/snackbar.

Верификация: type-check, build, Playwright (даты дд.мм.гггг подтверждены).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:08:51 +03:00

363 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Settings → API и Webhook (audit D2/D3/D4/D5 + J5).
*
* API-ключ: GET /api/api-keys показывает key_prefix; «Копировать» — clipboard
* + toast; «Перегенерировать» — POST /api/api-keys/regenerate, полный ключ
* показывается ОДИН раз (затем доступен только префикс).
* Webhook: GET/PUT /api/tenants/me/webhook-settings (target_url + secret_prefix),
* «Тестовый webhook» — POST /api/webhooks/test (реальный unsigned POST).
*
* Полный ключ и полный секрет показываются один раз — в БД только bcrypt-хэши.
* Подписанная outbound-доставка событий — пост-MVP.
*/
import { onMounted, ref } from 'vue';
import { listApiKeys, regenerateApiKey } from '../../api/apiKeys';
import { getWebhookSettings, saveWebhookSettings, testWebhook } from '../../api/webhooks';
import { extractErrorMessage } from '../../api/client';
// --- API-ключ ---
const apiKeyExists = ref(false);
const apiTokenDisplay = ref('');
const fullKeyShown = ref(false);
const tokenVisible = ref(false);
const apiKeyError = ref<string | null>(null);
const regenDialogOpen = ref(false);
const regenerating = ref(false);
// --- Webhook ---
const webhookUrl = ref('');
const secretDisplay = ref('');
const fullSecretShown = ref(false);
const secretVisible = ref(false);
const webhookError = ref<string | null>(null);
const webhookSuccess = ref<string | null>(null);
const savingWebhook = ref(false);
const testingWebhook = ref(false);
// --- общий toast (D2 copy + D5 test) ---
const toastOpen = ref(false);
const toastText = ref('');
const toastColor = ref<'success' | 'error'>('success');
function showToast(text: string, color: 'success' | 'error' = 'success'): void {
toastText.value = text;
toastColor.value = color;
toastOpen.value = true;
}
async function loadApiKey(): Promise<void> {
apiKeyError.value = null;
try {
const keys = await listApiKeys();
const active = keys[0];
if (active) {
apiKeyExists.value = true;
apiTokenDisplay.value = active.key_prefix;
fullKeyShown.value = false;
} else {
apiKeyExists.value = false;
apiTokenDisplay.value = '';
}
} catch (err) {
apiKeyError.value = extractErrorMessage(err, 'Не удалось загрузить API-ключ.');
}
}
async function loadWebhook(): Promise<void> {
webhookError.value = null;
try {
const settings = await getWebhookSettings();
if (settings) {
webhookUrl.value = settings.target_url;
secretDisplay.value = settings.secret_prefix;
fullSecretShown.value = false;
}
} catch (err) {
webhookError.value = extractErrorMessage(err, 'Не удалось загрузить настройки webhook.');
}
}
onMounted(() => {
void loadApiKey();
void loadWebhook();
});
async function copyToken(): Promise<void> {
if (apiTokenDisplay.value === '') return;
try {
await navigator.clipboard.writeText(apiTokenDisplay.value);
showToast('Скопировано в буфер обмена.', 'success');
} catch {
showToast('Не удалось скопировать — выделите и скопируйте вручную.', 'error');
}
}
async function confirmRegenerate(): Promise<void> {
regenerating.value = true;
apiKeyError.value = null;
try {
const result = await regenerateApiKey();
apiTokenDisplay.value = result.key;
fullKeyShown.value = true;
apiKeyExists.value = true;
tokenVisible.value = true;
regenDialogOpen.value = false;
} catch (err) {
apiKeyError.value = extractErrorMessage(err, 'Не удалось перегенерировать ключ.');
regenDialogOpen.value = false;
} finally {
regenerating.value = false;
}
}
async function saveWebhook(): Promise<void> {
if (savingWebhook.value) return;
savingWebhook.value = true;
webhookError.value = null;
webhookSuccess.value = null;
try {
const result = await saveWebhookSettings({ target_url: webhookUrl.value });
if (result.secret) {
secretDisplay.value = result.secret;
fullSecretShown.value = true;
secretVisible.value = true;
} else {
secretDisplay.value = result.secret_prefix;
}
webhookSuccess.value = 'Настройки webhook сохранены.';
} catch (err) {
webhookError.value = extractErrorMessage(err, 'Не удалось сохранить webhook.');
} finally {
savingWebhook.value = false;
}
}
async function runWebhookTest(): Promise<void> {
if (testingWebhook.value) return;
testingWebhook.value = true;
try {
const result = await testWebhook();
showToast(result.message, result.ok ? 'success' : 'error');
} catch (err) {
showToast(extractErrorMessage(err, 'Не удалось отправить тестовый запрос.'), 'error');
} finally {
testingWebhook.value = false;
}
}
</script>
<template>
<div class="tab-content">
<h2 class="tab-title text-h6 mb-4">API и Webhook</h2>
<v-card variant="outlined" class="pa-4 mb-4">
<h3 class="text-subtitle-2 mb-3">API-ключ</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
Используется для подписи запросов в публичный API CRM. После регенерации старый ключ перестаёт работать
немедленно.
</p>
<v-alert
v-if="apiKeyError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="api-key-error"
@click:close="apiKeyError = null"
>
{{ apiKeyError }}
</v-alert>
<v-alert
v-if="fullKeyShown"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
data-testid="api-key-once-notice"
>
Новый ключ показывается <strong>один раз</strong>. Скопируйте и сохраните его сейчас.
</v-alert>
<v-text-field
:model-value="apiTokenDisplay"
:type="tokenVisible ? 'text' : 'password'"
:placeholder="apiKeyExists ? '' : 'Ключ ещё не создан — нажмите «Перегенерировать»'"
readonly
variant="outlined"
density="comfortable"
data-testid="api-key-field"
>
<template #append-inner>
<v-btn
:icon="tokenVisible ? 'mdi-eye-off' : 'mdi-eye'"
variant="text"
density="compact"
size="small"
:aria-label="tokenVisible ? 'Скрыть ключ' : 'Показать ключ'"
@click="tokenVisible = !tokenVisible"
/>
</template>
</v-text-field>
<div class="d-flex ga-2 mt-2">
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-content-copy"
:disabled="!apiTokenDisplay"
data-testid="api-key-copy-btn"
@click="copyToken"
>
Копировать
</v-btn>
<v-btn
variant="outlined"
size="small"
color="warning"
prepend-icon="mdi-refresh"
data-testid="api-key-regen-btn"
@click="regenDialogOpen = true"
>
Перегенерировать
</v-btn>
</div>
</v-card>
<v-card variant="outlined" class="pa-4">
<h3 class="text-subtitle-2 mb-3">Webhook для приёма лидов</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
URL источника лидов отправляет POST с подписью HMAC-SHA256. Дедуп по
<code>(tenant_id, source_crm_id)</code> в окне 24 ч (антифрод по phone §10.8.1).
</p>
<v-alert
v-if="webhookError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="webhook-error"
@click:close="webhookError = null"
>
{{ webhookError }}
</v-alert>
<v-alert
v-if="webhookSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="webhook-success"
@click:close="webhookSuccess = null"
>
{{ webhookSuccess }}
</v-alert>
<v-alert
v-if="fullSecretShown"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
data-testid="webhook-secret-once-notice"
>
Signing secret показывается <strong>один раз</strong>. Сохраните его в настройках вашего приёмника.
</v-alert>
<v-text-field
v-model="webhookUrl"
label="Endpoint URL"
placeholder="https://..."
variant="outlined"
density="comfortable"
data-testid="webhook-url-field"
/>
<v-text-field
:model-value="secretDisplay"
:type="secretVisible ? 'text' : 'password'"
label="Signing secret (HMAC)"
:placeholder="secretDisplay ? '' : 'Появится после первого сохранения URL'"
readonly
variant="outlined"
density="comfortable"
data-testid="webhook-secret-field"
>
<template #append-inner>
<v-btn
:icon="secretVisible ? 'mdi-eye-off' : 'mdi-eye'"
variant="text"
density="compact"
size="small"
:aria-label="secretVisible ? 'Скрыть секрет' : 'Показать секрет'"
@click="secretVisible = !secretVisible"
/>
</template>
</v-text-field>
<div class="d-flex ga-2 mt-2">
<v-btn
color="primary"
variant="flat"
size="small"
:loading="savingWebhook"
data-testid="webhook-save-btn"
@click="saveWebhook"
>
Сохранить
</v-btn>
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-test-tube"
:loading="testingWebhook"
data-testid="webhook-test-btn"
@click="runWebhookTest"
>
Тестовый webhook
</v-btn>
</div>
</v-card>
<v-dialog v-model="regenDialogOpen" :max-width="440" data-testid="regen-dialog">
<v-card>
<v-card-title>Перегенерация API-ключа</v-card-title>
<v-card-text>
Текущий ключ перестанет работать немедленно. Все интеграции с ним нужно будет обновить. Продолжить?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="regenerating" @click="regenDialogOpen = false">Отмена</v-btn>
<v-btn
color="warning"
variant="flat"
:loading="regenerating"
data-testid="regen-confirm-btn"
@click="confirmRegenerate"
>
Перегенерировать
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="toastOpen"
:timeout="4000"
:color="toastColor"
location="bottom right"
data-testid="api-tab-toast"
>
{{ toastText }}
</v-snackbar>
</div>
</template>
<style scoped>
.tab-title {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
</style>