5f209a2fcc
Раунд 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>
363 lines
13 KiB
Vue
363 lines
13 KiB
Vue
<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>
|