445 lines
12 KiB
Vue
445 lines
12 KiB
Vue
<script setup lang="ts">
|
||||
|
|
import { onMounted, ref } from 'vue';
|
|||
|
|
import axios from 'axios';
|
|||
|
|
import { api } from '@/lib/api';
|
|||
|
|
import type {
|
|||
|
|
CreateOrganizationApiKeyResponse,
|
|||
|
|
CreateWebhookEndpointResponse,
|
|||
|
|
OrganizationApiKeyDto,
|
|||
|
|
WebhookDeliveryDto,
|
|||
|
|
WebhookEndpointDto,
|
|||
|
|
WebhookEvent,
|
|||
|
|
} from '@mento/shared';
|
|||
|
|
import { WEBHOOK_EVENTS } from '@mento/shared';
|
|||
|
|
import {
|
|||
|
|
UiButton,
|
|||
|
|
UiCard,
|
|||
|
|
UiEmptyState,
|
|||
|
|
UiField,
|
|||
|
|
UiInput,
|
|||
|
|
UiPageHeader,
|
|||
|
|
} from '@/components/ui';
|
|||
|
|
import UiIcon from '@/components/UiIcon.vue';
|
|||
|
|
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
|||
|
|
|
|||
|
|
const loading = ref(false);
|
|||
|
|
const saving = ref(false);
|
|||
|
|
const error = ref('');
|
|||
|
|
const ok = ref('');
|
|||
|
|
|
|||
|
|
const keys = ref<OrganizationApiKeyDto[]>([]);
|
|||
|
|
const endpoints = ref<WebhookEndpointDto[]>([]);
|
|||
|
|
const deliveries = ref<WebhookDeliveryDto[]>([]);
|
|||
|
|
const newKeyName = ref('');
|
|||
|
|
const createdRawKey = ref('');
|
|||
|
|
const createdWebhookSecret = ref('');
|
|||
|
|
|
|||
|
|
const whName = ref('');
|
|||
|
|
const whUrl = ref('');
|
|||
|
|
const whEvents = ref<string[]>(['person.created', 'deal.created']);
|
|||
|
|
|
|||
|
|
const EVENT_LABELS: Record<WebhookEvent, string> = {
|
|||
|
|
'person.created': 'ایجاد شخص',
|
|||
|
|
'person.updated': 'ویرایش شخص',
|
|||
|
|
'deal.created': 'ایجاد معامله',
|
|||
|
|
'deal.updated': 'ویرایش معامله',
|
|||
|
|
'deal.won': 'برد معامله',
|
|||
|
|
'deal.lost': 'شکست معامله',
|
|||
|
|
'invoice.paid': 'پرداخت فاکتور',
|
|||
|
|
'activity.created': 'ایجاد فعالیت',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
async function load() {
|
|||
|
|
loading.value = true;
|
|||
|
|
error.value = '';
|
|||
|
|
try {
|
|||
|
|
const [keysRes, epRes, delRes] = await Promise.all([
|
|||
|
|
api.get<OrganizationApiKeyDto[]>('/api-keys'),
|
|||
|
|
api.get<WebhookEndpointDto[]>('/webhooks'),
|
|||
|
|
api.get<WebhookDeliveryDto[]>('/webhooks/deliveries'),
|
|||
|
|
]);
|
|||
|
|
keys.value = keysRes.data;
|
|||
|
|
endpoints.value = epRes.data;
|
|||
|
|
deliveries.value = delRes.data;
|
|||
|
|
} catch (e) {
|
|||
|
|
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
|||
|
|
error.value = 'ماژول API و وبهوک برای این محیط فعال نیست.';
|
|||
|
|
} else {
|
|||
|
|
error.value = 'بارگذاری تنظیمات ناموفق بود.';
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createKey() {
|
|||
|
|
if (!newKeyName.value.trim()) {
|
|||
|
|
error.value = 'نام کلید را وارد کنید.';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
saving.value = true;
|
|||
|
|
error.value = '';
|
|||
|
|
ok.value = '';
|
|||
|
|
createdRawKey.value = '';
|
|||
|
|
try {
|
|||
|
|
const { data } = await api.post<CreateOrganizationApiKeyResponse>(
|
|||
|
|
'/api-keys',
|
|||
|
|
{ name: newKeyName.value.trim() },
|
|||
|
|
);
|
|||
|
|
createdRawKey.value = data.rawKey;
|
|||
|
|
newKeyName.value = '';
|
|||
|
|
ok.value = 'کلید ساخته شد. همین الان کپی کنید؛ دوباره نمایش داده نمیشود.';
|
|||
|
|
await load();
|
|||
|
|
} catch {
|
|||
|
|
error.value = 'ساخت کلید ناموفق بود.';
|
|||
|
|
} finally {
|
|||
|
|
saving.value = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function revokeKey(id: string) {
|
|||
|
|
if (!confirm('این کلید باطل شود؟')) return;
|
|||
|
|
try {
|
|||
|
|
await api.delete(`/api-keys/${id}`);
|
|||
|
|
await load();
|
|||
|
|
} catch {
|
|||
|
|
error.value = 'باطلسازی کلید ناموفق بود.';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toggleEvent(ev: string) {
|
|||
|
|
if (whEvents.value.includes(ev)) {
|
|||
|
|
whEvents.value = whEvents.value.filter((e) => e !== ev);
|
|||
|
|
} else {
|
|||
|
|
whEvents.value = [...whEvents.value, ev];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createWebhook() {
|
|||
|
|
if (!whName.value.trim() || !whUrl.value.trim() || !whEvents.value.length) {
|
|||
|
|
error.value = 'نام، آدرس و حداقل یک رویداد لازم است.';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
saving.value = true;
|
|||
|
|
error.value = '';
|
|||
|
|
createdWebhookSecret.value = '';
|
|||
|
|
try {
|
|||
|
|
const { data } = await api.post<CreateWebhookEndpointResponse>('/webhooks', {
|
|||
|
|
name: whName.value.trim(),
|
|||
|
|
url: whUrl.value.trim(),
|
|||
|
|
events: whEvents.value,
|
|||
|
|
});
|
|||
|
|
whName.value = '';
|
|||
|
|
whUrl.value = '';
|
|||
|
|
createdWebhookSecret.value = data.secret;
|
|||
|
|
ok.value = 'وبهوک ذخیره شد. secret را همین الان کپی کنید.';
|
|||
|
|
await load();
|
|||
|
|
} catch {
|
|||
|
|
error.value = 'ذخیره وبهوک ناموفق بود.';
|
|||
|
|
} finally {
|
|||
|
|
saving.value = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function removeWebhook(id: string) {
|
|||
|
|
if (!confirm('وبهوک حذف شود؟')) return;
|
|||
|
|
try {
|
|||
|
|
await api.delete(`/webhooks/${id}`);
|
|||
|
|
await load();
|
|||
|
|
} catch {
|
|||
|
|
error.value = 'حذف وبهوک ناموفق بود.';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function copyText(text: string) {
|
|||
|
|
try {
|
|||
|
|
await navigator.clipboard.writeText(text);
|
|||
|
|
ok.value = 'کپی شد.';
|
|||
|
|
} catch {
|
|||
|
|
error.value = 'کپی در کلیپبورد ممکن نشد.';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
void load();
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="page">
|
|||
|
|
<UiPageHeader
|
|||
|
|
title="API و وبهوک"
|
|||
|
|
description="کلید عمومی برای یکپارچهسازی و ارسال رویداد به سرور شما"
|
|||
|
|
>
|
|||
|
|
<template #actions>
|
|||
|
|
<UiButton variant="secondary" :disabled="loading" @click="load">
|
|||
|
|
<UiIcon name="refresh" />
|
|||
|
|
بروزرسانی
|
|||
|
|
</UiButton>
|
|||
|
|
</template>
|
|||
|
|
</UiPageHeader>
|
|||
|
|
|
|||
|
|
<p v-if="error" class="error">{{ error }}</p>
|
|||
|
|
<p v-if="ok" class="ok">{{ ok }}</p>
|
|||
|
|
|
|||
|
|
<UiCard title="کلیدهای API">
|
|||
|
|
<p class="hint">
|
|||
|
|
هدر درخواست:
|
|||
|
|
<code>X-Api-Key: mk_…</code>
|
|||
|
|
یا
|
|||
|
|
<code>Authorization: Bearer mk_…</code>
|
|||
|
|
— مسیرها:
|
|||
|
|
<code>/api/public/v1/people</code>
|
|||
|
|
و
|
|||
|
|
<code>/api/public/v1/deals</code>
|
|||
|
|
</p>
|
|||
|
|
<div class="row">
|
|||
|
|
<UiField label="نام کلید">
|
|||
|
|
<UiInput v-model="newKeyName" placeholder="مثلاً اتصال سایت" />
|
|||
|
|
</UiField>
|
|||
|
|
<UiButton :loading="saving" @click="createKey">ساخت کلید</UiButton>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="createdRawKey" class="raw-key">
|
|||
|
|
<code>{{ createdRawKey }}</code>
|
|||
|
|
<UiButton variant="secondary" @click="copyText(createdRawKey)">کپی</UiButton>
|
|||
|
|
</div>
|
|||
|
|
<UiEmptyState
|
|||
|
|
v-if="!loading && !keys.length"
|
|||
|
|
title="کلیدی نیست"
|
|||
|
|
description="برای دسترسی خارجی یک کلید بسازید."
|
|||
|
|
/>
|
|||
|
|
<div v-else class="table-wrap">
|
|||
|
|
<table>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th>نام</th>
|
|||
|
|
<th>پیشوند</th>
|
|||
|
|
<th>آخرین استفاده</th>
|
|||
|
|
<th>وضعیت</th>
|
|||
|
|
<th></th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
<tr v-for="k in keys" :key="k.id">
|
|||
|
|
<td>{{ k.name }}</td>
|
|||
|
|
<td><code>{{ k.prefix }}…</code></td>
|
|||
|
|
<td>
|
|||
|
|
{{
|
|||
|
|
k.lastUsedAt
|
|||
|
|
? formatJalaliNumeric(new Date(k.lastUsedAt))
|
|||
|
|
: '—'
|
|||
|
|
}}
|
|||
|
|
</td>
|
|||
|
|
<td>{{ k.revokedAt ? 'باطل' : 'فعال' }}</td>
|
|||
|
|
<td>
|
|||
|
|
<UiButton
|
|||
|
|
v-if="!k.revokedAt"
|
|||
|
|
variant="secondary"
|
|||
|
|
@click="revokeKey(k.id)"
|
|||
|
|
>
|
|||
|
|
باطلسازی
|
|||
|
|
</UiButton>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</UiCard>
|
|||
|
|
|
|||
|
|
<UiCard title="وبهوکهای خروجی">
|
|||
|
|
<div class="form-grid">
|
|||
|
|
<UiField label="نام">
|
|||
|
|
<UiInput v-model="whName" placeholder="مثلاً n8n" />
|
|||
|
|
</UiField>
|
|||
|
|
<UiField label="آدرس URL">
|
|||
|
|
<UiInput v-model="whUrl" placeholder="https://example.com/hook" />
|
|||
|
|
</UiField>
|
|||
|
|
</div>
|
|||
|
|
<div class="events">
|
|||
|
|
<button
|
|||
|
|
v-for="ev in WEBHOOK_EVENTS"
|
|||
|
|
:key="ev"
|
|||
|
|
type="button"
|
|||
|
|
class="chip"
|
|||
|
|
:class="{ on: whEvents.includes(ev) }"
|
|||
|
|
@click="toggleEvent(ev)"
|
|||
|
|
>
|
|||
|
|
{{ EVENT_LABELS[ev] }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<UiButton :loading="saving" @click="createWebhook">افزودن وبهوک</UiButton>
|
|||
|
|
<div v-if="createdWebhookSecret" class="raw-key">
|
|||
|
|
<span>HMAC secret:</span>
|
|||
|
|
<code>{{ createdWebhookSecret }}</code>
|
|||
|
|
<UiButton variant="secondary" @click="copyText(createdWebhookSecret)">کپی</UiButton>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<UiEmptyState
|
|||
|
|
v-if="!loading && !endpoints.length"
|
|||
|
|
title="وبهوکی نیست"
|
|||
|
|
description="رویدادهای CRM به آدرس شما POST میشوند."
|
|||
|
|
/>
|
|||
|
|
<div v-else class="list">
|
|||
|
|
<article v-for="ep in endpoints" :key="ep.id" class="item">
|
|||
|
|
<div>
|
|||
|
|
<strong>{{ ep.name }}</strong>
|
|||
|
|
<p class="muted">{{ ep.url }}</p>
|
|||
|
|
<p class="muted">
|
|||
|
|
{{ ep.events.map((e) => EVENT_LABELS[e as WebhookEvent] ?? e).join('، ') }}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<UiButton variant="secondary" @click="removeWebhook(ep.id)">حذف</UiButton>
|
|||
|
|
</article>
|
|||
|
|
</div>
|
|||
|
|
</UiCard>
|
|||
|
|
|
|||
|
|
<UiCard title="آخرین ارسالهای وبهوک">
|
|||
|
|
<UiEmptyState
|
|||
|
|
v-if="!deliveries.length"
|
|||
|
|
title="ارسالی ثبت نشده"
|
|||
|
|
description="پس از وقوع رویدادها اینجا دیده میشوند."
|
|||
|
|
/>
|
|||
|
|
<div v-else class="table-wrap">
|
|||
|
|
<table>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th>زمان</th>
|
|||
|
|
<th>رویداد</th>
|
|||
|
|
<th>وضعیت</th>
|
|||
|
|
<th>کد</th>
|
|||
|
|
<th>تلاش</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
<tr v-for="d in deliveries" :key="d.id">
|
|||
|
|
<td>{{ formatJalaliNumeric(new Date(d.createdAt)) }}</td>
|
|||
|
|
<td>{{ EVENT_LABELS[d.event as WebhookEvent] ?? d.event }}</td>
|
|||
|
|
<td>{{ d.status }}</td>
|
|||
|
|
<td>{{ d.statusCode ?? '—' }}</td>
|
|||
|
|
<td>{{ toFaDigits(d.attempts) }}</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</UiCard>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.page {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 1rem;
|
|||
|
|
}
|
|||
|
|
.hint {
|
|||
|
|
color: #64748b;
|
|||
|
|
font-size: 0.88rem;
|
|||
|
|
line-height: 1.7;
|
|||
|
|
}
|
|||
|
|
.hint code {
|
|||
|
|
background: #f1f5f9;
|
|||
|
|
padding: 0.1rem 0.35rem;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
}
|
|||
|
|
.row {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 1fr auto;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
align-items: end;
|
|||
|
|
margin: 0.75rem 0;
|
|||
|
|
}
|
|||
|
|
.raw-key {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
align-items: center;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
padding: 0.75rem;
|
|||
|
|
background: #f0fdfa;
|
|||
|
|
border-radius: var(--radius);
|
|||
|
|
margin-bottom: 0.75rem;
|
|||
|
|
}
|
|||
|
|
.raw-key code {
|
|||
|
|
word-break: break-all;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: #0f766e;
|
|||
|
|
}
|
|||
|
|
.form-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 1fr 1.4fr;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
margin-bottom: 0.75rem;
|
|||
|
|
}
|
|||
|
|
.events {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 0.45rem;
|
|||
|
|
margin-bottom: 0.85rem;
|
|||
|
|
}
|
|||
|
|
.chip {
|
|||
|
|
border: 1px solid #e2e8f0;
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
padding: 0.35rem 0.75rem;
|
|||
|
|
font: inherit;
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: #334155;
|
|||
|
|
}
|
|||
|
|
.chip.on {
|
|||
|
|
background: #f0fdfa;
|
|||
|
|
border-color: #99f6e4;
|
|||
|
|
color: #0f766e;
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
.list {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 0.65rem;
|
|||
|
|
margin-top: 1rem;
|
|||
|
|
}
|
|||
|
|
.item {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
padding: 0.75rem;
|
|||
|
|
border: 1px solid #eef2f7;
|
|||
|
|
border-radius: var(--radius);
|
|||
|
|
}
|
|||
|
|
.muted {
|
|||
|
|
color: #64748b;
|
|||
|
|
margin: 0.2rem 0 0;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
}
|
|||
|
|
.error {
|
|||
|
|
color: #b91c1c;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
.ok {
|
|||
|
|
color: #0f766e;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
.table-wrap {
|
|||
|
|
overflow: auto;
|
|||
|
|
margin-top: 0.75rem;
|
|||
|
|
}
|
|||
|
|
table {
|
|||
|
|
width: 100%;
|
|||
|
|
border-collapse: collapse;
|
|||
|
|
font-size: 0.9rem;
|
|||
|
|
}
|
|||
|
|
th,
|
|||
|
|
td {
|
|||
|
|
text-align: right;
|
|||
|
|
padding: 0.55rem;
|
|||
|
|
border-bottom: 1px solid #eef2f7;
|
|||
|
|
}
|
|||
|
|
th {
|
|||
|
|
color: #64748b;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
}
|
|||
|
|
@media (max-width: 800px) {
|
|||
|
|
.row,
|
|||
|
|
.form-grid {
|
|||
|
|
grid-template-columns: 1fr;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|