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;
}
}, [tokenParam, handleVerify]); 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.");
}
});
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>
<h2 className="text-base font-bold tracking-tight text-foreground">
{t.developer.apiKeys.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.developer.apiKeys.modal.desc}
</p>
</div>
</div> </div>
<button <div>
onClick={handleClose} <DialogTitle className="text-base font-bold tracking-tight text-foreground">
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer" {t.developer.apiKeys.modal.title}
> </DialogTitle>
<X className="h-4 w-4" /> <DialogDescription className="text-xs text-muted-foreground mt-0.5">
</button> {t.developer.apiKeys.modal.desc}
</div> </DialogDescription>
</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>
); );
} }
...@@ -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 ( const rolePermIds = useMemo(() => rolePermissions?.map((p) => p.id) || [], [rolePermissions]);
prev.length === initialIds.length && const selectedIds = userSelectedIds ?? rolePermIds;
prev.every((id) => initialIds.includes(id))
) { const setSelectedIds = (action: string[] | ((prev: string[]) => string[])) => {
return prev; if (typeof action === "function") {
} setUserSelectedIds(action(selectedIds));
return initialIds; } else {
}); setUserSelectedIds(action);
}, [isOpen, rolePermissions]); }
};
// 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: "GENERAL", label: t.systemConfigs.tabs.general }, { key: "ALL", label: t.systemConfigs.tabs.all },
{ key: "FEATURE_FLAG", label: t.systemConfigs.tabs.featureFlag }, { key: "GENERAL", label: t.systemConfigs.tabs.general },
{ key: "INTEGRATION", label: t.systemConfigs.tabs.integration }, { key: "FEATURE_FLAG", label: t.systemConfigs.tabs.featureFlag },
{ key: "SECURITY", label: t.systemConfigs.tabs.security }, { key: "INTEGRATION", label: t.systemConfigs.tabs.integration },
].map((tab) => { { key: "SECURITY", label: t.systemConfigs.tabs.security },
] 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,41 +108,47 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -104,41 +108,47 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[role, roles] [role, roles]
); );
const login = async ( const login = useCallback(
input: LoginInput, async (
rememberMe: boolean = true input: LoginInput,
): Promise<BffLoginResponse> => { rememberMe: boolean = true
try { ): Promise<BffLoginResponse> => {
const response = await authService.login({ ...input, rememberMe }); try {
const response = await authService.login({ ...input, rememberMe });
// Proactively update query cache with logged in user data
if (response.user) { // Proactively update query cache with logged in user data
queryClient.setQueryData(AUTH_QUERY_KEYS.me, response.user); if (response.user) {
queryClient.setQueryData(AUTH_QUERY_KEYS.me, response.user);
}
await queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.me });
toast.success(t.auth.login.success);
return response;
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đăng nhập không thành công";
toast.error(message);
throw error;
} }
await queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.me }); },
[queryClient, t]
toast.success(t.auth.login.success); );
return response;
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đăng nhập không thành công";
toast.error(message);
throw error;
}
};
const register = async (input: RegisterInput): Promise<User> => { const register = useCallback(
try { async (input: RegisterInput): Promise<User> => {
const newUser = await authService.register(input); try {
toast.success(t.auth.register.success); const newUser = await authService.register(input);
return newUser; toast.success(t.auth.register.success);
} catch (error: unknown) { return newUser;
const message = } catch (error: unknown) {
error instanceof Error ? error.message : "Đăng ký không thành công"; const message =
toast.error(message); error instanceof Error ? error.message : "Đăng ký không thành công";
throw error; toast.error(message);
} 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,23 +179,42 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -169,23 +179,42 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}; };
}, [queryClient, router]); }, [queryClient, router]);
const contextValue: AuthContextType = { const contextValue: AuthContextType = useMemo(
user: user || null, () => ({
role, user: user || null,
roles, role,
permissions, roles,
isAuthenticated, permissions,
isLoading, isAuthenticated,
isAdmin, isLoading,
hasRole, isAdmin,
hasPermission, hasRole,
hasAnyPermission, hasPermission,
hasAllPermissions, hasAnyPermission,
login, hasAllPermissions,
register, login,
logout, register,
refreshProfile, logout,
}; 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 {
if (typeof window === "undefined") return "vi";
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
if (saved && (saved === "vi" || saved === "en")) {
return saved;
}
const browserLang = navigator.language?.toLowerCase() || "";
return browserLang.startsWith("vi") ? "vi" : "en";
} catch {
return "vi";
}
}
export function LanguageProvider({ children }: { children: ReactNode }) { export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale); 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")) {
targetLocale = saved;
} else {
const browserLang = navigator.language?.toLowerCase() || "";
targetLocale = browserLang.startsWith("vi") ? "vi" : "en";
}
if (targetLocale !== "vi") {
queueMicrotask(() => {
setLocaleState(targetLocale);
});
}
document.documentElement.lang = targetLocale;
} catch {
// Ignore storage or navigator errors
}
}, []);
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 />
<ReactQueryDevtools initialIsOpen={false} /> {process.env.NODE_ENV === "development" && (
<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.
This diff is collapsed.
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