Commit be29c7a3 authored by ThinhNC's avatar ThinhNC

fix: resolve 20 audit findings and stabilize BFF proxy integration

parent 8d925115
......@@ -6,7 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "node --max-old-space-size=4096 ./node_modules/eslint/bin/eslint.js ."
},
"dependencies": {
"@hookform/resolvers": "^5.9.1",
......
......@@ -13,17 +13,14 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getSafeRedirectUrl } from "@/lib/utils";
function LoginForm() {
const { t } = useLanguage();
const { login } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const rawRedirect = searchParams.get("redirect") || "/";
// Never redirect back to auth pages to prevent loops
const redirectUrl =
rawRedirect.startsWith("/login") || rawRedirect.startsWith("/register")
? "/"
: rawRedirect;
const redirectUrl = getSafeRedirectUrl(searchParams.get("redirect"), "/");
const [showPassword, setShowPassword] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
......@@ -45,8 +42,8 @@ function LoginForm() {
setAuthError(null);
try {
await login(data, data.rememberMe ?? true);
// Force hard reload navigation so Next.js server components and middleware receive fresh HttpOnly cookies
window.location.href = redirectUrl;
router.replace(redirectUrl);
router.refresh();
} catch (err: unknown) {
if (err instanceof Error) {
setAuthError(err.message);
......
......@@ -26,12 +26,13 @@ function VerifyEmailContent() {
const searchParams = useSearchParams();
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 [manualToken, setManualToken] = useState("");
const [resendEmail, setResendEmail] = useState("");
const [isResending, setIsResending] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
const verifiedRef = React.useRef(false);
const handleVerify = useCallback(async (tokenToVerify: string) => {
if (!tokenToVerify) return;
......@@ -52,10 +53,25 @@ function VerifyEmailContent() {
}, []);
useEffect(() => {
if (tokenParam) {
handleVerify(tokenParam);
if (!tokenParam || verifiedRef.current) return;
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) => {
e.preventDefault();
......
......@@ -41,7 +41,7 @@ async function handleProxy(
}
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();
if (rawBody.byteLength > 0) {
body = Buffer.from(rawBody);
......
......@@ -114,6 +114,11 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
const wasRunningRef = 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 {
status: sseStatus,
retryCount: sseRetryCount,
......@@ -122,7 +127,7 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
} = useEventSource<CrawlJob>(
jobId ? `/api/proxy/crawl-jobs/${jobId}/events` : null,
{
enabled: Boolean(jobId),
enabled: Boolean(jobId) && isJobActive,
onInitial: (data) => setLiveProgressJob(data),
onProgress: (data) => {
setLiveProgressJob(data);
......@@ -179,18 +184,18 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
}
}, [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(
jobId,
{ 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)
const { data: pagesData, isLoading: isPagesLoading, refetch: refetchPages } = useCrawlJobPages(
jobId,
{ 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)
......
......@@ -12,6 +12,7 @@ import {
import { ApiKey } from "@/types/developer";
import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal";
import { CopyButton } from "@/components/common/copy-button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export default function ApiKeysPage() {
const { t, locale } = useLanguage();
......@@ -19,6 +20,7 @@ export default function ApiKeysPage() {
const toggleApiKeyMutation = useToggleApiKey();
const revokeApiKeyMutation = useRevokeApiKey();
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" \\
-H "X-API-Key: dc_live_your_secret_key_here" \\
......@@ -51,8 +53,13 @@ console.log('Job Created:', response.data);`;
};
const handleRevokeKey = (key: ApiKey) => {
if (window.confirm(`${t.developer.apiKeys.table.revokeConfirm}\n("${key.name}")`)) {
revokeApiKeyMutation.mutate(key.id);
setKeyToRevoke(key);
};
const confirmRevokeKey = () => {
if (keyToRevoke) {
revokeApiKeyMutation.mutate(keyToRevoke.id);
setKeyToRevoke(null);
}
};
......@@ -257,6 +264,25 @@ console.log('Job Created:', response.data);`;
isOpen={isKeyModalOpen}
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>
);
}
......@@ -29,6 +29,7 @@ import { WebhookConfig, WebhookDelivery } from "@/types/developer";
import { CreateWebhookModal } from "@/components/developer/create-webhook-modal";
import { CopyButton } from "@/components/common/copy-button";
import { JsonViewer } from "@/components/common/json-viewer";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export default function WebhooksPage() {
const { t, locale } = useLanguage();
......@@ -41,6 +42,7 @@ export default function WebhooksPage() {
const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null);
const [selectedDelivery, setSelectedDelivery] = useState<WebhookDelivery | null>(null);
const [webhookToDelete, setWebhookToDelete] = useState<WebhookConfig | null>(null);
const deliveries = deliveriesData?.items || [];
......@@ -49,8 +51,13 @@ export default function WebhooksPage() {
};
const handleDeleteWebhook = (wh: WebhookConfig) => {
if (window.confirm(`${t.developer.webhooks.table.deleteConfirm}\n("${wh.url}")`)) {
deleteWebhookMutation.mutate(wh.id);
setWebhookToDelete(wh);
};
const confirmDeleteWebhook = () => {
if (webhookToDelete) {
deleteWebhookMutation.mutate(webhookToDelete.id);
setWebhookToDelete(null);
}
};
......
......@@ -53,8 +53,12 @@ export default function ProfileSettingsPage() {
const revokeSessionsMutation = useRevokeAllSessions();
// Form states
const [fullName, setFullName] = useState(user?.fullName || "");
const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatarUrl || null);
const [userEnteredFullName, setUserEnteredFullName] = useState<string | null>(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);
// Copy ID state
......@@ -73,21 +77,6 @@ export default function ProfileSettingsPage() {
const [deactivatePassword, setDeactivatePassword] = useState("");
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
const initialFullName = (profile?.fullName ?? user?.fullName ?? "").trim();
const isProfileChanged =
......@@ -112,7 +101,7 @@ export default function ProfileSettingsPage() {
// Instant local preview
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
setLocalAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
......@@ -124,12 +113,19 @@ export default function ProfileSettingsPage() {
const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault();
if (!isProfileChanged || updateProfileMutation.isPending) return;
updateProfileMutation.mutate({ fullName: fullName.trim() });
updateProfileMutation.mutate(
{ fullName: fullName.trim() },
{
onSuccess: () => {
setUserEnteredFullName(null);
},
}
);
};
// Revert Profile Name Handler
const handleCancelUpdateProfile = () => {
setFullName(profile?.fullName ?? user?.fullName ?? "");
setUserEnteredFullName(null);
};
// Change Password Handler
......@@ -341,7 +337,7 @@ export default function ProfileSettingsPage() {
<Input
id="prof-name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
onChange={(e) => setUserEnteredFullName(e.target.value)}
placeholder={t.profile.personal.namePlaceholder}
className="rounded-2xl text-xs"
/>
......
......@@ -9,6 +9,7 @@ import { AuditLogItem, AuditLogQuery } from "@/types/audit-log";
import { AuditLogsFilters, DatePreset } from "./audit-logs-filters";
import { AuditLogsTable } from "./audit-logs-table";
import { AuditLogDetailDrawer } from "./audit-log-detail-drawer";
import { getLocalDateRangeISO } from "@/lib/utils";
interface AuditLogsViewProps {
showHeader?: boolean;
......@@ -59,10 +60,11 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) {
const now = new Date();
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 {
queryStartDate: startOfDay.toISOString(),
queryEndDate: now.toISOString(),
queryEndDate: endOfDay.toISOString(),
};
}
if (datePreset === "7d") {
......@@ -80,9 +82,10 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) {
};
}
if (datePreset === "custom") {
const { startDate: qStart, endDate: qEnd } = getLocalDateRangeISO(startDate, endDate);
return {
queryStartDate: startDate ? new Date(startDate).toISOString() : undefined,
queryEndDate: endDate ? new Date(`${endDate}T23:59:59.999Z`).toISOString() : undefined,
queryStartDate: qStart,
queryEndDate: qEnd,
};
}
......
......@@ -6,14 +6,12 @@ import { usePathname } from "next/navigation";
import { Activity, Calendar, Download, FileCode2, Globe } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
const emptySubscribe = () => () => {};
export function BottomNav() {
const { t } = useLanguage();
const pathname = usePathname();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const mounted = React.useSyncExternalStore(emptySubscribe, () => true, () => false);
const navItems = [
{
......
......@@ -99,13 +99,13 @@ export function SafeHtmlPreview({
<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>
</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>
<iframe
title={title}
srcDoc={srcDoc}
sandbox="allow-same-origin"
sandbox=""
className="w-full border-0 transition-all bg-background"
style={{ height: maxHeight }}
/>
......
......@@ -121,6 +121,9 @@ export function UserMenu() {
{/* Trigger Button */}
<button
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"
>
{/* Avatar */}
......@@ -146,7 +149,11 @@ export function UserMenu() {
{/* Dropdown Menu */}
{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 */}
<div className="rounded-2xl border border-border/60 bg-muted/40 p-3 mb-2">
<p className="font-semibold text-xs text-foreground truncate">
......@@ -170,6 +177,7 @@ export function UserMenu() {
<div className="space-y-1">
<Link
href="/settings/profile"
role="menuitem"
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"
>
......@@ -180,6 +188,7 @@ export function UserMenu() {
{canAccessDeveloper && (
<Link
href={developerHref}
role="menuitem"
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"
>
......@@ -194,6 +203,7 @@ export function UserMenu() {
{/* Logout Action */}
<button
role="menuitem"
onClick={() => {
setIsOpen(false);
logout();
......
......@@ -158,15 +158,8 @@ export function CrawlerTaskTable() {
};
const jobs = jobsData?.items || [];
const rawTotal =
jobsData?.total ??
(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));
const total = jobsData?.total !== undefined && jobsData.total > 0 ? jobsData.total : jobs.length;
const totalPages = Math.max(1, jobsData?.totalPages ?? Math.ceil(total / limit));
return (
<div className="space-y-4">
......
......@@ -2,12 +2,18 @@
import React, { useState, useRef, useEffect } from "react";
import {
X,
KeyRound,
Sparkles,
ChevronDown,
Check,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
......@@ -66,8 +72,6 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
......@@ -90,34 +94,26 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
};
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">
<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">
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="max-w-lg rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<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">
<DialogHeader className="flex flex-row items-center gap-3 space-y-0 text-left pb-4 border-b border-border/60">
<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">
<KeyRound className="h-5 w-5" />
</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}
</h2>
<p className="text-xs text-muted-foreground">
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{t.developer.apiKeys.modal.desc}
</p>
</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>
</DialogDescription>
</div>
</DialogHeader>
{/* Content: Form or Secret Key View */}
{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="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs">
<Sparkles className="h-4 w-4" />
......@@ -157,7 +153,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
</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">
<Label htmlFor="api-key-name" className="text-xs font-semibold">
{t.developer.apiKeys.modal.nameLabel}
......@@ -227,7 +223,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
<Button
type="button"
variant="outline"
onClick={onClose}
onClick={handleClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.developer.apiKeys.modal.cancel}
......@@ -244,7 +240,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
</div>
</form>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
......@@ -2,12 +2,18 @@
import React, { useState } from "react";
import {
X,
Webhook,
Check,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
......@@ -82,8 +88,6 @@ export function CreateWebhookModal({
onClose();
};
if (!isOpen) return null;
const toggleEvent = (eventVal: string) => {
setSelectedEvents((prev) =>
prev.includes(eventVal)
......@@ -123,40 +127,32 @@ export function CreateWebhookModal({
};
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">
<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">
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<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 */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<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">
<DialogHeader className="flex flex-row items-center gap-3 space-y-0 text-left pb-4 border-b border-border/60">
<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">
<Webhook className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
<DialogTitle className="text-base font-bold tracking-tight text-foreground">
{createdSecretData
? t.developer.webhooks.modal.newSecretNotice.title
: isEditing
? t.developer.webhooks.modal.editTitle
: t.developer.webhooks.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{createdSecretData
? t.developer.webhooks.modal.newSecretNotice.desc
: t.developer.webhooks.modal.desc}
</p>
</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>
</DialogDescription>
</div>
</DialogHeader>
{/* Content: Form or 1-Time Secret View */}
{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="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs">
<ShieldCheck className="h-4 w-4" />
......@@ -198,7 +194,7 @@ export function CreateWebhookModal({
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="py-4 space-y-4">
<form onSubmit={handleSubmit} className="py-2 space-y-4">
{/* Endpoint URL */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
......@@ -297,7 +293,7 @@ export function CreateWebhookModal({
</div>
</form>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
......@@ -82,16 +82,12 @@ export function CreateExportModal({
const { data: jobsData } = useCrawlJobs({ limit: 10 });
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 [customFileName, setCustomFileName] = useState("");
useEffect(() => {
if (defaultJobId) {
setSelectedJobId(defaultJobId);
}
}, [defaultJobId, isOpen]);
const jobs = jobsData?.items || [];
const completedJobs = jobs.filter((j) => j.status === "COMPLETED");
const isDefaultInCompleted = completedJobs.some((j) => j.id === defaultJobId);
......@@ -166,7 +162,7 @@ export function CreateExportModal({
<button
key={job.id}
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 ${
selectedJobId === job.id
? "border-emerald-500/40 bg-emerald-500/10 text-foreground shadow-sm"
......
......@@ -129,24 +129,26 @@ export function PermissionMatrixDialog({
// Mutation to save matrix: PUT /roles/:id/permissions
const setPermissionsMutation = useSetRolePermissions();
// Local state of selected permission IDs
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// User modified permission IDs (or null if using server assigned permissions)
const [userSelectedIds, setUserSelectedIds] = useState<string[] | null>(null);
const [lastRoleId, setLastRoleId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
// Sync initial permissions into local state with equality check
useEffect(() => {
if (!isOpen || !rolePermissions) return;
const initialIds = rolePermissions.map((p) => p.id);
setSelectedIds((prev) => {
if (
prev.length === initialIds.length &&
prev.every((id) => initialIds.includes(id))
) {
return prev;
if (role?.id !== lastRoleId) {
setLastRoleId(role?.id || null);
setUserSelectedIds(null);
}
return initialIds;
});
}, [isOpen, rolePermissions]);
const rolePermIds = useMemo(() => rolePermissions?.map((p) => p.id) || [], [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
useEffect(() => {
......
......@@ -3,7 +3,7 @@
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
......@@ -11,8 +11,8 @@ import { useLanguage } from "@/providers/language-provider";
import { RoleItem } from "@/types/role";
import { useCreateRole, useUpdateRole } from "@/hooks/use-roles";
import {
createRoleSchema,
CreateRoleInput,
roleFormSchema,
RoleFormInput,
} from "@/schemas/role.schema";
interface RoleFormModalProps {
......@@ -21,7 +21,7 @@ interface RoleFormModalProps {
roleToEdit?: RoleItem | null;
}
type RoleFormData = CreateRoleInput & { syncUsersQuota?: boolean };
type RoleFormData = RoleFormInput;
export function RoleFormModal({
isOpen,
......@@ -43,7 +43,7 @@ export function RoleFormModal({
reset,
formState: { errors, isSubmitting },
} = useForm<RoleFormData>({
resolver: zodResolver(createRoleSchema) as any,
resolver: zodResolver(roleFormSchema),
defaultValues: {
name: "",
slug: "",
......@@ -118,7 +118,7 @@ export function RoleFormModal({
if (!isOpen) return null;
const onSubmit = async (data: RoleFormData) => {
const parseOptionalNumber = (val: any) => {
const parseOptionalNumber = (val: unknown) => {
if (val === "" || val === null || val === undefined || isNaN(Number(val))) {
return null;
}
......
......@@ -22,21 +22,20 @@ export function RoleResetQuotaDialog({
const resetMutation = useResetRoleQuota();
const [syncLimits, setSyncLimits] = useState(false);
useEffect(() => {
if (isOpen) {
setSyncLimits(false);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
}, [isOpen]);
const handleClose = () => {
setSyncLimits(false);
onClose();
};
if (!isOpen || !role) return null;
......@@ -45,7 +44,7 @@ export function RoleResetQuotaDialog({
id: role.id,
payload: { syncLimits },
});
onClose();
handleClose();
};
return (
......
......@@ -77,9 +77,12 @@ export function RolesManagementView({
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
const [prevFilterKey, setPrevFilterKey] = useState("");
const currentFilterKey = `${debouncedSearch}|${selectedStatus}|${selectedType}`;
if (currentFilterKey !== prevFilterKey) {
setPrevFilterKey(currentFilterKey);
setPage(1);
}, [debouncedSearch, selectedStatus, selectedType]);
}
// Click outside dropdowns
useEffect(() => {
......
......@@ -72,7 +72,11 @@ export function SystemConfigFormModal({
const categoryRef = useRef<HTMLDivElement>(null);
// 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) {
setKey(config.key);
setDescription(config.description || "");
......@@ -106,7 +110,7 @@ export function SystemConfigFormModal({
setIsPublic(false);
}
setValidationError(null);
}, [config, isOpen]);
}
// Click outside category dropdown
useEffect(() => {
......
......@@ -114,13 +114,13 @@ export function SystemConfigsManagementView({
const sortedItems = useMemo(() => {
if (!data?.items) return [];
return [...data.items].sort((a, b) => {
let aVal: any = a[sortBy as keyof SystemConfigItem];
let bVal: any = b[sortBy as keyof SystemConfigItem];
const aVal = a[sortBy as keyof SystemConfigItem];
const bVal = b[sortBy as keyof SystemConfigItem];
if (typeof aVal === "string") {
return sortOrder === "asc"
? aVal.localeCompare(String(bVal))
: String(bVal).localeCompare(aVal);
? aVal.localeCompare(String(bVal ?? ""))
: String(bVal ?? "").localeCompare(aVal);
}
if (typeof aVal === "boolean") {
return sortOrder === "asc"
......@@ -259,19 +259,21 @@ export function SystemConfigsManagementView({
{/* Category Pills */}
<div className="flex items-center gap-1 overflow-x-auto pb-1 md:pb-0">
{[
{(
[
{ key: "ALL", label: t.systemConfigs.tabs.all },
{ key: "GENERAL", label: t.systemConfigs.tabs.general },
{ key: "FEATURE_FLAG", label: t.systemConfigs.tabs.featureFlag },
{ key: "INTEGRATION", label: t.systemConfigs.tabs.integration },
{ key: "SECURITY", label: t.systemConfigs.tabs.security },
].map((tab) => {
] as const
).map((tab) => {
const isSelected = selectedCategory === tab.key;
return (
<button
key={tab.key}
onClick={() => {
setSelectedCategory(tab.key as any);
setSelectedCategory(tab.key);
setPage(1);
}}
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) {
if (!isOpen) return null;
const onSubmit = async (data: CreateUserInput) => {
const parseOptionalNumber = (val: any) => {
const parseOptionalNumber = (val: unknown) => {
if (val === "" || val === null || val === undefined || isNaN(Number(val))) {
return null;
}
......
......@@ -22,21 +22,20 @@ export function UserResetQuotaDialog({
const resetMutation = useResetUserQuota();
const [resetLimitsToRole, setResetLimitsToRole] = useState(false);
useEffect(() => {
if (isOpen) {
setResetLimitsToRole(false);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
}, [isOpen]);
const handleClose = () => {
setResetLimitsToRole(false);
onClose();
};
if (!isOpen || !user) return null;
......@@ -45,7 +44,7 @@ export function UserResetQuotaDialog({
id: user.id,
payload: { resetLimitsToRole },
});
onClose();
handleClose();
};
return (
......
......@@ -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)
*/
......@@ -79,18 +77,8 @@ export function useRerunCrawlJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
if (rerunInFlight.has(id)) {
return null as unknown as CrawlJob;
}
rerunInFlight.add(id);
try {
return await crawlJobService.rerunJob(id);
} finally {
setTimeout(() => rerunInFlight.delete(id), 2500);
}
},
onSuccess: (job) => {
mutationFn: (id: string) => crawlJobService.rerunJob(id),
onSuccess: (job: CrawlJob) => {
if (!job || !job.id) return;
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.`,
......@@ -170,7 +158,7 @@ export function useCrawlJobLogs(
queryKey: CRAWL_JOBS_QUERY_KEYS.logs(id, query),
queryFn: () => crawlJobService.getJobLogs(id, query),
enabled: Boolean(id),
refetchInterval: options?.refetchInterval ?? 5000,
refetchInterval: options?.refetchInterval ?? false,
});
}
......
......@@ -7,7 +7,7 @@ import { Locale, translations } from "./i18n/translations";
export function isConnectionLossError(
rawError: string | null | undefined
): boolean {
if (!rawError) return true;
if (!rawError) return false;
const err = rawError.toLowerCase();
......@@ -37,12 +37,8 @@ export function isConnectionLossError(
err.includes("getaddrinfo") ||
err.includes("enotfound") ||
// 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("500") ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
err.includes("bad gateway") ||
err.includes("gateway") ||
err.includes("service unavailable") ||
......@@ -90,7 +86,7 @@ export function getLocalizedCrawlErrorInfo(
const err = raw.toLowerCase();
if (
err.includes("402") ||
/\b(402)\b/.test(err) ||
err.includes("credit") ||
err.includes("xác thực") ||
err.includes("api key") ||
......@@ -169,10 +165,7 @@ export function getLocalizedCrawlErrorInfo(
}
if (
err.includes("500") ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
/\b(500|502|503|504)\b/.test(err) ||
err.includes("bad gateway") ||
err.includes("service unavailable")
) {
......
......@@ -67,7 +67,7 @@ export function downloadBlob(
type: mimeType || "text/plain;charset=utf-8",
});
} else {
blob = new Blob([content as unknown as BlobPart], {
blob = new Blob([content as BlobPart], {
type: mimeType || "application/octet-stream",
});
}
......
......@@ -5,16 +5,92 @@ export function cn(...inputs: ClassValue[]) {
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", {
timeZone: "Asia/Ho_Chi_Minh",
year: "numeric",
month: "short",
day: "numeric",
hour: "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 {
if (!+bytes) return "0 Bytes";
const k = 1024;
......
......@@ -10,20 +10,8 @@ const AUTH_ROUTES = [
"/verify-email",
];
// Routes strictly requiring ADMIN role (RBAC)
const ADMIN_ROUTES = [
"/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",
];
// Strict ADMIN-only root routes
const STRICT_ADMIN_ROUTES = ["/admin", "/settings/developer"];
// Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found)
const OPEN_ROUTES = [
......@@ -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) {
const { pathname, search } = request.nextUrl;
const authToken = request.cookies.get("auth_token")?.value;
const refreshToken = request.cookies.get("refresh_token")?.value;
const userRoleCookie = request.cookies.get("user_role")?.value;
// Determine user role and token expiration
let role = userRoleCookie;
// Determine user role and token expiration strictly from signed JWT
let role: string | undefined = undefined;
let isTokenExpired = false;
if (authToken) {
const payload = decodeJwtPayload(authToken);
if (payload) {
if (payload.role && !role) {
role = payload.role;
}
if (payload.exp && payload.exp * 1000 < Date.now()) {
isTokenExpired = true;
}
......@@ -77,17 +77,11 @@ export function middleware(request: NextRequest) {
const isAuthRoute = AUTH_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
if (isAuthRoute && isAuthenticated) {
let redirectUrl = request.nextUrl.searchParams.get("redirect") || "/";
if (
redirectUrl.startsWith("/login") ||
redirectUrl.startsWith("/register")
) {
redirectUrl = "/";
}
const redirectUrl = getSafeRedirectUrl(request.nextUrl.searchParams.get("redirect"), "/");
return NextResponse.redirect(new URL(redirectUrl, request.url));
}
......@@ -110,9 +104,8 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl);
}
// 4. Role-Based Access Control (RBAC): restrict ADMIN routes
if (isAdminRoute && role !== "ADMIN") {
// Redirect unauthorized role to /forbidden
// 4. Role-Based Access Control (RBAC): restrict strict ADMIN root routes
if (isStrictAdminRoute && role?.toUpperCase() !== "ADMIN") {
return NextResponse.redirect(new URL("/forbidden", request.url));
}
......
......@@ -5,6 +5,7 @@ import React, {
useContext,
useEffect,
useCallback,
useMemo,
ReactNode,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
......@@ -59,9 +60,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const isAuthenticated = !!user && !isError;
const role: UserRole | null = (user?.role as UserRole) || null;
const roles = user?.roles || (role ? [role.toLowerCase()] : []);
const permissions = user?.permissions || [];
const isAdmin = role === "ADMIN" || roles.includes("admin") || roles.includes("super_admin");
const roles = useMemo(
() => (user?.roles || (role ? [role] : [])).map((r) => r.toLowerCase()).filter(Boolean),
[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(
(permission: string): boolean => {
......@@ -104,7 +108,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[role, roles]
);
const login = async (
const login = useCallback(
async (
input: LoginInput,
rememberMe: boolean = true
): Promise<BffLoginResponse> => {
......@@ -125,9 +130,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
toast.error(message);
throw error;
}
};
},
[queryClient, t]
);
const register = async (input: RegisterInput): Promise<User> => {
const register = useCallback(
async (input: RegisterInput): Promise<User> => {
try {
const newUser = await authService.register(input);
toast.success(t.auth.register.success);
......@@ -138,7 +146,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
toast.error(message);
throw error;
}
};
},
[t]
);
const logout = useCallback(async (): Promise<void> => {
try {
......@@ -152,9 +162,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [queryClient, router, t]);
const refreshProfile = async (): Promise<void> => {
const refreshProfile = useCallback(async (): Promise<void> => {
await refetch();
};
}, [refetch]);
// Listen for unauthorized 401 events emitted by apiClient
useEffect(() => {
......@@ -169,7 +179,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, [queryClient, router]);
const contextValue: AuthContextType = {
const contextValue: AuthContextType = useMemo(
() => ({
user: user || null,
role,
roles,
......@@ -185,7 +196,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
register,
logout,
refreshProfile,
};
}),
[
user,
role,
roles,
permissions,
isAuthenticated,
isLoading,
isAdmin,
hasRole,
hasPermission,
hasAnyPermission,
hasAllPermissions,
login,
register,
logout,
refreshProfile,
]
);
return (
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
......
......@@ -13,22 +13,29 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
const LANGUAGE_STORAGE_KEY = "data_crawler_lang";
function getInitialLocale(): Locale {
if (typeof window === "undefined") return "vi";
export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>("vi");
React.useEffect(() => {
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
let targetLocale: Locale = "vi";
if (saved && (saved === "vi" || saved === "en")) {
return saved;
}
targetLocale = saved;
} else {
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 {
return "vi";
// Ignore storage or navigator errors
}
}
export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
}, []);
React.useEffect(() => {
try {
......
......@@ -31,7 +31,9 @@ export function Providers({ children }: ProvidersProps) {
<NetworkStatusBanner />
{children}
<Toaster position="top-right" richColors closeButton />
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</AuthProvider>
</QueryClientProvider>
</LanguageProvider>
......
......@@ -12,7 +12,21 @@ export function isValidCronExpression(cron: string | null | undefined): boolean
if (!cron || typeof cron !== "string") return false;
const parts = cron.trim().split(/\s+/);
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
......
......@@ -55,6 +55,11 @@ export const createRoleSchema = z.object({
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({
name: z
.string()
......
This diff is collapsed.
This diff is collapsed.
......@@ -5,159 +5,26 @@ import { CrawlerTask } from "@/types/crawler";
import { DashboardStats } from "@/types/dashboard";
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 = {
/**
* 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)
*/
async getStats(): Promise<DashboardStats> {
try {
const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats");
if (response.data?.data) {
return response.data.data;
}
return DEFAULT_FALLBACK_STATS;
} 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(),
},
};
}
throw new Error("Invalid dashboard stats response");
},
/**
* 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>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>("/crawl-jobs?limit=20");
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) => ({
id: job.id,
name: job.domain || job.startUrl || `Job ${job.id.slice(0, 8)}`,
......@@ -180,30 +47,13 @@ export const crawlerService = {
totalPages: data.totalPages ?? 1,
};
}
return {
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,
};
}
throw new Error("Failed to fetch crawler tasks");
},
/**
* Tạo mới một task
*/
async createTask(input: CreateCrawlerTaskInput): Promise<CrawlerTask> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", {
startUrl: input.targetUrl,
maxDepth: input.maxDepth,
......@@ -226,69 +76,38 @@ export const crawlerService = {
lastRunAt: job.startedAt || job.createdAt,
};
}
throw new Error("Invalid response");
} 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;
}
throw new Error("Failed to create crawler task");
},
/**
* Dừng task
*/
async toggleTaskStatus(id: string): Promise<CrawlerTask> {
try {
await apiClient.post(`/crawl-jobs/${id}/cancel`);
} catch {
// Ignored for local fallback
}
const task = localTasksState.find((t) => t.id === id);
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",
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/cancel`);
const job = response.data?.data;
if (job) {
return {
id: job.id,
name: job.domain || job.startUrl,
targetUrl: job.startUrl,
status: "PAUSED",
maxDepth: 1,
maxPages: 20,
pagesCrawled: 1,
itemsExtracted: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
maxDepth: job.maxDepth ?? 1,
maxPages: job.maxPages ?? 20,
pagesCrawled: job.totalPages ?? 0,
itemsExtracted: job.successPages ?? 0,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
lastRunAt: job.startedAt || job.createdAt,
};
return fallbackTask;
}
task.status = task.status === "RUNNING" ? "PAUSED" : "RUNNING";
task.updatedAt = new Date().toISOString();
return task;
throw new Error("Failed to toggle task status");
},
/**
* Xóa task
*/
async deleteTask(id: string): Promise<boolean> {
try {
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;
}
},
};
This diff is collapsed.
......@@ -22,11 +22,7 @@ export class ProfileService {
const formData = new FormData();
formData.append("avatar", file);
const response = await apiClient.post<ApiResponse<{ avatarUrl: string }>>("/auth/avatar", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
const response = await apiClient.post<ApiResponse<{ avatarUrl: string }>>("/auth/avatar", formData);
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