453 lines
12 KiB
Vue
453 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, ref } from 'vue';
|
||
import TableColumnsMenu from '@/components/TableColumnsMenu.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">
|
||
<div class="tc-row">
|
||
<TableColumnsMenu table-key="public-api.keys" align="end" />
|
||
</div>
|
||
<table v-table-cols="'public-api.keys'">
|
||
<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">
|
||
<div class="tc-row">
|
||
<TableColumnsMenu table-key="public-api.deliveries" align="end" />
|
||
</div>
|
||
<table v-table-cols="'public-api.deliveries'">
|
||
<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;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
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>
|