Commit 37bafeba authored by BangNSK's avatar BangNSK

feat: add webhook management interface

parent a5729e5c
...@@ -11,6 +11,7 @@ import { Users } from './pages/Users'; ...@@ -11,6 +11,7 @@ import { Users } from './pages/Users';
import { Logs } from './pages/Logs'; import { Logs } from './pages/Logs';
import { Profile } from './pages/Profile'; import { Profile } from './pages/Profile';
import { ApiKeys } from './pages/ApiKeys'; import { ApiKeys } from './pages/ApiKeys';
import { Webhooks } from './pages/Webhooks';
import { ForgotPassword } from './pages/ForgotPassword'; import { ForgotPassword } from './pages/ForgotPassword';
import { ResetPassword } from './pages/ResetPassword'; import { ResetPassword } from './pages/ResetPassword';
import { VerifyEmail } from './pages/VerifyEmail'; import { VerifyEmail } from './pages/VerifyEmail';
...@@ -61,6 +62,7 @@ export default function App() { ...@@ -61,6 +62,7 @@ export default function App() {
{/* Profile */} {/* Profile */}
<Route path="profile" element={<Profile />} /> <Route path="profile" element={<Profile />} />
<Route path="api-keys" element={<ApiKeys />} /> <Route path="api-keys" element={<ApiKeys />} />
<Route path="webhooks" element={<Webhooks />} />
{/* Admin Only Routes */} {/* Admin Only Routes */}
<Route <Route
......
...@@ -10,6 +10,7 @@ const TITLES: Record<string, string> = { ...@@ -10,6 +10,7 @@ const TITLES: Record<string, string> = {
'/verify-email': 'Xác minh email | Data Crawler', '/verify-email': 'Xác minh email | Data Crawler',
'/profile': 'Hồ sơ cá nhân | Data Crawler', '/profile': 'Hồ sơ cá nhân | Data Crawler',
'/api-keys': 'Quản lý API Key | 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', '/users': 'Quản lý người dùng | Data Crawler',
'/logs': 'Nhật ký hệ thống | Data Crawler', '/logs': 'Nhật ký hệ thống | Data Crawler',
}; };
......
import React from 'react'; import React from 'react';
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'; import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom';
import { useAuth } from '../context/auth'; 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 = () => { export const DashboardLayout: React.FC = () => {
const { user, logout, isAdmin } = useAuth(); const { user, logout, isAdmin } = useAuth();
...@@ -26,6 +26,12 @@ export const DashboardLayout: React.FC = () => { ...@@ -26,6 +26,12 @@ export const DashboardLayout: React.FC = () => {
icon: <KeyRound className="h-5 w-5" />, icon: <KeyRound className="h-5 w-5" />,
allowed: true, allowed: true,
}, },
{
name: 'Quản lý Webhook',
path: '/webhooks',
icon: <Webhook className="h-5 w-5" />,
allowed: true,
},
{ {
name: 'Quản lý Users', name: 'Quản lý Users',
path: '/users', path: '/users',
...@@ -51,6 +57,10 @@ export const DashboardLayout: React.FC = () => { ...@@ -51,6 +57,10 @@ export const DashboardLayout: React.FC = () => {
? location.pathname === '/' || location.pathname.startsWith('/jobs/') ? location.pathname === '/' || location.pathname.startsWith('/jobs/')
: location.pathname === path; : 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 ( return (
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-800"> <div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-800">
{/* Sidebar */} {/* Sidebar */}
...@@ -68,8 +78,7 @@ export const DashboardLayout: React.FC = () => { ...@@ -68,8 +78,7 @@ export const DashboardLayout: React.FC = () => {
{/* Navigation Links */} {/* Navigation Links */}
<nav className="flex-1 px-2 space-y-1 bg-white"> <nav className="flex-1 px-2 space-y-1 bg-white">
{navItems {visibleNavItems
.filter((item) => item.allowed)
.map((item) => { .map((item) => {
const isActive = isPathActive(item.path); const isActive = isPathActive(item.path);
return ( return (
...@@ -142,9 +151,7 @@ export const DashboardLayout: React.FC = () => { ...@@ -142,9 +151,7 @@ export const DashboardLayout: React.FC = () => {
</header> </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"> <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 {mobilePrimaryItems.map((item) => {
.filter((item) => item.allowed)
.map((item) => {
const isActive = isPathActive(item.path); const isActive = isPathActive(item.path);
return ( return (
<Link <Link
...@@ -159,6 +166,35 @@ export const DashboardLayout: React.FC = () => { ...@@ -159,6 +166,35 @@ export const DashboardLayout: React.FC = () => {
</Link> </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> </nav>
{/* Page Content */} {/* Page Content */}
......
This diff is collapsed.
...@@ -16,6 +16,38 @@ export interface CreatedApiKey extends ApiKeyRecord { ...@@ -16,6 +16,38 @@ export interface CreatedApiKey extends ApiKeyRecord {
rawKey: string; 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> { interface ApiResponse<T> {
success: boolean; success: boolean;
data: T; data: T;
...@@ -133,6 +165,36 @@ export const apiKeysApi = { ...@@ -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() { function notifyAuthExpired() {
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken'); localStorage.removeItem('refreshToken');
......
...@@ -59,3 +59,20 @@ test('profile rejects no-op submissions instead of reporting success', () => { ...@@ -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, /Bạn chưa thay đổi thông tin nào/);
assert.match(profile, /profileChanged/); 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