Files
CRM_Montzar/apps/web/src/views/PublicApiSettingsView.vue
T

453 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>