feat(settings): ApiTab wired to api-key + webhook endpoints (closes D2-D5)

Audit D2/D3/D4/D5: all four ApiTab buttons were handler-less and the
fields were hardcoded. Adds api/apiKeys.ts + api/webhooks.ts modules and
rewires ApiTab: loads the api-key prefix + webhook settings on mount;
Copy -> clipboard + snackbar; Regenerate -> confirm dialog -> POST
regenerate (full key shown once); Save Webhook -> PUT webhook-settings;
Test Webhook -> POST test with the result in a snackbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 22:27:56 +03:00
parent c78300d843
commit dab00a54ab
4 changed files with 490 additions and 15 deletions
+32
View File
@@ -0,0 +1,32 @@
import { apiClient, ensureCsrfCookie } from './client';
/**
* API-ключи тенанта (audit D2/D3). Backend: ApiKeyController.
* Полный ключ доступен только в ответе regenerateApiKey().
*/
export interface ApiKeyInfo {
id: number;
name: string;
key_prefix: string;
last_used_at: string | null;
expires_at: string | null;
created_at: string | null;
}
export interface RegeneratedApiKey {
id: number;
name: string;
key: string;
key_prefix: string;
}
export async function listApiKeys(): Promise<ApiKeyInfo[]> {
const { data } = await apiClient.get<{ data: ApiKeyInfo[] }>('/api/api-keys');
return data.data;
}
export async function regenerateApiKey(): Promise<RegeneratedApiKey> {
await ensureCsrfCookie();
const { data } = await apiClient.post<RegeneratedApiKey>('/api/api-keys/regenerate');
return data;
}
+40
View File
@@ -0,0 +1,40 @@
import { apiClient, ensureCsrfCookie } from './client';
/**
* Настройки исходящего webhook'а тенанта (audit D4/D5). Backend:
* WebhookSettingsController. Полный secret доступен только в ответе
* saveWebhookSettings() при первом создании подписки.
*/
export interface WebhookSettings {
target_url: string;
secret_prefix: string;
events: string[];
is_active: boolean;
}
export interface SavedWebhookSettings extends WebhookSettings {
secret?: string;
}
export interface WebhookTestResult {
ok: boolean;
status: number | null;
message: string;
}
export async function getWebhookSettings(): Promise<WebhookSettings | null> {
const { data } = await apiClient.get<{ data: WebhookSettings | null }>('/api/tenants/me/webhook-settings');
return data.data;
}
export async function saveWebhookSettings(payload: { target_url: string }): Promise<SavedWebhookSettings> {
await ensureCsrfCookie();
const { data } = await apiClient.put<{ data: SavedWebhookSettings }>('/api/tenants/me/webhook-settings', payload);
return data.data;
}
export async function testWebhook(): Promise<WebhookTestResult> {
await ensureCsrfCookie();
const { data } = await apiClient.post<WebhookTestResult>('/api/webhooks/test');
return data;
}
+284 -15
View File
@@ -1,18 +1,150 @@
<script setup lang="ts">
/**
* Settings → API и Webhook. Token + endpoint URL + signing secret + история webhook-вызовов.
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #api.
* Settings → API и Webhook (audit D2/D3/D4/D5 + J5).
*
* Реальная логика по ТЗ §5/§5.5 + schema v8.7 webhook_dedup_keys (CTO-17 addendum).
* Token + secret НЕ показываются в открытом виде после генерации (single-time view).
* 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 { ref } from 'vue';
import { onMounted, ref } from 'vue';
import { listApiKeys, regenerateApiKey } from '../../api/apiKeys';
import { getWebhookSettings, saveWebhookSettings, testWebhook } from '../../api/webhooks';
import { extractErrorMessage } from '../../api/client';
const apiToken = ref('lpkapi_7g8h********************************2klm');
const webhookUrl = ref('https://crm.example.ru/api/webhook/leads');
const signingSecret = ref('whsec_********************************************');
// --- 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>
@@ -25,18 +157,60 @@ const secretVisible = ref(false);
Используется для подписи запросов в публичный 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="apiToken"
:model-value="apiTokenDisplay"
:type="tokenVisible ? 'text' : 'password'"
:placeholder="apiKeyExists ? '' : 'Ключ ещё не создан — нажмите «Перегенерировать»'"
readonly
variant="outlined"
density="comfortable"
data-testid="api-key-field"
:append-inner-icon="tokenVisible ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="tokenVisible = !tokenVisible"
/>
<div class="d-flex ga-2 mt-2">
<v-btn variant="outlined" size="small" prepend-icon="mdi-content-copy">Копировать</v-btn>
<v-btn variant="outlined" size="small" color="warning" prepend-icon="mdi-refresh">
<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>
@@ -48,22 +222,117 @@ const secretVisible = ref(false);
URL источника лидов отправляет POST с подписью HMAC-SHA256. Дедуп по
<code>(tenant_id, source_crm_id)</code> в окне 24 ч (антифрод по phone §10.8.1).
</p>
<v-text-field v-model="webhookUrl" label="Endpoint URL" variant="outlined" density="comfortable" />
<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
:model-value="signingSecret"
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"
:append-inner-icon="secretVisible ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="secretVisible = !secretVisible"
/>
<div class="d-flex ga-2 mt-2">
<v-btn color="primary" variant="flat" size="small">Сохранить</v-btn>
<v-btn variant="outlined" size="small" prepend-icon="mdi-test-tube"> Тестовый webhook </v-btn>
<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>
+134
View File
@@ -0,0 +1,134 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
vi.mock('../../resources/js/api/apiKeys', () => ({
listApiKeys: vi.fn(),
regenerateApiKey: vi.fn(),
}));
vi.mock('../../resources/js/api/webhooks', () => ({
getWebhookSettings: vi.fn(),
saveWebhookSettings: vi.fn(),
testWebhook: vi.fn(),
}));
vi.mock('../../resources/js/api/client', () => ({
apiClient: {},
ensureCsrfCookie: vi.fn(),
extractValidationErrors: vi.fn(() => null),
extractErrorMessage: vi.fn((_e, fallback) => fallback ?? 'Произошла ошибка.'),
extractRateLimitRetry: vi.fn(() => null),
}));
import * as apiKeysApi from '../../resources/js/api/apiKeys';
import * as webhooksApi from '../../resources/js/api/webhooks';
import ApiTab from '../../resources/js/views/settings/ApiTab.vue';
const vuetify = createVuetify();
const flush = () => new Promise((r) => setTimeout(r, 30));
const mountTab = () => mount(ApiTab, { global: { plugins: [vuetify] } });
describe('ApiTab.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
(apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(webhooksApi.getWebhookSettings as ReturnType<typeof vi.fn>).mockResolvedValue(null);
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
});
it('загружает и показывает префикс API-ключа', async () => {
(apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, name: 'API-ключ', key_prefix: 'lpkapi_abc', last_used_at: null, expires_at: null, created_at: null },
]);
const wrapper = mountTab();
await flush();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.apiKeyExists).toBe(true);
expect(vm.apiTokenDisplay).toBe('lpkapi_abc');
});
it('copyToken() пишет в буфер и открывает toast', async () => {
(apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, name: 'API-ключ', key_prefix: 'lpkapi_abc', last_used_at: null, expires_at: null, created_at: null },
]);
const wrapper = mountTab();
await flush();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
await vm.copyToken();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('lpkapi_abc');
expect(vm.toastOpen).toBe(true);
});
it('confirmRegenerate() показывает полный новый ключ один раз', async () => {
(apiKeysApi.regenerateApiKey as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 2,
name: 'API-ключ',
key: 'lpkapi_FULLNEWKEYVALUE0000000000',
key_prefix: 'lpkapi_FUL',
});
const wrapper = mountTab();
await flush();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
await vm.confirmRegenerate();
expect(apiKeysApi.regenerateApiKey).toHaveBeenCalled();
expect(vm.apiTokenDisplay).toBe('lpkapi_FULLNEWKEYVALUE0000000000');
expect(vm.fullKeyShown).toBe(true);
});
it('загружает настройки webhook', async () => {
(webhooksApi.getWebhookSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
target_url: 'https://crm.example.ru/hook',
secret_prefix: 'whsec_abc',
events: ['deal.created'],
is_active: true,
});
const wrapper = mountTab();
await flush();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.webhookUrl).toBe('https://crm.example.ru/hook');
expect(vm.secretDisplay).toBe('whsec_abc');
});
it('saveWebhook() вызывает saveWebhookSettings и показывает secret один раз', async () => {
(webhooksApi.saveWebhookSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
target_url: 'https://crm.example.ru/hook',
secret_prefix: 'whsec_new',
events: ['deal.created'],
is_active: true,
secret: 'whsec_FULLSECRETVALUE0000',
});
const wrapper = mountTab();
await flush();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.webhookUrl = 'https://crm.example.ru/hook';
await vm.saveWebhook();
expect(webhooksApi.saveWebhookSettings).toHaveBeenCalledWith({ target_url: 'https://crm.example.ru/hook' });
expect(vm.secretDisplay).toBe('whsec_FULLSECRETVALUE0000');
expect(vm.fullSecretShown).toBe(true);
expect(vm.webhookSuccess).toBeTruthy();
});
it('runWebhookTest() вызывает testWebhook и открывает toast с результатом', async () => {
(webhooksApi.testWebhook as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
message: 'Тестовый запрос доставлен (HTTP 200).',
});
const wrapper = mountTab();
await flush();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
await vm.runWebhookTest();
expect(webhooksApi.testWebhook).toHaveBeenCalled();
expect(vm.toastOpen).toBe(true);
expect(vm.toastText).toContain('HTTP 200');
});
});