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()
......
This diff is collapsed.
This diff is collapsed.
...@@ -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;
}
}, },
}; };
This diff is collapsed.
...@@ -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