Commit 37bafeba authored by BangNSK's avatar BangNSK

feat: add webhook management interface

parent a5729e5c
......@@ -11,6 +11,7 @@ import { Users } from './pages/Users';
import { Logs } from './pages/Logs';
import { Profile } from './pages/Profile';
import { ApiKeys } from './pages/ApiKeys';
import { Webhooks } from './pages/Webhooks';
import { ForgotPassword } from './pages/ForgotPassword';
import { ResetPassword } from './pages/ResetPassword';
import { VerifyEmail } from './pages/VerifyEmail';
......@@ -61,6 +62,7 @@ export default function App() {
{/* Profile */}
<Route path="profile" element={<Profile />} />
<Route path="api-keys" element={<ApiKeys />} />
<Route path="webhooks" element={<Webhooks />} />
{/* Admin Only Routes */}
<Route
......
......@@ -10,6 +10,7 @@ const TITLES: Record<string, string> = {
'/verify-email': 'Xác minh email | Data Crawler',
'/profile': 'Hồ sơ cá nhân | Data Crawler',
'/api-keys': 'Quản lý API Key | Data Crawler',
'/webhooks': 'Quản lý Webhook | Data Crawler',
'/users': 'Quản lý người dùng | Data Crawler',
'/logs': 'Nhật ký hệ thống | Data Crawler',
};
......
import React from 'react';
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom';
import { useAuth } from '../context/auth';
import { Database, Users, History, User, LogOut, Compass, KeyRound } from 'lucide-react';
import { Database, Users, History, User, LogOut, Compass, KeyRound, MoreHorizontal, Webhook } from 'lucide-react';
export const DashboardLayout: React.FC = () => {
const { user, logout, isAdmin } = useAuth();
......@@ -26,6 +26,12 @@ export const DashboardLayout: React.FC = () => {
icon: <KeyRound className="h-5 w-5" />,
allowed: true,
},
{
name: 'Quản lý Webhook',
path: '/webhooks',
icon: <Webhook className="h-5 w-5" />,
allowed: true,
},
{
name: 'Quản lý Users',
path: '/users',
......@@ -51,6 +57,10 @@ export const DashboardLayout: React.FC = () => {
? location.pathname === '/' || location.pathname.startsWith('/jobs/')
: location.pathname === path;
const visibleNavItems = navItems.filter((item) => item.allowed);
const mobilePrimaryItems = visibleNavItems.length > 5 ? visibleNavItems.slice(0, 4) : visibleNavItems;
const mobileOverflowItems = visibleNavItems.length > 5 ? visibleNavItems.slice(4) : [];
return (
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-800">
{/* Sidebar */}
......@@ -68,8 +78,7 @@ export const DashboardLayout: React.FC = () => {
{/* Navigation Links */}
<nav className="flex-1 px-2 space-y-1 bg-white">
{navItems
.filter((item) => item.allowed)
{visibleNavItems
.map((item) => {
const isActive = isPathActive(item.path);
return (
......@@ -142,9 +151,7 @@ export const DashboardLayout: React.FC = () => {
</header>
<nav className="fixed inset-x-0 bottom-0 z-40 grid grid-flow-col auto-cols-fr border-t border-slate-200 bg-white/95 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-lg backdrop-blur md:hidden" aria-label="Điều hướng chính">
{navItems
.filter((item) => item.allowed)
.map((item) => {
{mobilePrimaryItems.map((item) => {
const isActive = isPathActive(item.path);
return (
<Link
......@@ -159,6 +166,35 @@ export const DashboardLayout: React.FC = () => {
</Link>
);
})}
{mobileOverflowItems.length > 0 && (
<details className="group relative">
<summary className={`flex min-w-0 cursor-pointer list-none flex-col items-center gap-1 rounded-lg px-1 py-1.5 text-[10px] font-medium ${
mobileOverflowItems.some((item) => isPathActive(item.path))
? 'bg-indigo-50 text-indigo-700'
: 'text-slate-500'
}`}>
<MoreHorizontal className="h-5 w-5" />
<span>Thêm</span>
</summary>
<div className="fixed bottom-20 right-2 z-50 w-56 overflow-hidden rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl">
{mobileOverflowItems.map((item) => (
<Link
key={item.name}
to={item.path}
onClick={(event) => event.currentTarget.closest('details')?.removeAttribute('open')}
className={`flex min-h-11 items-center rounded-lg px-3 text-sm font-medium ${
isPathActive(item.path)
? 'bg-indigo-50 text-indigo-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<span className="mr-3">{item.icon}</span>
{item.name}
</Link>
))}
</div>
</details>
)}
</nav>
{/* Page Content */}
......
This diff is collapsed.
......@@ -16,6 +16,38 @@ export interface CreatedApiKey extends ApiKeyRecord {
rawKey: string;
}
export type WebhookEvent = 'job.completed' | 'job.failed';
export type WebhookDeliveryStatus = 'PENDING' | 'SUCCESS' | 'FAILED';
export interface WebhookConfig {
id: string;
userId: string;
url: string;
isActive: boolean;
events: WebhookEvent[];
createdAt: string;
updatedAt: string;
}
export interface WebhookDelivery {
id: string;
webhookConfigId: string;
crawlJobId: string;
event: WebhookEvent;
payload: unknown;
status: WebhookDeliveryStatus;
statusCode: number | null;
attempt: number;
responseBody: string | null;
errorMessage: string | null;
deliveredAt: string | null;
createdAt: string;
updatedAt: string;
webhookConfig: {
url: string;
};
}
interface ApiResponse<T> {
success: boolean;
data: T;
......@@ -133,6 +165,36 @@ export const apiKeysApi = {
},
};
export const webhooksApi = {
async listConfigs(): Promise<WebhookConfig[]> {
const response = await api.get<ApiResponse<WebhookConfig[]>>('/webhooks/configs');
return response.data.data;
},
async createConfig(input: {
url: string;
secret: string;
events: WebhookEvent[];
}): Promise<WebhookConfig> {
const response = await api.post<ApiResponse<WebhookConfig>>('/webhooks/configs', input);
return response.data.data;
},
async deleteConfig(id: string): Promise<void> {
await api.delete(`/webhooks/configs/${id}`);
},
async listDeliveries(filters: {
jobId?: string;
status?: WebhookDeliveryStatus;
} = {}): Promise<WebhookDelivery[]> {
const response = await api.get<ApiResponse<WebhookDelivery[]>>('/webhooks/deliveries', {
params: filters,
});
return response.data.data;
},
};
function notifyAuthExpired() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
......
......@@ -59,3 +59,20 @@ test('profile rejects no-op submissions instead of reporting success', () => {
assert.match(profile, /Bạn chưa thay đổi thông tin nào/);
assert.match(profile, /profileChanged/);
});
test('webhook management is routed and integrates the supported backend endpoints', () => {
const app = read('App.tsx');
const layout = read('layouts/DashboardLayout.tsx');
const pageTitle = read('components/PageTitle.tsx');
const api = read('services/api.ts');
const webhooks = read('pages/Webhooks.tsx');
assert.match(app, /path="webhooks"/);
assert.match(layout, /Quản lý Webhook/);
assert.match(pageTitle, /Quản lý Webhook \| Data Crawler/);
assert.match(api, /\/webhooks\/configs/);
assert.match(api, /\/webhooks\/deliveries/);
assert.match(webhooks, /job\.completed/);
assert.match(webhooks, /job\.failed/);
assert.match(webhooks, /X-Webhook-Signature/);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment