Commit b3ac64f7 authored by ThinhNC's avatar ThinhNC

feat(fe): implement admin control center ui for ai assistant, notifications...

feat(fe): implement admin control center ui for ai assistant, notifications broadcast, and system settings
parent c544869c
...@@ -39,6 +39,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -39,6 +39,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Tiêu đề trang**: Mỗi route cập nhật `document.title` theo mẫu `<Tên trang> | FinWise` thông qua component dùng chung trong router; route chưa nhận diện dùng tiêu đề mô tả sản phẩm mặc định. - **Tiêu đề trang**: Mỗi route cập nhật `document.title` theo mẫu `<Tên trang> | FinWise` thông qua component dùng chung trong router; route chưa nhận diện dùng tiêu đề mô tả sản phẩm mặc định.
- **Khoảng trống và nút điều khiển trên header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất. Cụm điều khiển riêng của app (hiện gồm theme và ngôn ngữ) phải nằm trong `.finwise-header-controls`, đặt về bên trái vùng native `right-buttons` rộng 96px; đồng thời `zaui-header` phải dành đủ `padding-right` cho cả `right-buttons` và cụm này. Không đặt từng nút bằng offset `right` rời rạc vì có thể làm switch ngôn ngữ bị che khuất. - **Khoảng trống và nút điều khiển trên header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất. Cụm điều khiển riêng của app (hiện gồm theme và ngôn ngữ) phải nằm trong `.finwise-header-controls`, đặt về bên trái vùng native `right-buttons` rộng 96px; đồng thời `zaui-header` phải dành đủ `padding-right` cho cả `right-buttons` và cụm này. Không đặt từng nút bằng offset `right` rời rạc vì có thể làm switch ngôn ngữ bị che khuất.
- **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend). - **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend).
- **API Key & Webhook Callback**: Trang `/admin/integrations` thuộc Trung tâm quản trị và quản lý API key/webhook theo permission động. Raw API key và webhook signing secret chỉ được giữ trong state của modal tạo mới, xóa khỏi mutation cache ngay sau khi nhận và không lưu vào browser storage; UI luôn yêu cầu API key mới có ít nhất một scope để tránh cấp toàn quyền ngầm.
- **Vite/ZMP entry**: Giữ `index.html` tại root repository và không cấu hình Vite `root: "./src"`. ZMP CLI khởi chạy dev server với project root; cấu hình khác sẽ khiến iframe app trả 404. Build output chuẩn là `www/` tại root. - **Vite/ZMP entry**: Giữ `index.html` tại root repository và không cấu hình Vite `root: "./src"`. ZMP CLI khởi chạy dev server với project root; cấu hình khác sẽ khiến iframe app trả 404. Build output chuẩn là `www/` tại root.
- **Luồng xác thực**: Khi app mount, `AuthInitializer` gọi `/auth/me`; `AuthGuard` chỉ render private route sau khi khởi tạo xong và chuyển người dùng chưa đăng nhập tới `/login`. Cookie HTTP-only là cơ chế xác thực ưu tiên. - **Luồng xác thực**: Khi app mount, `AuthInitializer` gọi `/auth/me`; `AuthGuard` chỉ render private route sau khi khởi tạo xong và chuyển người dùng chưa đăng nhập tới `/login`. Cookie HTTP-only là cơ chế xác thực ưu tiên.
- **Upload file**: Browser tải file trực tiếp lên Cloudflare R2 bằng presigned PUT URL do backend - **Upload file**: Browser tải file trực tiếp lên Cloudflare R2 bằng presigned PUT URL do backend
...@@ -79,6 +80,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -79,6 +80,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Module giao dịch tự động định kỳ (Upgrade 6) có frontend tại `/recurring-transactions`, kết nối - Module giao dịch tự động định kỳ (Upgrade 6) có frontend tại `/recurring-transactions`, kết nối
CRUD, pause/resume, preview và history API; hỗ trợ tạo lịch trực tiếp hoặc chuyển từ subscription, CRUD, pause/resume, preview và history API; hỗ trợ tạo lịch trực tiếp hoặc chuyển từ subscription,
có loading/error/empty state và bản dịch Việt/Anh. có loading/error/empty state và bản dịch Việt/Anh.
- Module tích hợp hệ thống thuộc Trung tâm quản trị tại `/admin/integrations`, kết nối API Key và Webhook API qua TanStack Query; gồm tạo/thu hồi key, giới hạn scope/IP/hết hạn, tạo/sửa/tắt/xóa/test webhook, xem lịch sử delivery và phát lại delivery lỗi, với secret một lần, loading/error/empty state và bản dịch Việt/Anh.
- **Dynamic RBAC & Phân quyền Frontend (Upgrade 7)**: - **Dynamic RBAC & Phân quyền Frontend (Upgrade 7)**:
- Phân quyền dựa trên danh sách `user.permissions` động từ API `/auth/me` và JWT token. - Phân quyền dựa trên danh sách `user.permissions` động từ API `/auth/me` và JWT token.
- Sử dụng `<PermissionGate permission={PERMISSIONS.*} fallback={<AccessDenied />}>` để bảo vệ routes và ẩn/hiện action buttons trên UI. - Sử dụng `<PermissionGate permission={PERMISSIONS.*} fallback={<AccessDenied />}>` để bảo vệ routes và ẩn/hiện action buttons trên UI.
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
], ],
"scripts": { "scripts": {
"login": "zmp login", "login": "zmp login",
"start": "zmp start --port 13580", "start": "zmp start",
"deploy": "zmp deploy", "deploy": "zmp deploy",
"build": "vite build" "build": "vite build"
}, },
......
...@@ -84,6 +84,12 @@ export const PERMISSIONS = { ...@@ -84,6 +84,12 @@ export const PERMISSIONS = {
// NOTIFICATION // NOTIFICATION
NOTIFICATION_READ: 'NOTIFICATION_READ', NOTIFICATION_READ: 'NOTIFICATION_READ',
NOTIFICATION_UPDATE: 'NOTIFICATION_UPDATE', NOTIFICATION_UPDATE: 'NOTIFICATION_UPDATE',
NOTIFICATION_DELETE: 'NOTIFICATION_DELETE',
NOTIFICATION_ADMIN_READ: 'NOTIFICATION_ADMIN_READ',
NOTIFICATION_RETRY: 'NOTIFICATION_RETRY',
NOTIFICATION_TEMPLATE_READ: 'NOTIFICATION_TEMPLATE_READ',
NOTIFICATION_TEMPLATE_UPDATE: 'NOTIFICATION_TEMPLATE_UPDATE',
NOTIFICATION_CONFIG_UPDATE: 'NOTIFICATION_CONFIG_UPDATE',
// REMINDER // REMINDER
REMINDER_READ: 'REMINDER_READ', REMINDER_READ: 'REMINDER_READ',
...@@ -91,11 +97,35 @@ export const PERMISSIONS = { ...@@ -91,11 +97,35 @@ export const PERMISSIONS = {
REMINDER_UPDATE: 'REMINDER_UPDATE', REMINDER_UPDATE: 'REMINDER_UPDATE',
REMINDER_DELETE: 'REMINDER_DELETE', REMINDER_DELETE: 'REMINDER_DELETE',
// AI ASSISTANT // AI ASSISTANT & AI ADMIN
AI_ASSISTANT_USE: 'AI_ASSISTANT_USE', AI_ASSISTANT_USE: 'AI_ASSISTANT_USE',
AI_ADMIN_READ: 'AI_ADMIN_READ',
AI_CONFIG_UPDATE: 'AI_CONFIG_UPDATE',
AI_USAGE_READ: 'AI_USAGE_READ',
// SYSTEM CONFIGURATION & MAINTENANCE
SYSTEM_CONFIG_READ: 'SYSTEM_CONFIG_READ',
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
MAINTENANCE_MODE_UPDATE: 'MAINTENANCE_MODE_UPDATE',
// UPLOAD // UPLOAD
UPLOAD_FILE: 'UPLOAD_FILE', UPLOAD_FILE: 'UPLOAD_FILE',
// API KEY INTEGRATION
API_KEY_READ: 'API_KEY_READ',
API_KEY_CREATE: 'API_KEY_CREATE',
API_KEY_DELETE: 'API_KEY_DELETE',
// WEBHOOK INTEGRATION
WEBHOOK_READ: 'WEBHOOK_READ',
WEBHOOK_CREATE: 'WEBHOOK_CREATE',
WEBHOOK_UPDATE: 'WEBHOOK_UPDATE',
WEBHOOK_DELETE: 'WEBHOOK_DELETE',
WEBHOOK_TEST: 'WEBHOOK_TEST',
// ASYNC JOB
JOB_READ: 'JOB_READ',
JOB_CREATE: 'JOB_CREATE',
} as const; } as const;
export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
...@@ -48,10 +48,15 @@ import AdminDashboardPage from "@/pages/admin/index"; ...@@ -48,10 +48,15 @@ import AdminDashboardPage from "@/pages/admin/index";
import AdminUsersPage from "@/pages/admin/users/index"; import AdminUsersPage from "@/pages/admin/users/index";
import AdminUserDetailPage from "@/pages/admin/users/detail"; import AdminUserDetailPage from "@/pages/admin/users/detail";
import AdminAuditLogsPage from "@/pages/admin/audit-logs/index"; import AdminAuditLogsPage from "@/pages/admin/audit-logs/index";
import AdminSettingsPage from "@/pages/admin/settings/index";
import AdminNotificationsPage from "@/pages/admin/notifications/index";
import AdminAiPage from "@/pages/admin/ai/index";
import IntegrationsPage from "@/pages/integrations/index";
import { PermissionGate } from "@/components/shared/PermissionGate"; import { PermissionGate } from "@/components/shared/PermissionGate";
import { AccessDenied } from "@/components/shared/AccessDenied"; import { AccessDenied } from "@/components/shared/AccessDenied";
import { PERMISSIONS } from "@/common/constants"; import { PERMISSIONS } from "@/common/constants";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => { const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore(); const { setAuth, clearAuth, setInitialized } = useAuthStore();
...@@ -125,16 +130,21 @@ export const Layout = () => { ...@@ -125,16 +130,21 @@ export const Layout = () => {
<Route path="/recurring-transactions" element={<AuthGuard><PermissionGate permission={PERMISSIONS.RECURRING_TRANSACTION_READ} fallback={<AccessDenied />}><RecurringTransactionsPage /></PermissionGate></AuthGuard>}></Route> <Route path="/recurring-transactions" element={<AuthGuard><PermissionGate permission={PERMISSIONS.RECURRING_TRANSACTION_READ} fallback={<AccessDenied />}><RecurringTransactionsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/query" element={<AuthGuard><PermissionGate permission={PERMISSIONS.QUERY_EXECUTE} fallback={<AccessDenied />}><QueryPage /></PermissionGate></AuthGuard>}></Route> <Route path="/query" element={<AuthGuard><PermissionGate permission={PERMISSIONS.QUERY_EXECUTE} fallback={<AccessDenied />}><QueryPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/notifications" element={<AuthGuard><PermissionGate permission={PERMISSIONS.NOTIFICATION_READ} fallback={<AccessDenied />}><NotificationsPage /></PermissionGate></AuthGuard>}></Route> <Route path="/notifications" element={<AuthGuard><PermissionGate permission={PERMISSIONS.NOTIFICATION_READ} fallback={<AccessDenied />}><NotificationsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/integrations" element={<AuthGuard><PermissionGate permissions={[PERMISSIONS.API_KEY_READ, PERMISSIONS.WEBHOOK_READ]} mode="any" fallback={<AccessDenied />}><IntegrationsPage /></PermissionGate></AuthGuard>}></Route>
{/* Admin Control Center Routes */} {/* Admin Control Center Routes */}
<Route path="/admin" element={<AuthGuard><PermissionGate permission={PERMISSIONS.USER_READ} fallback={<AccessDenied />}><AdminDashboardPage /></PermissionGate></AuthGuard>}></Route> <Route path="/admin" element={<AuthGuard><PermissionGate permission={PERMISSIONS.USER_READ} fallback={<AccessDenied />}><AdminDashboardPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/users" element={<AuthGuard><PermissionGate permission={PERMISSIONS.USER_READ} fallback={<AccessDenied />}><AdminUsersPage /></PermissionGate></AuthGuard>}></Route> <Route path="/admin/users" element={<AuthGuard><PermissionGate permission={PERMISSIONS.USER_READ} fallback={<AccessDenied />}><AdminUsersPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/users/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.USER_READ} fallback={<AccessDenied />}><AdminUserDetailPage /></PermissionGate></AuthGuard>}></Route> <Route path="/admin/users/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.USER_READ} fallback={<AccessDenied />}><AdminUserDetailPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/audit-logs" element={<AuthGuard><PermissionGate permission={PERMISSIONS.AUDIT_LOG_READ} fallback={<AccessDenied />}><AdminAuditLogsPage /></PermissionGate></AuthGuard>}></Route> <Route path="/admin/audit-logs" element={<AuthGuard><PermissionGate permission={PERMISSIONS.AUDIT_LOG_READ} fallback={<AccessDenied />}><AdminAuditLogsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/settings" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SYSTEM_CONFIG_READ} fallback={<AccessDenied />}><AdminSettingsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/notifications" element={<AuthGuard><PermissionGate permission={PERMISSIONS.NOTIFICATION_ADMIN_READ} fallback={<AccessDenied />}><AdminNotificationsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/ai" element={<AuthGuard><PermissionGate permission={PERMISSIONS.AI_ADMIN_READ} fallback={<AccessDenied />}><AdminAiPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/roles" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ROLE_READ} fallback={<AccessDenied />}><RolesPage /></PermissionGate></AuthGuard>}></Route> <Route path="/roles" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ROLE_READ} fallback={<AccessDenied />}><RolesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/roles" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ROLE_READ} fallback={<AccessDenied />}><RolesPage /></PermissionGate></AuthGuard>}></Route> <Route path="/admin/roles" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ROLE_READ} fallback={<AccessDenied />}><RolesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/ai-assistant" element={<AuthGuard><PermissionGate permission={PERMISSIONS.AI_ASSISTANT_USE} fallback={<AccessDenied />}><AIAssistantPage /></PermissionGate></AuthGuard>}></Route> <Route path="/ai-assistant" element={<AuthGuard><PermissionGate permission={PERMISSIONS.AI_ASSISTANT_USE} fallback={<AccessDenied />}><AIAssistantPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route> <Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes> </AnimationRoutes>
</AuthInitializer> </AuthInitializer>
</ZMPRouter> </ZMPRouter>
</SnackbarProvider> </SnackbarProvider>
......
...@@ -31,15 +31,20 @@ const PAGE_TITLES: ReadonlyArray<{ ...@@ -31,15 +31,20 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/recurring-transactions", key: "document.recurringTransactions" }, { matches: (pathname) => pathname === "/recurring-transactions", key: "document.recurringTransactions" },
{ matches: (pathname) => pathname === "/query", key: "document.query" }, { matches: (pathname) => pathname === "/query", key: "document.query" },
{ matches: (pathname) => pathname === "/notifications", key: "document.notifications" }, { matches: (pathname) => pathname === "/notifications", key: "document.notifications" },
{ matches: (pathname) => pathname === "/admin/integrations", key: "document.integrations" },
{ matches: (pathname) => pathname === "/admin", key: "document.admin" }, { matches: (pathname) => pathname === "/admin", key: "document.admin" },
{ matches: (pathname) => pathname === "/admin/users", key: "document.adminUsers" }, { matches: (pathname) => pathname === "/admin/users", key: "document.adminUsers" },
{ matches: (pathname) => pathname.startsWith("/admin/users/"), key: "document.adminUserDetail" }, { matches: (pathname) => pathname.startsWith("/admin/users/"), key: "document.adminUserDetail" },
{ matches: (pathname) => pathname === "/admin/audit-logs", key: "document.adminAuditLogs" }, { matches: (pathname) => pathname === "/admin/audit-logs", key: "document.adminAuditLogs" },
{ matches: (pathname) => pathname === "/admin/settings", key: "document.adminSettings" },
{ matches: (pathname) => pathname === "/admin/notifications", key: "document.adminNotifications" },
{ matches: (pathname) => pathname === "/admin/ai", key: "document.adminAi" },
{ matches: (pathname) => pathname === "/roles" || pathname === "/admin/roles", key: "document.roles" }, { matches: (pathname) => pathname === "/roles" || pathname === "/admin/roles", key: "document.roles" },
{ matches: (pathname) => pathname === "/ai-assistant", key: "document.aiAssistant" }, { matches: (pathname) => pathname === "/ai-assistant", key: "document.aiAssistant" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" }, { matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
]; ];
export const DocumentTitle: React.FC = () => { export const DocumentTitle: React.FC = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { t } = useI18n(); const { t } = useI18n();
......
...@@ -263,3 +263,48 @@ export const EditIcon: React.FC<IconProps> = ({ size = 18, ...props }) => ( ...@@ -263,3 +263,48 @@ export const EditIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
</svg> </svg>
); );
// Settings
export const SettingsIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<circle cx="12" cy="12" r="3" fill="url(#clay-grad-primary)" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
// Refresh
export const RefreshIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
);
// Alert Triangle
export const AlertTriangleIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" fill="url(#clay-grad-warning)" stroke="rgb(var(--color-clay-warning))" />
<line x1="12" y1="9" x2="12" y2="13" stroke="rgb(var(--color-clay-text))" />
<line x1="12" y1="17" x2="12.01" y2="17" stroke="rgb(var(--color-clay-text))" />
</svg>
);
// Server
export const ServerIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" fill="url(#clay-grad-info)" fillOpacity="0.2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" fill="url(#clay-grad-info)" fillOpacity="0.2" />
<line x1="6" y1="6" x2="6.01" y2="6" stroke="currentColor" />
<line x1="6" y1="18" x2="6.01" y2="18" stroke="currentColor" />
</svg>
);
// Send
export const SendIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" fill="url(#clay-grad-primary)" fillOpacity="0.3" />
</svg>
);
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
adminAiService,
AiRateLimitConfig,
AiRequestLogQueryParams,
} from '@/services/admin-ai.service';
export const ADMIN_AI_KEYS = {
all: ['admin-ai'] as const,
status: ['admin-ai', 'status'] as const,
usage: (period?: string) => ['admin-ai', 'usage', period] as const,
logs: (params?: AiRequestLogQueryParams) => ['admin-ai', 'logs', params] as const,
rateLimit: ['admin-ai', 'rate-limit'] as const,
};
export function useAiFeatureStatuses() {
return useQuery({
queryKey: ADMIN_AI_KEYS.status,
queryFn: () => adminAiService.getFeatureStatuses(),
});
}
export function useAiUsageSummary(period: 'today' | 'week' | 'month' = 'today') {
return useQuery({
queryKey: ADMIN_AI_KEYS.usage(period),
queryFn: () => adminAiService.getUsageSummary({ period }),
});
}
export function useAiRequestLogs(params?: AiRequestLogQueryParams) {
return useQuery({
queryKey: ADMIN_AI_KEYS.logs(params),
queryFn: () => adminAiService.getLogs(params),
});
}
export function useAiRateLimitConfig() {
return useQuery({
queryKey: ADMIN_AI_KEYS.rateLimit,
queryFn: () => adminAiService.getRateLimitConfig(),
});
}
export function useToggleAiFeature() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ featureKey, enabled }: { featureKey: string; enabled: boolean }) =>
adminAiService.toggleFeature(featureKey, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_AI_KEYS.status });
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-system-config'] });
},
});
}
export function useUpdateAiRateLimit() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: AiRateLimitConfig) => adminAiService.updateRateLimitConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_AI_KEYS.rateLimit });
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-system-config'] });
},
});
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
adminNotificationService,
AdminDeliveryQueryParams,
NotificationChannel,
NotificationChannelConfig,
NotificationType,
} from '@/services/admin-notification.service';
export const ADMIN_NOTIFICATION_KEYS = {
all: ['admin-notifications'] as const,
overview: (params?: { dateFrom?: string; dateTo?: string }) =>
['admin-notifications', 'overview', params] as const,
deliveries: (params?: AdminDeliveryQueryParams) =>
['admin-notifications', 'deliveries', params] as const,
templates: (params?: { type?: NotificationType; channel?: NotificationChannel; language?: string; isActive?: boolean }) =>
['admin-notifications', 'templates', params] as const,
channels: ['admin-notifications', 'channels'] as const,
};
export function useNotificationOverview(params?: { dateFrom?: string; dateTo?: string }) {
return useQuery({
queryKey: ADMIN_NOTIFICATION_KEYS.overview(params),
queryFn: () => adminNotificationService.getOverview(params),
});
}
export function useNotificationDeliveries(params?: AdminDeliveryQueryParams) {
return useQuery({
queryKey: ADMIN_NOTIFICATION_KEYS.deliveries(params),
queryFn: () => adminNotificationService.getDeliveries(params),
});
}
export function useNotificationTemplates(params?: {
type?: NotificationType;
channel?: NotificationChannel;
language?: string;
isActive?: boolean;
}) {
return useQuery({
queryKey: ADMIN_NOTIFICATION_KEYS.templates(params),
queryFn: () => adminNotificationService.getTemplates(params),
});
}
export function useNotificationChannels() {
return useQuery({
queryKey: ADMIN_NOTIFICATION_KEYS.channels,
queryFn: () => adminNotificationService.getChannelConfig(),
});
}
export function useRetryNotificationDelivery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (deliveryId: string) => adminNotificationService.retryDelivery(deliveryId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_NOTIFICATION_KEYS.all });
},
});
}
export function useUpdateNotificationTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: { titleTemplate?: string; bodyTemplate?: string; isActive?: boolean };
}) => adminNotificationService.updateTemplate(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_NOTIFICATION_KEYS.templates() });
},
});
}
export function useUpdateNotificationChannels() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<NotificationChannelConfig>) =>
adminNotificationService.updateChannelConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_NOTIFICATION_KEYS.all });
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-system-config'] });
},
});
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
adminSettingsService,
MaintenanceModeConfig,
SettingCategory,
} from '@/services/admin-settings.service';
export const ADMIN_SETTINGS_KEYS = {
all: ['admin-settings'] as const,
list: (params?: { category?: SettingCategory; isPublic?: boolean; search?: string }) =>
['admin-settings', 'list', params] as const,
detail: (key: string) => ['admin-settings', 'detail', key] as const,
publicConfig: ['public-system-config'] as const,
};
export function useAdminSettings(params?: {
category?: SettingCategory;
isPublic?: boolean;
search?: string;
}) {
return useQuery({
queryKey: ADMIN_SETTINGS_KEYS.list(params),
queryFn: () => adminSettingsService.getSettings(params),
});
}
export function usePublicConfig() {
return useQuery({
queryKey: ADMIN_SETTINGS_KEYS.publicConfig,
queryFn: () => adminSettingsService.getPublicConfig(),
staleTime: 60 * 1000,
});
}
export function useUpdateSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, data }: { key: string; data: { value: any; description?: string } }) =>
adminSettingsService.updateSetting(key, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.all });
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.detail(variables.key) });
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.publicConfig });
if (variables.key.startsWith('notifications.')) {
queryClient.invalidateQueries({ queryKey: ['admin-notifications'] });
}
if (variables.key.startsWith('ai.')) {
queryClient.invalidateQueries({ queryKey: ['admin-ai'] });
}
},
});
}
export function useUpdateMaintenanceMode() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: MaintenanceModeConfig) => adminSettingsService.updateMaintenanceMode(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.all });
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.publicConfig });
},
});
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { integrationService } from "@/services/integration.service";
import { CreateApiKeyInput, CreateWebhookInput, UpdateWebhookInput } from "@/types/integration";
export const integrationKeys = {
all: ["integrations"] as const,
apiKeys: ["integrations", "api-keys"] as const,
webhooks: ["integrations", "webhooks"] as const,
deliveries: (endpointId: string) => ["integrations", "webhooks", endpointId, "deliveries"] as const,
};
export function useApiKeys(enabled: boolean) {
return useQuery({
queryKey: integrationKeys.apiKeys,
queryFn: integrationService.listApiKeys,
enabled,
staleTime: 30_000,
});
}
export function useCreateApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateApiKeyInput) => integrationService.createApiKey(input),
onSuccess: () => queryClient.invalidateQueries({ queryKey: integrationKeys.apiKeys }),
// Raw keys must leave the mutation cache as soon as the one-time modal owns the value.
gcTime: 0,
});
}
export function useRevokeApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => integrationService.revokeApiKey(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: integrationKeys.apiKeys }),
});
}
export function useWebhooks(enabled: boolean) {
return useQuery({
queryKey: integrationKeys.webhooks,
queryFn: integrationService.listWebhooks,
enabled,
staleTime: 30_000,
});
}
export function useCreateWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateWebhookInput) => integrationService.createWebhook(input),
onSuccess: () => queryClient.invalidateQueries({ queryKey: integrationKeys.webhooks }),
// Signing secrets are one-time values and must not remain in the mutation cache.
gcTime: 0,
});
}
export function useUpdateWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateWebhookInput }) => integrationService.updateWebhook(id, input),
onSuccess: () => queryClient.invalidateQueries({ queryKey: integrationKeys.webhooks }),
});
}
export function useDeleteWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => integrationService.deleteWebhook(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: integrationKeys.webhooks }),
});
}
export function useWebhookDeliveries(endpointId: string | null) {
return useQuery({
queryKey: integrationKeys.deliveries(endpointId || "none"),
queryFn: () => {
if (!endpointId) throw new Error("Webhook endpoint ID is required");
return integrationService.listDeliveries(endpointId);
},
enabled: Boolean(endpointId),
refetchInterval: endpointId ? 10_000 : false,
});
}
export function useTestWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (endpointId: string) => integrationService.testWebhook(endpointId),
onSuccess: (_, endpointId) => queryClient.invalidateQueries({ queryKey: integrationKeys.deliveries(endpointId) }),
});
}
export function useRetryWebhookDelivery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ endpointId, deliveryId }: { endpointId: string; deliveryId: string }) =>
integrationService.retryDelivery(endpointId, deliveryId),
onSuccess: (_, variables) =>
queryClient.invalidateQueries({ queryKey: integrationKeys.deliveries(variables.endpointId) }),
});
}
...@@ -69,13 +69,18 @@ ...@@ -69,13 +69,18 @@
"recurringTransactions": "Automated Recurring Transactions", "recurringTransactions": "Automated Recurring Transactions",
"query": "Smart Natural Language Query DSL", "query": "Smart Natural Language Query DSL",
"notifications": "Notifications & Reminders", "notifications": "Notifications & Reminders",
"integrations": "API Keys & Webhook Callbacks",
"admin": "Admin Control Center", "admin": "Admin Control Center",
"adminUsers": "User Management", "adminUsers": "User Management",
"adminUserDetail": "User Details", "adminUserDetail": "User Details",
"adminAuditLogs": "Audit Logs", "adminAuditLogs": "Audit Logs",
"adminSettings": "System Configuration",
"adminNotifications": "Notification Management",
"adminAi": "AI Administration",
"roles": "Roles & Permissions", "roles": "Roles & Permissions",
"aiAssistant": "AI Financial Assistant" "aiAssistant": "AI Financial Assistant"
}, },
"validation": { "validation": {
"emailRequired": "Email is required", "emailRequired": "Email is required",
"emailInvalid": "Enter a valid email address", "emailInvalid": "Enter a valid email address",
...@@ -1663,6 +1668,136 @@ ...@@ -1663,6 +1668,136 @@
"breakdownTitle": "Grouped Breakdown", "breakdownTitle": "Grouped Breakdown",
"detailedItemsTitle": "Detailed Matching Transactions" "detailedItemsTitle": "Detailed Matching Transactions"
}, },
"integrations": {
"header": "System Integrations",
"title": "API Keys & Webhook Callbacks",
"subtitle": "Securely connect FinWise to external systems and automated workflows.",
"tabs": {
"apiKeys": "API Keys",
"webhooks": "Webhook Callbacks"
},
"status": {
"ACTIVE": "Active",
"DISABLED": "Disabled",
"REVOKED": "Revoked",
"EXPIRED": "Expired",
"PENDING": "Pending",
"SUCCESS": "Successful",
"FAILED": "Failed"
},
"secret": {
"apiKeyTitle": "Save your new API key",
"webhookTitle": "Save the signing secret",
"apiKeyLabel": "API Key",
"webhookLabel": "Webhook signing secret",
"onceTitle": "This value is shown only once",
"onceHint": "Copy it into a secure secret manager. FinWise cannot display it again after this window closes.",
"copy": "Copy",
"copied": "Copied",
"done": "I stored it securely"
},
"apiKeys": {
"title": "API Keys",
"subtitle": "Grant scoped access to external applications and services.",
"create": "Create API Key",
"securityHint": "Give each key only the permissions it needs. Set an expiry, restrict source IPs when possible, and revoke unused keys immediately.",
"permissions": "Permission scopes",
"unscoped": "All account permissions (legacy key)",
"ipWhitelist": "Allowed IPs",
"anyIp": "Any IP address",
"lastUsed": "Last used",
"never": "Never",
"expires": "Expires",
"noExpiry": "No expiry",
"emptyTitle": "No API keys yet",
"emptyHint": "Create your first key to connect an external system.",
"loadFailed": "Unable to load API keys.",
"createSuccess": "API key created. Store it before closing the window.",
"createFailed": "Unable to create API key.",
"revoke": "Revoke key",
"revokeTitle": "Revoke API key?",
"revokeConfirm": "“{{name}}” will stop working immediately and cannot be restored.",
"revokeSuccess": "API key revoked.",
"revokeFailed": "Unable to revoke API key.",
"form": {
"title": "Create a new API key",
"name": "Display name",
"namePlaceholder": "Example: Accounting sync",
"nameRequired": "Enter a name for this API key.",
"nameMax": "The name cannot exceed 100 characters.",
"permissions": "Permission scopes",
"permissionsHint": "Only permissions currently granted to your account appear here.",
"permissionsRequired": "Select at least one permission.",
"noScopes": "Your account has no supported permission to grant this API key.",
"ipWhitelist": "Allowed IP addresses (optional)",
"ipPlaceholder": "203.0.113.10\n198.51.100.20",
"ipHint": "Use one IP per line or separate them with commas. Leaving this blank allows every IP.",
"ipInvalid": "The list may contain only valid IPv4 or IPv6 addresses.",
"expiresAt": "Expiration time (optional)"
},
"scopes": {
"walletRead": "Read wallets", "walletCreate": "Create wallets", "walletUpdate": "Update wallets", "walletDelete": "Archive wallets",
"categoryRead": "Read categories", "categoryCreate": "Create categories", "categoryUpdate": "Update categories", "categoryDelete": "Archive categories",
"transactionRead": "Read transactions", "transactionCreate": "Create transactions", "transactionUpdate": "Update transactions", "transactionDelete": "Delete transactions",
"reportRead": "Read reports", "jobRead": "Read async jobs", "jobCreate": "Create async jobs"
}
},
"webhooks": {
"title": "Webhook Callbacks",
"subtitle": "Receive near-real-time FinWise events protected by HMAC signatures.",
"create": "Add webhook",
"emptyTitle": "No webhooks yet",
"emptyHint": "Register a public endpoint to receive callbacks from FinWise.",
"loadFailed": "Unable to load webhooks.",
"subscribedEvents": "Subscribed events",
"updatedAt": "Updated {{date}}",
"test": "Send test",
"testSuccess": "Test ping queued for delivery.",
"testFailed": "Unable to send the test ping.",
"createSuccess": "Webhook created. Store the signing secret before closing the window.",
"createFailed": "Unable to create the webhook.",
"updateSuccess": "Webhook updated.",
"updateFailed": "Unable to update the webhook.",
"deleteTitle": "Delete webhook?",
"deleteConfirm": "{{url}} will stop receiving callbacks and be removed from the list.",
"deleteSuccess": "Webhook deleted.",
"deleteFailed": "Unable to delete the webhook.",
"form": {
"createTitle": "Register webhook",
"editTitle": "Edit webhook",
"url": "Public callback URL",
"urlInvalid": "Enter a valid public HTTP/HTTPS URL; localhost is not supported.",
"description": "Description (optional)",
"descriptionPlaceholder": "Example: Production accounting endpoint",
"descriptionMax": "The description cannot exceed 255 characters.",
"events": "Events to receive",
"eventsRequired": "Select at least one event.",
"active": "Enable endpoint",
"activeHint": "A disabled endpoint will not receive new callbacks."
},
"events": {
"transactionCreated": "Transaction created",
"budgetWarning": "Budget warning",
"budgetExceeded": "Budget exceeded",
"savingGoalAchieved": "Saving goal achieved",
"anomalyDetected": "Transaction anomaly detected",
"jobCompleted": "Async job completed",
"jobFailed": "Async job failed"
},
"deliveries": {
"button": "Delivery history",
"title": "Webhook delivery history",
"createdAt": "Created",
"attempts": "Attempts",
"empty": "No deliveries yet",
"emptyHint": "Send a test ping or wait for an event to occur.",
"loadFailed": "Unable to load delivery history.",
"retry": "Retry now",
"retrySuccess": "Delivery queued for retry.",
"retryFailed": "Unable to retry the delivery."
}
}
},
"admin": { "admin": {
"controlCenter": "Admin Control Center", "controlCenter": "Admin Control Center",
"manageUsersAndRoles": "Manage users, roles, permissions, and audit activity", "manageUsersAndRoles": "Manage users, roles, permissions, and audit activity",
...@@ -1681,9 +1816,193 @@ ...@@ -1681,9 +1816,193 @@
"actions": { "actions": {
"users": "User management", "users": "User management",
"roles": "Role management", "roles": "Role management",
"auditLogs": "Audit logs" "auditLogs": "Audit logs",
"settings": "System configuration",
"notifications": "Notification management",
"ai": "AI administration",
"integrations": "API Keys & Webhook Callbacks"
},
"settings": {
"header": "System Configuration",
"title": "System Configuration & Maintenance",
"subtitle": "Adjust operational parameters, manage cached settings, and control maintenance mode",
"maintenance": {
"title": "System Maintenance Mode",
"active": "UNDER MAINTENANCE",
"normal": "NORMAL OPERATION",
"description": "When active, normal users will receive a 503 response with the notice below. Administrators maintain regular access.",
"messageLabel": "Notice displayed to users:",
"messagePlaceholder": "Enter maintenance notice...",
"startAtLabel": "Start time:",
"endAtLabel": "Estimated completion:",
"quickSelect": "Quick select:",
"minutes": "{{count}} mins",
"hours": "{{count}} hours",
"clearSchedule": "Clear schedule",
"cancel": "Cancel",
"save": "Save Maintenance Status",
"saving": "Saving...",
"success": "System maintenance configuration updated successfully",
"error": "Failed to update maintenance configuration"
},
"list": {
"title": "System Dynamic Settings",
"searchPlaceholder": "Search by key or description...",
"empty": "No configuration parameters found matching criteria.",
"public": "PUBLIC",
"private": "INTERNAL",
"immutable": "Immutable",
"edit": "Edit",
"saveSuccess": "Configuration updated successfully."
},
"categories": {
"ALL": "All",
"GENERAL": "General",
"SECURITY": "Security",
"NOTIFICATION": "Notification",
"AI": "Artificial Intelligence",
"SYSTEM": "System"
},
"modal": {
"title": "Edit Configuration",
"immutableDesc": "This parameter is an immutable system setting and cannot be modified.",
"valueLabel": "New value ({{type}}):",
"cancel": "Cancel",
"save": "Save Setting",
"saving": "Saving...",
"numberError": "Value must be a valid number",
"booleanError": "Value must be true or false",
"jsonError": "Invalid JSON format string"
}
},
"notifications": {
"header": "Notification Management",
"title": "Notification Control Center",
"subtitle": "Monitor multi-channel deliveries, resolve dispatch failures, and manage bilingual templates",
"tabs": {
"overview": "Overview & Stats",
"deliveries": "Delivery Logs",
"templates": "Message Templates",
"channels": "Channel Config"
},
"overview": {
"total": "Total Notifications",
"sent": "Delivered",
"pending": "Pending",
"failed": "Failed",
"performance": "Delivery Performance",
"successRate": "delivery success rate",
"read": "Read: {{count}}",
"unread": "Unread: {{count}}",
"channelBreakdown": "Channel Distribution",
"channelItem": "{{sent}} sent / {{failed}} failed",
"error": "Failed to load notification statistics."
},
"deliveries": {
"searchPlaceholder": "Search recipient, title, message content...",
"allChannels": "All Channels",
"allStatuses": "All Statuses",
"empty": "No notification delivery records found.",
"anonymousUser": "Anonymous user",
"retry": "Retry Delivery",
"retrying": "Retrying...",
"retrySuccess": "Notification delivery retry initiated.",
"retryError": "Failed to retry notification delivery",
"attempts": "Attempts: {{count}}",
"errorLabel": "Error:"
},
"templates": {
"empty": "No notification templates found in system.",
"edit": "Edit Template",
"variables": "Dynamic Variables:",
"modalTitle": "Edit Notification Template",
"titleLabel": "Title Template:",
"bodyLabel": "Body Template:",
"availableVars": "Available Variables:",
"cancel": "Cancel",
"save": "Save Template",
"saving": "Saving...",
"success": "Notification template updated successfully.",
"error": "Failed to update notification template"
},
"channels": {
"title": "Notification Dispatch Channels",
"subtitle": "Instantly enable/disable real-time delivery channels to application users",
"syncing": "Syncing...",
"inAppTitle": "In-App Notifications (IN_APP)",
"inAppDesc": "Deliver notifications directly to the in-app notification inbox",
"emailTitle": "Transactional Email (EMAIL)",
"emailDesc": "Send budget limit warning emails and monthly account statements",
"zaloTitle": "Zalo Notification Service (ZNS)",
"zaloDesc": "Deliver automated messages via verified Zalo Official Account",
"pushTitle": "Mobile Push Notifications (PUSH)",
"pushDesc": "Native push notifications via Firebase FCM and Apple APNs",
"error": "Failed to update channel configuration"
}
},
"ai": {
"header": "AI Administration",
"title": "AI Operations & Rate Limiting",
"subtitle": "Control feature availability, monitor token burn metrics, and configure Sliding Window Rate Limits",
"tabs": {
"features": "Features & Status",
"usage": "Token Usage",
"logs": "Request Logs",
"rateLimit": "Rate Limit Config"
},
"features": {
"activeCount": "{{active}}/{{total}} features operational",
"enabled": "Operational",
"disabled": "Paused",
"degraded": "Degraded",
"empty": "No AI feature status data available.",
"modelLabel": "Model:",
"toggleSuccess": "AI feature operational status updated.",
"toggleError": "Failed to update AI feature status"
},
"usage": {
"periods": {
"today": "Today",
"week": "Past 7 days",
"month": "Past 30 days"
},
"totalTokens": "Total Tokens Burned",
"totalRequests": "Total Requests",
"avgLatency": "Average Latency",
"successRate": "Success Rate",
"promptTokens": "Prompt: {{count}}",
"completionTokens": "Completion: {{count}}",
"tokenBreakdown": "Prompt vs Completion Token Distribution",
"latencyMetrics": "AI Latency & Response Metrics",
"avgLabel": "Average:",
"minMaxLabel": "Min / Max:",
"error": "Failed to load AI usage statistics."
},
"logs": {
"searchPlaceholder": "Search by feature, model, error code...",
"allFeatures": "All Features",
"allStatuses": "All Statuses",
"empty": "No AI request logs found matching filters.",
"anonymousUser": "Anonymous user",
"tokens": "Tokens: {{count}}",
"latency": "Latency: {{latency}}ms",
"errorLabel": "Error:"
},
"rateLimit": {
"title": "AI Sliding Window Rate Limit Configuration",
"subtitle": "Protect infrastructure and limit OpenAI / Gemini invocation frequency per sliding window",
"maxRequestsLabel": "Maximum requests per window:",
"windowSecondsLabel": "Sliding window duration (seconds):",
"summary": "Each user is capped at {{max}} AI requests per {{window}} seconds window.",
"quickPresets": "Quick presets:",
"save": "Save Rate Limit",
"saving": "Saving...",
"success": "AI rate limit configuration saved successfully.",
"error": "Failed to update AI rate limit configuration"
}
}, },
"users": { "users": {
"title": "User Management", "title": "User Management",
"heading": "Users", "heading": "Users",
"description": "Search accounts, update access status, and manage assigned roles.", "description": "Search accounts, update access status, and manage assigned roles.",
......
...@@ -69,13 +69,18 @@ ...@@ -69,13 +69,18 @@
"recurringTransactions": "Giao dịch Tự động Định kỳ", "recurringTransactions": "Giao dịch Tự động Định kỳ",
"query": "Truy vấn Thông minh DSL", "query": "Truy vấn Thông minh DSL",
"notifications": "Thông báo & nhắc nhở", "notifications": "Thông báo & nhắc nhở",
"integrations": "API Key & Webhook Callback",
"admin": "Trung tâm quản trị", "admin": "Trung tâm quản trị",
"adminUsers": "Quản lý người dùng", "adminUsers": "Quản lý người dùng",
"adminUserDetail": "Chi tiết người dùng", "adminUserDetail": "Chi tiết người dùng",
"adminAuditLogs": "Nhật ký kiểm toán", "adminAuditLogs": "Nhật ký kiểm toán",
"adminSettings": "Cấu hình hệ thống",
"adminNotifications": "Quản lý thông báo",
"adminAi": "Quản trị AI",
"roles": "Phân quyền & Vai trò", "roles": "Phân quyền & Vai trò",
"aiAssistant": "Trợ lý tài chính AI" "aiAssistant": "Trợ lý tài chính AI"
}, },
"validation": { "validation": {
"emailRequired": "Email không được để trống", "emailRequired": "Email không được để trống",
"emailInvalid": "Email không hợp lệ", "emailInvalid": "Email không hợp lệ",
...@@ -1764,6 +1769,136 @@ ...@@ -1764,6 +1769,136 @@
"breakdownTitle": "Phân tích theo Nhóm", "breakdownTitle": "Phân tích theo Nhóm",
"detailedItemsTitle": "Danh sách Giao dịch Chi tiết" "detailedItemsTitle": "Danh sách Giao dịch Chi tiết"
}, },
"integrations": {
"header": "Tích hợp hệ thống",
"title": "API Key & Webhook Callback",
"subtitle": "Kết nối FinWise an toàn với hệ thống và quy trình tự động bên ngoài.",
"tabs": {
"apiKeys": "API Keys",
"webhooks": "Webhook Callback"
},
"status": {
"ACTIVE": "Đang hoạt động",
"DISABLED": "Đã tắt",
"REVOKED": "Đã thu hồi",
"EXPIRED": "Đã hết hạn",
"PENDING": "Đang chờ",
"SUCCESS": "Thành công",
"FAILED": "Thất bại"
},
"secret": {
"apiKeyTitle": "Lưu API Key mới",
"webhookTitle": "Lưu signing secret",
"apiKeyLabel": "API Key",
"webhookLabel": "Webhook signing secret",
"onceTitle": "Thông tin này chỉ hiển thị một lần",
"onceHint": "Sao chép và lưu trong secret manager an toàn. FinWise không thể hiển thị lại sau khi đóng cửa sổ này.",
"copy": "Sao chép",
"copied": "Đã sao chép",
"done": "Tôi đã lưu an toàn"
},
"apiKeys": {
"title": "API Keys",
"subtitle": "Cấp quyền có giới hạn cho ứng dụng và dịch vụ bên ngoài.",
"create": "Tạo API Key",
"securityHint": "Mỗi key chỉ nên có đúng quyền cần dùng. Hãy đặt ngày hết hạn, giới hạn IP khi có thể và thu hồi ngay khi không còn sử dụng.",
"permissions": "Phạm vi quyền",
"unscoped": "Toàn bộ quyền tài khoản (key legacy)",
"ipWhitelist": "IP được phép",
"anyIp": "Mọi địa chỉ IP",
"lastUsed": "Dùng gần nhất",
"never": "Chưa sử dụng",
"expires": "Hết hạn",
"noExpiry": "Không hết hạn",
"emptyTitle": "Chưa có API Key",
"emptyHint": "Tạo key đầu tiên để bắt đầu kết nối hệ thống bên ngoài.",
"loadFailed": "Không thể tải danh sách API Key.",
"createSuccess": "Đã tạo API Key. Hãy lưu key trước khi đóng cửa sổ.",
"createFailed": "Không thể tạo API Key.",
"revoke": "Thu hồi key",
"revokeTitle": "Thu hồi API Key?",
"revokeConfirm": "Key “{{name}}” sẽ ngừng hoạt động ngay lập tức và không thể khôi phục.",
"revokeSuccess": "Đã thu hồi API Key.",
"revokeFailed": "Không thể thu hồi API Key.",
"form": {
"title": "Tạo API Key mới",
"name": "Tên nhận biết",
"namePlaceholder": "Ví dụ: Đồng bộ kế toán",
"nameRequired": "Vui lòng nhập tên API Key.",
"nameMax": "Tên không được vượt quá 100 ký tự.",
"permissions": "Phạm vi quyền",
"permissionsHint": "Chỉ các quyền tài khoản của bạn đang có mới xuất hiện ở đây.",
"permissionsRequired": "Chọn ít nhất một quyền.",
"noScopes": "Tài khoản chưa có quyền phù hợp để cấp cho API Key.",
"ipWhitelist": "Danh sách IP cho phép (tùy chọn)",
"ipPlaceholder": "203.0.113.10\n198.51.100.20",
"ipHint": "Mỗi IP một dòng hoặc phân tách bằng dấu phẩy. Để trống nghĩa là cho phép mọi IP.",
"ipInvalid": "Danh sách chỉ được chứa địa chỉ IPv4 hoặc IPv6 hợp lệ.",
"expiresAt": "Thời điểm hết hạn (tùy chọn)"
},
"scopes": {
"walletRead": "Xem ví", "walletCreate": "Tạo ví", "walletUpdate": "Cập nhật ví", "walletDelete": "Lưu trữ ví",
"categoryRead": "Xem danh mục", "categoryCreate": "Tạo danh mục", "categoryUpdate": "Cập nhật danh mục", "categoryDelete": "Lưu trữ danh mục",
"transactionRead": "Xem giao dịch", "transactionCreate": "Tạo giao dịch", "transactionUpdate": "Cập nhật giao dịch", "transactionDelete": "Xóa giao dịch",
"reportRead": "Xem báo cáo", "jobRead": "Xem async job", "jobCreate": "Tạo async job"
}
},
"webhooks": {
"title": "Webhook Callback",
"subtitle": "Nhận sự kiện FinWise theo thời gian gần thực với chữ ký HMAC.",
"create": "Thêm webhook",
"emptyTitle": "Chưa có webhook",
"emptyHint": "Đăng ký endpoint công khai để nhận callback từ FinWise.",
"loadFailed": "Không thể tải danh sách webhook.",
"subscribedEvents": "Sự kiện đăng ký",
"updatedAt": "Cập nhật {{date}}",
"test": "Gửi thử",
"testSuccess": "Đã đưa ping thử nghiệm vào hàng đợi.",
"testFailed": "Không thể gửi ping thử nghiệm.",
"createSuccess": "Đã tạo webhook. Hãy lưu signing secret trước khi đóng cửa sổ.",
"createFailed": "Không thể tạo webhook.",
"updateSuccess": "Đã cập nhật webhook.",
"updateFailed": "Không thể cập nhật webhook.",
"deleteTitle": "Xóa webhook?",
"deleteConfirm": "Endpoint {{url}} sẽ ngừng nhận callback và bị xóa khỏi danh sách.",
"deleteSuccess": "Đã xóa webhook.",
"deleteFailed": "Không thể xóa webhook.",
"form": {
"createTitle": "Đăng ký webhook",
"editTitle": "Chỉnh sửa webhook",
"url": "Callback URL công khai",
"urlInvalid": "Nhập URL HTTP/HTTPS công khai hợp lệ; localhost không được hỗ trợ.",
"description": "Mô tả (tùy chọn)",
"descriptionPlaceholder": "Ví dụ: Endpoint production của hệ thống kế toán",
"descriptionMax": "Mô tả không được vượt quá 255 ký tự.",
"events": "Sự kiện muốn nhận",
"eventsRequired": "Chọn ít nhất một sự kiện.",
"active": "Kích hoạt endpoint",
"activeHint": "Endpoint đã tắt sẽ không nhận callback mới."
},
"events": {
"transactionCreated": "Giao dịch được tạo",
"budgetWarning": "Ngân sách sắp vượt",
"budgetExceeded": "Ngân sách đã vượt",
"savingGoalAchieved": "Đạt mục tiêu tiết kiệm",
"anomalyDetected": "Phát hiện giao dịch bất thường",
"jobCompleted": "Async job hoàn tất",
"jobFailed": "Async job thất bại"
},
"deliveries": {
"button": "Lịch sử gửi",
"title": "Lịch sử webhook delivery",
"createdAt": "Khởi tạo",
"attempts": "Số lần thử",
"empty": "Chưa có delivery",
"emptyHint": "Gửi ping thử nghiệm hoặc chờ một sự kiện được phát sinh.",
"loadFailed": "Không thể tải lịch sử delivery.",
"retry": "Phát lại ngay",
"retrySuccess": "Đã đưa delivery vào hàng đợi phát lại.",
"retryFailed": "Không thể phát lại delivery."
}
}
},
"admin": { "admin": {
"controlCenter": "Trung tâm quản trị", "controlCenter": "Trung tâm quản trị",
"manageUsersAndRoles": "Quản lý người dùng, vai trò, quyền hạn và hoạt động kiểm toán", "manageUsersAndRoles": "Quản lý người dùng, vai trò, quyền hạn và hoạt động kiểm toán",
...@@ -1782,9 +1917,193 @@ ...@@ -1782,9 +1917,193 @@
"actions": { "actions": {
"users": "Quản lý người dùng", "users": "Quản lý người dùng",
"roles": "Quản lý vai trò", "roles": "Quản lý vai trò",
"auditLogs": "Nhật ký kiểm toán" "auditLogs": "Nhật ký kiểm toán",
"settings": "Cấu hình hệ thống",
"notifications": "Quản lý thông báo",
"ai": "Quản trị AI",
"integrations": "API Key & Webhook Callback"
},
"settings": {
"header": "Cấu hình hệ thống",
"title": "Quản trị cấu hình & Bảo trì",
"subtitle": "Điều chỉnh thông số hoạt động, quản lý bộ nhớ đệm và kích hoạt chế độ bảo trì",
"maintenance": {
"title": "Chế độ bảo trì hệ thống",
"active": "ĐANG BẢO TRÌ",
"normal": "HOẠT ĐỘNG BÌNH THƯỜNG",
"description": "Khi kích hoạt, người dùng thông thường sẽ nhận phản hồi 503 với thông báo bên dưới. Admin vẫn truy cập bình thường.",
"messageLabel": "Thông điệp hiển thị cho người dùng:",
"messagePlaceholder": "Nhập thông báo bảo trì...",
"startAtLabel": "Thời gian bắt đầu:",
"endAtLabel": "Dự kiến hoàn tất:",
"quickSelect": "Chọn nhanh:",
"minutes": "{{count}} phút",
"hours": "{{count}} giờ",
"clearSchedule": "Xóa lịch",
"cancel": "Hủy bỏ",
"save": "Lưu trạng thái bảo trì",
"saving": "Đang lưu...",
"success": "Đã cập nhật cấu hình bảo trì hệ thống thành công",
"error": "Cập nhật chế độ bảo trì thất bại"
},
"list": {
"title": "Tham số cấu hình hệ thống",
"searchPlaceholder": "Tìm kiếm theo khóa hoặc mô tả...",
"empty": "Không tìm thấy tham số cấu hình phù hợp.",
"public": "PUBLIC",
"private": "INTERNAL",
"immutable": "Bất biến",
"edit": "Sửa",
"saveSuccess": "Cập nhật cấu hình thành công."
},
"categories": {
"ALL": "Tất cả",
"GENERAL": "Chung",
"SECURITY": "Bảo mật",
"NOTIFICATION": "Thông báo",
"AI": "Trí tuệ nhân tạo",
"SYSTEM": "Hệ thống"
},
"modal": {
"title": "Chỉnh sửa cấu hình",
"immutableDesc": "Tham số này là thông số hệ thống và không thể thay đổi giá trị.",
"valueLabel": "Giá trị mới ({{type}}):",
"cancel": "Hủy",
"save": "Lưu cấu hình",
"saving": "Đang lưu...",
"numberError": "Giá trị phải là một số hợp lệ",
"booleanError": "Giá trị phải là true hoặc false",
"jsonError": "Chuỗi JSON không hợp lệ"
}
},
"notifications": {
"header": "Quản lý thông báo",
"title": "Trung tâm quản trị Thông báo",
"subtitle": "Giám sát tình trạng gửi tin đa kênh, xử lý lỗi phân phối và quản lý mẫu song ngữ",
"tabs": {
"overview": "Tổng quan & Thống kê",
"deliveries": "Nhật ký gửi tin",
"templates": "Mẫu thông báo",
"channels": "Cấu hình kênh"
},
"overview": {
"total": "Tổng thông báo",
"sent": "Đã gửi thành công",
"pending": "Đang chờ xử lý",
"failed": "Gửi thất bại",
"performance": "Hiệu suất phân phối",
"successRate": "tỷ lệ gửi thành công",
"read": "Đã đọc: {{count}}",
"unread": "Chưa đọc: {{count}}",
"channelBreakdown": "Phân bổ theo kênh",
"channelItem": "{{sent}} thành công / {{failed}} lỗi",
"error": "Không thể tải dữ liệu thống kê."
},
"deliveries": {
"searchPlaceholder": "Tìm người nhận, tiêu đề, nội dung...",
"allChannels": "Tất cả kênh",
"allStatuses": "Tất cả trạng thái",
"empty": "Không tìm thấy bản ghi phân phối thông báo nào.",
"anonymousUser": "Người dùng ẩn danh",
"retry": "Thử lại gửi",
"retrying": "Đang thử...",
"retrySuccess": "Đã kích hoạt thử lại gửi thông báo.",
"retryError": "Thử lại gửi thông báo thất bại",
"attempts": "Đã thử: {{count}} lần",
"errorLabel": "Lỗi:"
},
"templates": {
"empty": "Chưa có mẫu thông báo nào trong hệ thống.",
"edit": "Sửa mẫu",
"variables": "Biến thay thế:",
"modalTitle": "Chỉnh sửa mẫu thông báo",
"titleLabel": "Mẫu tiêu đề (Title Template):",
"bodyLabel": "Mẫu nội dung (Body Template):",
"availableVars": "Biến khả dụng:",
"cancel": "Hủy",
"save": "Lưu mẫu",
"saving": "Đang lưu...",
"success": "Cập nhật mẫu thông báo thành công.",
"error": "Cập nhật mẫu thông báo thất bại"
},
"channels": {
"title": "Cấu hình kênh phát tán thông báo",
"subtitle": "Bật/tắt ngay các kênh truyền tải thông báo thời gian thực đến người dùng ứng dụng",
"syncing": "Đang đồng bộ...",
"inAppTitle": "Thông báo trong ứng dụng (IN_APP)",
"inAppDesc": "Gửi tin nhắn trực tiếp vào hộp thư thông báo trên app",
"emailTitle": "Email Transactional (EMAIL)",
"emailDesc": "Gửi email cảnh báo vượt hạn mức & sao kê tháng",
"zaloTitle": "Zalo Notification Service (ZNS)",
"zaloDesc": "Gửi tin nhắn qua Zalo Official Account",
"pushTitle": "Mobile Push Notifications (PUSH)",
"pushDesc": "Thông báo đẩy native qua FCM / Apple APNs",
"error": "Lưu cấu hình kênh thất bại"
}
},
"ai": {
"header": "Quản trị AI",
"title": "Trung tâm điều hành AI & Giới hạn tốc độ",
"subtitle": "Kiểm soát tính năng, theo dõi token tiêu thụ và cấu hình Sliding Window Rate Limit",
"tabs": {
"features": "Tính năng & Trạng thái",
"usage": "Tiêu thụ Token",
"logs": "Nhật ký truy vấn",
"rateLimit": "Cấu hình Rate Limit"
},
"features": {
"activeCount": "{{active}}/{{total}} tính năng đang hoạt động",
"enabled": "Hoạt động",
"disabled": "Tạm dừng",
"degraded": "Giảm tải",
"empty": "Không có dữ liệu tính năng AI.",
"modelLabel": "Model:",
"toggleSuccess": "Đã cập nhật trạng thái tính năng AI.",
"toggleError": "Không thể cập nhật trạng thái tính năng AI"
},
"usage": {
"periods": {
"today": "Hôm nay",
"week": "7 ngày qua",
"month": "30 ngày qua"
},
"totalTokens": "Tổng Token tiêu thụ",
"totalRequests": "Tổng số truy vấn",
"avgLatency": "Độ trễ trung bình",
"successRate": "Tỷ lệ thành công",
"promptTokens": "Prompt: {{count}}",
"completionTokens": "Completion: {{count}}",
"tokenBreakdown": "Phân bổ Token Prompt / Completion",
"latencyMetrics": "Chỉ số độ trễ phản hồi AI",
"avgLabel": "Trung bình:",
"minMaxLabel": "Thấp nhất / Cao nhất:",
"error": "Không thể tải dữ liệu sử dụng AI."
},
"logs": {
"searchPlaceholder": "Tìm theo tính năng, model, mã lỗi...",
"allFeatures": "Tất cả tính năng",
"allStatuses": "Tất cả trạng thái",
"empty": "Không tìm thấy nhật ký truy vấn AI nào.",
"anonymousUser": "Người dùng ẩn danh",
"tokens": "Tokens: {{count}}",
"latency": "Độ trễ: {{latency}}ms",
"errorLabel": "Lỗi:"
},
"rateLimit": {
"title": "Cấu hình AI Sliding Window Rate Limit",
"subtitle": "Bảo vệ hệ thống và giới hạn tần suất gọi API OpenAI / Gemini theo từng cửa sổ thời gian",
"maxRequestsLabel": "Số yêu cầu tối đa cho mỗi cửa sổ:",
"windowSecondsLabel": "Thời lượng cửa sổ trượt (giây):",
"summary": "Mỗi người dùng được thực hiện tối đa {{max}} yêu cầu AI trong mỗi {{window}} giây.",
"quickPresets": "Preset nhanh:",
"save": "Lưu giới hạn AI",
"saving": "Đang lưu...",
"success": "Đã lưu giới hạn AI thành công.",
"error": "Cập nhật giới hạn tốc độ AI thất bại"
}
}, },
"users": { "users": {
"title": "Quản lý người dùng", "title": "Quản lý người dùng",
"heading": "Người dùng", "heading": "Người dùng",
"description": "Tìm kiếm tài khoản, cập nhật trạng thái truy cập và quản lý vai trò.", "description": "Tìm kiếm tài khoản, cập nhật trạng thái truy cập và quản lý vai trò.",
......
/**
* url.ts — Browser-safe URL validation utility
*
* Phản ánh chính sách chặn SSRF của backend (url.helper.ts).
* Không dùng AppError, không phụ thuộc Node.js built-ins.
* Mọi hàm trả về kết quả dạng discriminated union thay vì throw,
* để component tự quyết định cách hiển thị lỗi.
*/
/** Kết quả trả về từ validatePublicUrl() */
export type UrlValidationResult =
| { valid: true; parsed: URL }
| { valid: false; reason: string; code: 'INVALID_URL' | 'PRIVATE_IP_BLOCKED' };
/**
* Patterns hostname bị chặn (mirrors url.helper.ts trên backend):
* - localhost — loopback name
* - 127.x.x.x — IPv4 loopback
* - 10.x.x.x — RFC-1918 class A
* - 172.16–31.x.x — RFC-1918 class B
* - 192.168.x.x — RFC-1918 class C
* - 169.254.x.x — IPv4 link-local (APIPA / AWS metadata endpoint)
* - 0.0.0.0 — unspecified
* - [::1] — IPv6 loopback (browser wraps IPv6 in brackets)
* - [fd…] — IPv6 ULA
* - [::ffff:…] — IPv4-mapped IPv6
*/
const PRIVATE_HOSTNAME_PATTERNS: RegExp[] = [
// IPv4 patterns
/^localhost$/i,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^169\.254\./,
/^0\.0\.0\.0$/,
// IPv6 patterns — browsers preserve brackets in URL.hostname for IPv6 literals
// e.g. new URL('http://[::1]/').hostname === '[::1]'
/^\[::1\]$/, // IPv6 loopback
/^\[fd[0-9a-f]{2}:/i, // IPv6 ULA fd::/8
/^\[::ffff:/i, // IPv4-mapped IPv6 (normalized to hex by URL parser)
];
const ALLOWED_SCHEMES: string[] = ['http:', 'https:'];
/**
* Kiểm tra xem một địa chỉ IP (IPv4 hoặc IPv6 dạng chuỗi) có thuộc dải private/reserved không.
*/
export function isPrivateIp(ip: string): boolean {
if (!ip) return false;
const cleanIp = ip.replace(/^\[|\]$/g, '').trim().toLowerCase();
// IPv4 Checks
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(cleanIp) || (cleanIp.startsWith('::ffff:') && cleanIp.includes('.'))) {
const ipv4 = cleanIp.startsWith('::ffff:') ? cleanIp.substring(7) : cleanIp;
const parts = ipv4.split('.').map((p) => parseInt(p, 10));
if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255)) return true;
const [a, b] = parts;
if (a === 0 || a === 10 || a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
if (a === 198 && (b === 18 || b === 19)) return true;
if (a >= 224) return true;
return false;
}
// IPv6 Checks
if (cleanIp.includes(':')) {
if (cleanIp === '::1' || cleanIp === '::') return true;
if (cleanIp.startsWith('fc') || cleanIp.startsWith('fd')) return true;
if (cleanIp.startsWith('fe80:') || cleanIp.startsWith('ff')) return true;
}
return false;
}
/**
* Kiểm tra hostname có thuộc private / local network không.
*/
export function isPrivateHostname(hostname: string): boolean {
if (PRIVATE_HOSTNAME_PATTERNS.some((pattern) => pattern.test(hostname))) {
return true;
}
return isPrivateIp(hostname);
}
/**
* Validate URL đầy đủ: parse → kiểm tra scheme → kiểm tra hostname.
*
* @returns `{ valid: true, parsed }` nếu URL an toàn để dùng
* @returns `{ valid: false, reason, code }` nếu không hợp lệ
*
* @example
* const result = validatePublicUrl('https://example.com');
* if (!result.valid) {
* toast.error(result.reason);
* return;
* }
* // result.parsed là URL đã parse an toàn
*/
export function validatePublicUrl(url: string): UrlValidationResult {
if (!url || typeof url !== 'string') {
return { valid: false, reason: 'URL không được để trống', code: 'INVALID_URL' };
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return { valid: false, reason: 'URL không đúng định dạng', code: 'INVALID_URL' };
}
if (!ALLOWED_SCHEMES.includes(parsed.protocol)) {
return {
valid: false,
reason: `Chỉ cho phép URL dạng http/https (nhận được "${parsed.protocol}")`,
code: 'INVALID_URL',
};
}
if (isPrivateHostname(parsed.hostname)) {
return {
valid: false,
reason: 'Không được phép trỏ đến địa chỉ nội bộ hoặc private',
code: 'PRIVATE_IP_BLOCKED',
};
}
return { valid: true, parsed };
}
/**
* Trích xuất hostname từ URL string.
* Trả về chuỗi rỗng nếu URL không parse được.
*/
export function extractDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return '';
}
}
import React, { useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate';
import {
AIAssistantIcon,
AlertTriangleIcon,
CheckIcon,
ClockIcon,
CloseIcon,
EditIcon,
IconGradients,
LockIcon,
RefreshIcon,
ServerIcon,
ShieldIcon,
} from '@/components/ui/icons';
import { PERMISSIONS } from '@/common/constants';
import { usePermission } from '@/hooks/use-permission';
import {
useAiFeatureStatuses,
useAiRateLimitConfig,
useAiRequestLogs,
useAiUsageSummary,
useToggleAiFeature,
useUpdateAiRateLimit,
} from '@/hooks/use-admin-ai';
import {
AiFeatureStatus,
AiFeatureStatusItem,
AiRequestStatus,
} from '@/services/admin-ai.service';
import { useI18n } from '@/i18n';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
type TabType = 'status' | 'usage' | 'logs' | 'rate-limit';
export const AdminAiPage: React.FC = () => {
const navigate = useNavigate();
const { t, formatNumber } = useI18n();
const { hasPermission } = usePermission();
const canUpdateConfig = hasPermission(PERMISSIONS.AI_CONFIG_UPDATE);
const canReadUsage = hasPermission(PERMISSIONS.AI_USAGE_READ);
const [activeTab, setActiveTab] = useState<TabType>('status');
const [usagePeriod, setUsagePeriod] = useState<'today' | 'week' | 'month'>('today');
// Filters for request logs
const [filterFeature, setFilterFeature] = useState<string>('');
const [filterStatus, setFilterStatus] = useState<AiRequestStatus | ''>('');
const [logPage, setLogPage] = useState(1);
// Rate limit editing
const [rateLimitMax, setRateLimitMax] = useState(20);
const [rateLimitWindowMinutes, setRateLimitWindowMinutes] = useState(15);
const [rateLimitDirty, setRateLimitDirty] = useState(false);
const statusQuery = useAiFeatureStatuses();
const usageQuery = useAiUsageSummary(usagePeriod);
const logsQuery = useAiRequestLogs({
feature: filterFeature || undefined,
status: filterStatus || undefined,
page: logPage,
limit: 15,
});
const rateLimitQuery = useAiRateLimitConfig();
const toggleFeatureMutation = useToggleAiFeature();
const updateRateLimitMutation = useUpdateAiRateLimit();
// Sync rate limit
React.useEffect(() => {
if (rateLimitQuery.data?.data) {
setRateLimitMax(rateLimitQuery.data.data.maxRequests);
setRateLimitWindowMinutes(Math.round(rateLimitQuery.data.data.windowMs / 60000));
}
}, [rateLimitQuery.data]);
const features = statusQuery.data?.data ?? [];
const usage = usageQuery.data?.data;
const logs = logsQuery.data?.data ?? [];
const logsMeta = logsQuery.data?.meta;
const handleToggleFeature = async (feature: AiFeatureStatusItem) => {
try {
await toggleFeatureMutation.mutateAsync({
featureKey: feature.key,
enabled: !feature.enabled,
});
} catch (err: any) {
alert(err?.response?.data?.message || t('admin.ai.features.toggleError') || 'Chuyển đổi trạng thái tính năng AI thất bại');
}
};
const handleSaveRateLimit = async () => {
try {
await updateRateLimitMutation.mutateAsync({
maxRequests: Number(rateLimitMax),
windowMs: Number(rateLimitWindowMinutes) * 60 * 1000,
});
setRateLimitDirty(false);
alert(t('admin.ai.rateLimit.success') || 'Đã cập nhật giới hạn AI Rate Limit thành công');
} catch (err: any) {
alert(err?.response?.data?.message || t('admin.ai.rateLimit.error') || 'Cập nhật giới hạn thất bại');
}
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Header title={t('admin.ai.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
{/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
<div className="rounded-clay bg-clay-primary p-3 text-clay-on-primary shadow-clay-raised">
<AIAssistantIcon size={28} />
</div>
<div>
<h1 className="clay-title-h2 text-clay-text">{t('admin.ai.title')}</h1>
<p className="text-sm text-clay-text-muted">{t('admin.ai.subtitle')}</p>
</div>
</div>
</Card>
{/* Navigation Tabs */}
<div className="flex flex-wrap gap-2 border-b border-clay-highlight/40 pb-2">
{[
{ key: 'status', label: t('admin.ai.tabs.features') },
...(canReadUsage ? [{ key: 'usage', label: t('admin.ai.tabs.usage') }] : []),
...(canReadUsage ? [{ key: 'logs', label: t('admin.ai.tabs.logs') }] : []),
{ key: 'rate-limit', label: t('admin.ai.tabs.rateLimit') },
].map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key as TabType)}
className={`rounded-clay px-4 py-2 text-xs font-bold transition-all ${
activeTab === tab.key
? 'bg-clay-primary text-clay-on-primary shadow-clay-raised'
: 'bg-clay-surface text-clay-text-muted hover:text-clay-text'
}`}
>
{tab.label}
</button>
))}
</div>
{/* TAB 1: FEATURE STATUS */}
{activeTab === 'status' && (
<section className="space-y-4">
{statusQuery.isLoading ? (
<AdminSkeleton rows={4} />
) : features.length === 0 ? (
<Card className="text-center py-8 text-clay-text-muted">
{t('admin.ai.features.empty')}
</Card>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{features.map((item) => (
<Card
key={item.key}
className={`flex flex-col justify-between space-y-4 border p-5 transition-all ${
item.status === 'ENABLED'
? 'border-clay-income/30 bg-clay-surface'
: item.status === 'DEGRADED'
? 'border-clay-warning/40 bg-clay-warning/5'
: 'border-clay-border/60 bg-clay-bg/60 opacity-80'
}`}
>
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-baloo text-base font-bold text-clay-text">{item.name}</h3>
<span className="font-mono text-[11px] text-clay-primary">{item.settingKey}</span>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-[11px] font-bold ${
item.status === 'ENABLED'
? 'bg-clay-income/20 text-clay-income'
: item.status === 'DEGRADED'
? 'bg-clay-warning/20 text-clay-warning'
: 'bg-clay-expense/20 text-clay-expense'
}`}
>
{item.status}
</span>
</div>
<p className="text-xs text-clay-text-muted leading-relaxed">{item.description}</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-clay-highlight/30 pt-3">
<div className="text-[11px] text-clay-text-muted">
Provider: <strong className="text-clay-text uppercase">{item.provider}</strong>
{item.model && (
<span>{t('admin.ai.features.modelLabel')} <strong className="text-clay-text font-mono">{item.model}</strong></span>
)}
</div>
{canUpdateConfig && (
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
disabled={toggleFeatureMutation.isPending}
className="peer sr-only"
checked={item.enabled}
onChange={() => handleToggleFeature(item)}
/>
<div className="peer h-6 w-11 rounded-full bg-clay-border after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:bg-clay-income peer-checked:after:translate-x-full shadow-inner" />
</label>
)}
</div>
</Card>
))}
</div>
)}
</section>
)}
{/* TAB 2: USAGE & TOKENS */}
{activeTab === 'usage' && (
<section className="space-y-5">
{/* Period Selector */}
<div className="flex items-center justify-between">
<h2 className="clay-title-h3 text-clay-text">{t('admin.ai.usage.tokenBreakdown')}</h2>
<div className="flex rounded-clay border border-clay-border bg-clay-surface p-0.5">
{[
{ key: 'today', label: t('admin.ai.usage.periods.today') },
{ key: 'week', label: t('admin.ai.usage.periods.week') },
{ key: 'month', label: t('admin.ai.usage.periods.month') },
].map((p) => (
<button
key={p.key}
type="button"
onClick={() => setUsagePeriod(p.key as any)}
className={`rounded px-3 py-1 text-xs font-semibold transition-all ${
usagePeriod === p.key
? 'bg-clay-primary text-clay-on-primary shadow-clay-raised'
: 'text-clay-text-muted hover:text-clay-text'
}`}
>
{p.label}
</button>
))}
</div>
</div>
{usageQuery.isLoading ? (
<AdminSkeleton rows={3} />
) : !usage ? (
<Card className="text-center py-8 text-clay-expense">{t('admin.ai.usage.error')}</Card>
) : !usage.isUsageAvailable ? (
<Card className="text-center py-10 space-y-2">
<ClockIcon size={32} className="mx-auto text-clay-text-muted" />
<h3 className="font-baloo text-base font-bold text-clay-text">{t('admin.ai.logs.empty')}</h3>
<p className="text-xs text-clay-text-muted max-w-md mx-auto">
{t('admin.ai.usage.tokenBreakdown')}
</p>
</Card>
) : (
<>
{/* Metric KPIs */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card className="p-4 text-center">
<p className="font-baloo text-3xl font-bold text-clay-primary">
{formatNumber(usage.totalRequests)}
</p>
<p className="mt-1 text-xs font-semibold text-clay-text-muted">{t('admin.ai.usage.totalRequests')}</p>
</Card>
<Card className="p-4 text-center">
<p className="font-baloo text-3xl font-bold text-clay-income">
{usage.successRate}%
</p>
<p className="mt-1 text-xs font-semibold text-clay-text-muted">{t('admin.ai.usage.successRate')}</p>
</Card>
<Card className="p-4 text-center">
<p className="font-baloo text-3xl font-bold text-clay-info">
{usage.avgLatencyMs} ms
</p>
<p className="mt-1 text-xs font-semibold text-clay-text-muted">{t('admin.ai.usage.avgLatency')}</p>
</Card>
<Card className="p-4 text-center">
<p className="font-baloo text-3xl font-bold text-clay-warning">
{formatNumber(usage.totalTokens)}
</p>
<p className="mt-1 text-xs font-semibold text-clay-text-muted">{t('admin.ai.usage.totalTokens')}</p>
</Card>
</div>
{/* Token Detailed Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Card className="p-5 space-y-3">
<h3 className="clay-title-h3 text-clay-text">{t('admin.ai.usage.tokenBreakdown')}</h3>
<div className="space-y-3">
<div className="flex justify-between items-center text-xs">
<span className="text-clay-text-muted">{t('admin.ai.usage.promptTokens', { count: '' })}:</span>
<strong className="font-mono text-clay-text">{formatNumber(usage.promptTokens)}</strong>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-clay-text-muted">{t('admin.ai.usage.completionTokens', { count: '' })}:</span>
<strong className="font-mono text-clay-text">{formatNumber(usage.completionTokens)}</strong>
</div>
<div className="flex justify-between items-center text-xs pt-2 border-t border-clay-highlight/30">
<span className="font-semibold text-clay-text">{t('admin.ai.usage.totalTokens')}:</span>
<strong className="font-mono text-clay-primary text-sm font-bold">
{formatNumber(usage.totalTokens)}
</strong>
</div>
</div>
</Card>
<Card className="p-5 space-y-3">
<h3 className="clay-title-h3 text-clay-text">{t('admin.ai.usage.latencyMetrics')}</h3>
<div className="space-y-3">
<div className="flex justify-between items-center text-xs">
<span className="text-clay-text-muted">{t('admin.ai.usage.avgLabel')}</span>
<strong className="text-clay-income font-bold">{usage.avgLatencyMs} ms</strong>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-clay-text-muted">Requests:</span>
<strong className="text-clay-text font-bold">{usage.successRequests} ok / {usage.failedRequests} fail</strong>
</div>
<div className="flex justify-between items-center text-xs pt-2 border-t border-clay-highlight/30">
<span className="text-clay-text-muted">Rate limit events:</span>
<strong className="text-clay-warning font-bold">{usage.rateLimitEvents}</strong>
</div>
</div>
</Card>
</div>
</>
)}
</section>
)}
{/* TAB 3: LOGS */}
{activeTab === 'logs' && (
<section className="space-y-4">
<Card className="flex flex-wrap items-center gap-3 p-4">
<select
value={filterFeature}
onChange={(e) => setFilterFeature(e.target.value)}
className="rounded-clay border border-clay-border bg-clay-surface px-3 py-1.5 text-xs text-clay-text focus:outline-none"
>
<option value="">{t('admin.ai.logs.allFeatures')}</option>
<option value="CHAT">CHAT</option>
<option value="CATEGORIZE">CATEGORIZE</option>
<option value="RECEIPT">RECEIPT</option>
<option value="INSIGHTS">INSIGHTS</option>
<option value="RECOMMENDATIONS">RECOMMENDATIONS</option>
<option value="RATE_LIMIT">RATE_LIMIT</option>
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as any)}
className="rounded-clay border border-clay-border bg-clay-surface px-3 py-1.5 text-xs text-clay-text focus:outline-none"
>
<option value="">{t('admin.ai.logs.allStatuses')}</option>
<option value="SUCCESS">SUCCESS</option>
<option value="FAILED">FAILED</option>
<option value="RATE_LIMITED">RATE_LIMITED</option>
</select>
<Button
variant="ghost"
className="px-2.5 py-1.5 text-xs"
onClick={() => logsQuery.refetch()}
disabled={logsQuery.isFetching}
>
<RefreshIcon size={16} />
</Button>
</Card>
{logsQuery.isLoading ? (
<AdminSkeleton rows={5} />
) : logs.length === 0 ? (
<Card className="text-center py-8 text-clay-text-muted">
{t('admin.ai.logs.empty')}
</Card>
) : (
<div className="grid gap-3">
{logs.map((log) => (
<Card key={log.id} className="p-4 space-y-2 border border-clay-highlight/40">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="rounded bg-clay-primary/10 px-2 py-0.5 text-xs font-bold text-clay-primary">
{log.feature}
</span>
<span
className={`rounded px-2 py-0.5 text-xs font-bold ${
log.status === 'SUCCESS'
? 'bg-clay-income/20 text-clay-income'
: log.status === 'RATE_LIMITED'
? 'bg-clay-warning/20 text-clay-warning'
: 'bg-clay-expense/20 text-clay-expense'
}`}
>
{log.status}
</span>
<span className="font-mono text-xs text-clay-text-muted">{log.model}</span>
</div>
<span className="text-[11px] text-clay-text-muted">
{new Date(log.createdAt).toLocaleString()}
</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-clay-text-muted">
<div>
{t('admin.ai.logs.latency', { latency: log.latencyMs })}
{log.totalTokens !== null && (
<span>{t('admin.ai.logs.tokens', { count: log.totalTokens })} ({log.promptTokens}+{log.completionTokens})</span>
)}
</div>
{log.userId && (
<span className="font-mono text-[11px] text-clay-text-muted">User: {log.userId.slice(0, 8)}...</span>
)}
</div>
{log.errorMessage && (
<div className="rounded bg-clay-expense/10 p-2 text-xs text-clay-expense font-mono">
{log.errorMessage}
</div>
)}
</Card>
))}
</div>
)}
</section>
)}
{/* TAB 4: RATE LIMIT */}
{activeTab === 'rate-limit' && (
<section className="space-y-4">
<Card className="space-y-5 p-6">
<div>
<h3 className="clay-title-h3 text-clay-text">{t('admin.ai.rateLimit.title')}</h3>
<p className="text-xs text-clay-text-muted mt-1">
{t('admin.ai.rateLimit.subtitle')}
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold text-clay-text mb-1">
{t('admin.ai.rateLimit.maxRequestsLabel')}
</label>
<input
type="number"
disabled={!canUpdateConfig}
className="w-full sm:w-64 rounded-clay border border-clay-border bg-clay-surface p-2 text-sm text-clay-text focus:outline-none focus:ring-2 focus:ring-clay-primary/50 shadow-inner"
value={rateLimitMax}
onChange={(e) => {
setRateLimitMax(Number(e.target.value));
setRateLimitDirty(true);
}}
/>
</div>
<div>
<label className="block text-xs font-semibold text-clay-text mb-1">
{t('admin.ai.rateLimit.windowSecondsLabel')}
</label>
<input
type="number"
disabled={!canUpdateConfig}
className="w-full sm:w-64 rounded-clay border border-clay-border bg-clay-surface p-2 text-sm text-clay-text focus:outline-none focus:ring-2 focus:ring-clay-primary/50 shadow-inner"
value={rateLimitWindowMinutes}
onChange={(e) => {
setRateLimitWindowMinutes(Number(e.target.value));
setRateLimitDirty(true);
}}
/>
</div>
{/* Presets */}
{canUpdateConfig && (
<div>
<label className="block text-[11px] font-semibold text-clay-text-muted mb-1.5">
{t('admin.ai.rateLimit.quickPresets')}
</label>
<div className="flex flex-wrap gap-2">
{[
{ label: '20 req / 15m', max: 20, win: 15 },
{ label: '50 req / 15m', max: 50, win: 15 },
{ label: '100 req / 60m', max: 100, win: 60 },
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => {
setRateLimitMax(preset.max);
setRateLimitWindowMinutes(preset.win);
setRateLimitDirty(true);
}}
className="rounded bg-clay-bg px-2.5 py-1 text-xs text-clay-text border border-clay-border hover:bg-clay-surface"
>
{preset.label}
</button>
))}
</div>
</div>
)}
</div>
{canUpdateConfig && rateLimitDirty && (
<div className="flex justify-end pt-4 border-t border-clay-highlight/30">
<Button
variant="primary"
onClick={handleSaveRateLimit}
disabled={updateRateLimitMutation.isPending}
>
{updateRateLimitMutation.isPending ? t('admin.ai.rateLimit.saving') : t('admin.ai.rateLimit.save')}
</Button>
</div>
)}
</Card>
</section>
)}
</main>
</Page>
);
};
export default AdminAiPage;
...@@ -4,10 +4,13 @@ import { Card } from '@/components/ui/Card'; ...@@ -4,10 +4,13 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate'; import { PermissionGate } from '@/components/shared/PermissionGate';
import { import {
AIAssistantIcon,
ChevronRightIcon, ChevronRightIcon,
ClockIcon, ClockIcon,
IconGradients, IconGradients,
KeyIcon, KeyIcon,
NotificationIcon,
SettingsIcon,
ShieldIcon, ShieldIcon,
UserIcon, UserIcon,
} from '@/components/ui/icons'; } from '@/components/ui/icons';
...@@ -103,9 +106,51 @@ const AdminDashboardPage: React.FC = () => { ...@@ -103,9 +106,51 @@ const AdminDashboardPage: React.FC = () => {
<ChevronRightIcon /> <ChevronRightIcon />
</button> </button>
</PermissionGate> </PermissionGate>
<PermissionGate permission={PERMISSIONS.SYSTEM_CONFIG_READ}>
<button
type="button"
onClick={() => navigate('/admin/settings')}
className="flex w-full items-center justify-between rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-5 text-left shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed"
>
<span className="flex items-center gap-3"><SettingsIcon /><span className="font-baloo font-bold text-clay-text">{t('admin.actions.settings')}</span></span>
<ChevronRightIcon />
</button>
</PermissionGate>
<PermissionGate permission={PERMISSIONS.NOTIFICATION_ADMIN_READ}>
<button
type="button"
onClick={() => navigate('/admin/notifications')}
className="flex w-full items-center justify-between rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-5 text-left shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed"
>
<span className="flex items-center gap-3"><NotificationIcon /><span className="font-baloo font-bold text-clay-text">{t('admin.actions.notifications')}</span></span>
<ChevronRightIcon />
</button>
</PermissionGate>
<PermissionGate permission={PERMISSIONS.AI_ADMIN_READ}>
<button
type="button"
onClick={() => navigate('/admin/ai')}
className="flex w-full items-center justify-between rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-5 text-left shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed"
>
<span className="flex items-center gap-3"><AIAssistantIcon /><span className="font-baloo font-bold text-clay-text">{t('admin.actions.ai')}</span></span>
<ChevronRightIcon />
</button>
</PermissionGate>
<PermissionGate permissions={[PERMISSIONS.API_KEY_READ, PERMISSIONS.WEBHOOK_READ]} mode="any">
<button
type="button"
onClick={() => navigate('/admin/integrations')}
className="flex w-full items-center justify-between rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-5 text-left shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed"
>
<span className="flex items-center gap-3"><KeyIcon /><span className="font-baloo font-bold text-clay-text">{t('admin.actions.integrations')}</span></span>
<ChevronRightIcon />
</button>
</PermissionGate>
</div> </div>
</section> </section>
<PermissionGate permission={PERMISSIONS.AUDIT_LOG_READ}> <PermissionGate permission={PERMISSIONS.AUDIT_LOG_READ}>
<section aria-labelledby="admin-activity-heading"> <section aria-labelledby="admin-activity-heading">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
......
import React, { useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate';
import {
AlertTriangleIcon,
CheckIcon,
CloseIcon,
EditIcon,
IconGradients,
NotificationIcon,
RefreshIcon,
SendIcon,
} from '@/components/ui/icons';
import { PERMISSIONS } from '@/common/constants';
import { usePermission } from '@/hooks/use-permission';
import {
useNotificationChannels,
useNotificationDeliveries,
useNotificationOverview,
useNotificationTemplates,
useRetryNotificationDelivery,
useUpdateNotificationChannels,
useUpdateNotificationTemplate,
} from '@/hooks/use-admin-notifications';
import {
AdminDeliveryItem,
AdminTemplateItem,
NotificationChannel,
NotificationDeliveryStatus,
} from '@/services/admin-notification.service';
import { useI18n } from '@/i18n';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
function extractTemplateVariables(item?: { titleTemplate?: string; bodyTemplate?: string; variables?: string[] } | null): string[] {
if (!item) return [];
if (Array.isArray(item.variables) && item.variables.length > 0) {
return item.variables;
}
const text = `${item.titleTemplate || ''} ${item.bodyTemplate || ''}`;
const matches = text.match(/\{\{([a-zA-Z0-9_]+)\}\}/g);
if (!matches) return [];
return Array.from(new Set(matches.map((m) => m.replace(/[{}]/g, ''))));
}
type TabType = 'overview' | 'deliveries' | 'templates' | 'channels';
export const AdminNotificationsPage: React.FC = () => {
const navigate = useNavigate();
const { t, formatNumber } = useI18n();
const { hasPermission } = usePermission();
const canRetry = hasPermission(PERMISSIONS.NOTIFICATION_RETRY);
const canUpdateTemplate = hasPermission(PERMISSIONS.NOTIFICATION_TEMPLATE_UPDATE);
const canUpdateConfig = hasPermission(PERMISSIONS.NOTIFICATION_CONFIG_UPDATE);
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Filters for deliveries
const [filterChannel, setFilterChannel] = useState<NotificationChannel | ''>('');
const [filterStatus, setFilterStatus] = useState<NotificationDeliveryStatus | ''>('');
const [searchDelivery, setSearchDelivery] = useState('');
const [deliveryPage, setDeliveryPage] = useState(1);
// Template editing
const [selectedTemplate, setSelectedTemplate] = useState<AdminTemplateItem | null>(null);
const [editTitleTemplate, setEditTitleTemplate] = useState('');
const [editBodyTemplate, setEditBodyTemplate] = useState('');
const [editTemplateActive, setEditTemplateActive] = useState(true);
// Channel toggles
const [channelConfigState, setChannelConfigState] = useState({
inAppEnabled: true,
emailEnabled: true,
zaloEnabled: false,
pushEnabled: false,
});
const overviewQuery = useNotificationOverview();
const deliveriesQuery = useNotificationDeliveries({
channel: filterChannel || undefined,
status: filterStatus || undefined,
search: searchDelivery.trim() || undefined,
page: deliveryPage,
limit: 15,
});
const templatesQuery = useNotificationTemplates();
const channelsQuery = useNotificationChannels();
const retryMutation = useRetryNotificationDelivery();
const updateTemplateMutation = useUpdateNotificationTemplate();
const updateChannelsMutation = useUpdateNotificationChannels();
// Sync channels query state
React.useEffect(() => {
if (channelsQuery.data?.data) {
setChannelConfigState(channelsQuery.data.data);
}
}, [channelsQuery.data]);
const overview = overviewQuery.data?.data;
const deliveries = deliveriesQuery.data?.data ?? [];
const deliveriesMeta = deliveriesQuery.data?.meta;
const templates = templatesQuery.data?.data ?? [];
const handleRetry = async (deliveryId: string) => {
try {
await retryMutation.mutateAsync(deliveryId);
} catch (err: any) {
alert(err?.response?.data?.message || t('admin.notifications.deliveries.retryError') || 'Thử lại gửi thông báo thất bại');
}
};
const handleOpenEditTemplate = (tmpl: AdminTemplateItem) => {
setSelectedTemplate(tmpl);
setEditTitleTemplate(tmpl.titleTemplate);
setEditBodyTemplate(tmpl.bodyTemplate);
setEditTemplateActive(tmpl.isActive);
};
const handleSaveTemplate = async () => {
if (!selectedTemplate) return;
try {
await updateTemplateMutation.mutateAsync({
id: selectedTemplate.id,
data: {
titleTemplate: editTitleTemplate,
bodyTemplate: editBodyTemplate,
isActive: editTemplateActive,
},
});
setSelectedTemplate(null);
} catch (err: any) {
alert(err?.response?.data?.message || t('admin.notifications.templates.error') || 'Cập nhật mẫu thông báo thất bại');
}
};
const handleToggleChannel = async (key: 'inAppEnabled' | 'emailEnabled' | 'zaloEnabled' | 'pushEnabled', value: boolean) => {
setChannelConfigState((prev) => ({ ...prev, [key]: value }));
try {
await updateChannelsMutation.mutateAsync({ [key]: value });
} catch (err: any) {
if (channelsQuery.data?.data) {
setChannelConfigState(channelsQuery.data.data);
}
alert(err?.response?.data?.message || t('admin.notifications.channels.error') || 'Lưu cấu hình kênh thất bại');
}
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Header title={t('admin.notifications.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
{/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
<div className="rounded-clay bg-clay-primary p-3 text-clay-on-primary shadow-clay-raised">
<NotificationIcon size={28} />
</div>
<div>
<h1 className="clay-title-h2 text-clay-text">{t('admin.notifications.title')}</h1>
<p className="text-sm text-clay-text-muted">{t('admin.notifications.subtitle')}</p>
</div>
</div>
</Card>
{/* Navigation Tabs */}
<div className="flex flex-wrap gap-2 border-b border-clay-highlight/40 pb-2">
{[
{ key: 'overview', label: t('admin.notifications.tabs.overview') },
{ key: 'deliveries', label: t('admin.notifications.tabs.deliveries') },
{ key: 'templates', label: t('admin.notifications.tabs.templates') },
{ key: 'channels', label: t('admin.notifications.tabs.channels') },
].map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key as TabType)}
className={`rounded-clay px-4 py-2 text-xs font-bold transition-all ${
activeTab === tab.key
? 'bg-clay-primary text-clay-on-primary shadow-clay-raised'
: 'bg-clay-surface text-clay-text-muted hover:text-clay-text'
}`}
>
{tab.label}
</button>
))}
</div>
{/* TAB 1: OVERVIEW */}
{activeTab === 'overview' && (
<section className="space-y-6">
{overviewQuery.isLoading ? (
<AdminSkeleton rows={3} />
) : !overview ? (
<Card className="text-center py-8 text-clay-expense">{t('admin.notifications.overview.error')}</Card>
) : (
<>
{/* Metric KPIs */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card className="p-4 text-center">
<p className="font-baloo text-3xl font-bold text-clay-primary">
{formatNumber(overview.totalNotifications || 0)}
</p>
<p className="mt-1 text-xs font-semibold text-clay-text-muted">{t('admin.notifications.overview.total')}</p>
</Card>
<Card className="p-4 text-center">
<p className="font-baloo text-3xl font-bold text-clay-income">
{formatNumber(overview.totalSent || 0)}
</p>
<p className="mt-1 text-xs font-semibold text-clay-text-muted">{t('admin.notifications.overview.sent')}</p>
</Card>
<Card className="p-4 text-center">
<p className="font-baloo text-3xl font-bold text-clay-warning">
{formatNumber(overview.totalPending || 0)}
</p>
<p className="mt-1 text-xs font-semibold text-clay-text-muted">{t('admin.notifications.overview.pending')}</p>
</Card>
<Card className="p-4 text-center">
<p className="font-baloo text-3xl font-bold text-clay-expense">
{formatNumber(overview.totalFailed || 0)}
</p>
<p className="mt-1 text-xs font-semibold text-clay-text-muted">{t('admin.notifications.overview.failed')}</p>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Delivery Success Rate Card */}
<Card className="space-y-3 p-5">
<h3 className="clay-title-h3 text-clay-text">{t('admin.notifications.overview.performance')}</h3>
<div className="flex items-baseline gap-2">
<span className="font-baloo text-4xl font-bold text-clay-income">
{overview.deliverySuccessRate || 0}%
</span>
<span className="text-xs text-clay-text-muted">{t('admin.notifications.overview.successRate')}</span>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-clay-border shadow-inner">
<div
className="h-full bg-clay-income transition-all"
style={{ width: `${Math.min(100, overview.deliverySuccessRate || 0)}%` }}
/>
</div>
<div className="flex justify-between text-xs text-clay-text-muted pt-1">
<span>{t('admin.notifications.overview.read', { count: formatNumber(overview.totalRead || 0) })}</span>
<span>{t('admin.notifications.overview.unread', { count: formatNumber(overview.totalUnread || 0) })}</span>
</div>
</Card>
{/* Channel Breakdown */}
<Card className="space-y-3 p-5">
<h3 className="clay-title-h3 text-clay-text">{t('admin.notifications.overview.channelBreakdown')}</h3>
<div className="space-y-2.5">
{(overview.channelBreakdown || []).map((ch) => (
<div key={ch.channel} className="space-y-1">
<div className="flex justify-between text-xs font-semibold">
<span className="text-clay-text">{ch.channel}</span>
<span className="text-clay-text-muted">
{t('admin.notifications.overview.channelItem', { sent: ch.sent, failed: ch.failed })}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-clay-border shadow-inner">
<div
className="h-full bg-clay-primary"
style={{
width: `${ch.total > 0 ? (ch.sent / ch.total) * 100 : 0}%`,
}}
/>
</div>
</div>
))}
</div>
</Card>
</div>
</>
)}
</section>
)}
{/* TAB 2: DELIVERIES */}
{activeTab === 'deliveries' && (
<section className="space-y-4">
{/* Filter Bar */}
<Card className="flex flex-wrap items-center gap-3 p-4">
<input
type="text"
value={searchDelivery}
onChange={(e) => setSearchDelivery(e.target.value)}
placeholder={t('admin.notifications.deliveries.searchPlaceholder')}
className="w-full sm:w-64 rounded-clay border border-clay-border bg-clay-surface px-3 py-1.5 text-xs text-clay-text focus:outline-none focus:ring-2 focus:ring-clay-primary/50 shadow-inner"
/>
<select
value={filterChannel}
onChange={(e) => setFilterChannel(e.target.value as any)}
className="rounded-clay border border-clay-border bg-clay-surface px-3 py-1.5 text-xs text-clay-text focus:outline-none"
>
<option value="">{t('admin.notifications.deliveries.allChannels')}</option>
<option value="IN_APP">IN_APP</option>
<option value="EMAIL">EMAIL</option>
<option value="PUSH">PUSH</option>
<option value="ZALO">ZALO</option>
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as any)}
className="rounded-clay border border-clay-border bg-clay-surface px-3 py-1.5 text-xs text-clay-text focus:outline-none"
>
<option value="">{t('admin.notifications.deliveries.allStatuses')}</option>
<option value="PENDING">PENDING</option>
<option value="SENT">SENT</option>
<option value="FAILED">FAILED</option>
<option value="SKIPPED">SKIPPED</option>
</select>
<Button
variant="ghost"
className="px-2.5 py-1.5 text-xs"
onClick={() => deliveriesQuery.refetch()}
disabled={deliveriesQuery.isFetching}
>
<RefreshIcon size={16} />
</Button>
</Card>
{/* Deliveries List */}
{deliveriesQuery.isLoading ? (
<AdminSkeleton rows={5} />
) : deliveries.length === 0 ? (
<Card className="text-center py-8 text-clay-text-muted">
{t('admin.notifications.deliveries.empty')}
</Card>
) : (
<div className="grid gap-3">
{deliveries.map((item) => (
<Card key={item.id} className="p-4 space-y-2 border border-clay-highlight/40">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="rounded bg-clay-primary/10 px-2 py-0.5 text-xs font-bold text-clay-primary">
{item.channel}
</span>
<span
className={`rounded px-2 py-0.5 text-xs font-bold ${
item.status === 'SENT'
? 'bg-clay-income/20 text-clay-income'
: item.status === 'FAILED'
? 'bg-clay-expense/20 text-clay-expense'
: 'bg-clay-warning/20 text-clay-warning'
}`}
>
{item.status}
</span>
<span className="text-xs font-semibold text-clay-text">
{item.user?.fullName || item.user?.email || t('admin.notifications.deliveries.anonymousUser')}
</span>
</div>
<span className="text-[11px] text-clay-text-muted">
{item.createdAt ? new Date(item.createdAt).toLocaleString() : ''}
</span>
</div>
<div>
<p className="text-xs font-bold text-clay-text">{item.notification?.title}</p>
<p className="text-xs text-clay-text-muted line-clamp-2">{item.notification?.message}</p>
</div>
{item.failureReason && (
<div className="rounded bg-clay-expense/10 p-2 text-xs text-clay-expense">
<strong>{t('admin.notifications.deliveries.errorLabel')}</strong> {item.failureReason} ({t('admin.notifications.deliveries.attempts', { count: item.attemptCount })})
</div>
)}
{canRetry && item.status === 'FAILED' && (
<div className="flex justify-end pt-1">
<Button
variant="secondary"
className="px-3 py-1 text-xs"
onClick={() => handleRetry(item.id)}
disabled={retryMutation.isPending}
>
<RefreshIcon size={14} /> {retryMutation.isPending ? t('admin.notifications.deliveries.retrying') : t('admin.notifications.deliveries.retry')}
</Button>
</div>
)}
</Card>
))}
</div>
)}
</section>
)}
{/* TAB 3: TEMPLATES */}
{activeTab === 'templates' && (
<section className="space-y-4">
{templatesQuery.isLoading ? (
<AdminSkeleton rows={4} />
) : templates.length === 0 ? (
<Card className="text-center py-8 text-clay-text-muted">
{t('admin.notifications.templates.empty')}
</Card>
) : (
<div className="grid gap-3">
{templates.map((tmpl) => {
const tmplVars = extractTemplateVariables(tmpl);
return (
<Card key={tmpl.id} className="p-4 space-y-2 border border-clay-highlight/40">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="rounded bg-clay-primary/10 px-2 py-0.5 text-xs font-bold text-clay-primary">
{tmpl.type}
</span>
<span className="rounded bg-clay-surface px-2 py-0.5 text-xs font-semibold text-clay-text border border-clay-border">
{tmpl.channel}
</span>
<span className="rounded bg-clay-info/10 px-2 py-0.5 text-xs font-bold text-clay-info uppercase">
{tmpl.language}
</span>
</div>
{canUpdateTemplate && (
<Button
variant="ghost"
className="px-2.5 py-1 text-xs"
onClick={() => handleOpenEditTemplate(tmpl)}
>
<EditIcon size={14} /> {t('admin.notifications.templates.edit')}
</Button>
)}
</div>
<div className="space-y-1">
<p className="text-xs font-bold text-clay-text">{tmpl.titleTemplate}</p>
<p className="text-xs text-clay-text-muted whitespace-pre-line">{tmpl.bodyTemplate}</p>
</div>
{tmplVars.length > 0 && (
<div className="flex flex-wrap items-center gap-1 pt-1">
<span className="text-[11px] text-clay-text-muted">{t('admin.notifications.templates.variables')}</span>
{tmplVars.map((v) => (
<span key={v} className="rounded bg-clay-bg px-1.5 py-0.5 font-mono text-[10px] text-clay-primary shadow-inner">
{`{{${v}}}`}
</span>
))}
</div>
)}
</Card>
);
})}
</div>
)}
</section>
)}
{/* TAB 4: CHANNELS */}
{activeTab === 'channels' && (
<section className="space-y-4">
<Card className="space-y-5 p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="clay-title-h3 text-clay-text">{t('admin.notifications.channels.title')}</h3>
<p className="text-xs text-clay-text-muted mt-1">
{t('admin.notifications.channels.subtitle')}
</p>
</div>
{updateChannelsMutation.isPending && (
<span className="text-xs font-semibold text-clay-primary animate-pulse">
{t('admin.notifications.channels.syncing')}
</span>
)}
</div>
<div className="space-y-4 divide-y divide-clay-highlight/30">
<div className="flex items-center justify-between pt-3">
<div>
<p className="text-sm font-bold text-clay-text">{t('admin.notifications.channels.inAppTitle')}</p>
<p className="text-xs text-clay-text-muted">{t('admin.notifications.channels.inAppDesc')}</p>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
disabled={!canUpdateConfig || updateChannelsMutation.isPending}
className="peer sr-only"
checked={channelConfigState.inAppEnabled}
onChange={(e) => handleToggleChannel('inAppEnabled', e.target.checked)}
/>
<div className="peer h-6 w-11 rounded-full bg-clay-border after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:bg-clay-primary peer-checked:after:translate-x-full shadow-inner" />
</label>
</div>
<div className="flex items-center justify-between pt-3">
<div>
<p className="text-sm font-bold text-clay-text">{t('admin.notifications.channels.emailTitle')}</p>
<p className="text-xs text-clay-text-muted">{t('admin.notifications.channels.emailDesc')}</p>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
disabled={!canUpdateConfig || updateChannelsMutation.isPending}
className="peer sr-only"
checked={channelConfigState.emailEnabled}
onChange={(e) => handleToggleChannel('emailEnabled', e.target.checked)}
/>
<div className="peer h-6 w-11 rounded-full bg-clay-border after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:bg-clay-primary peer-checked:after:translate-x-full shadow-inner" />
</label>
</div>
<div className="flex items-center justify-between pt-3">
<div>
<p className="text-sm font-bold text-clay-text">{t('admin.notifications.channels.zaloTitle')}</p>
<p className="text-xs text-clay-text-muted">{t('admin.notifications.channels.zaloDesc')}</p>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
disabled={!canUpdateConfig || updateChannelsMutation.isPending}
className="peer sr-only"
checked={channelConfigState.zaloEnabled}
onChange={(e) => handleToggleChannel('zaloEnabled', e.target.checked)}
/>
<div className="peer h-6 w-11 rounded-full bg-clay-border after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:bg-clay-primary peer-checked:after:translate-x-full shadow-inner" />
</label>
</div>
<div className="flex items-center justify-between pt-3">
<div>
<p className="text-sm font-bold text-clay-text">{t('admin.notifications.channels.pushTitle')}</p>
<p className="text-xs text-clay-text-muted">{t('admin.notifications.channels.pushDesc')}</p>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
disabled={!canUpdateConfig || updateChannelsMutation.isPending}
className="peer sr-only"
checked={channelConfigState.pushEnabled}
onChange={(e) => handleToggleChannel('pushEnabled', e.target.checked)}
/>
<div className="peer h-6 w-11 rounded-full bg-clay-border after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:bg-clay-primary peer-checked:after:translate-x-full shadow-inner" />
</label>
</div>
</div>
</Card>
</section>
)}
</main>
{/* Edit Template Modal */}
{selectedTemplate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<Card className="w-full max-w-lg space-y-4 bg-clay-surface p-6 shadow-2xl">
<div className="flex items-center justify-between border-b border-clay-highlight/40 pb-3">
<div>
<h3 className="clay-title-h3 text-clay-text">{t('admin.notifications.templates.modalTitle')}</h3>
<p className="font-mono text-xs text-clay-primary mt-0.5">
{selectedTemplate.type} ({selectedTemplate.language})
</p>
</div>
<button
type="button"
onClick={() => setSelectedTemplate(null)}
className="text-clay-text-muted hover:text-clay-text"
>
<CloseIcon size={20} />
</button>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-semibold text-clay-text mb-1">
{t('admin.notifications.templates.titleLabel')}
</label>
<input
type="text"
className="w-full rounded-clay border border-clay-border bg-clay-surface p-2 text-sm text-clay-text focus:outline-none focus:ring-2 focus:ring-clay-primary/50 shadow-inner"
value={editTitleTemplate}
onChange={(e) => setEditTitleTemplate(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-semibold text-clay-text mb-1">
{t('admin.notifications.templates.bodyLabel')}
</label>
<textarea
className="w-full rounded-clay border border-clay-border bg-clay-surface p-2 text-xs text-clay-text focus:outline-none focus:ring-2 focus:ring-clay-primary/50 shadow-inner"
rows={4}
value={editBodyTemplate}
onChange={(e) => setEditBodyTemplate(e.target.value)}
/>
</div>
{extractTemplateVariables(selectedTemplate).length > 0 && (
<div className="rounded bg-clay-bg p-2 text-[11px] text-clay-text-muted">
<strong>{t('admin.notifications.templates.availableVars')}</strong> {extractTemplateVariables(selectedTemplate).map((v) => `{{${v}}}`).join(', ')}
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-clay-highlight/40">
<Button variant="ghost" onClick={() => setSelectedTemplate(null)}>
{t('admin.notifications.templates.cancel')}
</Button>
<Button
variant="primary"
onClick={handleSaveTemplate}
disabled={updateTemplateMutation.isPending}
>
{updateTemplateMutation.isPending ? t('admin.notifications.templates.saving') : t('admin.notifications.templates.save')}
</Button>
</div>
</Card>
</div>
)}
</Page>
);
};
export default AdminNotificationsPage;
...@@ -35,8 +35,15 @@ const ACTION_COLORS: Record<string, string> = { ...@@ -35,8 +35,15 @@ const ACTION_COLORS: Record<string, string> = {
USE: 'bg-indigo-500/15 text-indigo-600 border-indigo-500/30', USE: 'bg-indigo-500/15 text-indigo-600 border-indigo-500/30',
MANAGE: 'bg-teal-500/15 text-teal-600 border-teal-500/30', MANAGE: 'bg-teal-500/15 text-teal-600 border-teal-500/30',
EVALUATE: 'bg-amber-500/15 text-amber-600 border-amber-500/30', EVALUATE: 'bg-amber-500/15 text-amber-600 border-amber-500/30',
CONFIG_UPDATE: 'bg-rose-500/15 text-rose-600 border-rose-500/30',
USAGE_READ: 'bg-sky-500/15 text-sky-600 border-sky-500/30',
MAINTENANCE_UPDATE: 'bg-orange-500/15 text-orange-600 border-orange-500/30',
TEMPLATE_READ: 'bg-blue-500/15 text-blue-600 border-blue-500/30',
TEMPLATE_UPDATE: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
RETRY: 'bg-emerald-500/15 text-emerald-600 border-emerald-500/30',
}; };
const RolesPage: React.FC = () => { const RolesPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
......
import React, { useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate';
import { LocalizedDateInput } from '@/components/shared/LocalizedDateInput';
import {
AlertTriangleIcon,
CheckIcon,
ClockIcon,
CloseIcon,
EditIcon,
IconGradients,
LockIcon,
RefreshIcon,
ServerIcon,
SettingsIcon,
ShieldIcon,
} from '@/components/ui/icons';
import { PERMISSIONS } from '@/common/constants';
import { usePermission } from '@/hooks/use-permission';
import {
useAdminSettings,
useUpdateMaintenanceMode,
useUpdateSetting,
} from '@/hooks/use-admin-settings';
import { SettingCategory, SystemSettingItem } from '@/services/admin-settings.service';
import { useI18n } from '@/i18n';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
function toDatetimeLocalValue(isoOrDateString?: string | null): string {
if (!isoOrDateString) return '';
const date = new Date(isoOrDateString);
if (isNaN(date.getTime())) return '';
const pad = (n: number) => n.toString().padStart(2, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function toIsoValue(datetimeLocalString?: string | null): string | null {
if (!datetimeLocalString || !datetimeLocalString.trim()) return null;
const date = new Date(datetimeLocalString);
if (isNaN(date.getTime())) return null;
return date.toISOString();
}
const CATEGORIES: Array<{ key: SettingCategory | 'ALL'; labelKey: string }> = [
{ key: 'ALL', labelKey: 'admin.settings.categories.ALL' },
{ key: 'GENERAL', labelKey: 'admin.settings.categories.GENERAL' },
{ key: 'SECURITY', labelKey: 'admin.settings.categories.SECURITY' },
{ key: 'NOTIFICATION', labelKey: 'admin.settings.categories.NOTIFICATION' },
{ key: 'AI', labelKey: 'admin.settings.categories.AI' },
{ key: 'SYSTEM', labelKey: 'admin.settings.categories.SYSTEM' },
];
export const AdminSettingsPage: React.FC = () => {
const navigate = useNavigate();
const { t } = useI18n();
const { hasPermission } = usePermission();
const canUpdateConfig = hasPermission(PERMISSIONS.SYSTEM_CONFIG_UPDATE);
const canUpdateMaintenance = hasPermission(PERMISSIONS.MAINTENANCE_MODE_UPDATE);
const [activeCategory, setActiveCategory] = useState<SettingCategory | 'ALL'>('ALL');
const [searchTerm, setSearchTerm] = useState('');
const [selectedSetting, setSelectedSetting] = useState<SystemSettingItem | null>(null);
const [editValue, setEditValue] = useState('');
const [editError, setEditError] = useState<string | null>(null);
// Maintenance state (stores datetime-local string 'YYYY-MM-DDTHH:mm')
const [maintEnabled, setMaintEnabled] = useState<boolean | null>(null);
const [maintMessage, setMaintMessage] = useState<string>('');
const [maintStartAt, setMaintStartAt] = useState<string>('');
const [maintEndAt, setMaintEndAt] = useState<string>('');
const [maintenanceDirty, setMaintenanceDirty] = useState(false);
const settingsQuery = useAdminSettings(
activeCategory === 'ALL' ? undefined : { category: activeCategory },
);
const updateSettingMutation = useUpdateSetting();
const updateMaintenanceMutation = useUpdateMaintenanceMode();
const settings = settingsQuery.data?.data ?? [];
// Initialize maintenance fields once loaded
React.useEffect(() => {
if (settings.length > 0 && maintEnabled === null) {
const enabledSetting = settings.find((s) => s.key === 'system.maintenance.enabled');
const msgSetting = settings.find((s) => s.key === 'system.maintenance.message');
const startSetting = settings.find((s) => s.key === 'system.maintenance.start_at');
const endSetting = settings.find((s) => s.key === 'system.maintenance.end_at');
setMaintEnabled(enabledSetting ? enabledSetting.value === 'true' : false);
setMaintMessage(msgSetting?.value || 'Hệ thống FinWise đang bảo trì để nâng cấp định kỳ.');
setMaintStartAt(toDatetimeLocalValue(startSetting?.value));
setMaintEndAt(toDatetimeLocalValue(endSetting?.value));
}
}, [settings, maintEnabled]);
const filteredSettings = settings.filter((s) => {
if (!searchTerm.trim()) return true;
const term = searchTerm.toLowerCase();
return s.key.toLowerCase().includes(term) || (s.description && s.description.toLowerCase().includes(term));
});
const handleOpenEdit = (setting: SystemSettingItem) => {
setSelectedSetting(setting);
setEditValue(setting.value);
setEditError(null);
};
const handleSaveSetting = async () => {
if (!selectedSetting) return;
// Type validation
if (selectedSetting.type === 'NUMBER') {
const num = Number(editValue);
if (isNaN(num)) {
setEditError(t('admin.settings.modal.numberError') || 'Giá trị phải là một số hợp lệ');
return;
}
} else if (selectedSetting.type === 'BOOLEAN') {
if (editValue !== 'true' && editValue !== 'false') {
setEditError(t('admin.settings.modal.booleanError') || 'Giá trị phải là true hoặc false');
return;
}
} else if (selectedSetting.type === 'JSON') {
try {
JSON.parse(editValue);
} catch {
setEditError(t('admin.settings.modal.jsonError') || 'Chuỗi JSON không hợp lệ');
return;
}
}
try {
await updateSettingMutation.mutateAsync({
key: selectedSetting.key,
data: {
value:
selectedSetting.type === 'BOOLEAN'
? editValue === 'true'
: selectedSetting.type === 'NUMBER'
? Number(editValue)
: selectedSetting.type === 'JSON'
? JSON.parse(editValue)
: editValue,
},
});
setSelectedSetting(null);
} catch (err: any) {
setEditError(err?.response?.data?.message || t('admin.settings.modal.error') || 'Cập nhật cấu hình thất bại');
}
};
const handleSaveMaintenance = async () => {
try {
await updateMaintenanceMutation.mutateAsync({
enabled: !!maintEnabled,
message: maintMessage,
startAt: toIsoValue(maintStartAt),
endAt: toIsoValue(maintEndAt),
});
setMaintenanceDirty(false);
alert(t('admin.settings.maintenance.success') || 'Đã cập nhật cấu hình bảo trì hệ thống thành công');
} catch (err: any) {
alert(err?.response?.data?.message || t('admin.settings.maintenance.error') || 'Cập nhật chế độ bảo trì thất bại');
}
};
const setPresetDuration = (minutes: number) => {
const now = new Date();
const end = new Date(now.getTime() + minutes * 60 * 1000);
setMaintStartAt(toDatetimeLocalValue(now.toISOString()));
setMaintEndAt(toDatetimeLocalValue(end.toISOString()));
setMaintenanceDirty(true);
};
const clearSchedule = () => {
setMaintStartAt('');
setMaintEndAt('');
setMaintenanceDirty(true);
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Header title={t('admin.settings.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
{/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
<div className="rounded-clay bg-clay-primary p-3 text-clay-on-primary shadow-clay-raised">
<SettingsIcon size={28} />
</div>
<div>
<h1 className="clay-title-h2 text-clay-text">{t('admin.settings.title')}</h1>
<p className="text-sm text-clay-text-muted">{t('admin.settings.subtitle')}</p>
</div>
</div>
</Card>
{/* Global Maintenance Mode Section */}
<section aria-labelledby="maintenance-heading">
<Card className={`border-2 transition-colors ${maintEnabled ? 'border-clay-warning/70 bg-clay-warning/10' : 'border-clay-highlight/40'}`}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className={`rounded-full p-2.5 ${maintEnabled ? 'bg-clay-warning text-clay-on-primary animate-pulse' : 'bg-clay-surface text-clay-text-muted'} shadow-clay-raised`}>
<AlertTriangleIcon size={22} />
</div>
<div>
<div className="flex items-center gap-2">
<h2 id="maintenance-heading" className="clay-title-h3 text-clay-text">{t('admin.settings.maintenance.title')}</h2>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-bold ${maintEnabled ? 'bg-clay-warning text-white' : 'bg-clay-income/20 text-clay-income'}`}>
{maintEnabled ? t('admin.settings.maintenance.active') : t('admin.settings.maintenance.normal')}
</span>
</div>
<p className="text-xs text-clay-text-muted mt-0.5">
{t('admin.settings.maintenance.description')}
</p>
</div>
</div>
{canUpdateMaintenance && (
<div className="flex items-center gap-3">
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
className="peer sr-only"
checked={!!maintEnabled}
onChange={(e) => {
setMaintEnabled(e.target.checked);
setMaintenanceDirty(true);
}}
/>
<div className="peer h-7 w-12 rounded-full bg-clay-border after:absolute after:left-[4px] after:top-[4px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-clay-warning peer-checked:after:translate-x-full peer-checked:after:border-white shadow-inner" />
</label>
</div>
)}
</div>
{/* Maintenance Parameters */}
<div className="mt-4 space-y-4 pt-3 border-t border-clay-highlight/30">
<div>
<label className="block text-xs font-semibold text-clay-text-muted mb-1">
{t('admin.settings.maintenance.messageLabel')}
</label>
<textarea
className="w-full rounded-clay border border-clay-border bg-clay-surface p-2.5 text-sm text-clay-text focus:outline-none focus:ring-2 focus:ring-clay-primary/50 shadow-inner"
rows={2}
disabled={!canUpdateMaintenance}
value={maintMessage}
onChange={(e) => {
setMaintMessage(e.target.value);
setMaintenanceDirty(true);
}}
placeholder={t('admin.settings.maintenance.messagePlaceholder')}
/>
</div>
{/* Date Time Picker Fields */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-xs font-semibold text-clay-text-muted mb-1">
{t('admin.settings.maintenance.startAtLabel')}
</label>
<LocalizedDateInput
type="datetime-local"
disabled={!canUpdateMaintenance}
value={maintStartAt}
onChange={(e) => {
setMaintStartAt(e.target.value);
setMaintenanceDirty(true);
}}
/>
</div>
<div>
<label className="block text-xs font-semibold text-clay-text-muted mb-1">
{t('admin.settings.maintenance.endAtLabel')}
</label>
<LocalizedDateInput
type="datetime-local"
disabled={!canUpdateMaintenance}
value={maintEndAt}
onChange={(e) => {
setMaintEndAt(e.target.value);
setMaintenanceDirty(true);
}}
/>
</div>
</div>
{/* Quick Preset Buttons */}
{canUpdateMaintenance && (
<div className="flex flex-wrap items-center gap-2 pt-1">
<span className="text-[11px] font-semibold text-clay-text-muted">{t('admin.settings.maintenance.quickSelect')}</span>
{[
{ label: t('admin.settings.maintenance.minutes', { count: 30 }), mins: 30 },
{ label: t('admin.settings.maintenance.hours', { count: 1 }), mins: 60 },
{ label: t('admin.settings.maintenance.hours', { count: 2 }), mins: 120 },
{ label: t('admin.settings.maintenance.hours', { count: 4 }), mins: 240 },
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setPresetDuration(preset.mins)}
className="rounded-full bg-clay-surface px-2.5 py-1 text-[11px] font-semibold text-clay-text border border-clay-highlight/40 hover:bg-clay-primary/10 hover:text-clay-primary transition-all shadow-clay-sm"
>
+{preset.label}
</button>
))}
{(maintStartAt || maintEndAt) && (
<button
type="button"
onClick={clearSchedule}
className="rounded-full bg-clay-expense/10 px-2.5 py-1 text-[11px] font-semibold text-clay-expense border border-clay-expense/20 hover:bg-clay-expense/20 transition-all"
>
{t('admin.settings.maintenance.clearSchedule')}
</button>
)}
</div>
)}
{canUpdateMaintenance && maintenanceDirty && (
<div className="flex items-center justify-end gap-2 pt-3 border-t border-clay-highlight/20">
<Button
variant="ghost"
onClick={() => {
const enabledSetting = settings.find((s) => s.key === 'system.maintenance.enabled');
const msgSetting = settings.find((s) => s.key === 'system.maintenance.message');
const startSetting = settings.find((s) => s.key === 'system.maintenance.start_at');
const endSetting = settings.find((s) => s.key === 'system.maintenance.end_at');
setMaintEnabled(enabledSetting ? enabledSetting.value === 'true' : false);
setMaintMessage(msgSetting?.value || 'Hệ thống FinWise đang bảo trì để nâng cấp định kỳ.');
setMaintStartAt(toDatetimeLocalValue(startSetting?.value));
setMaintEndAt(toDatetimeLocalValue(endSetting?.value));
setMaintenanceDirty(false);
}}
className="px-4 py-2 text-sm text-clay-text-muted hover:text-clay-text"
>
{t('admin.settings.maintenance.cancel')}
</Button>
<Button
variant="primary"
onClick={handleSaveMaintenance}
disabled={updateMaintenanceMutation.isPending}
className="gap-2 px-5 py-2 text-sm"
>
{updateMaintenanceMutation.isPending ? t('admin.settings.maintenance.saving') : t('admin.settings.maintenance.save')}
</Button>
</div>
)}
</div>
</Card>
</section>
{/* Dynamic System Settings Section */}
<section aria-labelledby="settings-list-heading" className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 id="settings-list-heading" className="clay-title-h3 text-clay-text">
{t('admin.settings.list.title')} ({filteredSettings.length})
</h2>
<div className="flex items-center gap-2">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('admin.settings.list.searchPlaceholder')}
className="w-full sm:w-64 rounded-clay border border-clay-border bg-clay-surface px-3 py-1.5 text-xs text-clay-text focus:outline-none focus:ring-2 focus:ring-clay-primary/50 shadow-inner"
/>
<Button
variant="ghost"
className="px-2.5 py-1.5 text-xs"
onClick={() => settingsQuery.refetch()}
disabled={settingsQuery.isFetching}
>
<RefreshIcon size={16} />
</Button>
</div>
</div>
{/* Category Tabs */}
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((cat) => (
<button
key={cat.key}
type="button"
onClick={() => setActiveCategory(cat.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all ${
activeCategory === cat.key
? 'bg-clay-primary text-clay-on-primary shadow-clay-raised'
: 'bg-clay-surface text-clay-text-muted border border-clay-highlight/40 hover:text-clay-text'
}`}
>
{t(cat.labelKey)}
</button>
))}
</div>
{/* Settings Grid */}
{settingsQuery.isLoading ? (
<AdminSkeleton rows={4} />
) : filteredSettings.length === 0 ? (
<Card className="text-center py-8 text-clay-text-muted">
{t('admin.settings.list.empty')}
</Card>
) : (
<div className="grid gap-3">
{filteredSettings.map((setting) => (
<Card
key={setting.key}
className="flex flex-col justify-between gap-3 border border-clay-highlight/40 p-4 transition-all sm:flex-row sm:items-center"
>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-bold text-clay-text">{setting.key}</span>
<span className="rounded bg-clay-primary/10 px-2 py-0.5 text-[11px] font-bold text-clay-primary">
{setting.category}
</span>
<span className="rounded bg-clay-surface px-2 py-0.5 text-[11px] font-semibold text-clay-text-muted border border-clay-border">
{setting.type}
</span>
{setting.isPublic && (
<span className="rounded bg-clay-info/10 px-2 py-0.5 text-[11px] font-semibold text-clay-info">
{t('admin.settings.list.public')}
</span>
)}
{!setting.isEditable && (
<span className="flex items-center gap-1 rounded bg-clay-expense/10 px-2 py-0.5 text-[11px] font-bold text-clay-expense">
<LockIcon size={11} /> {t('admin.settings.list.immutable')}
</span>
)}
</div>
{setting.description && (
<p className="text-xs text-clay-text-muted">{setting.description}</p>
)}
</div>
<div className="flex items-center gap-3 self-end sm:self-center">
<div className="max-w-[220px] truncate rounded bg-clay-bg px-3 py-1 font-mono text-xs font-semibold text-clay-text shadow-inner">
{setting.value}
</div>
{canUpdateConfig && setting.isEditable && (
<Button
variant="secondary"
className="px-2.5 py-1 text-xs"
onClick={() => handleOpenEdit(setting)}
>
<EditIcon size={14} /> {t('admin.settings.list.edit')}
</Button>
)}
</div>
</Card>
))}
</div>
)}
</section>
</main>
{/* Edit Setting Modal */}
{selectedSetting && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<Card className="w-full max-w-lg space-y-4 bg-clay-surface p-6 shadow-2xl">
<div className="flex items-center justify-between border-b border-clay-highlight/40 pb-3">
<div>
<h3 className="clay-title-h3 text-clay-text">{t('admin.settings.modal.title')}</h3>
<p className="font-mono text-xs text-clay-primary mt-0.5">{selectedSetting.key}</p>
</div>
<button
type="button"
onClick={() => setSelectedSetting(null)}
className="text-clay-text-muted hover:text-clay-text"
>
<CloseIcon size={20} />
</button>
</div>
<div className="space-y-3">
{selectedSetting.description && (
<p className="text-xs text-clay-text-muted">{selectedSetting.description}</p>
)}
<div>
<label className="block text-xs font-semibold text-clay-text mb-1">
{t('admin.settings.modal.valueLabel', { type: selectedSetting.type })}
</label>
{selectedSetting.type === 'BOOLEAN' ? (
<select
className="w-full rounded-clay border border-clay-border bg-clay-surface p-2.5 text-sm text-clay-text focus:outline-none focus:ring-2 focus:ring-clay-primary/50"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : selectedSetting.type === 'JSON' ? (
<textarea
className="w-full font-mono rounded-clay border border-clay-border bg-clay-surface p-2.5 text-xs text-clay-text focus:outline-none focus:ring-2 focus:ring-clay-primary/50 shadow-inner"
rows={6}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
/>
) : (
<input
type={selectedSetting.type === 'NUMBER' ? 'number' : 'text'}
className="w-full rounded-clay border border-clay-border bg-clay-surface p-2.5 text-sm text-clay-text focus:outline-none focus:ring-2 focus:ring-clay-primary/50 shadow-inner"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
/>
)}
</div>
{editError && (
<p className="text-xs font-semibold text-clay-expense">{editError}</p>
)}
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-clay-highlight/40">
<Button variant="ghost" onClick={() => setSelectedSetting(null)}>
{t('admin.settings.modal.cancel')}
</Button>
<Button
variant="primary"
onClick={handleSaveSetting}
disabled={updateSettingMutation.isPending}
>
{updateSettingMutation.isPending ? t('admin.settings.modal.saving') : t('admin.settings.modal.save')}
</Button>
</div>
</Card>
</div>
)}
</Page>
);
};
export default AdminSettingsPage;
import React, { useState } from "react";
import { useSnackbar } from "zmp-ui";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Modal } from "@/components/ui/Modal";
import { ClockIcon, KeyIcon, PlusIcon, ShieldIcon, TrashIcon } from "@/components/ui/icons";
import { getErrorMessage } from "@/lib/error-message";
import { useCreateApiKey, useApiKeys, useRevokeApiKey } from "@/hooks/use-integrations";
import { ApiKey, ApiKeyStatus, CreateApiKeyInput } from "@/types/integration";
import { useI18n } from "@/i18n";
import { ApiKeyFormModal } from "./IntegrationForms";
import { OneTimeSecretModal } from "./OneTimeSecretModal";
interface ApiKeyPanelProps {
canCreate: boolean;
canDelete: boolean;
allowedPermissions: string[];
}
const statusBadge: Record<ApiKey["status"], BadgeType> = {
ACTIVE: "income",
REVOKED: "expense",
EXPIRED: "warning",
};
const ApiKeySkeleton: React.FC = () => (
<div className="space-y-3" aria-hidden="true">
{[0, 1].map((item) => <div key={item} className="h-44 animate-pulse rounded-clay-lg bg-clay-surface shadow-clay-raised" />)}
</div>
);
function getEffectiveStatus(apiKey: ApiKey): ApiKeyStatus {
if (apiKey.status === "ACTIVE" && apiKey.expiresAt && new Date(apiKey.expiresAt).getTime() <= Date.now()) {
return "EXPIRED";
}
return apiKey.status;
}
export const ApiKeyPanel: React.FC<ApiKeyPanelProps> = ({ canCreate, canDelete, allowedPermissions }) => {
const { t, formatDate } = useI18n();
const { openSnackbar } = useSnackbar();
const apiKeysQuery = useApiKeys(true);
const createMutation = useCreateApiKey();
const revokeMutation = useRevokeApiKey();
const [isFormOpen, setIsFormOpen] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<ApiKey | null>(null);
const [oneTimeKey, setOneTimeKey] = useState("");
const apiKeys = apiKeysQuery.data?.data ?? [];
const createApiKey = async (input: CreateApiKeyInput) => {
try {
const response = await createMutation.mutateAsync(input);
setIsFormOpen(false);
setOneTimeKey(response.data.rawKey);
createMutation.reset();
openSnackbar({ type: "success", text: t("integrations.apiKeys.createSuccess") });
} catch (error) {
openSnackbar({ type: "error", text: getErrorMessage(error, t("integrations.apiKeys.createFailed")) });
}
};
const revokeApiKey = async () => {
if (!revokeTarget) return;
try {
await revokeMutation.mutateAsync(revokeTarget.id);
setRevokeTarget(null);
openSnackbar({ type: "success", text: t("integrations.apiKeys.revokeSuccess") });
} catch (error) {
openSnackbar({ type: "error", text: getErrorMessage(error, t("integrations.apiKeys.revokeFailed")) });
}
};
return (
<section aria-labelledby="api-key-heading" className="space-y-4">
<Card className="overflow-hidden bg-clay-primary/10 p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<span className="rounded-clay-sm bg-clay-primary p-3 text-clay-on-primary shadow-clay-raised"><KeyIcon size={26} /></span>
<div>
<h2 id="api-key-heading" className="clay-title-h2">{t("integrations.apiKeys.title")}</h2>
<p className="mt-1 text-sm text-clay-text-muted">{t("integrations.apiKeys.subtitle")}</p>
</div>
</div>
{canCreate && (
<Button type="button" onClick={() => setIsFormOpen(true)} className="shrink-0 gap-2 text-sm">
<PlusIcon size={18} /> {t("integrations.apiKeys.create")}
</Button>
)}
</div>
</Card>
<Card className="flex items-start gap-3 bg-clay-info/10 p-4">
<ShieldIcon size={22} className="mt-0.5 shrink-0 text-clay-info" />
<p className="text-xs leading-relaxed text-clay-text-muted">{t("integrations.apiKeys.securityHint")}</p>
</Card>
{apiKeysQuery.isLoading ? <ApiKeySkeleton /> : apiKeysQuery.isError ? (
<Card className="flex flex-col items-center gap-3 py-8 text-center">
<p className="font-baloo font-bold text-clay-expense">{t("integrations.apiKeys.loadFailed")}</p>
<Button variant="secondary" onClick={() => void apiKeysQuery.refetch()} className="gap-2 text-sm"><ClockIcon size={18} />{t("common.retry")}</Button>
</Card>
) : apiKeys.length === 0 ? (
<Card className="flex flex-col items-center gap-3 py-8 text-center">
<span className="rounded-full bg-clay-primary/10 p-4 text-clay-primary shadow-clay-pressed"><KeyIcon size={30} /></span>
<div><h3 className="clay-title-h3">{t("integrations.apiKeys.emptyTitle")}</h3><p className="clay-caption mt-1">{t("integrations.apiKeys.emptyHint")}</p></div>
{canCreate && <Button onClick={() => setIsFormOpen(true)} className="mt-1 text-sm">{t("integrations.apiKeys.create")}</Button>}
</Card>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{apiKeys.map((apiKey) => (
<Card key={apiKey.id} className="flex flex-col gap-4 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate font-baloo text-lg font-bold text-clay-text">{apiKey.name}</h3>
<code className="mt-1 block text-xs text-clay-primary">{apiKey.keyPrefix}••••••••</code>
</div>
<Badge type={statusBadge[getEffectiveStatus(apiKey)]}>{t(`integrations.status.${getEffectiveStatus(apiKey)}`)}</Badge>
</div>
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-clay-text-muted">{t("integrations.apiKeys.permissions")}</p>
<div className="flex flex-wrap gap-1.5">
{apiKey.permissions.length > 0
? apiKey.permissions.map((permission) => <span key={permission} className="rounded-full bg-clay-primary/10 px-2.5 py-1 font-mono text-[10px] text-clay-primary">{permission}</span>)
: <span className="rounded-full bg-clay-warning/15 px-2.5 py-1 text-[10px] font-bold text-clay-warning">{t("integrations.apiKeys.unscoped")}</span>}
</div>
</div>
<div><p className="mb-1 text-xs font-bold text-clay-text-muted">{t("integrations.apiKeys.ipWhitelist")}</p><p className="break-words font-mono text-xs text-clay-text">{apiKey.ipWhitelist.length > 0 ? apiKey.ipWhitelist.join(", ") : t("integrations.apiKeys.anyIp")}</p></div>
<dl className="grid grid-cols-2 gap-3 rounded-clay-sm bg-clay-bg p-3 text-xs shadow-clay-pressed">
<div><dt className="text-clay-text-muted">{t("integrations.apiKeys.lastUsed")}</dt><dd className="mt-1 font-bold text-clay-text">{apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt, { dateStyle: "short", timeStyle: "short" }) : t("integrations.apiKeys.never")}</dd></div>
<div><dt className="text-clay-text-muted">{t("integrations.apiKeys.expires")}</dt><dd className="mt-1 flex items-center gap-1 font-bold text-clay-text"><ClockIcon size={13} />{apiKey.expiresAt ? formatDate(apiKey.expiresAt, { dateStyle: "short" }) : t("integrations.apiKeys.noExpiry")}</dd></div>
</dl>
{canDelete && getEffectiveStatus(apiKey) === "ACTIVE" && (
<Button variant="ghost" fullWidth onClick={() => setRevokeTarget(apiKey)} className="gap-2 text-sm text-clay-expense"><TrashIcon size={17} />{t("integrations.apiKeys.revoke")}</Button>
)}
</Card>
))}
</div>
)}
<ApiKeyFormModal
isOpen={isFormOpen}
isSubmitting={createMutation.isPending}
allowedPermissions={allowedPermissions}
onClose={() => setIsFormOpen(false)}
onSubmit={createApiKey}
/>
<OneTimeSecretModal isOpen={Boolean(oneTimeKey)} secret={oneTimeKey} kind="apiKey" onClose={() => setOneTimeKey("")} />
<Modal
isOpen={Boolean(revokeTarget)}
onClose={() => setRevokeTarget(null)}
title={t("integrations.apiKeys.revokeTitle")}
footer={(
<>
<Button variant="ghost" onClick={() => setRevokeTarget(null)} disabled={revokeMutation.isPending} className="px-4 text-sm">{t("common.cancel")}</Button>
<Button onClick={() => void revokeApiKey()} disabled={revokeMutation.isPending} className="!bg-clay-expense px-4 text-sm">{t(revokeMutation.isPending ? "common.processing" : "integrations.apiKeys.revoke")}</Button>
</>
)}
>
<p className="text-sm text-clay-text">{t("integrations.apiKeys.revokeConfirm", { name: revokeTarget?.name || "" })}</p>
</Modal>
</section>
);
};
import React, { useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { useI18n } from "@/i18n";
import { businessWallTimeToIso, instantToBusinessDateTimeInput } from "@/lib/business-time";
import { CreateApiKeyInput, CreateWebhookInput, UpdateWebhookInput, WebhookEndpoint } from "@/types/integration";
import { API_KEY_SCOPE_OPTIONS, WEBHOOK_EVENT_OPTIONS } from "../options";
import {
ApiKeyFormValues,
createApiKeyFormSchema,
createWebhookFormSchema,
WebhookFormValues,
} from "../validation";
const fieldClass = "w-full rounded-clay-sm border border-transparent bg-clay-bg px-4 py-3 text-sm text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out placeholder:text-clay-text-muted/65 focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20";
interface ApiKeyFormModalProps {
isOpen: boolean;
isSubmitting: boolean;
allowedPermissions: string[];
onClose: () => void;
onSubmit: (input: CreateApiKeyInput) => Promise<void>;
}
export const ApiKeyFormModal: React.FC<ApiKeyFormModalProps> = ({
isOpen,
isSubmitting,
allowedPermissions,
onClose,
onSubmit,
}) => {
const { t } = useI18n();
const schema = useMemo(() => createApiKeyFormSchema({
nameRequired: t("integrations.apiKeys.form.nameRequired"),
nameMax: t("integrations.apiKeys.form.nameMax"),
permissionsRequired: t("integrations.apiKeys.form.permissionsRequired"),
ipInvalid: t("integrations.apiKeys.form.ipInvalid"),
}), [t]);
const { register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<ApiKeyFormValues>({
resolver: zodResolver(schema),
defaultValues: { name: "", permissions: [], ipWhitelist: "", expiresAt: "" },
});
const selectedPermissions = watch("permissions");
const options = API_KEY_SCOPE_OPTIONS.filter((option) => allowedPermissions.includes(option.value));
useEffect(() => {
if (isOpen) reset({ name: "", permissions: [], ipWhitelist: "", expiresAt: "" });
}, [isOpen, reset]);
const togglePermission = (permission: string) => {
const next = selectedPermissions.includes(permission)
? selectedPermissions.filter((value) => value !== permission)
: [...selectedPermissions, permission];
setValue("permissions", next, { shouldDirty: true, shouldValidate: true });
};
const submit = async (values: ApiKeyFormValues) => {
const ipWhitelist = values.ipWhitelist.split(/[\n,]/).map((value) => value.trim()).filter(Boolean);
await onSubmit({
name: values.name.trim(),
permissions: values.permissions,
ipWhitelist,
expiresAt: values.expiresAt ? businessWallTimeToIso(values.expiresAt) : null,
});
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t("integrations.apiKeys.form.title")}
footer={(
<>
<Button type="button" variant="ghost" onClick={onClose} disabled={isSubmitting} className="px-4 text-sm">{t("common.cancel")}</Button>
<Button type="submit" form="api-key-form" disabled={isSubmitting || options.length === 0} className="px-4 text-sm">
{t(isSubmitting ? "common.processing" : "integrations.apiKeys.create")}
</Button>
</>
)}
>
<form id="api-key-form" onSubmit={handleSubmit((values) => void submit(values))} className="space-y-4">
<Input
label={t("integrations.apiKeys.form.name")}
placeholder={t("integrations.apiKeys.form.namePlaceholder")}
error={errors.name?.message}
maxLength={100}
{...register("name")}
/>
<fieldset>
<legend className="px-1 text-sm font-bold text-clay-text">{t("integrations.apiKeys.form.permissions")}</legend>
<p className="mb-2 px-1 text-xs text-clay-text-muted">{t("integrations.apiKeys.form.permissionsHint")}</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{options.map((option) => (
<label key={option.value} className="flex cursor-pointer items-start gap-2 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed transition-all duration-200 ease-in-out hover:text-clay-primary">
<input
type="checkbox"
checked={selectedPermissions.includes(option.value)}
onChange={() => togglePermission(option.value)}
className="mt-1 h-4 w-4 accent-clay-primary"
/>
<span><span className="block text-sm font-bold">{t(option.labelKey)}</span><span className="font-mono text-[10px] text-clay-text-muted">{option.value}</span></span>
</label>
))}
</div>
{options.length === 0 && <p className="mt-2 text-xs font-bold text-clay-expense">{t("integrations.apiKeys.form.noScopes")}</p>}
{errors.permissions?.message && <p className="mt-2 px-1 text-xs text-clay-expense">{errors.permissions.message}</p>}
</fieldset>
<div>
<label htmlFor="api-key-ip-list" className="mb-2 block px-1 text-sm font-bold text-clay-text">{t("integrations.apiKeys.form.ipWhitelist")}</label>
<textarea id="api-key-ip-list" rows={3} className={fieldClass} placeholder={t("integrations.apiKeys.form.ipPlaceholder")} {...register("ipWhitelist")} />
<p className="mt-1 px-1 text-xs text-clay-text-muted">{t("integrations.apiKeys.form.ipHint")}</p>
{errors.ipWhitelist?.message && <p className="mt-1 px-1 text-xs text-clay-expense">{errors.ipWhitelist.message}</p>}
</div>
<LocalizedDateInput
type="datetime-local"
label={t("integrations.apiKeys.form.expiresAt")}
error={errors.expiresAt?.message}
min={instantToBusinessDateTimeInput()}
{...register("expiresAt")}
/>
</form>
</Modal>
);
};
interface WebhookFormModalProps {
isOpen: boolean;
isSubmitting: boolean;
endpoint?: WebhookEndpoint;
onClose: () => void;
onSubmit: (submission: WebhookFormSubmission) => Promise<void>;
}
export type WebhookFormSubmission =
| { mode: "create"; input: CreateWebhookInput }
| { mode: "update"; input: UpdateWebhookInput };
export const WebhookFormModal: React.FC<WebhookFormModalProps> = ({ isOpen, isSubmitting, endpoint, onClose, onSubmit }) => {
const { t } = useI18n();
const schema = useMemo(() => createWebhookFormSchema({
urlInvalid: t("integrations.webhooks.form.urlInvalid"),
descriptionMax: t("integrations.webhooks.form.descriptionMax"),
eventsRequired: t("integrations.webhooks.form.eventsRequired"),
}), [t]);
const { register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<WebhookFormValues>({
resolver: zodResolver(schema),
defaultValues: { url: "", description: "", events: ["job.completed", "job.failed"], status: "ACTIVE" },
});
const selectedEvents = watch("events");
useEffect(() => {
if (!isOpen) return;
reset({
url: endpoint?.url || "",
description: endpoint?.description || "",
events: endpoint?.events || ["job.completed", "job.failed"],
status: endpoint?.status || "ACTIVE",
});
}, [endpoint, isOpen, reset]);
const toggleEvent = (eventName: string) => {
const next = selectedEvents.includes(eventName)
? selectedEvents.filter((value) => value !== eventName)
: [...selectedEvents, eventName];
setValue("events", next, { shouldDirty: true, shouldValidate: true });
};
const submit = async (values: WebhookFormValues) => {
const baseInput: CreateWebhookInput = {
url: values.url.trim(),
description: values.description.trim(),
events: values.events,
};
await onSubmit(endpoint
? { mode: "update", input: { ...baseInput, status: values.status } }
: { mode: "create", input: baseInput });
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t(endpoint ? "integrations.webhooks.form.editTitle" : "integrations.webhooks.form.createTitle")}
footer={(
<>
<Button type="button" variant="ghost" onClick={onClose} disabled={isSubmitting} className="px-4 text-sm">{t("common.cancel")}</Button>
<Button type="submit" form="webhook-form" disabled={isSubmitting} className="px-4 text-sm">{t(isSubmitting ? "common.processing" : "common.save")}</Button>
</>
)}
>
<form id="webhook-form" onSubmit={handleSubmit((values) => void submit(values))} className="space-y-4">
<Input label={t("integrations.webhooks.form.url")} placeholder="https://api.example.com/webhooks/finwise" error={errors.url?.message} {...register("url")} />
<div>
<label htmlFor="webhook-description" className="mb-2 block px-1 text-sm font-bold text-clay-text">{t("integrations.webhooks.form.description")}</label>
<textarea id="webhook-description" rows={2} maxLength={255} className={fieldClass} placeholder={t("integrations.webhooks.form.descriptionPlaceholder")} {...register("description")} />
{errors.description?.message && <p className="mt-1 px-1 text-xs text-clay-expense">{errors.description.message}</p>}
</div>
<fieldset>
<legend className="px-1 text-sm font-bold text-clay-text">{t("integrations.webhooks.form.events")}</legend>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
{WEBHOOK_EVENT_OPTIONS.map((option) => (
<label key={option.value} className="flex cursor-pointer items-center gap-2 rounded-clay-sm bg-clay-bg p-3 text-sm shadow-clay-pressed transition-all duration-200 ease-in-out hover:text-clay-primary">
<input type="checkbox" checked={selectedEvents.includes(option.value)} onChange={() => toggleEvent(option.value)} className="h-4 w-4 accent-clay-primary" />
<span>{t(option.labelKey)}</span>
</label>
))}
</div>
{errors.events?.message && <p className="mt-2 px-1 text-xs text-clay-expense">{errors.events.message}</p>}
</fieldset>
{endpoint && (
<label className="flex items-center justify-between rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<span><span className="block text-sm font-bold text-clay-text">{t("integrations.webhooks.form.active")}</span><span className="text-xs text-clay-text-muted">{t("integrations.webhooks.form.activeHint")}</span></span>
<input type="checkbox" checked={watch("status") === "ACTIVE"} onChange={(event) => setValue("status", event.target.checked ? "ACTIVE" : "DISABLED", { shouldDirty: true })} className="h-5 w-5 accent-clay-primary" />
</label>
)}
</form>
</Modal>
);
};
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { CheckIcon, KeyIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
interface OneTimeSecretModalProps {
isOpen: boolean;
secret: string;
kind: "apiKey" | "webhook";
onClose: () => void;
}
export const OneTimeSecretModal: React.FC<OneTimeSecretModalProps> = ({
isOpen,
secret,
kind,
onClose,
}) => {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
useEffect(() => setCopied(false), [isOpen, secret]);
const copySecret = async () => {
try {
await navigator.clipboard.writeText(secret);
setCopied(true);
} catch {
setCopied(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t(`integrations.secret.${kind}Title`)}
footer={(
<Button onClick={onClose} className="px-5 text-sm">
{t("integrations.secret.done")}
</Button>
)}
>
<div className="space-y-4">
<div className="flex gap-3 rounded-clay bg-clay-warning/15 p-4 text-clay-text shadow-clay-pressed">
<span className="mt-0.5 shrink-0 text-clay-warning"><KeyIcon size={22} /></span>
<div>
<p className="font-baloo font-bold">{t("integrations.secret.onceTitle")}</p>
<p className="mt-1 text-xs text-clay-text-muted">{t("integrations.secret.onceHint")}</p>
</div>
</div>
<div>
<label htmlFor="one-time-secret" className="mb-2 block px-1 text-sm font-bold text-clay-text">
{t(`integrations.secret.${kind}Label`)}
</label>
<textarea
id="one-time-secret"
readOnly
rows={3}
value={secret}
onFocus={(event) => event.currentTarget.select()}
className="w-full resize-none rounded-clay-sm border border-clay-highlight/50 bg-clay-bg p-3 font-mono text-sm text-clay-text shadow-clay-pressed focus:outline-none focus:ring-2 focus:ring-clay-primary/35"
/>
</div>
<Button type="button" variant="secondary" fullWidth onClick={() => void copySecret()} className="gap-2 text-sm">
{copied && <CheckIcon />}
{t(copied ? "integrations.secret.copied" : "integrations.secret.copy")}
</Button>
</div>
</Modal>
);
};
import React, { useState } from "react";
import { useSnackbar } from "zmp-ui";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Modal } from "@/components/ui/Modal";
import { ClockIcon, EditIcon, NotificationIcon, PlusIcon, TransactionIcon, TrashIcon } from "@/components/ui/icons";
import {
useCreateWebhook,
useDeleteWebhook,
useRetryWebhookDelivery,
useTestWebhook,
useUpdateWebhook,
useWebhookDeliveries,
useWebhooks,
} from "@/hooks/use-integrations";
import { getErrorMessage } from "@/lib/error-message";
import { WebhookDelivery, WebhookEndpoint } from "@/types/integration";
import { useI18n } from "@/i18n";
import { WebhookFormModal, WebhookFormSubmission } from "./IntegrationForms";
import { OneTimeSecretModal } from "./OneTimeSecretModal";
interface WebhookPanelProps {
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
canTest: boolean;
}
const deliveryBadge: Record<WebhookDelivery["status"], BadgeType> = {
PENDING: "warning",
SUCCESS: "income",
FAILED: "expense",
};
const WebhookSkeleton: React.FC = () => (
<div className="space-y-3" aria-hidden="true">
{[0, 1].map((item) => <div key={item} className="h-52 animate-pulse rounded-clay-lg bg-clay-surface shadow-clay-raised" />)}
</div>
);
interface DeliveryModalProps {
endpoint: WebhookEndpoint | null;
canRetry: boolean;
onClose: () => void;
}
const DeliveryModal: React.FC<DeliveryModalProps> = ({ endpoint, canRetry, onClose }) => {
const { t, formatDate } = useI18n();
const { openSnackbar } = useSnackbar();
const deliveriesQuery = useWebhookDeliveries(endpoint?.id || null);
const retryMutation = useRetryWebhookDelivery();
const deliveries = deliveriesQuery.data?.data ?? [];
const retry = async (deliveryId: string) => {
if (!endpoint) return;
try {
await retryMutation.mutateAsync({ endpointId: endpoint.id, deliveryId });
openSnackbar({ type: "success", text: t("integrations.webhooks.deliveries.retrySuccess") });
} catch (error) {
openSnackbar({ type: "error", text: getErrorMessage(error, t("integrations.webhooks.deliveries.retryFailed")) });
}
};
return (
<Modal isOpen={Boolean(endpoint)} onClose={onClose} title={t("integrations.webhooks.deliveries.title")}>
<div className="space-y-3">
{endpoint && <p className="break-all rounded-clay-sm bg-clay-bg p-3 font-mono text-xs text-clay-primary shadow-clay-pressed">{endpoint.url}</p>}
{deliveriesQuery.isLoading ? (
<div className="space-y-2">{[0, 1, 2].map((item) => <div key={item} className="h-24 animate-pulse rounded-clay-sm bg-clay-bg" />)}</div>
) : deliveriesQuery.isError ? (
<Card className="flex flex-col items-center gap-2 p-5 text-center"><p className="text-sm font-bold text-clay-expense">{t("integrations.webhooks.deliveries.loadFailed")}</p><Button variant="secondary" onClick={() => void deliveriesQuery.refetch()} className="text-sm">{t("common.retry")}</Button></Card>
) : deliveries.length === 0 ? (
<Card className="p-6 text-center"><p className="font-baloo font-bold">{t("integrations.webhooks.deliveries.empty")}</p><p className="mt-1 text-xs text-clay-text-muted">{t("integrations.webhooks.deliveries.emptyHint")}</p></Card>
) : deliveries.map((delivery) => (
<article key={delivery.id} className="space-y-2 rounded-clay-sm bg-clay-bg p-4 shadow-clay-pressed">
<div className="flex items-start justify-between gap-2">
<div><h4 className="font-mono text-xs font-bold text-clay-text">{delivery.event}</h4><p className="mt-0.5 font-mono text-[10px] text-clay-text-muted">{delivery.eventId}</p></div>
<Badge type={deliveryBadge[delivery.status]}>{t(`integrations.status.${delivery.status}`)}</Badge>
</div>
<dl className="grid grid-cols-2 gap-2 text-xs">
<div><dt className="text-clay-text-muted">{t("integrations.webhooks.deliveries.createdAt")}</dt><dd className="font-bold">{formatDate(delivery.createdAt, { dateStyle: "short", timeStyle: "short" })}</dd></div>
<div><dt className="text-clay-text-muted">{t("integrations.webhooks.deliveries.attempts")}</dt><dd className="font-bold">{delivery.attemptCount}/{delivery.maxAttempts}{delivery.statusCode ? ` · HTTP ${delivery.statusCode}` : ""}</dd></div>
</dl>
{delivery.errorMessage && <p className="break-words rounded-clay-sm bg-clay-expense/10 p-2 text-xs text-clay-expense">{delivery.errorMessage}</p>}
{canRetry && delivery.status === "FAILED" && (
<Button variant="secondary" fullWidth disabled={retryMutation.isPending} onClick={() => void retry(delivery.id)} className="gap-2 py-2 text-xs"><ClockIcon size={15} />{t(retryMutation.isPending ? "common.processing" : "integrations.webhooks.deliveries.retry")}</Button>
)}
</article>
))}
</div>
</Modal>
);
};
export const WebhookPanel: React.FC<WebhookPanelProps> = ({ canCreate, canUpdate, canDelete, canTest }) => {
const { t, formatDate } = useI18n();
const { openSnackbar } = useSnackbar();
const webhooksQuery = useWebhooks(true);
const createMutation = useCreateWebhook();
const updateMutation = useUpdateWebhook();
const deleteMutation = useDeleteWebhook();
const testMutation = useTestWebhook();
const [formEndpoint, setFormEndpoint] = useState<WebhookEndpoint | undefined>();
const [isFormOpen, setIsFormOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<WebhookEndpoint | null>(null);
const [deliveryEndpoint, setDeliveryEndpoint] = useState<WebhookEndpoint | null>(null);
const [oneTimeSecret, setOneTimeSecret] = useState("");
const webhooks = webhooksQuery.data?.data ?? [];
const openCreateForm = () => {
setFormEndpoint(undefined);
setIsFormOpen(true);
};
const openEditForm = (endpoint: WebhookEndpoint) => {
setFormEndpoint(endpoint);
setIsFormOpen(true);
};
const saveWebhook = async (submission: WebhookFormSubmission) => {
try {
if (submission.mode === "update" && formEndpoint) {
await updateMutation.mutateAsync({ id: formEndpoint.id, input: submission.input });
openSnackbar({ type: "success", text: t("integrations.webhooks.updateSuccess") });
} else {
if (submission.mode !== "create") throw new Error("Webhook update target is missing");
const response = await createMutation.mutateAsync(submission.input);
setOneTimeSecret(response.data.secret);
createMutation.reset();
openSnackbar({ type: "success", text: t("integrations.webhooks.createSuccess") });
}
setIsFormOpen(false);
setFormEndpoint(undefined);
} catch (error) {
openSnackbar({ type: "error", text: getErrorMessage(error, t(formEndpoint ? "integrations.webhooks.updateFailed" : "integrations.webhooks.createFailed")) });
}
};
const deleteWebhook = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.id);
setDeleteTarget(null);
openSnackbar({ type: "success", text: t("integrations.webhooks.deleteSuccess") });
} catch (error) {
openSnackbar({ type: "error", text: getErrorMessage(error, t("integrations.webhooks.deleteFailed")) });
}
};
const testWebhook = async (endpoint: WebhookEndpoint) => {
try {
await testMutation.mutateAsync(endpoint.id);
openSnackbar({ type: "success", text: t("integrations.webhooks.testSuccess") });
} catch (error) {
openSnackbar({ type: "error", text: getErrorMessage(error, t("integrations.webhooks.testFailed")) });
}
};
return (
<section aria-labelledby="webhook-heading" className="space-y-4">
<Card className="overflow-hidden bg-clay-info/10 p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<span className="rounded-clay-sm bg-clay-info p-3 text-clay-on-primary shadow-clay-raised"><NotificationIcon size={26} /></span>
<div><h2 id="webhook-heading" className="clay-title-h2">{t("integrations.webhooks.title")}</h2><p className="mt-1 text-sm text-clay-text-muted">{t("integrations.webhooks.subtitle")}</p></div>
</div>
{canCreate && <Button type="button" onClick={openCreateForm} className="shrink-0 gap-2 text-sm"><PlusIcon size={18} />{t("integrations.webhooks.create")}</Button>}
</div>
</Card>
{webhooksQuery.isLoading ? <WebhookSkeleton /> : webhooksQuery.isError ? (
<Card className="flex flex-col items-center gap-3 py-8 text-center"><p className="font-baloo font-bold text-clay-expense">{t("integrations.webhooks.loadFailed")}</p><Button variant="secondary" onClick={() => void webhooksQuery.refetch()} className="gap-2 text-sm"><ClockIcon size={18} />{t("common.retry")}</Button></Card>
) : webhooks.length === 0 ? (
<Card className="flex flex-col items-center gap-3 py-8 text-center"><span className="rounded-full bg-clay-info/10 p-4 text-clay-info shadow-clay-pressed"><NotificationIcon size={30} /></span><div><h3 className="clay-title-h3">{t("integrations.webhooks.emptyTitle")}</h3><p className="clay-caption mt-1">{t("integrations.webhooks.emptyHint")}</p></div>{canCreate && <Button onClick={openCreateForm} className="mt-1 text-sm">{t("integrations.webhooks.create")}</Button>}</Card>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{webhooks.map((endpoint) => (
<Card key={endpoint.id} className="flex flex-col gap-4 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0"><h3 className="break-all font-mono text-sm font-bold text-clay-primary">{endpoint.url}</h3>{endpoint.description && <p className="mt-1 text-xs text-clay-text-muted">{endpoint.description}</p>}</div>
<Badge type={endpoint.status === "ACTIVE" ? "income" : "warning"}>{t(`integrations.status.${endpoint.status}`)}</Badge>
</div>
<div><p className="mb-2 text-xs font-bold uppercase tracking-wide text-clay-text-muted">{t("integrations.webhooks.subscribedEvents")}</p><div className="flex flex-wrap gap-1.5">{endpoint.events.map((event) => <span key={event} className="rounded-full bg-clay-info/10 px-2.5 py-1 font-mono text-[10px] text-clay-info">{event}</span>)}</div></div>
<p className="text-xs text-clay-text-muted">{t("integrations.webhooks.updatedAt", { date: formatDate(endpoint.updatedAt, { dateStyle: "short", timeStyle: "short" }) })}</p>
<div className="mt-auto grid grid-cols-2 gap-2">
<Button variant="secondary" onClick={() => setDeliveryEndpoint(endpoint)} className="gap-1 px-3 py-2 text-xs"><ClockIcon size={15} />{t("integrations.webhooks.deliveries.button")}</Button>
{canTest && endpoint.status === "ACTIVE" && <Button variant="secondary" disabled={testMutation.isPending} onClick={() => void testWebhook(endpoint)} className="gap-1 px-3 py-2 text-xs"><TransactionIcon size={15} />{t("integrations.webhooks.test")}</Button>}
{canUpdate && <Button variant="ghost" onClick={() => openEditForm(endpoint)} className="gap-1 px-3 py-2 text-xs"><EditIcon size={15} />{t("common.edit")}</Button>}
{canDelete && <Button variant="ghost" onClick={() => setDeleteTarget(endpoint)} className="gap-1 px-3 py-2 text-xs text-clay-expense"><TrashIcon size={15} />{t("common.delete")}</Button>}
</div>
</Card>
))}
</div>
)}
<WebhookFormModal isOpen={isFormOpen} isSubmitting={createMutation.isPending || updateMutation.isPending} endpoint={formEndpoint} onClose={() => { setIsFormOpen(false); setFormEndpoint(undefined); }} onSubmit={saveWebhook} />
<OneTimeSecretModal isOpen={Boolean(oneTimeSecret)} secret={oneTimeSecret} kind="webhook" onClose={() => setOneTimeSecret("")} />
<DeliveryModal endpoint={deliveryEndpoint} canRetry={canUpdate} onClose={() => setDeliveryEndpoint(null)} />
<Modal
isOpen={Boolean(deleteTarget)}
onClose={() => setDeleteTarget(null)}
title={t("integrations.webhooks.deleteTitle")}
footer={<><Button variant="ghost" onClick={() => setDeleteTarget(null)} disabled={deleteMutation.isPending} className="px-4 text-sm">{t("common.cancel")}</Button><Button onClick={() => void deleteWebhook()} disabled={deleteMutation.isPending} className="!bg-clay-expense px-4 text-sm">{t(deleteMutation.isPending ? "common.processing" : "common.delete")}</Button></>}
>
<p className="break-words text-sm text-clay-text">{t("integrations.webhooks.deleteConfirm", { url: deleteTarget?.url || "" })}</p>
</Modal>
</section>
);
};
import React, { useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate } from "zmp-ui";
import { AccessDenied } from "@/components/shared/AccessDenied";
import { Tabs } from "@/components/ui/Tabs";
import { IconGradients, KeyIcon, NotificationIcon } from "@/components/ui/icons";
import { PERMISSIONS } from "@/common/constants";
import { usePermission } from "@/hooks/use-permission";
import { useI18n } from "@/i18n";
import { ApiKeyPanel } from "./components/ApiKeyPanel";
import { WebhookPanel } from "./components/WebhookPanel";
type IntegrationTab = "apiKeys" | "webhooks";
const IntegrationsPage: React.FC = () => {
const navigate = useNavigate();
const { t } = useI18n();
const { permissions, hasPermission } = usePermission();
const canReadApiKeys = hasPermission(PERMISSIONS.API_KEY_READ);
const canReadWebhooks = hasPermission(PERMISSIONS.WEBHOOK_READ);
const [activeTab, setActiveTab] = useState<IntegrationTab>(canReadApiKeys ? "apiKeys" : "webhooks");
const tabs = useMemo(() => [
...(canReadApiKeys ? [{ key: "apiKeys", label: t("integrations.tabs.apiKeys") }] : []),
...(canReadWebhooks ? [{ key: "webhooks", label: t("integrations.tabs.webhooks") }] : []),
], [canReadApiKeys, canReadWebhooks, t]);
useEffect(() => {
if (activeTab === "apiKeys" && !canReadApiKeys && canReadWebhooks) setActiveTab("webhooks");
if (activeTab === "webhooks" && !canReadWebhooks && canReadApiKeys) setActiveTab("apiKeys");
}, [activeTab, canReadApiKeys, canReadWebhooks]);
if (!canReadApiKeys && !canReadWebhooks) return <AccessDenied />;
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Header title={t("integrations.header")} showBackIcon onBackClick={() => navigate("/admin")} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-5 px-4 pt-6">
<div className="flex items-center gap-3 px-1">
<span className="relative flex h-12 w-16 items-center justify-center">
<span className="absolute left-0 rounded-clay-sm bg-clay-primary p-2 text-clay-on-primary shadow-clay-raised"><KeyIcon size={20} /></span>
<span className="absolute right-0 rounded-clay-sm bg-clay-info p-2 text-clay-on-primary shadow-clay-raised"><NotificationIcon size={20} /></span>
</span>
<div><h1 className="clay-title-h1">{t("integrations.title")}</h1><p className="text-sm text-clay-text-muted">{t("integrations.subtitle")}</p></div>
</div>
{tabs.length > 1 && <Tabs tabs={tabs} activeTab={activeTab} onChange={(key) => setActiveTab(key as IntegrationTab)} />}
{activeTab === "apiKeys" && canReadApiKeys ? (
<ApiKeyPanel
canCreate={hasPermission(PERMISSIONS.API_KEY_CREATE)}
canDelete={hasPermission(PERMISSIONS.API_KEY_DELETE)}
allowedPermissions={permissions}
/>
) : canReadWebhooks ? (
<WebhookPanel
canCreate={hasPermission(PERMISSIONS.WEBHOOK_CREATE)}
canUpdate={hasPermission(PERMISSIONS.WEBHOOK_UPDATE)}
canDelete={hasPermission(PERMISSIONS.WEBHOOK_DELETE)}
canTest={hasPermission(PERMISSIONS.WEBHOOK_TEST)}
/>
) : null}
</main>
</Page>
);
};
export default IntegrationsPage;
import { PERMISSIONS, PermissionName } from "@/common/constants";
export interface IntegrationOption<T extends string = string> {
value: T;
labelKey: string;
}
export const API_KEY_SCOPE_OPTIONS: ReadonlyArray<IntegrationOption<PermissionName>> = [
{ value: PERMISSIONS.WALLET_READ, labelKey: "integrations.apiKeys.scopes.walletRead" },
{ value: PERMISSIONS.WALLET_CREATE, labelKey: "integrations.apiKeys.scopes.walletCreate" },
{ value: PERMISSIONS.WALLET_UPDATE, labelKey: "integrations.apiKeys.scopes.walletUpdate" },
{ value: PERMISSIONS.WALLET_DELETE, labelKey: "integrations.apiKeys.scopes.walletDelete" },
{ value: PERMISSIONS.CATEGORY_READ, labelKey: "integrations.apiKeys.scopes.categoryRead" },
{ value: PERMISSIONS.CATEGORY_CREATE, labelKey: "integrations.apiKeys.scopes.categoryCreate" },
{ value: PERMISSIONS.CATEGORY_UPDATE, labelKey: "integrations.apiKeys.scopes.categoryUpdate" },
{ value: PERMISSIONS.CATEGORY_DELETE, labelKey: "integrations.apiKeys.scopes.categoryDelete" },
{ value: PERMISSIONS.TRANSACTION_READ, labelKey: "integrations.apiKeys.scopes.transactionRead" },
{ value: PERMISSIONS.TRANSACTION_CREATE, labelKey: "integrations.apiKeys.scopes.transactionCreate" },
{ value: PERMISSIONS.TRANSACTION_UPDATE, labelKey: "integrations.apiKeys.scopes.transactionUpdate" },
{ value: PERMISSIONS.TRANSACTION_DELETE, labelKey: "integrations.apiKeys.scopes.transactionDelete" },
{ value: PERMISSIONS.REPORT_READ, labelKey: "integrations.apiKeys.scopes.reportRead" },
{ value: PERMISSIONS.JOB_READ, labelKey: "integrations.apiKeys.scopes.jobRead" },
{ value: PERMISSIONS.JOB_CREATE, labelKey: "integrations.apiKeys.scopes.jobCreate" },
];
export const WEBHOOK_EVENT_OPTIONS: ReadonlyArray<IntegrationOption> = [
{ value: "transaction.created", labelKey: "integrations.webhooks.events.transactionCreated" },
{ value: "budget.warning", labelKey: "integrations.webhooks.events.budgetWarning" },
{ value: "budget.exceeded", labelKey: "integrations.webhooks.events.budgetExceeded" },
{ value: "saving_goal.achieved", labelKey: "integrations.webhooks.events.savingGoalAchieved" },
{ value: "transaction.anomaly_detected", labelKey: "integrations.webhooks.events.anomalyDetected" },
{ value: "job.completed", labelKey: "integrations.webhooks.events.jobCompleted" },
{ value: "job.failed", labelKey: "integrations.webhooks.events.jobFailed" },
];
import { z } from "zod";
const isPublicHttpUrl = (value: string): boolean => {
try {
const url = new URL(value);
const host = url.hostname.toLowerCase();
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
if (["localhost", "127.0.0.1", "::1", "[::1]"].includes(host)) return false;
if (/^(10\.|127\.|169\.254\.|192\.168\.)/.test(host)) return false;
const private172 = /^172\.(\d{1,3})\./.exec(host);
if (private172 && Number(private172[1]) >= 16 && Number(private172[1]) <= 31) return false;
return !/^\[?(?:f[cd][0-9a-f]{2}|fe80):/i.test(host);
} catch {
return false;
}
};
const isIpAddress = (value: string): boolean => {
const ipv4Parts = value.split(".");
if (ipv4Parts.length === 4) {
return ipv4Parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
}
return value.includes(":")
&& value.replace(/:/g, "").length > 0
&& /^[0-9a-f:]+$/i.test(value)
&& value.split(":").length <= 8;
};
export function createApiKeyFormSchema(messages: { nameRequired: string; nameMax: string; permissionsRequired: string; ipInvalid: string }) {
return z.object({
name: z.string().trim().min(1, messages.nameRequired).max(100, messages.nameMax),
permissions: z.array(z.string()).min(1, messages.permissionsRequired),
ipWhitelist: z.string().refine((value) => value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean)
.every(isIpAddress), messages.ipInvalid),
expiresAt: z.string(),
});
}
export function createWebhookFormSchema(messages: {
urlInvalid: string;
descriptionMax: string;
eventsRequired: string;
}) {
return z.object({
url: z.string().trim().refine(isPublicHttpUrl, messages.urlInvalid),
description: z.string().trim().max(255, messages.descriptionMax),
events: z.array(z.string()).min(1, messages.eventsRequired),
status: z.enum(["ACTIVE", "DISABLED"]),
});
}
export type ApiKeyFormValues = z.infer<ReturnType<typeof createApiKeyFormSchema>>;
export type WebhookFormValues = z.infer<ReturnType<typeof createWebhookFormSchema>>;
import { apiClient } from '@/lib/api-client';
import { ApiResponse } from '@/types/auth';
export type AiFeatureKey = 'assistant' | 'forecasting' | 'anomalies' | 'query';
export type AiFeatureStatus = 'ENABLED' | 'DISABLED' | 'DEGRADED';
export type AiRequestStatus = 'SUCCESS' | 'FAILED' | 'RATE_LIMITED';
export interface AiFeatureStatusItem {
key: AiFeatureKey;
name: string;
enabled: boolean;
status: AiFeatureStatus;
settingKey: string;
provider: string;
model: string;
description: string;
}
export interface AiUsageSummary {
period: 'today' | 'week' | 'month';
totalRequests: number;
successRequests: number;
failedRequests: number;
rateLimitEvents: number;
successRate: number;
avgLatencyMs: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
isUsageAvailable: boolean;
byFeature: Array<{
feature: string;
total: number;
success: number;
failed: number;
avgLatencyMs: number;
totalTokens: number;
}>;
}
export interface AiRequestLogItem {
id: string;
userId: string | null;
feature: string;
provider: string;
model: string;
status: AiRequestStatus;
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
latencyMs: number;
errorMessage: string | null;
createdAt: string;
}
export interface AiRequestLogQueryParams {
feature?: string;
status?: AiRequestStatus;
userId?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
limit?: number;
}
export interface AiRateLimitConfig {
maxRequests: number;
windowMs: number;
}
export const adminAiService = {
async getFeatureStatuses(): Promise<ApiResponse<AiFeatureStatusItem[]>> {
const response = await apiClient.get('/admin/ai/status');
return response.data;
},
async toggleFeature(featureKey: string, enabled: boolean): Promise<ApiResponse<AiFeatureStatusItem>> {
const response = await apiClient.post(`/admin/ai/features/${featureKey}/toggle`, { enabled });
return response.data;
},
async getUsageSummary(params?: { period?: 'today' | 'week' | 'month' }): Promise<ApiResponse<AiUsageSummary>> {
const response = await apiClient.get('/admin/ai/usage', { params });
return response.data;
},
async getLogs(
params?: AiRequestLogQueryParams,
): Promise<ApiResponse<AiRequestLogItem[]> & { meta: { total: number; page: number; limit: number; totalPages: number } }> {
const response = await apiClient.get('/admin/ai/logs', { params });
return response.data;
},
async getRateLimitConfig(): Promise<ApiResponse<AiRateLimitConfig>> {
const response = await apiClient.get('/admin/ai/rate-limit');
return response.data;
},
async updateRateLimitConfig(data: AiRateLimitConfig): Promise<ApiResponse<AiRateLimitConfig>> {
const response = await apiClient.put('/admin/ai/rate-limit', data);
return response.data;
},
};
import { apiClient } from '@/lib/api-client';
import { ApiResponse } from '@/types/auth';
export type NotificationChannel = 'IN_APP' | 'EMAIL' | 'PUSH' | 'ZALO';
export type NotificationDeliveryStatus = 'PENDING' | 'PROCESSING' | 'SENT' | 'FAILED' | 'SKIPPED';
export type NotificationType =
| 'BUDGET_NEAR_LIMIT'
| 'BUDGET_EXCEEDED'
| 'SAVING_GOAL_NEAR_TARGET'
| 'SAVING_GOAL_ACHIEVED'
| 'SAVING_GOAL_DUE_SOON'
| 'RECURRING_PAYMENT_DUE'
| 'UNUSUAL_TRANSACTION'
| 'USER_REMINDER'
| 'SYSTEM';
export interface NotificationOverviewStats {
totalNotifications: number;
totalSent: number;
totalPending: number;
totalFailed: number;
totalSkipped: number;
totalRead: number;
totalUnread: number;
deliverySuccessRate: number;
deliveryFailureRate: number;
channelBreakdown: Array<{
channel: NotificationChannel;
total: number;
sent: number;
failed: number;
pending: number;
skipped: number;
}>;
typeBreakdown: Array<{
type: NotificationType;
count: number;
}>;
}
export interface AdminDeliveryItem {
id: string;
notificationId: string;
channel: NotificationChannel;
status: NotificationDeliveryStatus;
attemptCount: number;
nextAttemptAt: string;
sentAt: string | null;
failureReason: string | null;
createdAt: string;
updatedAt: string;
notification: {
id: string;
type: NotificationType;
priority: string;
title: string;
message: string;
createdAt: string;
};
user: {
id: string;
email: string | null;
fullName: string | null;
};
}
export interface AdminDeliveryQueryParams {
channel?: NotificationChannel;
status?: NotificationDeliveryStatus;
type?: NotificationType;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
order?: 'asc' | 'desc';
}
export interface AdminTemplateItem {
id: string;
type: NotificationType;
channel: NotificationChannel;
language: string;
titleTemplate: string;
bodyTemplate: string;
variables: string[];
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface NotificationChannelConfig {
inAppEnabled: boolean;
emailEnabled: boolean;
zaloEnabled: boolean;
pushEnabled: boolean;
}
export const adminNotificationService = {
async getOverview(params?: { dateFrom?: string; dateTo?: string }): Promise<ApiResponse<NotificationOverviewStats>> {
const response = await apiClient.get('/admin/notifications/overview', { params });
return response.data;
},
async getDeliveries(
params?: AdminDeliveryQueryParams,
): Promise<ApiResponse<AdminDeliveryItem[]> & { meta: { total: number; page: number; limit: number; totalPages: number } }> {
const response = await apiClient.get('/admin/notifications/deliveries', { params });
return response.data;
},
async retryDelivery(id: string): Promise<ApiResponse<AdminDeliveryItem>> {
const response = await apiClient.post(`/admin/notifications/deliveries/${id}/retry`);
return response.data;
},
async getTemplates(params?: {
type?: NotificationType;
channel?: NotificationChannel;
language?: string;
isActive?: boolean;
}): Promise<ApiResponse<AdminTemplateItem[]>> {
const response = await apiClient.get('/admin/notifications/templates', { params });
return response.data;
},
async updateTemplate(
id: string,
data: { titleTemplate?: string; bodyTemplate?: string; isActive?: boolean },
): Promise<ApiResponse<AdminTemplateItem>> {
const response = await apiClient.put(`/admin/notifications/templates/${id}`, data);
return response.data;
},
async getChannelConfig(): Promise<ApiResponse<NotificationChannelConfig>> {
const response = await apiClient.get('/admin/notifications/channels');
return response.data;
},
async updateChannelConfig(data: Partial<NotificationChannelConfig>): Promise<ApiResponse<NotificationChannelConfig>> {
const response = await apiClient.put('/admin/notifications/channels', data);
return response.data;
},
};
import { apiClient } from '@/lib/api-client';
import { ApiResponse } from '@/types/auth';
export type SettingType = 'STRING' | 'NUMBER' | 'BOOLEAN' | 'JSON';
export type SettingCategory = 'GENERAL' | 'SECURITY' | 'NOTIFICATION' | 'AI' | 'SYSTEM';
export interface SystemSettingItem {
key: string;
value: string;
type: SettingType;
category: SettingCategory;
description: string | null;
isPublic: boolean;
isEditable: boolean;
createdAt: string;
updatedAt: string;
updatedBy: string | null;
}
export interface MaintenanceModeConfig {
enabled: boolean;
message?: string;
startAt?: string | null;
endAt?: string | null;
}
export interface PublicSystemConfig {
maintenance: {
enabled: boolean;
message: string;
startAt: string | null;
endAt: string | null;
};
features: {
aiAssistant: boolean;
cashflowForecasting: boolean;
anomalyDetection: boolean;
queryEngine: boolean;
};
general: {
defaultCurrency: string;
defaultTimezone: string;
maxUploadSizeMb: number;
};
}
export const adminSettingsService = {
async getSettings(params?: {
category?: SettingCategory;
isPublic?: boolean;
search?: string;
}): Promise<ApiResponse<SystemSettingItem[]>> {
const response = await apiClient.get('/admin/settings', { params });
return response.data;
},
async getSettingByKey(key: string): Promise<ApiResponse<SystemSettingItem>> {
const response = await apiClient.get(`/admin/settings/${key}`);
return response.data;
},
async updateSetting(key: string, data: { value: any; description?: string }): Promise<ApiResponse<SystemSettingItem>> {
const response = await apiClient.patch(`/admin/settings/${key}`, data);
return response.data;
},
async updateMaintenanceMode(data: MaintenanceModeConfig): Promise<ApiResponse<MaintenanceModeConfig>> {
const response = await apiClient.post('/admin/settings/maintenance', data);
return response.data;
},
async getPublicConfig(): Promise<ApiResponse<PublicSystemConfig>> {
const response = await apiClient.get('/system/public-config');
return response.data;
},
};
import { apiClient } from "@/lib/api-client";
import {
ApiKey,
CreateApiKeyInput,
CreatedApiKey,
CreatedWebhook,
CreateWebhookInput,
IntegrationApiResponse,
IntegrationActionResponse,
TestWebhookResult,
UpdateWebhookInput,
WebhookDelivery,
WebhookEndpoint,
} from "@/types/integration";
function ensureSuccess<T extends { success: boolean; message?: string }>(response: T, fallback: string): T {
if (!response.success) throw new Error(response.message || fallback);
return response;
}
export const integrationService = {
async listApiKeys(): Promise<IntegrationApiResponse<ApiKey[]>> {
const response = await apiClient.get<IntegrationApiResponse<ApiKey[]>>("/api-keys");
return ensureSuccess(response.data, "Unable to load API keys");
},
async createApiKey(input: CreateApiKeyInput): Promise<IntegrationApiResponse<CreatedApiKey>> {
const response = await apiClient.post<IntegrationApiResponse<CreatedApiKey>>("/api-keys", input);
return ensureSuccess(response.data, "Unable to create API key");
},
async revokeApiKey(id: string): Promise<IntegrationActionResponse> {
const response = await apiClient.delete<IntegrationActionResponse>(`/api-keys/${id}`);
return ensureSuccess(response.data, "Unable to revoke API key");
},
async listWebhooks(): Promise<IntegrationApiResponse<WebhookEndpoint[]>> {
const response = await apiClient.get<IntegrationApiResponse<WebhookEndpoint[]>>("/webhooks");
return ensureSuccess(response.data, "Unable to load webhooks");
},
async createWebhook(input: CreateWebhookInput): Promise<IntegrationApiResponse<CreatedWebhook>> {
const response = await apiClient.post<IntegrationApiResponse<CreatedWebhook>>("/webhooks", input);
return ensureSuccess(response.data, "Unable to create webhook");
},
async updateWebhook(id: string, input: UpdateWebhookInput): Promise<IntegrationApiResponse<WebhookEndpoint>> {
const response = await apiClient.put<IntegrationApiResponse<WebhookEndpoint>>(`/webhooks/${id}`, input);
return ensureSuccess(response.data, "Unable to update webhook");
},
async deleteWebhook(id: string): Promise<IntegrationActionResponse> {
const response = await apiClient.delete<IntegrationActionResponse>(`/webhooks/${id}`);
return ensureSuccess(response.data, "Unable to delete webhook");
},
async listDeliveries(id: string): Promise<IntegrationApiResponse<WebhookDelivery[]>> {
const response = await apiClient.get<IntegrationApiResponse<WebhookDelivery[]>>(`/webhooks/${id}/deliveries`);
return ensureSuccess(response.data, "Unable to load webhook deliveries");
},
async testWebhook(id: string): Promise<IntegrationApiResponse<TestWebhookResult>> {
const response = await apiClient.post<IntegrationApiResponse<TestWebhookResult>>(`/webhooks/${id}/test`);
return ensureSuccess(response.data, "Unable to test webhook");
},
async retryDelivery(endpointId: string, deliveryId: string): Promise<IntegrationActionResponse> {
const response = await apiClient.post<IntegrationActionResponse>(
`/webhooks/${endpointId}/deliveries/${deliveryId}/retry`,
);
return ensureSuccess(response.data, "Unable to retry webhook delivery");
},
};
export type ApiKeyStatus = "ACTIVE" | "REVOKED" | "EXPIRED";
export type WebhookStatus = "ACTIVE" | "DISABLED";
export type WebhookDeliveryStatus = "PENDING" | "SUCCESS" | "FAILED";
export interface IntegrationApiResponse<T> {
success: boolean;
data: T;
message?: string;
errors?: unknown[] | null;
}
export interface IntegrationActionResponse {
success: boolean;
data?: undefined;
message?: string;
errors?: unknown[] | null;
}
export interface ApiKey {
id: string;
name: string;
keyPrefix: string;
permissions: string[];
ipWhitelist: string[];
status: ApiKeyStatus;
lastUsedAt: string | null;
expiresAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateApiKeyInput {
name: string;
permissions?: string[];
ipWhitelist?: string[];
expiresAt?: string | null;
}
export interface CreatedApiKey {
apiKey: ApiKey;
rawKey: string;
}
export interface WebhookEndpoint {
id: string;
url: string;
description: string | null;
events: string[];
status: WebhookStatus;
createdAt: string;
updatedAt: string;
}
export interface CreateWebhookInput {
url: string;
description?: string;
events: string[];
}
export interface UpdateWebhookInput {
url?: string;
description?: string;
events?: string[];
status?: WebhookStatus;
}
export interface CreatedWebhook {
endpoint: WebhookEndpoint;
secret: string;
}
export interface WebhookDelivery {
id: string;
eventId: string;
event: string;
status: WebhookDeliveryStatus;
statusCode: number | null;
responseBody: string | null;
attemptCount: number;
maxAttempts: number;
lastAttemptAt: string | null;
nextRetryAt: string | null;
errorMessage: string | null;
createdAt: string;
}
export interface TestWebhookResult {
deliveryId: string;
message: string;
}
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