Commit be29c7a3 authored by ThinhNC's avatar ThinhNC

fix: resolve 20 audit findings and stabilize BFF proxy integration

parent 8d925115
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "node --max-old-space-size=4096 ./node_modules/eslint/bin/eslint.js ."
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
......
...@@ -13,17 +13,14 @@ import { Button } from "@/components/ui/button"; ...@@ -13,17 +13,14 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { getSafeRedirectUrl } from "@/lib/utils";
function LoginForm() { function LoginForm() {
const { t } = useLanguage(); const { t } = useLanguage();
const { login } = useAuth(); const { login } = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const rawRedirect = searchParams.get("redirect") || "/"; const redirectUrl = getSafeRedirectUrl(searchParams.get("redirect"), "/");
// Never redirect back to auth pages to prevent loops
const redirectUrl =
rawRedirect.startsWith("/login") || rawRedirect.startsWith("/register")
? "/"
: rawRedirect;
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [authError, setAuthError] = useState<string | null>(null); const [authError, setAuthError] = useState<string | null>(null);
...@@ -45,8 +42,8 @@ function LoginForm() { ...@@ -45,8 +42,8 @@ function LoginForm() {
setAuthError(null); setAuthError(null);
try { try {
await login(data, data.rememberMe ?? true); await login(data, data.rememberMe ?? true);
// Force hard reload navigation so Next.js server components and middleware receive fresh HttpOnly cookies router.replace(redirectUrl);
window.location.href = redirectUrl; router.refresh();
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {
setAuthError(err.message); setAuthError(err.message);
......
...@@ -26,12 +26,13 @@ function VerifyEmailContent() { ...@@ -26,12 +26,13 @@ function VerifyEmailContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const tokenParam = searchParams.get("token"); const tokenParam = searchParams.get("token");
const [status, setStatus] = useState<VerifyStatus>("idle"); const [status, setStatus] = useState<VerifyStatus>(tokenParam ? "verifying" : "idle");
const [errorMessage, setErrorMessage] = useState<string>(""); const [errorMessage, setErrorMessage] = useState<string>("");
const [manualToken, setManualToken] = useState(""); const [manualToken, setManualToken] = useState("");
const [resendEmail, setResendEmail] = useState(""); const [resendEmail, setResendEmail] = useState("");
const [isResending, setIsResending] = useState(false); const [isResending, setIsResending] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false); const [resendSuccess, setResendSuccess] = useState(false);
const verifiedRef = React.useRef(false);
const handleVerify = useCallback(async (tokenToVerify: string) => { const handleVerify = useCallback(async (tokenToVerify: string) => {
if (!tokenToVerify) return; if (!tokenToVerify) return;
...@@ -52,10 +53,25 @@ function VerifyEmailContent() { ...@@ -52,10 +53,25 @@ function VerifyEmailContent() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (tokenParam) { if (!tokenParam || verifiedRef.current) return;
handleVerify(tokenParam); verifiedRef.current = true;
let isMounted = true;
authService.verifyEmail(tokenParam)
.then(() => {
if (isMounted) setStatus("success");
})
.catch((err: unknown) => {
if (isMounted) {
setStatus("error");
setErrorMessage(err instanceof Error ? err.message : "Mã kích hoạt không hợp lệ hoặc đã hết hạn.");
} }
}, [tokenParam, handleVerify]); });
return () => {
isMounted = false;
};
}, [tokenParam]);
const handleManualSubmit = (e: React.FormEvent) => { const handleManualSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
......
...@@ -41,7 +41,7 @@ async function handleProxy( ...@@ -41,7 +41,7 @@ async function handleProxy(
} }
let body: BodyInit | undefined = undefined; let body: BodyInit | undefined = undefined;
if (["POST", "PUT", "PATCH"].includes(request.method)) { if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) {
const rawBody = await request.arrayBuffer(); const rawBody = await request.arrayBuffer();
if (rawBody.byteLength > 0) { if (rawBody.byteLength > 0) {
body = Buffer.from(rawBody); body = Buffer.from(rawBody);
......
...@@ -114,6 +114,11 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -114,6 +114,11 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
const wasRunningRef = useRef<boolean>(false); const wasRunningRef = useRef<boolean>(false);
const hasNotifiedRef = useRef<boolean>(false); const hasNotifiedRef = useRef<boolean>(false);
const isJobInitialActive = !initialJob || initialJob.status === "RUNNING" || initialJob.status === "PROCESSING_EXPORT" || initialJob.status === "PENDING";
const isJobActive = liveProgressJob
? liveProgressJob.status === "RUNNING" || liveProgressJob.status === "PROCESSING_EXPORT" || liveProgressJob.status === "PENDING"
: isJobInitialActive;
const { const {
status: sseStatus, status: sseStatus,
retryCount: sseRetryCount, retryCount: sseRetryCount,
...@@ -122,7 +127,7 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -122,7 +127,7 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
} = useEventSource<CrawlJob>( } = useEventSource<CrawlJob>(
jobId ? `/api/proxy/crawl-jobs/${jobId}/events` : null, jobId ? `/api/proxy/crawl-jobs/${jobId}/events` : null,
{ {
enabled: Boolean(jobId), enabled: Boolean(jobId) && isJobActive,
onInitial: (data) => setLiveProgressJob(data), onInitial: (data) => setLiveProgressJob(data),
onProgress: (data) => { onProgress: (data) => {
setLiveProgressJob(data); setLiveProgressJob(data);
...@@ -179,18 +184,18 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -179,18 +184,18 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
} }
}, [job?.status, job?.id, job?.successPages, job?.totalPages, job?.domain, t.notifications]); }, [job?.status, job?.id, job?.successPages, job?.totalPages, job?.domain, t.notifications]);
// Query Logs (active when on logs tab) // Query Logs (active when on logs tab and job is active)
const { data: logsData, isLoading: isLogsLoading, refetch: refetchLogs } = useCrawlJobLogs( const { data: logsData, isLoading: isLogsLoading, refetch: refetchLogs } = useCrawlJobLogs(
jobId, jobId,
{ limit: 100 }, { limit: 100 },
{ refetchInterval: activeTab === "logs" ? 4000 : false } { refetchInterval: activeTab === "logs" && isJobActive ? 4000 : false }
); );
// Query Crawled Pages (active when on pages tab, auto-poll while running) // Query Crawled Pages (active when on pages tab, auto-poll while running)
const { data: pagesData, isLoading: isPagesLoading, refetch: refetchPages } = useCrawlJobPages( const { data: pagesData, isLoading: isPagesLoading, refetch: refetchPages } = useCrawlJobPages(
jobId, jobId,
{ limit: 50 }, { limit: 50 },
{ refetchInterval: activeTab === "pages" && (liveProgressJob?.status === "RUNNING" || initialJob?.status === "RUNNING") ? 3000 : false } { refetchInterval: activeTab === "pages" && isJobActive ? 3000 : false }
); );
// Query Diff Report (active when on diff tab) // Query Diff Report (active when on diff tab)
......
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
import { ApiKey } from "@/types/developer"; import { ApiKey } from "@/types/developer";
import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal"; import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal";
import { CopyButton } from "@/components/common/copy-button"; import { CopyButton } from "@/components/common/copy-button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export default function ApiKeysPage() { export default function ApiKeysPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
...@@ -19,6 +20,7 @@ export default function ApiKeysPage() { ...@@ -19,6 +20,7 @@ export default function ApiKeysPage() {
const toggleApiKeyMutation = useToggleApiKey(); const toggleApiKeyMutation = useToggleApiKey();
const revokeApiKeyMutation = useRevokeApiKey(); const revokeApiKeyMutation = useRevokeApiKey();
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false); const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\ const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\
-H "X-API-Key: dc_live_your_secret_key_here" \\ -H "X-API-Key: dc_live_your_secret_key_here" \\
...@@ -51,8 +53,13 @@ console.log('Job Created:', response.data);`; ...@@ -51,8 +53,13 @@ console.log('Job Created:', response.data);`;
}; };
const handleRevokeKey = (key: ApiKey) => { const handleRevokeKey = (key: ApiKey) => {
if (window.confirm(`${t.developer.apiKeys.table.revokeConfirm}\n("${key.name}")`)) { setKeyToRevoke(key);
revokeApiKeyMutation.mutate(key.id); };
const confirmRevokeKey = () => {
if (keyToRevoke) {
revokeApiKeyMutation.mutate(keyToRevoke.id);
setKeyToRevoke(null);
} }
}; };
...@@ -257,6 +264,25 @@ console.log('Job Created:', response.data);`; ...@@ -257,6 +264,25 @@ console.log('Job Created:', response.data);`;
isOpen={isKeyModalOpen} isOpen={isKeyModalOpen}
onClose={() => setIsKeyModalOpen(false)} onClose={() => setIsKeyModalOpen(false)}
/> />
{/* Revoke Confirmation Dialog */}
<ConfirmDialog
open={!!keyToRevoke}
onOpenChange={(open) => !open && setKeyToRevoke(null)}
title={t.developer.apiKeys.table.revoke}
description={
keyToRevoke ? (
<span>
{t.developer.apiKeys.table.revokeConfirm}:{" "}
<strong className="text-foreground">{keyToRevoke.name}</strong>
</span>
) : undefined
}
confirmText={t.developer.apiKeys.table.revoke}
variant="destructive"
isLoading={revokeApiKeyMutation.isPending}
onConfirm={confirmRevokeKey}
/>
</div> </div>
); );
} }
...@@ -29,6 +29,7 @@ import { WebhookConfig, WebhookDelivery } from "@/types/developer"; ...@@ -29,6 +29,7 @@ import { WebhookConfig, WebhookDelivery } from "@/types/developer";
import { CreateWebhookModal } from "@/components/developer/create-webhook-modal"; import { CreateWebhookModal } from "@/components/developer/create-webhook-modal";
import { CopyButton } from "@/components/common/copy-button"; import { CopyButton } from "@/components/common/copy-button";
import { JsonViewer } from "@/components/common/json-viewer"; import { JsonViewer } from "@/components/common/json-viewer";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export default function WebhooksPage() { export default function WebhooksPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
...@@ -41,6 +42,7 @@ export default function WebhooksPage() { ...@@ -41,6 +42,7 @@ export default function WebhooksPage() {
const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false); const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null); const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null);
const [selectedDelivery, setSelectedDelivery] = useState<WebhookDelivery | null>(null); const [selectedDelivery, setSelectedDelivery] = useState<WebhookDelivery | null>(null);
const [webhookToDelete, setWebhookToDelete] = useState<WebhookConfig | null>(null);
const deliveries = deliveriesData?.items || []; const deliveries = deliveriesData?.items || [];
...@@ -49,8 +51,13 @@ export default function WebhooksPage() { ...@@ -49,8 +51,13 @@ export default function WebhooksPage() {
}; };
const handleDeleteWebhook = (wh: WebhookConfig) => { const handleDeleteWebhook = (wh: WebhookConfig) => {
if (window.confirm(`${t.developer.webhooks.table.deleteConfirm}\n("${wh.url}")`)) { setWebhookToDelete(wh);
deleteWebhookMutation.mutate(wh.id); };
const confirmDeleteWebhook = () => {
if (webhookToDelete) {
deleteWebhookMutation.mutate(webhookToDelete.id);
setWebhookToDelete(null);
} }
}; };
......
...@@ -53,8 +53,12 @@ export default function ProfileSettingsPage() { ...@@ -53,8 +53,12 @@ export default function ProfileSettingsPage() {
const revokeSessionsMutation = useRevokeAllSessions(); const revokeSessionsMutation = useRevokeAllSessions();
// Form states // Form states
const [fullName, setFullName] = useState(user?.fullName || ""); const [userEnteredFullName, setUserEnteredFullName] = useState<string | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatarUrl || null); const fullName = userEnteredFullName ?? (profile?.fullName ?? user?.fullName ?? "");
const [localAvatarPreview, setLocalAvatarPreview] = useState<string | null>(null);
const avatarPreview = localAvatarPreview ?? (profile?.avatarUrl ?? user?.avatarUrl ?? null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Copy ID state // Copy ID state
...@@ -73,21 +77,6 @@ export default function ProfileSettingsPage() { ...@@ -73,21 +77,6 @@ export default function ProfileSettingsPage() {
const [deactivatePassword, setDeactivatePassword] = useState(""); const [deactivatePassword, setDeactivatePassword] = useState("");
const [showDeactivatePassword, setShowDeactivatePassword] = useState(false); const [showDeactivatePassword, setShowDeactivatePassword] = useState(false);
// Keep state synced with user profile
React.useEffect(() => {
if (profile) {
if (profile.fullName !== undefined && profile.fullName !== null) {
setFullName(profile.fullName);
}
if (profile.avatarUrl) setAvatarPreview(profile.avatarUrl);
} else if (user) {
if (user.fullName !== undefined && user.fullName !== null) {
setFullName(user.fullName);
}
if (user.avatarUrl) setAvatarPreview(user.avatarUrl);
}
}, [profile, user]);
// Compute if personal profile has changes // Compute if personal profile has changes
const initialFullName = (profile?.fullName ?? user?.fullName ?? "").trim(); const initialFullName = (profile?.fullName ?? user?.fullName ?? "").trim();
const isProfileChanged = const isProfileChanged =
...@@ -112,7 +101,7 @@ export default function ProfileSettingsPage() { ...@@ -112,7 +101,7 @@ export default function ProfileSettingsPage() {
// Instant local preview // Instant local preview
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
setAvatarPreview(reader.result as string); setLocalAvatarPreview(reader.result as string);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
...@@ -124,12 +113,19 @@ export default function ProfileSettingsPage() { ...@@ -124,12 +113,19 @@ export default function ProfileSettingsPage() {
const handleUpdateProfile = (e: React.FormEvent) => { const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!isProfileChanged || updateProfileMutation.isPending) return; if (!isProfileChanged || updateProfileMutation.isPending) return;
updateProfileMutation.mutate({ fullName: fullName.trim() }); updateProfileMutation.mutate(
{ fullName: fullName.trim() },
{
onSuccess: () => {
setUserEnteredFullName(null);
},
}
);
}; };
// Revert Profile Name Handler // Revert Profile Name Handler
const handleCancelUpdateProfile = () => { const handleCancelUpdateProfile = () => {
setFullName(profile?.fullName ?? user?.fullName ?? ""); setUserEnteredFullName(null);
}; };
// Change Password Handler // Change Password Handler
...@@ -341,7 +337,7 @@ export default function ProfileSettingsPage() { ...@@ -341,7 +337,7 @@ export default function ProfileSettingsPage() {
<Input <Input
id="prof-name" id="prof-name"
value={fullName} value={fullName}
onChange={(e) => setFullName(e.target.value)} onChange={(e) => setUserEnteredFullName(e.target.value)}
placeholder={t.profile.personal.namePlaceholder} placeholder={t.profile.personal.namePlaceholder}
className="rounded-2xl text-xs" className="rounded-2xl text-xs"
/> />
......
...@@ -9,6 +9,7 @@ import { AuditLogItem, AuditLogQuery } from "@/types/audit-log"; ...@@ -9,6 +9,7 @@ import { AuditLogItem, AuditLogQuery } from "@/types/audit-log";
import { AuditLogsFilters, DatePreset } from "./audit-logs-filters"; import { AuditLogsFilters, DatePreset } from "./audit-logs-filters";
import { AuditLogsTable } from "./audit-logs-table"; import { AuditLogsTable } from "./audit-logs-table";
import { AuditLogDetailDrawer } from "./audit-log-detail-drawer"; import { AuditLogDetailDrawer } from "./audit-log-detail-drawer";
import { getLocalDateRangeISO } from "@/lib/utils";
interface AuditLogsViewProps { interface AuditLogsViewProps {
showHeader?: boolean; showHeader?: boolean;
...@@ -59,10 +60,11 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) { ...@@ -59,10 +60,11 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) {
const now = new Date(); const now = new Date();
if (datePreset === "today") { if (datePreset === "today") {
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
return { return {
queryStartDate: startOfDay.toISOString(), queryStartDate: startOfDay.toISOString(),
queryEndDate: now.toISOString(), queryEndDate: endOfDay.toISOString(),
}; };
} }
if (datePreset === "7d") { if (datePreset === "7d") {
...@@ -80,9 +82,10 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) { ...@@ -80,9 +82,10 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) {
}; };
} }
if (datePreset === "custom") { if (datePreset === "custom") {
const { startDate: qStart, endDate: qEnd } = getLocalDateRangeISO(startDate, endDate);
return { return {
queryStartDate: startDate ? new Date(startDate).toISOString() : undefined, queryStartDate: qStart,
queryEndDate: endDate ? new Date(`${endDate}T23:59:59.999Z`).toISOString() : undefined, queryEndDate: qEnd,
}; };
} }
......
...@@ -6,14 +6,12 @@ import { usePathname } from "next/navigation"; ...@@ -6,14 +6,12 @@ import { usePathname } from "next/navigation";
import { Activity, Calendar, Download, FileCode2, Globe } from "lucide-react"; import { Activity, Calendar, Download, FileCode2, Globe } from "lucide-react";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
const emptySubscribe = () => () => {};
export function BottomNav() { export function BottomNav() {
const { t } = useLanguage(); const { t } = useLanguage();
const pathname = usePathname(); const pathname = usePathname();
const [mounted, setMounted] = useState(false); const mounted = React.useSyncExternalStore(emptySubscribe, () => true, () => false);
useEffect(() => {
setMounted(true);
}, []);
const navItems = [ const navItems = [
{ {
......
...@@ -99,13 +99,13 @@ export function SafeHtmlPreview({ ...@@ -99,13 +99,13 @@ export function SafeHtmlPreview({
<ShieldCheck className="h-3.5 w-3.5 shrink-0" /> <ShieldCheck className="h-3.5 w-3.5 shrink-0" />
<span>Cô lập bảo mật Sandbox &amp; DOMPurify (Chống XSS)</span> <span>Cô lập bảo mật Sandbox &amp; DOMPurify (Chống XSS)</span>
</div> </div>
<span className="text-[10px] text-muted-foreground font-mono">sandbox=&quot;allow-same-origin&quot;</span> <span className="text-[10px] text-muted-foreground font-mono">sandbox=&quot;&quot; (Strict Isolation)</span>
</div> </div>
<iframe <iframe
title={title} title={title}
srcDoc={srcDoc} srcDoc={srcDoc}
sandbox="allow-same-origin" sandbox=""
className="w-full border-0 transition-all bg-background" className="w-full border-0 transition-all bg-background"
style={{ height: maxHeight }} style={{ height: maxHeight }}
/> />
......
...@@ -121,6 +121,9 @@ export function UserMenu() { ...@@ -121,6 +121,9 @@ export function UserMenu() {
{/* Trigger Button */} {/* Trigger Button */}
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label={user.fullName || user.email}
className="flex items-center gap-2 rounded-2xl border border-border/80 bg-card/60 p-1.5 pl-2 pr-2.5 text-xs text-foreground hover:bg-muted/60 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-emerald-500/30" className="flex items-center gap-2 rounded-2xl border border-border/80 bg-card/60 p-1.5 pl-2 pr-2.5 text-xs text-foreground hover:bg-muted/60 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-emerald-500/30"
> >
{/* Avatar */} {/* Avatar */}
...@@ -146,7 +149,11 @@ export function UserMenu() { ...@@ -146,7 +149,11 @@ export function UserMenu() {
{/* Dropdown Menu */} {/* Dropdown Menu */}
{isOpen && ( {isOpen && (
<div className="absolute right-0 mt-2 w-64 rounded-3xl border border-emerald-500/15 bg-card/95 p-3 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150"> <div
role="menu"
aria-orientation="vertical"
className="absolute right-0 mt-2 w-64 rounded-3xl border border-emerald-500/15 bg-card/95 p-3 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150"
>
{/* User Header */} {/* User Header */}
<div className="rounded-2xl border border-border/60 bg-muted/40 p-3 mb-2"> <div className="rounded-2xl border border-border/60 bg-muted/40 p-3 mb-2">
<p className="font-semibold text-xs text-foreground truncate"> <p className="font-semibold text-xs text-foreground truncate">
...@@ -170,6 +177,7 @@ export function UserMenu() { ...@@ -170,6 +177,7 @@ export function UserMenu() {
<div className="space-y-1"> <div className="space-y-1">
<Link <Link
href="/settings/profile" href="/settings/profile"
role="menuitem"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
...@@ -180,6 +188,7 @@ export function UserMenu() { ...@@ -180,6 +188,7 @@ export function UserMenu() {
{canAccessDeveloper && ( {canAccessDeveloper && (
<Link <Link
href={developerHref} href={developerHref}
role="menuitem"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
...@@ -194,6 +203,7 @@ export function UserMenu() { ...@@ -194,6 +203,7 @@ export function UserMenu() {
{/* Logout Action */} {/* Logout Action */}
<button <button
role="menuitem"
onClick={() => { onClick={() => {
setIsOpen(false); setIsOpen(false);
logout(); logout();
......
...@@ -158,15 +158,8 @@ export function CrawlerTaskTable() { ...@@ -158,15 +158,8 @@ export function CrawlerTaskTable() {
}; };
const jobs = jobsData?.items || []; const jobs = jobsData?.items || [];
const rawTotal = const total = jobsData?.total !== undefined && jobsData.total > 0 ? jobsData.total : jobs.length;
jobsData?.total ?? const totalPages = Math.max(1, jobsData?.totalPages ?? Math.ceil(total / limit));
(jobsData as unknown as { meta?: { total?: number } })?.meta?.total;
const total =
rawTotal !== undefined && rawTotal > 0 ? rawTotal : jobs.length;
const rawTotalPages =
jobsData?.totalPages ??
(jobsData as unknown as { meta?: { totalPages?: number } })?.meta?.totalPages;
const totalPages = Math.max(1, rawTotalPages ?? Math.ceil(total / limit));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
......
...@@ -2,12 +2,18 @@ ...@@ -2,12 +2,18 @@
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from "react";
import { import {
X,
KeyRound, KeyRound,
Sparkles, Sparkles,
ChevronDown, ChevronDown,
Check, Check,
} from "lucide-react"; } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
...@@ -66,8 +72,6 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -66,8 +72,6 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, []); }, []);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) return; if (!name.trim()) return;
...@@ -90,34 +94,26 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -90,34 +94,26 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200"> <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<div className="relative w-full max-w-lg flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden"> <DialogContent className="max-w-lg rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60"> <DialogHeader className="flex flex-row items-center gap-3 space-y-0 text-left pb-4 border-b border-border/60">
<div className="flex items-center gap-3"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<KeyRound className="h-5 w-5" /> <KeyRound className="h-5 w-5" />
</div> </div>
<div> <div>
<h2 className="text-base font-bold tracking-tight text-foreground"> <DialogTitle className="text-base font-bold tracking-tight text-foreground">
{t.developer.apiKeys.modal.title} {t.developer.apiKeys.modal.title}
</h2> </DialogTitle>
<p className="text-xs text-muted-foreground"> <DialogDescription className="text-xs text-muted-foreground mt-0.5">
{t.developer.apiKeys.modal.desc} {t.developer.apiKeys.modal.desc}
</p> </DialogDescription>
</div>
</div>
<button
onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div> </div>
</DialogHeader>
{/* Content: Form or Secret Key View */} {/* Content: Form or Secret Key View */}
{createdKeyData ? ( {createdKeyData ? (
<div className="py-4 space-y-4"> <div className="py-2 space-y-4">
<div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2"> <div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs"> <div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
...@@ -157,7 +153,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -157,7 +153,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
</div> </div>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="py-4 space-y-4"> <form onSubmit={handleSubmit} className="py-2 space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="api-key-name" className="text-xs font-semibold"> <Label htmlFor="api-key-name" className="text-xs font-semibold">
{t.developer.apiKeys.modal.nameLabel} {t.developer.apiKeys.modal.nameLabel}
...@@ -227,7 +223,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -227,7 +223,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={onClose} onClick={handleClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer" className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
> >
{t.developer.apiKeys.modal.cancel} {t.developer.apiKeys.modal.cancel}
...@@ -244,7 +240,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -244,7 +240,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
</div> </div>
</form> </form>
)} )}
</div> </DialogContent>
</div> </Dialog>
); );
} }
...@@ -2,12 +2,18 @@ ...@@ -2,12 +2,18 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { import {
X,
Webhook, Webhook,
Check, Check,
RefreshCw, RefreshCw,
ShieldCheck, ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
...@@ -82,8 +88,6 @@ export function CreateWebhookModal({ ...@@ -82,8 +88,6 @@ export function CreateWebhookModal({
onClose(); onClose();
}; };
if (!isOpen) return null;
const toggleEvent = (eventVal: string) => { const toggleEvent = (eventVal: string) => {
setSelectedEvents((prev) => setSelectedEvents((prev) =>
prev.includes(eventVal) prev.includes(eventVal)
...@@ -123,40 +127,32 @@ export function CreateWebhookModal({ ...@@ -123,40 +127,32 @@ export function CreateWebhookModal({
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200"> <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<div className="relative w-full max-w-xl flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden"> <DialogContent className="max-w-xl rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 max-h-[90vh] overflow-y-auto">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60"> <DialogHeader className="flex flex-row items-center gap-3 space-y-0 text-left pb-4 border-b border-border/60">
<div className="flex items-center gap-3"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Webhook className="h-5 w-5" /> <Webhook className="h-5 w-5" />
</div> </div>
<div> <div>
<h2 className="text-base font-bold tracking-tight text-foreground"> <DialogTitle className="text-base font-bold tracking-tight text-foreground">
{createdSecretData {createdSecretData
? t.developer.webhooks.modal.newSecretNotice.title ? t.developer.webhooks.modal.newSecretNotice.title
: isEditing : isEditing
? t.developer.webhooks.modal.editTitle ? t.developer.webhooks.modal.editTitle
: t.developer.webhooks.modal.title} : t.developer.webhooks.modal.title}
</h2> </DialogTitle>
<p className="text-xs text-muted-foreground"> <DialogDescription className="text-xs text-muted-foreground mt-0.5">
{createdSecretData {createdSecretData
? t.developer.webhooks.modal.newSecretNotice.desc ? t.developer.webhooks.modal.newSecretNotice.desc
: t.developer.webhooks.modal.desc} : t.developer.webhooks.modal.desc}
</p> </DialogDescription>
</div>
</div>
<button
onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div> </div>
</DialogHeader>
{/* Content: Form or 1-Time Secret View */} {/* Content: Form or 1-Time Secret View */}
{createdSecretData ? ( {createdSecretData ? (
<div className="py-4 space-y-4"> <div className="py-2 space-y-4">
<div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2"> <div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs"> <div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs">
<ShieldCheck className="h-4 w-4" /> <ShieldCheck className="h-4 w-4" />
...@@ -198,7 +194,7 @@ export function CreateWebhookModal({ ...@@ -198,7 +194,7 @@ export function CreateWebhookModal({
</div> </div>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="py-4 space-y-4"> <form onSubmit={handleSubmit} className="py-2 space-y-4">
{/* Endpoint URL */} {/* Endpoint URL */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-semibold"> <Label className="text-xs font-semibold">
...@@ -297,7 +293,7 @@ export function CreateWebhookModal({ ...@@ -297,7 +293,7 @@ export function CreateWebhookModal({
</div> </div>
</form> </form>
)} )}
</div> </DialogContent>
</div> </Dialog>
); );
} }
...@@ -82,16 +82,12 @@ export function CreateExportModal({ ...@@ -82,16 +82,12 @@ export function CreateExportModal({
const { data: jobsData } = useCrawlJobs({ limit: 10 }); const { data: jobsData } = useCrawlJobs({ limit: 10 });
const createExportMutation = useCreateCrawlExport(); const createExportMutation = useCreateCrawlExport();
const [selectedJobId, setSelectedJobId] = useState(defaultJobId); const [userSelectedJobId, setUserSelectedJobId] = useState<string | null>(null);
const selectedJobId = userSelectedJobId ?? defaultJobId;
const setSelectedJobId = (id: string) => setUserSelectedJobId(id);
const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV"); const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV");
const [customFileName, setCustomFileName] = useState(""); const [customFileName, setCustomFileName] = useState("");
useEffect(() => {
if (defaultJobId) {
setSelectedJobId(defaultJobId);
}
}, [defaultJobId, isOpen]);
const jobs = jobsData?.items || []; const jobs = jobsData?.items || [];
const completedJobs = jobs.filter((j) => j.status === "COMPLETED"); const completedJobs = jobs.filter((j) => j.status === "COMPLETED");
const isDefaultInCompleted = completedJobs.some((j) => j.id === defaultJobId); const isDefaultInCompleted = completedJobs.some((j) => j.id === defaultJobId);
...@@ -166,7 +162,7 @@ export function CreateExportModal({ ...@@ -166,7 +162,7 @@ export function CreateExportModal({
<button <button
key={job.id} key={job.id}
type="button" type="button"
onClick={() => setSelectedJobId(job.id)} onClick={() => setUserSelectedJobId(job.id)}
className={`w-full flex items-center justify-between p-2.5 rounded-2xl border text-left text-xs transition-colors cursor-pointer ${ className={`w-full flex items-center justify-between p-2.5 rounded-2xl border text-left text-xs transition-colors cursor-pointer ${
selectedJobId === job.id selectedJobId === job.id
? "border-emerald-500/40 bg-emerald-500/10 text-foreground shadow-sm" ? "border-emerald-500/40 bg-emerald-500/10 text-foreground shadow-sm"
......
...@@ -129,24 +129,26 @@ export function PermissionMatrixDialog({ ...@@ -129,24 +129,26 @@ export function PermissionMatrixDialog({
// Mutation to save matrix: PUT /roles/:id/permissions // Mutation to save matrix: PUT /roles/:id/permissions
const setPermissionsMutation = useSetRolePermissions(); const setPermissionsMutation = useSetRolePermissions();
// Local state of selected permission IDs // User modified permission IDs (or null if using server assigned permissions)
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [userSelectedIds, setUserSelectedIds] = useState<string[] | null>(null);
const [lastRoleId, setLastRoleId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
// Sync initial permissions into local state with equality check if (role?.id !== lastRoleId) {
useEffect(() => { setLastRoleId(role?.id || null);
if (!isOpen || !rolePermissions) return; setUserSelectedIds(null);
const initialIds = rolePermissions.map((p) => p.id);
setSelectedIds((prev) => {
if (
prev.length === initialIds.length &&
prev.every((id) => initialIds.includes(id))
) {
return prev;
} }
return initialIds;
}); const rolePermIds = useMemo(() => rolePermissions?.map((p) => p.id) || [], [rolePermissions]);
}, [isOpen, rolePermissions]); const selectedIds = userSelectedIds ?? rolePermIds;
const setSelectedIds = (action: string[] | ((prev: string[]) => string[])) => {
if (typeof action === "function") {
setUserSelectedIds(action(selectedIds));
} else {
setUserSelectedIds(action);
}
};
// Handle escape key // Handle escape key
useEffect(() => { useEffect(() => {
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { X, ShieldPlus, Edit3, Sparkles, Gauge, Sliders, ChevronDown } from "lucide-react"; import { X, ShieldPlus, Edit3, Sparkles, Gauge, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
...@@ -11,8 +11,8 @@ import { useLanguage } from "@/providers/language-provider"; ...@@ -11,8 +11,8 @@ import { useLanguage } from "@/providers/language-provider";
import { RoleItem } from "@/types/role"; import { RoleItem } from "@/types/role";
import { useCreateRole, useUpdateRole } from "@/hooks/use-roles"; import { useCreateRole, useUpdateRole } from "@/hooks/use-roles";
import { import {
createRoleSchema, roleFormSchema,
CreateRoleInput, RoleFormInput,
} from "@/schemas/role.schema"; } from "@/schemas/role.schema";
interface RoleFormModalProps { interface RoleFormModalProps {
...@@ -21,7 +21,7 @@ interface RoleFormModalProps { ...@@ -21,7 +21,7 @@ interface RoleFormModalProps {
roleToEdit?: RoleItem | null; roleToEdit?: RoleItem | null;
} }
type RoleFormData = CreateRoleInput & { syncUsersQuota?: boolean }; type RoleFormData = RoleFormInput;
export function RoleFormModal({ export function RoleFormModal({
isOpen, isOpen,
...@@ -43,7 +43,7 @@ export function RoleFormModal({ ...@@ -43,7 +43,7 @@ export function RoleFormModal({
reset, reset,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<RoleFormData>({ } = useForm<RoleFormData>({
resolver: zodResolver(createRoleSchema) as any, resolver: zodResolver(roleFormSchema),
defaultValues: { defaultValues: {
name: "", name: "",
slug: "", slug: "",
...@@ -118,7 +118,7 @@ export function RoleFormModal({ ...@@ -118,7 +118,7 @@ export function RoleFormModal({
if (!isOpen) return null; if (!isOpen) return null;
const onSubmit = async (data: RoleFormData) => { const onSubmit = async (data: RoleFormData) => {
const parseOptionalNumber = (val: any) => { const parseOptionalNumber = (val: unknown) => {
if (val === "" || val === null || val === undefined || isNaN(Number(val))) { if (val === "" || val === null || val === undefined || isNaN(Number(val))) {
return null; return null;
} }
......
...@@ -22,21 +22,20 @@ export function RoleResetQuotaDialog({ ...@@ -22,21 +22,20 @@ export function RoleResetQuotaDialog({
const resetMutation = useResetRoleQuota(); const resetMutation = useResetRoleQuota();
const [syncLimits, setSyncLimits] = useState(false); const [syncLimits, setSyncLimits] = useState(false);
useEffect(() => {
if (isOpen) {
setSyncLimits(false);
}
}, [isOpen]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) { if (e.key === "Escape" && isOpen) {
onClose(); handleClose();
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]); }, [isOpen]);
const handleClose = () => {
setSyncLimits(false);
onClose();
};
if (!isOpen || !role) return null; if (!isOpen || !role) return null;
...@@ -45,7 +44,7 @@ export function RoleResetQuotaDialog({ ...@@ -45,7 +44,7 @@ export function RoleResetQuotaDialog({
id: role.id, id: role.id,
payload: { syncLimits }, payload: { syncLimits },
}); });
onClose(); handleClose();
}; };
return ( return (
......
...@@ -77,9 +77,12 @@ export function RolesManagementView({ ...@@ -77,9 +77,12 @@ export function RolesManagementView({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [search]); }, [search]);
useEffect(() => { const [prevFilterKey, setPrevFilterKey] = useState("");
const currentFilterKey = `${debouncedSearch}|${selectedStatus}|${selectedType}`;
if (currentFilterKey !== prevFilterKey) {
setPrevFilterKey(currentFilterKey);
setPage(1); setPage(1);
}, [debouncedSearch, selectedStatus, selectedType]); }
// Click outside dropdowns // Click outside dropdowns
useEffect(() => { useEffect(() => {
......
...@@ -72,7 +72,11 @@ export function SystemConfigFormModal({ ...@@ -72,7 +72,11 @@ export function SystemConfigFormModal({
const categoryRef = useRef<HTMLDivElement>(null); const categoryRef = useRef<HTMLDivElement>(null);
// Detect and initialize values when modal opens or config changes // Detect and initialize values when modal opens or config changes
useEffect(() => { const [lastConfigKey, setLastConfigKey] = useState<string | null>(null);
const currentKey = isOpen ? (config?.key || "__new__") : "__closed__";
if (currentKey !== lastConfigKey) {
setLastConfigKey(currentKey);
if (config) { if (config) {
setKey(config.key); setKey(config.key);
setDescription(config.description || ""); setDescription(config.description || "");
...@@ -106,7 +110,7 @@ export function SystemConfigFormModal({ ...@@ -106,7 +110,7 @@ export function SystemConfigFormModal({
setIsPublic(false); setIsPublic(false);
} }
setValidationError(null); setValidationError(null);
}, [config, isOpen]); }
// Click outside category dropdown // Click outside category dropdown
useEffect(() => { useEffect(() => {
......
...@@ -114,13 +114,13 @@ export function SystemConfigsManagementView({ ...@@ -114,13 +114,13 @@ export function SystemConfigsManagementView({
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
if (!data?.items) return []; if (!data?.items) return [];
return [...data.items].sort((a, b) => { return [...data.items].sort((a, b) => {
let aVal: any = a[sortBy as keyof SystemConfigItem]; const aVal = a[sortBy as keyof SystemConfigItem];
let bVal: any = b[sortBy as keyof SystemConfigItem]; const bVal = b[sortBy as keyof SystemConfigItem];
if (typeof aVal === "string") { if (typeof aVal === "string") {
return sortOrder === "asc" return sortOrder === "asc"
? aVal.localeCompare(String(bVal)) ? aVal.localeCompare(String(bVal ?? ""))
: String(bVal).localeCompare(aVal); : String(bVal ?? "").localeCompare(aVal);
} }
if (typeof aVal === "boolean") { if (typeof aVal === "boolean") {
return sortOrder === "asc" return sortOrder === "asc"
...@@ -259,19 +259,21 @@ export function SystemConfigsManagementView({ ...@@ -259,19 +259,21 @@ export function SystemConfigsManagementView({
{/* Category Pills */} {/* Category Pills */}
<div className="flex items-center gap-1 overflow-x-auto pb-1 md:pb-0"> <div className="flex items-center gap-1 overflow-x-auto pb-1 md:pb-0">
{[ {(
[
{ key: "ALL", label: t.systemConfigs.tabs.all }, { key: "ALL", label: t.systemConfigs.tabs.all },
{ key: "GENERAL", label: t.systemConfigs.tabs.general }, { key: "GENERAL", label: t.systemConfigs.tabs.general },
{ key: "FEATURE_FLAG", label: t.systemConfigs.tabs.featureFlag }, { key: "FEATURE_FLAG", label: t.systemConfigs.tabs.featureFlag },
{ key: "INTEGRATION", label: t.systemConfigs.tabs.integration }, { key: "INTEGRATION", label: t.systemConfigs.tabs.integration },
{ key: "SECURITY", label: t.systemConfigs.tabs.security }, { key: "SECURITY", label: t.systemConfigs.tabs.security },
].map((tab) => { ] as const
).map((tab) => {
const isSelected = selectedCategory === tab.key; const isSelected = selectedCategory === tab.key;
return ( return (
<button <button
key={tab.key} key={tab.key}
onClick={() => { onClick={() => {
setSelectedCategory(tab.key as any); setSelectedCategory(tab.key);
setPage(1); setPage(1);
}} }}
className={`rounded-2xl px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-all cursor-pointer ${ className={`rounded-2xl px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-all cursor-pointer ${
......
"use client";
import React from "react";
import { AlertTriangle, AlertCircle, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
export interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description?: React.ReactNode;
confirmText?: string;
cancelText?: string;
variant?: "destructive" | "default";
isLoading?: boolean;
onConfirm: () => void | Promise<void>;
onCancel?: () => void;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmText,
cancelText,
variant = "destructive",
isLoading = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { locale } = useLanguage();
const defaultConfirmText =
confirmText ??
(variant === "destructive"
? locale === "vi"
? "Xác nhận xóa"
: "Confirm Delete"
: locale === "vi"
? "Xác nhận"
: "Confirm");
const defaultCancelText =
cancelText ?? (locale === "vi" ? "Hủy bỏ" : "Cancel");
const handleClose = () => {
if (isLoading) return;
onOpenChange(false);
onCancel?.();
};
const handleConfirm = async () => {
await onConfirm();
};
const isDestructive = variant === "destructive";
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className={`sm:max-w-[440px] rounded-3xl border bg-card/95 backdrop-blur-xl p-6 shadow-2xl ${
isDestructive
? "border-rose-500/20 shadow-rose-950/20"
: "border-emerald-500/20 shadow-emerald-950/20"
}`}
>
<DialogHeader className="space-y-3">
<div
className={`mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border ${
isDestructive
? "bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
}`}
>
{isDestructive ? (
<AlertTriangle className="h-6 w-6" />
) : (
<AlertCircle className="h-6 w-6" />
)}
</div>
<DialogTitle className="text-center text-lg font-bold text-foreground">
{title}
</DialogTitle>
{description && (
<DialogDescription className="text-center text-xs text-muted-foreground leading-relaxed">
{description}
</DialogDescription>
)}
</DialogHeader>
<DialogFooter className="flex flex-col sm:flex-row gap-2 mt-4">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
className="w-full sm:w-1/2 rounded-2xl border-border/80 text-xs font-semibold hover:bg-muted/70 cursor-pointer"
>
{defaultCancelText}
</Button>
<Button
type="button"
variant={isDestructive ? "destructive" : "default"}
onClick={handleConfirm}
disabled={isLoading}
className={`w-full sm:w-1/2 rounded-2xl text-xs font-bold shadow-md cursor-pointer ${
isDestructive
? "bg-rose-600 hover:bg-rose-700 text-white shadow-rose-600/20"
: "bg-emerald-600 hover:bg-emerald-700 text-white shadow-emerald-600/20"
}`}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
defaultConfirmText
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
...@@ -141,7 +141,7 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) { ...@@ -141,7 +141,7 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
if (!isOpen) return null; if (!isOpen) return null;
const onSubmit = async (data: CreateUserInput) => { const onSubmit = async (data: CreateUserInput) => {
const parseOptionalNumber = (val: any) => { const parseOptionalNumber = (val: unknown) => {
if (val === "" || val === null || val === undefined || isNaN(Number(val))) { if (val === "" || val === null || val === undefined || isNaN(Number(val))) {
return null; return null;
} }
......
...@@ -22,21 +22,20 @@ export function UserResetQuotaDialog({ ...@@ -22,21 +22,20 @@ export function UserResetQuotaDialog({
const resetMutation = useResetUserQuota(); const resetMutation = useResetUserQuota();
const [resetLimitsToRole, setResetLimitsToRole] = useState(false); const [resetLimitsToRole, setResetLimitsToRole] = useState(false);
useEffect(() => {
if (isOpen) {
setResetLimitsToRole(false);
}
}, [isOpen]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) { if (e.key === "Escape" && isOpen) {
onClose(); handleClose();
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]); }, [isOpen]);
const handleClose = () => {
setResetLimitsToRole(false);
onClose();
};
if (!isOpen || !user) return null; if (!isOpen || !user) return null;
...@@ -45,7 +44,7 @@ export function UserResetQuotaDialog({ ...@@ -45,7 +44,7 @@ export function UserResetQuotaDialog({
id: user.id, id: user.id,
payload: { resetLimitsToRole }, payload: { resetLimitsToRole },
}); });
onClose(); handleClose();
}; };
return ( return (
......
...@@ -70,8 +70,6 @@ export function useCreateCrawlJob() { ...@@ -70,8 +70,6 @@ export function useCreateCrawlJob() {
}); });
} }
const rerunInFlight = new Set<string>();
/** /**
* Hook chạy lại tác vụ (POST /crawl-jobs/:id/rerun) * Hook chạy lại tác vụ (POST /crawl-jobs/:id/rerun)
*/ */
...@@ -79,18 +77,8 @@ export function useRerunCrawlJob() { ...@@ -79,18 +77,8 @@ export function useRerunCrawlJob() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (id: string) => { mutationFn: (id: string) => crawlJobService.rerunJob(id),
if (rerunInFlight.has(id)) { onSuccess: (job: CrawlJob) => {
return null as unknown as CrawlJob;
}
rerunInFlight.add(id);
try {
return await crawlJobService.rerunJob(id);
} finally {
setTimeout(() => rerunInFlight.delete(id), 2500);
}
},
onSuccess: (job) => {
if (!job || !job.id) return; if (!job || !job.id) return;
toast.success("Đã kích hoạt chạy lại tác vụ", { toast.success("Đã kích hoạt chạy lại tác vụ", {
description: `Job ID: ${job.id.slice(0, 8)} đang được thực thi lại.`, description: `Job ID: ${job.id.slice(0, 8)} đang được thực thi lại.`,
...@@ -170,7 +158,7 @@ export function useCrawlJobLogs( ...@@ -170,7 +158,7 @@ export function useCrawlJobLogs(
queryKey: CRAWL_JOBS_QUERY_KEYS.logs(id, query), queryKey: CRAWL_JOBS_QUERY_KEYS.logs(id, query),
queryFn: () => crawlJobService.getJobLogs(id, query), queryFn: () => crawlJobService.getJobLogs(id, query),
enabled: Boolean(id), enabled: Boolean(id),
refetchInterval: options?.refetchInterval ?? 5000, refetchInterval: options?.refetchInterval ?? false,
}); });
} }
......
...@@ -7,7 +7,7 @@ import { Locale, translations } from "./i18n/translations"; ...@@ -7,7 +7,7 @@ import { Locale, translations } from "./i18n/translations";
export function isConnectionLossError( export function isConnectionLossError(
rawError: string | null | undefined rawError: string | null | undefined
): boolean { ): boolean {
if (!rawError) return true; if (!rawError) return false;
const err = rawError.toLowerCase(); const err = rawError.toLowerCase();
...@@ -37,12 +37,8 @@ export function isConnectionLossError( ...@@ -37,12 +37,8 @@ export function isConnectionLossError(
err.includes("getaddrinfo") || err.includes("getaddrinfo") ||
err.includes("enotfound") || err.includes("enotfound") ||
// Provider & gateway / credit errors (Firecrawl 402, 500, 502, 503, 504) // Provider & gateway / credit errors (Firecrawl 402, 500, 502, 503, 504)
err.includes("402") || /\b(402|500|502|503|504)\b/.test(err) ||
err.includes("credit") || err.includes("credit") ||
err.includes("500") ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
err.includes("bad gateway") || err.includes("bad gateway") ||
err.includes("gateway") || err.includes("gateway") ||
err.includes("service unavailable") || err.includes("service unavailable") ||
...@@ -90,7 +86,7 @@ export function getLocalizedCrawlErrorInfo( ...@@ -90,7 +86,7 @@ export function getLocalizedCrawlErrorInfo(
const err = raw.toLowerCase(); const err = raw.toLowerCase();
if ( if (
err.includes("402") || /\b(402)\b/.test(err) ||
err.includes("credit") || err.includes("credit") ||
err.includes("xác thực") || err.includes("xác thực") ||
err.includes("api key") || err.includes("api key") ||
...@@ -169,10 +165,7 @@ export function getLocalizedCrawlErrorInfo( ...@@ -169,10 +165,7 @@ export function getLocalizedCrawlErrorInfo(
} }
if ( if (
err.includes("500") || /\b(500|502|503|504)\b/.test(err) ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
err.includes("bad gateway") || err.includes("bad gateway") ||
err.includes("service unavailable") err.includes("service unavailable")
) { ) {
......
...@@ -67,7 +67,7 @@ export function downloadBlob( ...@@ -67,7 +67,7 @@ export function downloadBlob(
type: mimeType || "text/plain;charset=utf-8", type: mimeType || "text/plain;charset=utf-8",
}); });
} else { } else {
blob = new Blob([content as unknown as BlobPart], { blob = new Blob([content as BlobPart], {
type: mimeType || "application/octet-stream", type: mimeType || "application/octet-stream",
}); });
} }
......
...@@ -5,16 +5,92 @@ export function cn(...inputs: ClassValue[]) { ...@@ -5,16 +5,92 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function formatDate(date: string | Date | number, locale: string = "vi"): string { export function formatDate(
date: string | Date | number | null | undefined,
locale: string = "vi",
options?: Intl.DateTimeFormatOptions
): string {
if (!date) return "-";
const d = new Date(date);
if (isNaN(d.getTime())) return "-";
return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "vi-VN", { return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "vi-VN", {
timeZone: "Asia/Ho_Chi_Minh",
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}).format(new Date(date)); ...options,
}).format(d);
}
/**
* Validates and returns a safe relative redirect URL, preventing Open Redirect vulnerabilities.
*/
export function getSafeRedirectUrl(
url?: string | null,
defaultUrl: string = "/"
): string {
if (!url || typeof url !== "string") return defaultUrl;
const trimmed = url.trim();
// Must start with '/' and must NOT start with '//' (protocol-relative URL)
// or contain backslashes / protocol schemes (http:, https:, javascript:)
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
return defaultUrl;
}
// Avoid redirecting back to auth pages to prevent redirect loops
if (
trimmed.startsWith("/login") ||
trimmed.startsWith("/register") ||
trimmed.startsWith("/forgot-password") ||
trimmed.startsWith("/reset-password") ||
trimmed.startsWith("/verify-email")
) {
return defaultUrl;
}
// Safe relative URL path
return trimmed;
} }
const VN_OFFSET_MS = 7 * 60 * 60 * 1000;
/**
* Converts YYYY-MM-DD local dates (Asia/Ho_Chi_Minh, UTC+7) into ISO UTC string range for start and end of day.
*/
export function getLocalDateRangeISO(
startDateStr?: string | null,
endDateStr?: string | null
): { startDate?: string; endDate?: string } {
let startDate: string | undefined = undefined;
let endDate: string | undefined = undefined;
if (startDateStr) {
const parts = startDateStr.split("-").map(Number);
if (parts.length === 3 && !parts.some(isNaN)) {
const [year, month, day] = parts;
const utcMs = Date.UTC(year, month - 1, day, 0, 0, 0, 0) - VN_OFFSET_MS;
startDate = new Date(utcMs).toISOString();
}
}
if (endDateStr) {
const parts = endDateStr.split("-").map(Number);
if (parts.length === 3 && !parts.some(isNaN)) {
const [year, month, day] = parts;
const utcMs = Date.UTC(year, month - 1, day, 23, 59, 59, 999) - VN_OFFSET_MS;
endDate = new Date(utcMs).toISOString();
}
}
return { startDate, endDate };
}
export function formatBytes(bytes: number, decimals = 2): string { export function formatBytes(bytes: number, decimals = 2): string {
if (!+bytes) return "0 Bytes"; if (!+bytes) return "0 Bytes";
const k = 1024; const k = 1024;
......
...@@ -10,20 +10,8 @@ const AUTH_ROUTES = [ ...@@ -10,20 +10,8 @@ const AUTH_ROUTES = [
"/verify-email", "/verify-email",
]; ];
// Routes strictly requiring ADMIN role (RBAC) // Strict ADMIN-only root routes
const ADMIN_ROUTES = [ const STRICT_ADMIN_ROUTES = ["/admin", "/settings/developer"];
"/admin",
"/users",
"/audit-logs",
"/roles",
"/system-configs",
"/settings/developer/users",
"/settings/developer/roles",
"/settings/developer/audit-logs",
"/settings/developer/system-configs",
"/settings/developer/cron",
"/cron",
];
// Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found) // Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found)
const OPEN_ROUTES = [ const OPEN_ROUTES = [
...@@ -48,23 +36,35 @@ function decodeJwtPayload(token: string): { role?: string; exp?: number } | null ...@@ -48,23 +36,35 @@ function decodeJwtPayload(token: string): { role?: string; exp?: number } | null
} }
} }
/**
* Validates that redirect URL is a safe local relative path
*/
function getSafeRedirectUrl(url?: string | null, fallback = "/"): string {
if (!url || typeof url !== "string") return fallback;
const trimmed = url.trim();
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
return fallback;
}
if (AUTH_ROUTES.some((route) => trimmed.startsWith(route))) {
return fallback;
}
return trimmed;
}
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname, search } = request.nextUrl; const { pathname, search } = request.nextUrl;
const authToken = request.cookies.get("auth_token")?.value; const authToken = request.cookies.get("auth_token")?.value;
const refreshToken = request.cookies.get("refresh_token")?.value; const refreshToken = request.cookies.get("refresh_token")?.value;
const userRoleCookie = request.cookies.get("user_role")?.value;
// Determine user role and token expiration // Determine user role and token expiration strictly from signed JWT
let role = userRoleCookie; let role: string | undefined = undefined;
let isTokenExpired = false; let isTokenExpired = false;
if (authToken) { if (authToken) {
const payload = decodeJwtPayload(authToken); const payload = decodeJwtPayload(authToken);
if (payload) { if (payload) {
if (payload.role && !role) {
role = payload.role; role = payload.role;
}
if (payload.exp && payload.exp * 1000 < Date.now()) { if (payload.exp && payload.exp * 1000 < Date.now()) {
isTokenExpired = true; isTokenExpired = true;
} }
...@@ -77,17 +77,11 @@ export function middleware(request: NextRequest) { ...@@ -77,17 +77,11 @@ export function middleware(request: NextRequest) {
const isAuthRoute = AUTH_ROUTES.some((route) => pathname.startsWith(route)); const isAuthRoute = AUTH_ROUTES.some((route) => pathname.startsWith(route));
const isOpenRoute = OPEN_ROUTES.some((route) => pathname.startsWith(route)); const isOpenRoute = OPEN_ROUTES.some((route) => pathname.startsWith(route));
const isAdminRoute = ADMIN_ROUTES.some((route) => pathname.startsWith(route)); const isStrictAdminRoute = STRICT_ADMIN_ROUTES.some((route) => pathname.startsWith(route));
// 1. If user is already authenticated and visits /login, /register, etc. -> redirect to home or returnUrl // 1. If user is already authenticated and visits /login, /register, etc. -> redirect to home or returnUrl
if (isAuthRoute && isAuthenticated) { if (isAuthRoute && isAuthenticated) {
let redirectUrl = request.nextUrl.searchParams.get("redirect") || "/"; const redirectUrl = getSafeRedirectUrl(request.nextUrl.searchParams.get("redirect"), "/");
if (
redirectUrl.startsWith("/login") ||
redirectUrl.startsWith("/register")
) {
redirectUrl = "/";
}
return NextResponse.redirect(new URL(redirectUrl, request.url)); return NextResponse.redirect(new URL(redirectUrl, request.url));
} }
...@@ -110,9 +104,8 @@ export function middleware(request: NextRequest) { ...@@ -110,9 +104,8 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
// 4. Role-Based Access Control (RBAC): restrict ADMIN routes // 4. Role-Based Access Control (RBAC): restrict strict ADMIN root routes
if (isAdminRoute && role !== "ADMIN") { if (isStrictAdminRoute && role?.toUpperCase() !== "ADMIN") {
// Redirect unauthorized role to /forbidden
return NextResponse.redirect(new URL("/forbidden", request.url)); return NextResponse.redirect(new URL("/forbidden", request.url));
} }
......
...@@ -5,6 +5,7 @@ import React, { ...@@ -5,6 +5,7 @@ import React, {
useContext, useContext,
useEffect, useEffect,
useCallback, useCallback,
useMemo,
ReactNode, ReactNode,
} from "react"; } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
...@@ -59,9 +60,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -59,9 +60,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const isAuthenticated = !!user && !isError; const isAuthenticated = !!user && !isError;
const role: UserRole | null = (user?.role as UserRole) || null; const role: UserRole | null = (user?.role as UserRole) || null;
const roles = user?.roles || (role ? [role.toLowerCase()] : []); const roles = useMemo(
const permissions = user?.permissions || []; () => (user?.roles || (role ? [role] : [])).map((r) => r.toLowerCase()).filter(Boolean),
const isAdmin = role === "ADMIN" || roles.includes("admin") || roles.includes("super_admin"); [user?.roles, role]
);
const permissions = useMemo(() => user?.permissions || [], [user?.permissions]);
const isAdmin = (role && role.toUpperCase() === "ADMIN") || roles.includes("admin") || roles.includes("super_admin");
const hasPermission = useCallback( const hasPermission = useCallback(
(permission: string): boolean => { (permission: string): boolean => {
...@@ -104,7 +108,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -104,7 +108,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[role, roles] [role, roles]
); );
const login = async ( const login = useCallback(
async (
input: LoginInput, input: LoginInput,
rememberMe: boolean = true rememberMe: boolean = true
): Promise<BffLoginResponse> => { ): Promise<BffLoginResponse> => {
...@@ -125,9 +130,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -125,9 +130,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
toast.error(message); toast.error(message);
throw error; throw error;
} }
}; },
[queryClient, t]
);
const register = async (input: RegisterInput): Promise<User> => { const register = useCallback(
async (input: RegisterInput): Promise<User> => {
try { try {
const newUser = await authService.register(input); const newUser = await authService.register(input);
toast.success(t.auth.register.success); toast.success(t.auth.register.success);
...@@ -138,7 +146,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -138,7 +146,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
toast.error(message); toast.error(message);
throw error; throw error;
} }
}; },
[t]
);
const logout = useCallback(async (): Promise<void> => { const logout = useCallback(async (): Promise<void> => {
try { try {
...@@ -152,9 +162,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -152,9 +162,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
}, [queryClient, router, t]); }, [queryClient, router, t]);
const refreshProfile = async (): Promise<void> => { const refreshProfile = useCallback(async (): Promise<void> => {
await refetch(); await refetch();
}; }, [refetch]);
// Listen for unauthorized 401 events emitted by apiClient // Listen for unauthorized 401 events emitted by apiClient
useEffect(() => { useEffect(() => {
...@@ -169,7 +179,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -169,7 +179,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}; };
}, [queryClient, router]); }, [queryClient, router]);
const contextValue: AuthContextType = { const contextValue: AuthContextType = useMemo(
() => ({
user: user || null, user: user || null,
role, role,
roles, roles,
...@@ -185,7 +196,25 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -185,7 +196,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
register, register,
logout, logout,
refreshProfile, refreshProfile,
}; }),
[
user,
role,
roles,
permissions,
isAuthenticated,
isLoading,
isAdmin,
hasRole,
hasPermission,
hasAnyPermission,
hasAllPermissions,
login,
register,
logout,
refreshProfile,
]
);
return ( return (
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider> <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
......
...@@ -13,22 +13,29 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined ...@@ -13,22 +13,29 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
const LANGUAGE_STORAGE_KEY = "data_crawler_lang"; const LANGUAGE_STORAGE_KEY = "data_crawler_lang";
function getInitialLocale(): Locale { export function LanguageProvider({ children }: { children: ReactNode }) {
if (typeof window === "undefined") return "vi"; const [locale, setLocaleState] = useState<Locale>("vi");
React.useEffect(() => {
try { try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null; const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
let targetLocale: Locale = "vi";
if (saved && (saved === "vi" || saved === "en")) { if (saved && (saved === "vi" || saved === "en")) {
return saved; targetLocale = saved;
} } else {
const browserLang = navigator.language?.toLowerCase() || ""; const browserLang = navigator.language?.toLowerCase() || "";
return browserLang.startsWith("vi") ? "vi" : "en"; targetLocale = browserLang.startsWith("vi") ? "vi" : "en";
}
if (targetLocale !== "vi") {
queueMicrotask(() => {
setLocaleState(targetLocale);
});
}
document.documentElement.lang = targetLocale;
} catch { } catch {
return "vi"; // Ignore storage or navigator errors
} }
} }, []);
export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
React.useEffect(() => { React.useEffect(() => {
try { try {
......
...@@ -31,7 +31,9 @@ export function Providers({ children }: ProvidersProps) { ...@@ -31,7 +31,9 @@ export function Providers({ children }: ProvidersProps) {
<NetworkStatusBanner /> <NetworkStatusBanner />
{children} {children}
<Toaster position="top-right" richColors closeButton /> <Toaster position="top-right" richColors closeButton />
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
)}
</AuthProvider> </AuthProvider>
</QueryClientProvider> </QueryClientProvider>
</LanguageProvider> </LanguageProvider>
......
...@@ -12,7 +12,21 @@ export function isValidCronExpression(cron: string | null | undefined): boolean ...@@ -12,7 +12,21 @@ export function isValidCronExpression(cron: string | null | undefined): boolean
if (!cron || typeof cron !== "string") return false; if (!cron || typeof cron !== "string") return false;
const parts = cron.trim().split(/\s+/); const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return false; if (parts.length !== 5) return false;
return parts.every((p) => p.length > 0);
const patterns = [
// Minute: 0-59
/^(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?(,(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?)*$/,
// Hour: 0-23
/^(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?(,(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?)*$/,
// Day of month: 1-31
/^(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?(,(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?)*$/,
// Month: 1-12 or JAN-DEC
/^(\*|\d{1,2}|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(-\w+)?(\/\d{1,2})?(,(\*|\d{1,2}|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(-\w+)?(\/\d{1,2})?)*$/i,
// Day of week: 0-7 or SUN-SAT
/^(\*|\d{1,2}|SUN|MON|TUE|WED|THU|FRI|SAT)(-\w+)?(\/\d{1,2})?(,(\*|\d{1,2}|SUN|MON|TUE|WED|THU|FRI|SAT)(-\w+)?(\/\d{1,2})?)*$/i,
];
return parts.every((part, i) => patterns[i].test(part));
} }
export const createCrawlScheduleSchema = z export const createCrawlScheduleSchema = z
......
...@@ -55,6 +55,11 @@ export const createRoleSchema = z.object({ ...@@ -55,6 +55,11 @@ export const createRoleSchema = z.object({
export type CreateRoleInput = z.infer<typeof createRoleSchema>; export type CreateRoleInput = z.infer<typeof createRoleSchema>;
export const roleFormSchema = createRoleSchema.extend({
syncUsersQuota: z.boolean().optional(),
});
export type RoleFormInput = z.infer<typeof roleFormSchema>;
export const updateRoleSchema = z.object({ export const updateRoleSchema = z.object({
name: z name: z
.string() .string()
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
DiffReportEnvelope, DiffReportEnvelope,
} from "@/types/crawl-job"; } from "@/types/crawl-job";
import { ExtractionTemplate } from "@/types/extraction-template"; import { ExtractionTemplate } from "@/types/extraction-template";
import { DashboardStats } from "@/types/dashboard";
// Initial realistic mock data for local fallback resilience // Initial realistic mock data for local fallback resilience
const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [ const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [
...@@ -58,166 +59,20 @@ const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [ ...@@ -58,166 +59,20 @@ const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [
}, },
]; ];
let localMockJobs: CrawlJob[] = [
{
id: "088f635c-9c3a-4467-93bb-e58f001bf001",
userId: "usr-01",
startUrl: "https://vnexpress.net/so-hoa/cong-nghe",
domain: "vnexpress.net",
mode: "CRAWL",
status: "RUNNING",
maxPages: 100,
maxDepth: 3,
urls: [],
totalPages: 52,
successPages: 49,
failedPages: 3,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: {
totalCurrentPages: 52,
totalPreviousPages: 45,
newPagesCount: 12,
modifiedPagesCount: 7,
deletedPagesCount: 2,
unchangedPagesCount: 33,
changeRate: 0.4,
},
startedAt: new Date(Date.now() - 3600000 * 1.5).toISOString(),
finishedAt: null,
createdAt: new Date(Date.now() - 3600000 * 2).toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "199a746d-ad4b-5578-84cc-f69a112cf002",
userId: "usr-01",
startUrl: "https://tiki.vn/laptop/c8095",
domain: "tiki.vn",
mode: "SCRAPE",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 50,
successPages: 50,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "DataCrawler-Bot/2.0 (+https://datacrawler.internal)",
delayMs: 1500,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: "/storage/diffs/diff_report_199a746d.json",
diffSummary: {
totalCurrentPages: 50,
totalPreviousPages: 48,
newPagesCount: 5,
modifiedPagesCount: 18,
deletedPagesCount: 3,
unchangedPagesCount: 27,
changeRate: 0.54,
},
startedAt: new Date(Date.now() - 3600000 * 12).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 11).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 13).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 11).toISOString(),
},
{
id: "2aab857e-be5c-6689-95dd-07ab223df003",
userId: "usr-01",
startUrl: "https://yellowpages.vn/danh-ba-doanh-nghiep",
domain: "yellowpages.vn",
mode: "CRAWL",
status: "CANCELED",
maxPages: 200,
maxDepth: 2,
urls: [],
totalPages: 84,
successPages: 82,
failedPages: 2,
timeoutMs: 30000,
retryCount: 2,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1200,
errorMessage: "Tác vụ bị hủy bỏ theo yêu cầu của người dùng",
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 3600000 * 24).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 23.5).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 25).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 23.5).toISOString(),
},
{
id: "3bbc968f-cf6d-7790-06ee-18bc334ef004",
userId: "usr-01",
startUrl: "https://cafef.vn/tai-chinh-quoc-te",
domain: "cafef.vn",
mode: "SCRAPE",
status: "FAILED",
maxPages: 40,
maxDepth: 1,
urls: [],
totalPages: 12,
successPages: 6,
failedPages: 6,
timeoutMs: 15000,
retryCount: 3,
respectRobotsTxt: false,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
delayMs: 500,
errorMessage: "Mục tiêu phản hồi HTTP 403 Forbidden (Cloudflare bot protection triggered)",
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 3600000 * 48).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 47.9).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 49).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 47.9).toISOString(),
},
{
id: "4ccd0790-d07e-8801-17ff-29cd445ff005",
userId: "usr-01",
startUrl: "https://genk.vn/tin-ict.chn",
domain: "genk.vn",
mode: "CRAWL",
status: "PENDING",
maxPages: 80,
maxDepth: 2,
urls: [],
totalPages: 0,
successPages: 0,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: null,
finishedAt: null,
createdAt: new Date(Date.now() - 600000).toISOString(),
updatedAt: new Date(Date.now() - 600000).toISOString(),
},
];
export const crawlJobService = { export const crawlJobService = {
/**
* Lấy thống kê tổng quan thời gian thực (GET /dashboard/stats)
*/
async getStats(): Promise<DashboardStats> {
const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats");
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid dashboard stats response");
},
/** /**
* Danh sách Crawl Jobs hỗ trợ Server-side Pagination, Sorting và Multi-criteria Filtering * Danh sách Crawl Jobs hỗ trợ Server-side Pagination, Sorting và Multi-criteria Filtering
*/ */
...@@ -225,7 +80,6 @@ export const crawlJobService = { ...@@ -225,7 +80,6 @@ export const crawlJobService = {
const page = Number(query?.page) || 1; const page = Number(query?.page) || 1;
const limit = Number(query?.limit) || 20; const limit = Number(query?.limit) || 20;
try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query?.status) params.append("status", query.status); if (query?.status) params.append("status", query.status);
if (query?.mode) params.append("mode", query.mode); if (query?.mode) params.append("mode", query.mode);
...@@ -270,217 +124,58 @@ export const crawlJobService = { ...@@ -270,217 +124,58 @@ export const crawlJobService = {
}; };
} }
throw new Error("Invalid response format"); throw new Error("Invalid response format");
} catch {
// Fallback local memory filtering and pagination
let filtered = [...localMockJobs];
if (query?.status) {
filtered = filtered.filter((j) => j.status === query.status);
}
if (query?.mode) {
filtered = filtered.filter((j) => j.mode === query.mode);
}
if (query?.search) {
const term = query.search.toLowerCase();
filtered = filtered.filter(
(j) =>
j.startUrl.toLowerCase().includes(term) ||
(j.domain && j.domain.toLowerCase().includes(term)) ||
j.id.toLowerCase().includes(term)
);
}
if (query?.sortBy) {
filtered.sort((a, b) => {
const valA = a[query.sortBy as keyof CrawlJob];
const valB = b[query.sortBy as keyof CrawlJob];
if (valA === valB) return 0;
if (valA === null || valA === undefined) return 1;
if (valB === null || valB === undefined) return -1;
const result = valA > valB ? 1 : -1;
return query.order === "asc" ? result : -result;
});
} else {
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / limit));
const startIndex = (page - 1) * limit;
const items = filtered.slice(startIndex, startIndex + limit);
return {
items,
total,
page,
pageSize: limit,
totalPages,
};
}
}, },
/** /**
* Lấy chi tiết một Crawl Job theo ID * Lấy chi tiết một Crawl Job theo ID
*/ */
async getJobById(id: string): Promise<CrawlJob> { async getJobById(id: string): Promise<CrawlJob> {
try {
const response = await apiClient.get<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}`); const response = await apiClient.get<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}`);
if (response.data?.data) { if (response.data?.data) {
return response.data.data; return response.data.data;
} }
throw new Error("Job not found"); throw new Error("Job not found");
} catch {
const found = localMockJobs.find((j) => j.id === id);
if (found) return found;
// Tạo mock job nếu không tìm thấy
const fallbackJob: CrawlJob = {
id,
userId: "usr-current",
startUrl: "https://example.com/data",
domain: "example.com",
mode: "CRAWL",
status: "RUNNING",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 18,
successPages: 17,
failedPages: 1,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "DataCrawler-Bot/2.0",
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 300000).toISOString(),
finishedAt: null,
createdAt: new Date(Date.now() - 300000).toISOString(),
updatedAt: new Date().toISOString(),
};
localMockJobs = [fallbackJob, ...localMockJobs];
return fallbackJob;
}
}, },
/** /**
* Tạo Crawl Job mới toàn diện * Tạo Crawl Job mới toàn diện
*/ */
async createJob(dto: CreateCrawlJobDto): Promise<CrawlJob> { async createJob(dto: CreateCrawlJobDto): Promise<CrawlJob> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", dto); const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", dto);
if (response.data?.data) { if (response.data?.data) {
return response.data.data; return response.data.data;
} }
throw new Error("Failed to create crawl job"); throw new Error("Failed to create crawl job");
} catch {
let domain: string | null = null;
try {
if (dto.startUrl) {
domain = new URL(dto.startUrl).hostname;
}
} catch {
domain = "custom-target.com";
}
const newJob: CrawlJob = {
id: `job-${Date.now()}`,
userId: "usr-current",
startUrl: dto.startUrl || "https://example.com",
domain,
mode: dto.mode || "SCRAPE",
status: "RUNNING",
maxPages: dto.maxPages || 20,
maxDepth: dto.maxDepth || 1,
urls: dto.urls || [],
totalPages: 0,
successPages: 0,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: dto.respectRobotsTxt ?? true,
userAgent: dto.userAgent || null,
delayMs: dto.delayMs || 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: dto.scheduleId || null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date().toISOString(),
finishedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localMockJobs = [newJob, ...localMockJobs];
return newJob;
}
}, },
/** /**
* Chạy lại Job (POST /crawl-jobs/:id/rerun) * Chạy lại Job (POST /crawl-jobs/:id/rerun)
*/ */
async rerunJob(id: string): Promise<CrawlJob> { async rerunJob(id: string): Promise<CrawlJob> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/rerun`); const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/rerun`);
if (response.data?.data) { if (response.data?.data) {
return response.data.data; return response.data.data;
} }
throw new Error("Failed to rerun job"); throw new Error("Failed to rerun job");
} catch {
const existing = localMockJobs.find((j) => j.id === id);
if (existing) {
existing.status = "RUNNING";
existing.startedAt = new Date().toISOString();
existing.finishedAt = null;
existing.totalPages = 0;
existing.successPages = 0;
existing.failedPages = 0;
existing.updatedAt = new Date().toISOString();
return existing;
}
throw new Error("Tác vụ không tồn tại");
}
}, },
/** /**
* Hủy bỏ Job (POST /crawl-jobs/:id/cancel) * Hủy bỏ Job (POST /crawl-jobs/:id/cancel)
*/ */
async cancelJob(id: string): Promise<CrawlJob> { async cancelJob(id: string): Promise<CrawlJob> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/cancel`); const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/cancel`);
if (response.data?.data) { if (response.data?.data) {
return response.data.data; return response.data.data;
} }
throw new Error("Failed to cancel job"); throw new Error("Failed to cancel job");
} catch {
const existing = localMockJobs.find((j) => j.id === id);
if (existing) {
existing.status = "CANCELED";
existing.finishedAt = new Date().toISOString();
existing.errorMessage = "Tác vụ đã được hủy bởi người dùng.";
existing.updatedAt = new Date().toISOString();
return existing;
}
throw new Error("Tác vụ không tồn tại");
}
}, },
/** /**
* Xóa Job (DELETE /crawl-jobs/:id) * Xóa Job (DELETE /crawl-jobs/:id)
*/ */
async deleteJob(id: string): Promise<boolean> { async deleteJob(id: string): Promise<boolean> {
try {
await apiClient.delete(`/crawl-jobs/${id}`); await apiClient.delete(`/crawl-jobs/${id}`);
localMockJobs = localMockJobs.filter((j) => j.id !== id);
return true;
} catch {
localMockJobs = localMockJobs.filter((j) => j.id !== id);
return true; return true;
}
}, },
/** /**
...@@ -493,7 +188,6 @@ export const crawlJobService = { ...@@ -493,7 +188,6 @@ export const crawlJobService = {
const page = query?.page || 1; const page = query?.page || 1;
const limit = query?.limit || 50; const limit = query?.limit || 50;
try {
const response = await apiClient.get< const response = await apiClient.get<
ApiResponse<{ ApiResponse<{
items: CrawlJobLog[]; items: CrawlJobLog[];
...@@ -505,93 +199,6 @@ export const crawlJobService = { ...@@ -505,93 +199,6 @@ export const crawlJobService = {
return response.data.data; return response.data.data;
} }
throw new Error("Invalid logs response"); throw new Error("Invalid logs response");
} catch {
// Fallback realistic crawler logs
const mockLogs: CrawlJobLog[] = [
{
id: `log-${id}-01`,
jobId: id,
level: "INFO",
step: "INIT",
message: `Khởi tạo tiến trình cào dữ liệu cho tác vụ [${id}]. Đã nạp cấu hình bộ thu thập.`,
createdAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: `log-${id}-02`,
jobId: id,
level: "INFO",
step: "ROBOTS_TXT",
message: "Tải và phân tích robots.txt: Tuân thủ quy tắc Crawl-Delay 1.0s, Allow: /*.",
createdAt: new Date(Date.now() - 3550000).toISOString(),
},
{
id: `log-${id}-03`,
jobId: id,
level: "INFO",
step: "DISPATCH",
message: "Phân phối URL gốc vào hàng đợi Redis/BullMQ: https://vnexpress.net/so-hoa/cong-nghe",
createdAt: new Date(Date.now() - 3500000).toISOString(),
},
{
id: `log-${id}-04`,
jobId: id,
level: "INFO",
step: "SCRAPING",
message: "HTTP 200 OK: Đã tải về thành công DOM HTML (kích thước: 142.4 KB, thời gian: 238ms).",
createdAt: new Date(Date.now() - 3400000).toISOString(),
},
{
id: `log-${id}-05`,
jobId: id,
level: "INFO",
step: "EXTRACT",
message: "Trích xuất 14 liên kết con hợp lệ thuộc domain chính theo giới hạn depth = 2.",
createdAt: new Date(Date.now() - 3350000).toISOString(),
},
{
id: `log-${id}-06`,
jobId: id,
level: "WARNING",
step: "CONTENT_CLEAN",
message: "Phát hiện mã theo dõi quảng cáo bên thứ ba (Google Ads/Taboola). Đã loại bỏ thành công.",
createdAt: new Date(Date.now() - 3200000).toISOString(),
},
{
id: `log-${id}-07`,
jobId: id,
level: "INFO",
step: "DATABASE",
message: "Lưu bản ghi trang #42 vào PostgreSQL: 1,842 từ, DataQualityScore = 96/100.",
createdAt: new Date(Date.now() - 3100000).toISOString(),
},
{
id: `log-${id}-08`,
jobId: id,
level: "ERROR",
step: "NETWORK",
message: "HTTP 404 Not Found: Bỏ qua đường dẫn /tin-cu/bai-viet-da-xoa.html sau 3 lần thử.",
createdAt: new Date(Date.now() - 2900000).toISOString(),
},
{
id: `log-${id}-09`,
jobId: id,
level: "INFO",
step: "STREAM",
message: "Đồng bộ tiến độ qua SSE Event: 52/100 trang đã xử lý (52% hoàn tất).",
createdAt: new Date(Date.now() - 2500000).toISOString(),
},
];
return {
items: mockLogs,
meta: {
total: mockLogs.length,
page: 1,
limit: 50,
totalPages: 1,
},
};
}
}, },
/** /**
...@@ -604,7 +211,6 @@ export const crawlJobService = { ...@@ -604,7 +211,6 @@ export const crawlJobService = {
const page = query?.page || 1; const page = query?.page || 1;
const limit = query?.limit || 20; const limit = query?.limit || 20;
try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("page", String(page)); params.append("page", String(page));
params.append("limit", String(limit)); params.append("limit", String(limit));
...@@ -624,139 +230,12 @@ export const crawlJobService = { ...@@ -624,139 +230,12 @@ export const crawlJobService = {
return response.data.data; return response.data.data;
} }
throw new Error("Invalid pages preview response"); throw new Error("Invalid pages preview response");
} catch {
// Fallback realistic crawled pages
const mockPages: CrawlPagePreview[] = [
{
id: `page-${id}-01`,
jobId: id,
url: "https://vnexpress.net/so-hoa/cong-nghe",
normalizedUrl: "https://vnexpress.net/so-hoa/cong-nghe",
title: "Công nghệ - Tin tức công nghệ mới nhất hôm nay",
description: "Cập nhật nhanh tin tức công nghệ, thiết bị mới, trí tuệ nhân tạo AI và viễn thông.",
status: "SUCCESS",
statusCode: 200,
wordCount: 1420,
dataQualityScore: 98,
contentHash: "sha256-a1b2c3d4e5f67890",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 3600000).toISOString(),
warnings: [],
structuredData: {
"@context": "https://schema.org",
"@type": "CollectionPage",
headline: "Tin tức Công nghệ",
},
markdownContent: "# Tin tức Công nghệ Mới Nhất\n\nThị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM) và giải pháp điện toán đám mây sinh thái...\n\n## 1. Trí tuệ nhân tạo thế hệ mới\nCác giải pháp AI đang được tối ưu hóa nhằm giảm thiểu lượng khí thải carbon và điện năng tiêu thụ tại các trung tâm dữ liệu.\n\n## 2. Thiết bị phần cứng tiết kiệm năng lượng\nChip xử lý tiến trình 3nm mang lại hiệu suất vượt trội mà vẫn giữ được nhiệt độ vận hành lý tưởng.",
rawMarkdown: "# Tin tức Công nghệ Mới Nhất\n\nThị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
mainContent: "Tin tức Công nghệ Mới Nhất. Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
cleanText: "Tin tức Công nghệ Mới Nhất. Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
htmlContentPath: `/storage/crawled/${id}/page_01.html`,
content: "Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về AI...",
createdAt: new Date(Date.now() - 3600000).toISOString(),
updatedAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: `page-${id}-02`,
jobId: id,
url: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
title: "Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu",
description: "Các kỹ sư phát triển thuật toán điều phối thông minh giúp trung tâm dữ liệu xanh hóa quy trình xử lý.",
status: "SUCCESS",
statusCode: 200,
wordCount: 2150,
dataQualityScore: 95,
contentHash: "sha256-b2c3d4e5f6a78901",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 3200000).toISOString(),
warnings: [],
structuredData: {
"@context": "https://schema.org",
"@type": "NewsArticle",
headline: "Giải pháp AI xanh giúp giảm 40% điện năng",
datePublished: new Date(Date.now() - 3600000).toISOString(),
},
markdownContent: "# Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu\n\nNghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa và caching thông minh đã cắt giảm mạnh mức tiêu thụ điện của các cụm máy chủ GPU.\n\n> Đổi mới sáng tạo cần đi đôi với bảo vệ môi trường và phát triển bền vững.\n\n### Kết quả thử nghiệm thực tế\n- Mức tải tiêu thụ điện giảm từ 1.2MW xuống còn 720kW.\n- Tốc độ phản hồi (Latency) duy trì dưới 45ms.",
rawMarkdown: "# Giải pháp AI xanh...",
mainContent: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa...",
cleanText: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa...",
htmlContentPath: `/storage/crawled/${id}/page_02.html`,
content: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận...",
createdAt: new Date(Date.now() - 3200000).toISOString(),
updatedAt: new Date(Date.now() - 3200000).toISOString(),
},
{
id: `page-${id}-03`,
jobId: id,
url: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
normalizedUrl: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
title: "Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa",
description: "Các nhà máy đúc chip hàng đầu thế giới công bố tiến độ thương mại hóa chip 2nm vào cuối năm.",
status: "SUCCESS",
statusCode: 200,
wordCount: 1890,
dataQualityScore: 92,
contentHash: "sha256-c3d4e5f6a7b89012",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 2700000).toISOString(),
warnings: [],
structuredData: null,
markdownContent: "# Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa\n\nTiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA (Gate-All-Around) hứa hẹn tăng 15% hiệu năng và tiết kiệm 30% năng lượng so với thế hệ trước.",
rawMarkdown: "# Thế hệ vi xử lý bán dẫn 2nm...",
mainContent: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
cleanText: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
htmlContentPath: `/storage/crawled/${id}/page_03.html`,
content: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
createdAt: new Date(Date.now() - 2700000).toISOString(),
updatedAt: new Date(Date.now() - 2700000).toISOString(),
},
{
id: `page-${id}-04`,
jobId: id,
url: "https://vnexpress.net/so-hoa/khong-tim-thay-trang-cu.html",
normalizedUrl: "https://vnexpress.net/so-hoa/khong-tim-thay-trang-cu.html",
title: "Trang không tồn tại (404 Not Found)",
description: null,
status: "FAILED",
statusCode: 404,
wordCount: 45,
dataQualityScore: 0,
contentHash: null,
errorMessage: "Mã trạng thái phản hồi HTTP 404 Not Found",
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 2100000).toISOString(),
warnings: ["URL trả về lỗi 404"],
structuredData: null,
markdownContent: "# 404 Not Found\n\nTrang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
rawMarkdown: "# 404 Not Found",
mainContent: "Trang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
cleanText: "Trang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
htmlContentPath: null,
content: "404 Not Found",
createdAt: new Date(Date.now() - 2100000).toISOString(),
updatedAt: new Date(Date.now() - 2100000).toISOString(),
},
];
return {
items: mockPages,
total: mockPages.length,
page: 1,
limit: 20,
totalPages: 1,
};
}
}, },
/** /**
* Lấy báo cáo so sánh biến động dữ liệu Diff (GET /crawl-jobs/:id/diff) * Lấy báo cáo so sánh biến động dữ liệu Diff (GET /crawl-jobs/:id/diff)
*/ */
async getDiff(id: string, compareWithJobId?: string): Promise<DiffReportEnvelope> { async getDiff(id: string, compareWithJobId?: string): Promise<DiffReportEnvelope> {
try {
const url = compareWithJobId const url = compareWithJobId
? `/crawl-jobs/${id}/diff?compareWithJobId=${compareWithJobId}` ? `/crawl-jobs/${id}/diff?compareWithJobId=${compareWithJobId}`
: `/crawl-jobs/${id}/diff`; : `/crawl-jobs/${id}/diff`;
...@@ -765,103 +244,6 @@ export const crawlJobService = { ...@@ -765,103 +244,6 @@ export const crawlJobService = {
return response.data.data; return response.data.data;
} }
throw new Error("Invalid diff response"); throw new Error("Invalid diff response");
} catch {
// Fallback realistic Diff Report
return {
schemaVersion: "1.0.0",
generatedAt: new Date().toISOString(),
jobId: id,
previousJobId: compareWithJobId || "199a746d-ad4b-5578-84cc-f69a112cf002",
scheduleId: null,
startUrl: "https://vnexpress.net/so-hoa/cong-nghe",
domain: "vnexpress.net",
summary: {
totalCurrentPages: 52,
totalPreviousPages: 45,
newPagesCount: 8,
modifiedPagesCount: 14,
deletedPagesCount: 3,
unchangedPagesCount: 30,
changeRate: 0.48,
},
newPages: [
{
url: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
title: "Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu",
contentHash: "sha256-b2c3d4e5f6a78901",
wordCount: 2150,
statusCode: 200,
crawledAt: new Date(Date.now() - 3200000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
normalizedUrl: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
title: "Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa",
contentHash: "sha256-c3d4e5f6a7b89012",
wordCount: 1890,
statusCode: 200,
crawledAt: new Date(Date.now() - 2700000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/ve-tinh-bang-thong-rong-4712410.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ve-tinh-bang-thong-rong-4712410.html",
title: "Mạng lưới vệ tinh quỹ đạo thấp phủ sóng internet vùng sâu vùng xa",
contentHash: "sha256-d4e5f6a7b8c90123",
wordCount: 1450,
statusCode: 200,
crawledAt: new Date(Date.now() - 2200000).toISOString(),
},
],
modifiedPages: [
{
url: "https://vnexpress.net/so-hoa/cong-nghe",
normalizedUrl: "https://vnexpress.net/so-hoa/cong-nghe",
title: "Công nghệ - Cập nhật thông tin công nghệ nóng nhất",
oldTitle: "Công nghệ - Tin tức công nghệ trong ngày",
oldContentHash: "sha256-old-998877665544",
newContentHash: "sha256-a1b2c3d4e5f67890",
oldWordCount: 1200,
newWordCount: 1420,
wordCountDiff: 220,
statusCode: 200,
crawledAt: new Date(Date.now() - 3600000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/gia-smartphone-bien-dong-4711800.html",
normalizedUrl: "https://vnexpress.net/so-hoa/gia-smartphone-bien-dong-4711800.html",
title: "Giá smartphone cao cấp điều chỉnh giảm kích cầu quý 3",
oldTitle: "Giá smartphone cao cấp duy trì ổn định",
oldContentHash: "sha256-old-112233445566",
newContentHash: "sha256-mod-556677889900",
oldWordCount: 1650,
newWordCount: 1510,
wordCountDiff: -140,
statusCode: 200,
crawledAt: new Date(Date.now() - 3100000).toISOString(),
},
],
deletedPages: [
{
url: "https://vnexpress.net/so-hoa/su-kien-ra-mat-da-ket-thuc.html",
normalizedUrl: "https://vnexpress.net/so-hoa/su-kien-ra-mat-da-ket-thuc.html",
title: "Sự kiện tường thuật trực tiếp công nghệ mùa xuân (Đã đóng)",
previousContentHash: "sha256-del-443322110099",
previousWordCount: 3200,
lastCrawledAt: new Date(Date.now() - 86400000 * 3).toISOString(),
},
],
unchangedPages: [
{
url: "https://vnexpress.net/so-hoa/dieu-khoan-su-dung.html",
normalizedUrl: "https://vnexpress.net/so-hoa/dieu-khoan-su-dung.html",
title: "Điều khoản sử dụng và chính sách nội dung công nghệ",
contentHash: "sha256-static-unchanged-01",
wordCount: 850,
},
],
};
}
}, },
/** /**
......
...@@ -8,239 +8,59 @@ import { ...@@ -8,239 +8,59 @@ import {
UpdateCrawlScheduleDto, UpdateCrawlScheduleDto,
} from "@/types/crawl-schedule"; } from "@/types/crawl-schedule";
const MOCK_CRAWL_SCHEDULES: CrawlSchedule[] = [
{
id: "sch-01",
userId: "usr-01",
name: "Quét tin tức VnExpress sáng",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
frequency: "DAILY",
cronExpression: null,
hour: 6,
minute: 30,
dayOfWeek: null,
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 50,
maxDepth: 2,
urls: [],
isActive: true,
autoDiff: true,
lastRunAt: new Date(Date.now() - 86400000).toISOString(),
nextRunAt: new Date(Date.now() + 3600000 * 6).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 10).toISOString(),
updatedAt: new Date(Date.now() - 86400000).toISOString(),
},
{
id: "sch-02",
userId: "usr-01",
name: "Thu thập giá thị trường Tiki cuối tuần",
startUrl: "https://tiki.vn/dien-thoai-may-tinh-bang/c1789",
domain: "tiki.vn",
mode: "CRAWL",
frequency: "WEEKLY",
cronExpression: null,
hour: 9,
minute: 0,
dayOfWeek: 0, // Chủ nhật
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 100,
maxDepth: 3,
urls: [],
isActive: true,
autoDiff: true,
lastRunAt: new Date(Date.now() - 86400000 * 6).toISOString(),
nextRunAt: new Date(Date.now() + 86400000 * 1).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 20).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 6).toISOString(),
},
{
id: "sch-03",
userId: "usr-01",
name: "Giám sát thông số kỹ thuật (Custom Cron)",
startUrl: "https://dantri.com.vn/suc-manh-so.htm",
domain: "dantri.com.vn",
mode: "SCRAPE",
frequency: "CUSTOM",
cronExpression: "0 */3 * * *",
hour: 0,
minute: 0,
dayOfWeek: null,
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 20,
maxDepth: 1,
urls: [],
isActive: false,
autoDiff: false,
lastRunAt: new Date(Date.now() - 86400000 * 2).toISOString(),
nextRunAt: null,
createdAt: new Date(Date.now() - 86400000 * 15).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
},
];
let localSchedules = [...MOCK_CRAWL_SCHEDULES];
export class CrawlScheduleService { export class CrawlScheduleService {
async getSchedules(params?: CrawlScheduleQueryDto): Promise<PaginatedResponse<CrawlSchedule>> { async getSchedules(params?: CrawlScheduleQueryDto): Promise<PaginatedResponse<CrawlSchedule>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlSchedule>>>("/crawl-schedules", { const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlSchedule>>>("/crawl-schedules", {
params, params,
}); });
if (response.data?.data) { if (response.data?.data) {
return response.data.data; return response.data.data;
} }
return this.getLocalFilteredSchedules(params); throw new Error("Không thể tải danh sách lịch cào");
} catch {
return this.getLocalFilteredSchedules(params);
}
}
private getLocalFilteredSchedules(params?: CrawlScheduleQueryDto): PaginatedResponse<CrawlSchedule> {
let list = [...localSchedules];
if (params?.search) {
const s = params.search.toLowerCase();
list = list.filter((item) => item.name.toLowerCase().includes(s) || item.startUrl.toLowerCase().includes(s));
}
if (params?.frequency) {
list = list.filter((item) => item.frequency === params.frequency);
}
if (params?.isActive !== undefined) {
list = list.filter((item) => item.isActive === params.isActive);
}
const page = params?.page || 1;
const limit = params?.limit || 20;
const total = list.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const paginatedItems = list.slice(start, start + limit);
return {
items: paginatedItems,
total,
page,
pageSize: limit,
totalPages,
};
} }
async getScheduleById(id: string): Promise<CrawlSchedule> { async getScheduleById(id: string): Promise<CrawlSchedule> {
try {
const response = await apiClient.get<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`); const response = await apiClient.get<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`);
if (response.data?.data) { if (response.data?.data) {
return response.data.data; return response.data.data;
} }
const found = localSchedules.find((s) => s.id === id);
if (found) return found;
throw new Error("Không tìm thấy lịch cào"); throw new Error("Không tìm thấy lịch cào");
} catch {
const found = localSchedules.find((s) => s.id === id);
if (found) return found;
throw new Error("Không tìm thấy lịch cào");
}
} }
async createSchedule(dto: CreateCrawlScheduleDto): Promise<CrawlSchedule> { async createSchedule(dto: CreateCrawlScheduleDto): Promise<CrawlSchedule> {
try {
const response = await apiClient.post<ApiResponse<CrawlSchedule>>("/crawl-schedules", dto); const response = await apiClient.post<ApiResponse<CrawlSchedule>>("/crawl-schedules", dto);
if (response.data?.data) { if (response.data?.data) {
const created = response.data.data; return response.data.data;
localSchedules = [created, ...localSchedules];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
let domain = "";
try {
domain = new URL(dto.startUrl).hostname;
} catch {
domain = "example.com";
}
const newSchedule: CrawlSchedule = {
id: `sch-${Date.now()}`,
userId: "current-user",
name: dto.name,
startUrl: dto.startUrl,
domain,
mode: dto.mode || "SCRAPE",
frequency: dto.frequency || "DAILY",
cronExpression: dto.cronExpression || null,
hour: dto.hour ?? 0,
minute: dto.minute ?? 0,
dayOfWeek: dto.dayOfWeek ?? null,
dayOfMonth: dto.dayOfMonth ?? null,
timezone: dto.timezone || "Asia/Ho_Chi_Minh",
maxPages: dto.maxPages ?? 20,
maxDepth: dto.maxDepth ?? 1,
urls: dto.urls || [],
isActive: dto.isActive ?? true,
autoDiff: dto.autoDiff ?? true,
lastRunAt: null,
nextRunAt: new Date(Date.now() + 3600000 * 24).toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localSchedules = [newSchedule, ...localSchedules];
return newSchedule;
} }
throw new Error("Không thể tạo lịch cào");
} }
async updateSchedule(id: string, dto: UpdateCrawlScheduleDto): Promise<CrawlSchedule> { async updateSchedule(id: string, dto: UpdateCrawlScheduleDto): Promise<CrawlSchedule> {
try {
const response = await apiClient.patch<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`, dto); const response = await apiClient.patch<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`, dto);
if (response.data?.data) { if (response.data?.data) {
const updated = response.data.data; return response.data.data;
localSchedules = localSchedules.map((s) => (s.id === id ? updated : s));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const index = localSchedules.findIndex((s) => s.id === id);
if (index === -1) throw new Error("Không tìm thấy lịch cào");
const existing = localSchedules[index];
const updated: CrawlSchedule = {
...existing,
...dto,
updatedAt: new Date().toISOString(),
};
localSchedules[index] = updated;
return updated;
} }
throw new Error("Không thể cập nhật lịch cào");
} }
async deleteSchedule(id: string): Promise<void> { async deleteSchedule(id: string): Promise<void> {
try {
await apiClient.delete(`/crawl-schedules/${id}`); await apiClient.delete(`/crawl-schedules/${id}`);
localSchedules = localSchedules.filter((s) => s.id !== id);
} catch {
localSchedules = localSchedules.filter((s) => s.id !== id);
}
} }
async triggerRun(id: string): Promise<{ jobId: string; message: string }> { async triggerRun(id: string): Promise<{ jobId: string; message: string }> {
try {
const response = await apiClient.post<ApiResponse<{ jobId: string; message: string }>>( const response = await apiClient.post<ApiResponse<{ jobId: string; message: string }>>(
`/crawl-schedules/${id}/run` `/crawl-schedules/${id}/run`
); );
if (response.data?.data) { if (response.data?.data) {
return response.data.data; return response.data.data;
} }
return { jobId: `job-trig-${Date.now()}`, message: "Kích hoạt tác vụ cào thành công!" }; throw new Error("Không thể kích hoạt tác vụ cào");
} catch {
return { jobId: `job-trig-${Date.now()}`, message: "Kích hoạt tác vụ cào thành công (mô phỏng)!" };
}
} }
async getHistory( async getHistory(
id: string, id: string,
params?: { page?: number; limit?: number } params?: { page?: number; limit?: number }
): Promise<PaginatedResponse<CrawlJob>> { ): Promise<PaginatedResponse<CrawlJob>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>( const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>(
`/crawl-schedules/${id}/history`, `/crawl-schedules/${id}/history`,
{ params } { params }
...@@ -248,79 +68,7 @@ export class CrawlScheduleService { ...@@ -248,79 +68,7 @@ export class CrawlScheduleService {
if (response.data?.data) { if (response.data?.data) {
return response.data.data; return response.data.data;
} }
return this.getMockHistory(id, params); throw new Error("Không thể tải lịch sử chạy");
} catch {
return this.getMockHistory(id, params);
}
}
private getMockHistory(scheduleId: string, params?: { page?: number; limit?: number }): PaginatedResponse<CrawlJob> {
const mockJobs: CrawlJob[] = [
{
id: `job-sch-${scheduleId}-01`,
userId: "usr-01",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 48,
successPages: 48,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 86400000).toISOString(),
finishedAt: new Date(Date.now() - 86400000 + 420000).toISOString(),
createdAt: new Date(Date.now() - 86400000).toISOString(),
updatedAt: new Date(Date.now() - 86400000 + 420000).toISOString(),
},
{
id: `job-sch-${scheduleId}-02`,
userId: "usr-01",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 50,
successPages: 49,
failedPages: 1,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
finishedAt: new Date(Date.now() - 86400000 * 2 + 450000).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 2).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 2 + 450000).toISOString(),
},
];
return {
items: mockJobs,
total: mockJobs.length,
page: params?.page || 1,
pageSize: params?.limit || 20,
totalPages: 1,
};
} }
} }
......
...@@ -5,159 +5,26 @@ import { CrawlerTask } from "@/types/crawler"; ...@@ -5,159 +5,26 @@ import { CrawlerTask } from "@/types/crawler";
import { DashboardStats } from "@/types/dashboard"; import { DashboardStats } from "@/types/dashboard";
import { CrawlJob } from "@/types/crawl-job"; import { CrawlJob } from "@/types/crawl-job";
// Dữ liệu mẫu khởi đầu khi Backend chưa kết nối
const INITIAL_MOCK_TASKS: CrawlerTask[] = [
{
id: "task-01",
name: "Cào tin tức Công nghệ & AI",
targetUrl: "https://vnexpress.net/so-hoa/cong-nghe",
status: "RUNNING",
maxDepth: 3,
maxPages: 100,
pagesCrawled: 42,
itemsExtracted: 318,
createdAt: new Date(Date.now() - 3600000 * 2).toISOString(),
updatedAt: new Date().toISOString(),
lastRunAt: new Date(Date.now() - 60000).toISOString(),
},
{
id: "task-02",
name: "Trích xuất giá sản phẩm Laptop",
targetUrl: "https://tiki.vn/laptop/c8095",
status: "COMPLETED",
maxDepth: 2,
maxPages: 50,
pagesCrawled: 50,
itemsExtracted: 620,
createdAt: new Date(Date.now() - 3600000 * 24).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 5).toISOString(),
lastRunAt: new Date(Date.now() - 3600000 * 5).toISOString(),
},
{
id: "task-03",
name: "Thu thập danh bạ doanh nghiệp",
targetUrl: "https://yellowpages.vn/danh-ba",
status: "PAUSED",
maxDepth: 2,
maxPages: 200,
pagesCrawled: 75,
itemsExtracted: 180,
createdAt: new Date(Date.now() - 3600000 * 48).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 12).toISOString(),
lastRunAt: new Date(Date.now() - 3600000 * 12).toISOString(),
},
];
let localTasksState = [...INITIAL_MOCK_TASKS];
const DEFAULT_FALLBACK_STATS: DashboardStats = {
jobs: {
total: 3,
completed: 1,
failed: 0,
running: 1,
pending: 1,
},
pages: {
total: 167,
successful: 162,
failed: 5,
},
schedules: {
total: 2,
active: 1,
},
exports: {
total: 4,
},
quotaAndUsage: {
quota: {
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
},
usage: {
jobsUsedToday: 3,
jobsRemainingToday: 47,
concurrentJobsRunning: 1,
concurrentJobsAvailable: 4,
totalPagesCrawled: 167,
pagesCrawledToday: 24,
pagesRemainingToday: 976,
},
resetAt: new Date(Date.now() + 86400000).toISOString(),
},
};
export const crawlerService = { export const crawlerService = {
/** /**
* Lấy thống kê tổng quan thời gian thực từ endpoint chuẩn: * Lấy thống kê tổng quan thời gian thực từ endpoint chuẩn:
* GET /api/v1/dashboard/stats (qua Next.js BFF Proxy) * GET /api/v1/dashboard/stats (qua Next.js BFF Proxy)
*/ */
async getStats(): Promise<DashboardStats> { async getStats(): Promise<DashboardStats> {
try {
const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats"); const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats");
if (response.data?.data) { if (response.data?.data) {
return response.data.data; return response.data.data;
} }
return DEFAULT_FALLBACK_STATS; throw new Error("Invalid dashboard stats response");
} catch {
// Fallback tính toán từ local memory state hoặc default
const totalPages = localTasksState.reduce((acc, t) => acc + t.pagesCrawled, 0);
const totalItems = localTasksState.reduce((acc, t) => acc + t.itemsExtracted, 0);
const runningCount = localTasksState.filter((t) => t.status === "RUNNING").length;
const completedCount = localTasksState.filter((t) => t.status === "COMPLETED").length;
const failedCount = localTasksState.filter((t) => t.status === "FAILED").length;
return {
jobs: {
total: localTasksState.length,
completed: completedCount,
failed: failedCount,
running: runningCount,
pending: Math.max(0, localTasksState.length - completedCount - failedCount - runningCount),
},
pages: {
total: totalPages || 167,
successful: totalItems || 162,
failed: 5,
},
schedules: {
total: 2,
active: 1,
},
exports: {
total: 3,
},
quotaAndUsage: {
quota: {
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
},
usage: {
jobsUsedToday: localTasksState.length,
jobsRemainingToday: Math.max(0, 50 - localTasksState.length),
concurrentJobsRunning: runningCount,
concurrentJobsAvailable: Math.max(0, 5 - runningCount),
totalPagesCrawled: totalPages || 167,
pagesCrawledToday: totalPages || 0,
pagesRemainingToday: Math.max(0, 1000 - (totalPages || 0)),
},
resetAt: new Date(Date.now() + 86400000).toISOString(),
},
};
}
}, },
/** /**
* Lấy danh sách task cào dữ liệu (kết nối endpoint thực /crawl-jobs hoặc fallback) * Lấy danh sách task cào dữ liệu từ endpoint chuẩn /crawl-jobs
*/ */
async getTasks(): Promise<PaginatedResponse<CrawlerTask>> { async getTasks(): Promise<PaginatedResponse<CrawlerTask>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>("/crawl-jobs?limit=20"); const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>("/crawl-jobs?limit=20");
const data = response.data?.data; const data = response.data?.data;
if (data && Array.isArray(data.items) && data.items.length > 0) { if (data && Array.isArray(data.items)) {
const mappedItems: CrawlerTask[] = data.items.map((job) => ({ const mappedItems: CrawlerTask[] = data.items.map((job) => ({
id: job.id, id: job.id,
name: job.domain || job.startUrl || `Job ${job.id.slice(0, 8)}`, name: job.domain || job.startUrl || `Job ${job.id.slice(0, 8)}`,
...@@ -180,30 +47,13 @@ export const crawlerService = { ...@@ -180,30 +47,13 @@ export const crawlerService = {
totalPages: data.totalPages ?? 1, totalPages: data.totalPages ?? 1,
}; };
} }
return { throw new Error("Failed to fetch crawler tasks");
items: localTasksState,
total: localTasksState.length,
page: 1,
pageSize: 10,
totalPages: 1,
};
} catch {
// Fallback local memory state
return {
items: localTasksState,
total: localTasksState.length,
page: 1,
pageSize: 10,
totalPages: 1,
};
}
}, },
/** /**
* Tạo mới một task * Tạo mới một task
*/ */
async createTask(input: CreateCrawlerTaskInput): Promise<CrawlerTask> { async createTask(input: CreateCrawlerTaskInput): Promise<CrawlerTask> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", { const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", {
startUrl: input.targetUrl, startUrl: input.targetUrl,
maxDepth: input.maxDepth, maxDepth: input.maxDepth,
...@@ -226,69 +76,38 @@ export const crawlerService = { ...@@ -226,69 +76,38 @@ export const crawlerService = {
lastRunAt: job.startedAt || job.createdAt, lastRunAt: job.startedAt || job.createdAt,
}; };
} }
throw new Error("Invalid response"); throw new Error("Failed to create crawler task");
} catch {
const newTask: CrawlerTask = {
id: `task-${Date.now()}`,
name: input.name,
targetUrl: input.targetUrl,
status: "RUNNING",
maxDepth: input.maxDepth,
maxPages: input.maxPages,
pagesCrawled: 0,
itemsExtracted: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastRunAt: new Date().toISOString(),
};
localTasksState = [newTask, ...localTasksState];
return newTask;
}
}, },
/** /**
* Dừng task * Dừng task
*/ */
async toggleTaskStatus(id: string): Promise<CrawlerTask> { async toggleTaskStatus(id: string): Promise<CrawlerTask> {
try { const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/cancel`);
await apiClient.post(`/crawl-jobs/${id}/cancel`); const job = response.data?.data;
} catch { if (job) {
// Ignored for local fallback return {
} id: job.id,
name: job.domain || job.startUrl,
const task = localTasksState.find((t) => t.id === id); targetUrl: job.startUrl,
if (!task) {
// Tạo mock task nếu là job ID từ server
const fallbackTask: CrawlerTask = {
id,
name: `Tác vụ #${id.slice(0, 8)}`,
targetUrl: "https://example.com",
status: "PAUSED", status: "PAUSED",
maxDepth: 1, maxDepth: job.maxDepth ?? 1,
maxPages: 20, maxPages: job.maxPages ?? 20,
pagesCrawled: 1, pagesCrawled: job.totalPages ?? 0,
itemsExtracted: 1, itemsExtracted: job.successPages ?? 0,
createdAt: new Date().toISOString(), createdAt: job.createdAt,
updatedAt: new Date().toISOString(), updatedAt: job.updatedAt,
lastRunAt: job.startedAt || job.createdAt,
}; };
return fallbackTask;
} }
task.status = task.status === "RUNNING" ? "PAUSED" : "RUNNING"; throw new Error("Failed to toggle task status");
task.updatedAt = new Date().toISOString();
return task;
}, },
/** /**
* Xóa task * Xóa task
*/ */
async deleteTask(id: string): Promise<boolean> { async deleteTask(id: string): Promise<boolean> {
try {
await apiClient.delete(`/crawl-jobs/${id}`); await apiClient.delete(`/crawl-jobs/${id}`);
localTasksState = localTasksState.filter((t) => t.id !== id);
return true;
} catch {
localTasksState = localTasksState.filter((t) => t.id !== id);
return true; return true;
}
}, },
}; };
...@@ -11,308 +11,88 @@ import { ...@@ -11,308 +11,88 @@ import {
WebhookDeliveryQueryDto, WebhookDeliveryQueryDto,
} from "@/types/developer"; } from "@/types/developer";
const MOCK_API_KEYS: ApiKey[] = [
{
id: "key-01",
name: "Production Backend Integration",
keyPrefix: "dc_live_9f8a",
isActive: true,
expiresAt: new Date(Date.now() + 86400000 * 90).toISOString(),
lastUsedAt: new Date(Date.now() - 3600000 * 2).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 15).toISOString(),
},
{
id: "key-02",
name: "Zapier Automated Workflows",
keyPrefix: "dc_live_12bc",
isActive: true,
expiresAt: null,
lastUsedAt: new Date(Date.now() - 86400000).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 30).toISOString(),
},
{
id: "key-03",
name: "Dev Test Script (Staging)",
keyPrefix: "dc_live_77ef",
isActive: false,
expiresAt: new Date(Date.now() - 86400000 * 5).toISOString(),
lastUsedAt: new Date(Date.now() - 86400000 * 7).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 45).toISOString(),
},
];
const MOCK_WEBHOOK_CONFIGS: WebhookConfig[] = [
{
id: "wh-01",
url: "https://api.mycompany.com/v1/webhooks/crawler-events",
events: ["crawl.job.completed", "crawl.job.failed"],
isActive: true,
createdAt: new Date(Date.now() - 86400000 * 20).toISOString(),
},
{
id: "wh-02",
url: "https://hooks.slack.com/services/T00/B00/XXXXX",
events: ["crawl.job.failed", "export.completed"],
isActive: true,
createdAt: new Date(Date.now() - 86400000 * 10).toISOString(),
},
];
const MOCK_WEBHOOK_DELIVERIES: WebhookDelivery[] = [
{
id: "del-01",
webhookConfigId: "wh-01",
crawlJobId: "job-demo-vnexpress-01",
event: "crawl.job.completed",
status: "SUCCESS",
statusCode: 200,
attempt: 1,
responseBody: '{"received": true}',
deliveredAt: new Date(Date.now() - 3600000).toISOString(),
createdAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: "del-02",
webhookConfigId: "wh-01",
crawlJobId: "job-demo-tiki-02",
event: "crawl.job.completed",
status: "SUCCESS",
statusCode: 200,
attempt: 1,
responseBody: '{"status": "ok"}',
deliveredAt: new Date(Date.now() - 3600000 * 5).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 5).toISOString(),
},
{
id: "del-03",
webhookConfigId: "wh-02",
crawlJobId: "job-failed-test-03",
event: "crawl.job.failed",
status: "FAILED",
statusCode: 504,
attempt: 3,
errorMessage: "Gateway Timeout: Destination endpoint did not respond in 10000ms",
deliveredAt: new Date(Date.now() - 86400000).toISOString(),
createdAt: new Date(Date.now() - 86400000).toISOString(),
},
];
let localApiKeys = [...MOCK_API_KEYS];
let localWebhooks = [...MOCK_WEBHOOK_CONFIGS];
const localDeliveries = [...MOCK_WEBHOOK_DELIVERIES];
export class DeveloperService { export class DeveloperService {
// API Keys // API Keys
async listKeys(): Promise<ApiKey[]> { async listKeys(): Promise<ApiKey[]> {
try {
const response = await apiClient.get<ApiResponse<ApiKey[]>>("/api-keys"); const response = await apiClient.get<ApiResponse<ApiKey[]>>("/api-keys");
if (response.data?.data && Array.isArray(response.data.data)) { if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data; return response.data.data;
} }
return localApiKeys; throw new Error("Không thể tải danh sách khóa API");
} catch {
return localApiKeys;
}
} }
async createKey(dto: CreateApiKeyDto): Promise<CreateApiKeyResponse> { async createKey(dto: CreateApiKeyDto): Promise<CreateApiKeyResponse> {
try {
const response = await apiClient.post<ApiResponse<CreateApiKeyResponse>>("/api-keys", dto); const response = await apiClient.post<ApiResponse<CreateApiKeyResponse>>("/api-keys", dto);
if (response.data?.data) { if (response.data?.data) {
const created = response.data.data; return response.data.data;
localApiKeys = [
{
id: created.id,
name: created.name,
keyPrefix: created.keyPrefix,
key: created.key,
isActive: true,
expiresAt: created.expiresAt || null,
lastUsedAt: null,
createdAt: created.createdAt || new Date().toISOString(),
},
...localApiKeys,
];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const fullKey = `dc_live_${Math.random().toString(36).substring(2, 10)}${Math.random().toString(36).substring(2, 18)}`;
const prefix = fullKey.substring(0, 12);
const newKey: CreateApiKeyResponse = {
id: `key-${Date.now()}`,
name: dto.name,
key: fullKey,
keyPrefix: prefix,
expiresAt: dto.expiresAt || null,
createdAt: new Date().toISOString(),
};
localApiKeys = [
{
id: newKey.id,
name: newKey.name,
keyPrefix: prefix,
key: fullKey,
isActive: true,
expiresAt: newKey.expiresAt ?? null,
lastUsedAt: null,
createdAt: newKey.createdAt,
},
...localApiKeys,
];
return newKey;
} }
throw new Error("Không thể khởi tạo khóa API");
} }
async toggleActive(id: string, isActive: boolean): Promise<ApiKey> { async toggleActive(id: string, isActive: boolean): Promise<ApiKey> {
try {
const response = await apiClient.patch<ApiResponse<ApiKey>>(`/api-keys/${id}`, { isActive }); const response = await apiClient.patch<ApiResponse<ApiKey>>(`/api-keys/${id}`, { isActive });
if (response.data?.data) { if (response.data?.data) {
const updated = response.data.data; return response.data.data;
localApiKeys = localApiKeys.map((k) => (k.id === id ? updated : k));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const key = localApiKeys.find((k) => k.id === id);
if (!key) throw new Error("Không tìm thấy khóa API");
key.isActive = isActive;
return { ...key };
} }
throw new Error("Không thể cập nhật trạng thái khóa API");
} }
async revokeKey(id: string): Promise<void> { async revokeKey(id: string): Promise<void> {
try {
await apiClient.delete(`/api-keys/${id}`); await apiClient.delete(`/api-keys/${id}`);
localApiKeys = localApiKeys.filter((k) => k.id !== id);
} catch {
localApiKeys = localApiKeys.filter((k) => k.id !== id);
}
} }
// Webhook Configs // Webhook Configs
async listWebhookConfigs(): Promise<WebhookConfig[]> { async listWebhookConfigs(): Promise<WebhookConfig[]> {
try {
const response = await apiClient.get<ApiResponse<WebhookConfig[]>>("/webhooks/configs"); const response = await apiClient.get<ApiResponse<WebhookConfig[]>>("/webhooks/configs");
if (response.data?.data && Array.isArray(response.data.data)) { if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data; return response.data.data;
} }
return localWebhooks; throw new Error("Không thể tải danh sách cấu hình Webhook");
} catch {
return localWebhooks;
}
} }
async createWebhookConfig(dto: CreateWebhookConfigDto): Promise<WebhookConfig> { async createWebhookConfig(dto: CreateWebhookConfigDto): Promise<WebhookConfig> {
try {
const response = await apiClient.post<ApiResponse<WebhookConfig>>("/webhooks/configs", dto); const response = await apiClient.post<ApiResponse<WebhookConfig>>("/webhooks/configs", dto);
if (response.data?.data) { if (response.data?.data) {
const created = response.data.data; return response.data.data;
localWebhooks = [created, ...localWebhooks];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const newWh: WebhookConfig = {
id: `wh-${Date.now()}`,
url: dto.url,
events: dto.events,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localWebhooks = [newWh, ...localWebhooks];
return newWh;
} }
throw new Error("Không thể tạo cấu hình Webhook");
} }
async updateWebhookConfig(id: string, dto: UpdateWebhookConfigDto): Promise<WebhookConfig> { async updateWebhookConfig(id: string, dto: UpdateWebhookConfigDto): Promise<WebhookConfig> {
try {
const response = await apiClient.patch<ApiResponse<WebhookConfig>>(`/webhooks/configs/${id}`, dto); const response = await apiClient.patch<ApiResponse<WebhookConfig>>(`/webhooks/configs/${id}`, dto);
if (response.data?.data) { if (response.data?.data) {
const updated = response.data.data; return response.data.data;
localWebhooks = localWebhooks.map((w) => (w.id === id ? updated : w));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const index = localWebhooks.findIndex((w) => w.id === id);
if (index === -1) throw new Error("Không tìm thấy Webhook");
const existing = localWebhooks[index];
const updated: WebhookConfig = {
...existing,
...dto,
updatedAt: new Date().toISOString(),
};
localWebhooks[index] = updated;
return updated;
} }
throw new Error("Không thể cập nhật cấu hình Webhook");
} }
async deleteWebhookConfig(id: string): Promise<void> { async deleteWebhookConfig(id: string): Promise<void> {
try {
await apiClient.delete(`/webhooks/configs/${id}`); await apiClient.delete(`/webhooks/configs/${id}`);
localWebhooks = localWebhooks.filter((w) => w.id !== id);
} catch {
localWebhooks = localWebhooks.filter((w) => w.id !== id);
}
} }
async testWebhookConfig(id: string): Promise<{ success: boolean; statusCode: number; message: string }> { async testWebhookConfig(id: string): Promise<{ success: boolean; statusCode: number; message: string }> {
try {
const response = await apiClient.post<ApiResponse<{ success: boolean; statusCode: number; message: string }>>( const response = await apiClient.post<ApiResponse<{ success: boolean; statusCode: number; message: string }>>(
`/webhooks/configs/${id}/test` `/webhooks/configs/${id}/test`
); );
if (response.data?.data) { if (response.data?.data) {
return response.data.data; return response.data.data;
} }
return { success: true, statusCode: 200, message: "Webhook Test Ping sent successfully!" }; throw new Error("Gửi kiểm tra Webhook thất bại");
} catch {
return { success: true, statusCode: 200, message: "Webhook Test Ping succeeded (simulated)!" };
}
} }
// Webhook Deliveries // Webhook Deliveries
async listWebhookDeliveries(params?: WebhookDeliveryQueryDto): Promise<PaginatedResponse<WebhookDelivery>> { async listWebhookDeliveries(params?: WebhookDeliveryQueryDto): Promise<PaginatedResponse<WebhookDelivery>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<WebhookDelivery>>>("/webhooks/deliveries", { const response = await apiClient.get<ApiResponse<PaginatedResponse<WebhookDelivery>>>("/webhooks/deliveries", {
params, params,
}); });
if (response.data?.data) { if (response.data?.data) {
return response.data.data; return response.data.data;
} }
return this.getLocalDeliveries(params); throw new Error("Không thể tải nhật ký gửi Webhook");
} catch {
return this.getLocalDeliveries(params);
}
}
private getLocalDeliveries(params?: WebhookDeliveryQueryDto): PaginatedResponse<WebhookDelivery> {
let list = [...localDeliveries];
if (params?.status) {
list = list.filter((d) => d.status === params.status);
}
const page = params?.page || 1;
const limit = params?.limit || 20;
const total = list.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
return {
items: list.slice(start, start + limit),
total,
page,
pageSize: limit,
totalPages,
};
} }
async redeliverWebhook(id: string): Promise<void> { async redeliverWebhook(id: string): Promise<void> {
try {
await apiClient.post(`/webhooks/deliveries/${id}/redeliver`); await apiClient.post(`/webhooks/deliveries/${id}/redeliver`);
} catch {
// simulated success
}
} }
} }
......
...@@ -22,11 +22,7 @@ export class ProfileService { ...@@ -22,11 +22,7 @@ export class ProfileService {
const formData = new FormData(); const formData = new FormData();
formData.append("avatar", file); formData.append("avatar", file);
const response = await apiClient.post<ApiResponse<{ avatarUrl: string }>>("/auth/avatar", formData, { const response = await apiClient.post<ApiResponse<{ avatarUrl: string }>>("/auth/avatar", formData);
headers: {
"Content-Type": "multipart/form-data",
},
});
return response.data.data; return response.data.data;
} }
......
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