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

445 lines
12 KiB
Vue
Raw Normal View History

<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>