Commit 34d1e1c2 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/fe-admin-control-center-ai-notifications-settings' into 'develop'

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

See merge request !28
parents db43d70d b3ac64f7
......@@ -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.
- **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).
- **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.
- **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
......@@ -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
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.
- 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)**:
- 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.
......
......@@ -16,7 +16,7 @@
],
"scripts": {
"login": "zmp login",
"start": "zmp start --port 13580",
"start": "zmp start",
"deploy": "zmp deploy",
"build": "vite build"
},
......
......@@ -84,6 +84,12 @@ export const PERMISSIONS = {
// NOTIFICATION
NOTIFICATION_READ: 'NOTIFICATION_READ',
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_READ: 'REMINDER_READ',
......@@ -91,11 +97,35 @@ export const PERMISSIONS = {
REMINDER_UPDATE: 'REMINDER_UPDATE',
REMINDER_DELETE: 'REMINDER_DELETE',
// AI ASSISTANT
// AI ASSISTANT & AI ADMIN
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_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;
export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
......@@ -48,10 +48,15 @@ import AdminDashboardPage from "@/pages/admin/index";
import AdminUsersPage from "@/pages/admin/users/index";
import AdminUserDetailPage from "@/pages/admin/users/detail";
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 { AccessDenied } from "@/components/shared/AccessDenied";
import { PERMISSIONS } from "@/common/constants";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
......@@ -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="/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="/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 */}
<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/: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/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="/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="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes>
</AuthInitializer>
</ZMPRouter>
</SnackbarProvider>
......
......@@ -31,15 +31,20 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/recurring-transactions", key: "document.recurringTransactions" },
{ matches: (pathname) => pathname === "/query", key: "document.query" },
{ 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/users", key: "document.adminUsers" },
{ matches: (pathname) => pathname.startsWith("/admin/users/"), key: "document.adminUserDetail" },
{ 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 === "/ai-assistant", key: "document.aiAssistant" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
];
export const DocumentTitle: React.FC = () => {
const { pathname } = useLocation();
const { t } = useI18n();
......
......@@ -263,3 +263,48 @@ export const EditIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
</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) }),
});
}
This diff is collapsed.
This diff is collapsed.
/**
* 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 '';
}
}
This diff is collapsed.
......@@ -4,10 +4,13 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate';
import {
AIAssistantIcon,
ChevronRightIcon,
ClockIcon,
IconGradients,
KeyIcon,
NotificationIcon,
SettingsIcon,
ShieldIcon,
UserIcon,
} from '@/components/ui/icons';
......@@ -103,9 +106,51 @@ const AdminDashboardPage: React.FC = () => {
<ChevronRightIcon />
</button>
</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>
</section>
<PermissionGate permission={PERMISSIONS.AUDIT_LOG_READ}>
<section aria-labelledby="admin-activity-heading">
<div className="mb-3 flex items-center justify-between gap-3">
......
This diff is collapsed.
......@@ -35,8 +35,15 @@ const ACTION_COLORS: Record<string, string> = {
USE: 'bg-indigo-500/15 text-indigo-600 border-indigo-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',
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 navigate = useNavigate();
const location = useLocation();
......
This diff is collapsed.
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>
);
};
This diff is collapsed.
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>
);
};
This diff is collapsed.
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