From dab00a54ab59fc8d88cae6dd632aa5a6c3b9df37 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 15 May 2026 22:27:56 +0300
Subject: [PATCH] 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)
---
app/resources/js/api/apiKeys.ts | 32 +++
app/resources/js/api/webhooks.ts | 40 +++
app/resources/js/views/settings/ApiTab.vue | 299 +++++++++++++++++++--
app/tests/Frontend/ApiTab.spec.ts | 134 +++++++++
4 files changed, 490 insertions(+), 15 deletions(-)
create mode 100644 app/resources/js/api/apiKeys.ts
create mode 100644 app/resources/js/api/webhooks.ts
create mode 100644 app/tests/Frontend/ApiTab.spec.ts
diff --git a/app/resources/js/api/apiKeys.ts b/app/resources/js/api/apiKeys.ts
new file mode 100644
index 00000000..bc063792
--- /dev/null
+++ b/app/resources/js/api/apiKeys.ts
@@ -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 {
+ const { data } = await apiClient.get<{ data: ApiKeyInfo[] }>('/api/api-keys');
+ return data.data;
+}
+
+export async function regenerateApiKey(): Promise {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.post('/api/api-keys/regenerate');
+ return data;
+}
diff --git a/app/resources/js/api/webhooks.ts b/app/resources/js/api/webhooks.ts
new file mode 100644
index 00000000..529f66e4
--- /dev/null
+++ b/app/resources/js/api/webhooks.ts
@@ -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 {
+ 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 {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.put<{ data: SavedWebhookSettings }>('/api/tenants/me/webhook-settings', payload);
+ return data.data;
+}
+
+export async function testWebhook(): Promise {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.post('/api/webhooks/test');
+ return data;
+}
diff --git a/app/resources/js/views/settings/ApiTab.vue b/app/resources/js/views/settings/ApiTab.vue
index fff1f83f..0d1d8eef 100644
--- a/app/resources/js/views/settings/ApiTab.vue
+++ b/app/resources/js/views/settings/ApiTab.vue
@@ -1,18 +1,150 @@
@@ -25,18 +157,60 @@ const secretVisible = ref(false);
Используется для подписи запросов в публичный API CRM. После регенерации старый ключ перестаёт работать
немедленно.
+
+
+ {{ apiKeyError }}
+
+
+ Новый ключ показывается один раз. Скопируйте и сохраните его сейчас.
+
+
- Копировать
-
+
+ Копировать
+
+
Перегенерировать
@@ -48,22 +222,117 @@ const secretVisible = ref(false);
URL источника лидов отправляет POST с подписью HMAC-SHA256. Дедуп по
(tenant_id, source_crm_id) в окне 24 ч (антифрод по phone — §10.8.1).
-
+
+
+ {{ webhookError }}
+
+
+ {{ webhookSuccess }}
+
+
+ Signing secret показывается один раз. Сохраните его в настройках вашего приёмника.
+
+
+
- Сохранить
- Тестовый webhook
+
+ Сохранить
+
+
+ Тестовый webhook
+
+
+
+
+ Перегенерация API-ключа
+
+ Текущий ключ перестанет работать немедленно. Все интеграции с ним нужно будет обновить. Продолжить?
+
+
+
+ Отмена
+
+ Перегенерировать
+
+
+
+
+
+
+ {{ toastText }}
+
diff --git a/app/tests/Frontend/ApiTab.spec.ts b/app/tests/Frontend/ApiTab.spec.ts
new file mode 100644
index 00000000..9e3edc63
--- /dev/null
+++ b/app/tests/Frontend/ApiTab.spec.ts
@@ -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).mockResolvedValue([]);
+ (webhooksApi.getWebhookSettings as ReturnType).mockResolvedValue(null);
+ Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
+ });
+
+ it('загружает и показывает префикс API-ключа', async () => {
+ (apiKeysApi.listApiKeys as ReturnType).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).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).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).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).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).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');
+ });
+});