Skip to content

Commit 066850f

Browse files
authored
Features validator (#6)
1 parent 89e6e2c commit 066850f

File tree

16 files changed

+1855
-127
lines changed

16 files changed

+1855
-127
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ dist-ssr
2121
*.njsproj
2222
*.sln
2323
*.sw?
24-
src-tauri/target/*
24+
src-tauri/target/*

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "Nginx_WAF"
3-
version = "0.1.2"
3+
version = "0.1.3"
44
description = "Nginx WAF - Advanced Nginx Management Platform"
55
authors = ["TinyActive"]
66
edition = "2024"

src/components/access-lists/AccessListFormDialog.tsx

Lines changed: 146 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useEffect } from 'react';
22
import { useQuery } from '@tanstack/react-query';
3-
import { Plus, Trash2, Eye, EyeOff } from 'lucide-react';
3+
import { Plus, Trash2, Eye, EyeOff, AlertCircle, CheckCircle2, Info } from 'lucide-react';
44
import {
55
Dialog,
66
DialogContent,
@@ -23,7 +23,16 @@ import {
2323
} from '@/components/ui/select';
2424
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
2525
import { Badge } from '@/components/ui/badge';
26+
import { Alert, AlertDescription } from '@/components/ui/alert';
2627
import { useToast } from '@/hooks/use-toast';
28+
import {
29+
validateAccessListName,
30+
validateAccessListIp,
31+
validateUsername,
32+
validatePassword,
33+
getAccessListHints,
34+
getAccessListExample
35+
} from '@/utils/access-list-validators';
2736
import {
2837
useCreateAccessList,
2938
useUpdateAccessList,
@@ -75,6 +84,20 @@ export function AccessListFormDialog({
7584
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
7685
const [originalDomainIds, setOriginalDomainIds] = useState<string[]>([]); // Track original domains for edit mode
7786

87+
// Validation states
88+
const [nameValidation, setNameValidation] = useState<{ valid: boolean; error?: string }>({ valid: true });
89+
const [ipValidations, setIpValidations] = useState<Record<number, { valid: boolean; error?: string }>>({});
90+
const [userValidations, setUserValidations] = useState<Record<number, { username: { valid: boolean; error?: string }; password: { valid: boolean; error?: string } }>>({});
91+
92+
// Validate name in real-time
93+
useEffect(() => {
94+
if (formData.name.trim().length > 0) {
95+
setNameValidation(validateAccessListName(formData.name));
96+
} else {
97+
setNameValidation({ valid: true });
98+
}
99+
}, [formData.name]);
100+
78101
// Reset form when dialog opens or access list changes
79102
useEffect(() => {
80103
if (open) {
@@ -120,6 +143,10 @@ export function AccessListFormDialog({
120143
setSelectedDomains([]);
121144
setOriginalDomainIds([]); // Reset original domains
122145
}
146+
// Reset validations
147+
setNameValidation({ valid: true });
148+
setIpValidations({});
149+
setUserValidations({});
123150
}
124151
}, [open, accessList]);
125152

@@ -280,6 +307,18 @@ export function AccessListFormDialog({
280307
const newIps = [...allowedIps];
281308
newIps[index] = value;
282309
setAllowedIps(newIps);
310+
311+
// Validate IP in real-time
312+
if (value.trim().length > 0) {
313+
const validation = validateAccessListIp(value);
314+
setIpValidations(prev => ({ ...prev, [index]: validation }));
315+
} else {
316+
setIpValidations(prev => {
317+
const newValidations = { ...prev };
318+
delete newValidations[index];
319+
return newValidations;
320+
});
321+
}
283322
};
284323

285324
const addAuthUser = () => {
@@ -301,6 +340,31 @@ export function AccessListFormDialog({
301340
const newUsers = [...authUsers];
302341
(newUsers[index] as any)[field] = value;
303342
setAuthUsers(newUsers);
343+
344+
// Validate username/password in real-time
345+
if (field === 'username' && typeof value === 'string') {
346+
if (value.trim().length > 0) {
347+
const validation = validateUsername(value);
348+
setUserValidations(prev => ({
349+
...prev,
350+
[index]: {
351+
username: validation,
352+
password: prev[index]?.password || { valid: true }
353+
}
354+
}));
355+
}
356+
} else if (field === 'password' && typeof value === 'string') {
357+
if (value.trim().length > 0) {
358+
const validation = validatePassword(value, !isEditMode);
359+
setUserValidations(prev => ({
360+
...prev,
361+
[index]: {
362+
username: prev[index]?.username || { valid: true },
363+
password: validation
364+
}
365+
}));
366+
}
367+
}
304368
};
305369

306370
const toggleDomainSelection = (domainId: string) => {
@@ -338,16 +402,29 @@ export function AccessListFormDialog({
338402
<div className="space-y-4">
339403
<div>
340404
<Label htmlFor="name">Name *</Label>
341-
<Input
342-
id="name"
343-
value={formData.name}
344-
onChange={(e) =>
345-
setFormData({ ...formData, name: e.target.value })
346-
}
347-
placeholder="e.g., admin-panel-access"
348-
disabled={isPending}
349-
required
350-
/>
405+
<div className="relative">
406+
<Input
407+
id="name"
408+
value={formData.name}
409+
onChange={(e) =>
410+
setFormData({ ...formData, name: e.target.value })
411+
}
412+
placeholder={getAccessListExample('name')}
413+
disabled={isPending}
414+
required
415+
className={!nameValidation.valid && formData.name.trim().length > 0 ? 'border-red-500' : nameValidation.valid && formData.name.trim().length > 0 ? 'border-green-500' : ''}
416+
/>
417+
{nameValidation.valid && formData.name.trim().length > 0 && (
418+
<CheckCircle2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
419+
)}
420+
{!nameValidation.valid && formData.name.trim().length > 0 && (
421+
<AlertCircle className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-red-500" />
422+
)}
423+
</div>
424+
{!nameValidation.valid && nameValidation.error && (
425+
<p className="text-xs text-red-500 mt-1">{nameValidation.error}</p>
426+
)}
427+
<p className="text-xs text-muted-foreground mt-1">{getAccessListHints('name')}</p>
351428
</div>
352429

353430
<div>
@@ -430,29 +507,46 @@ export function AccessListFormDialog({
430507
</div>
431508

432509
{allowedIps.map((ip, index) => (
433-
<div key={index} className="flex gap-2">
434-
<Input
435-
value={ip}
436-
onChange={(e) => updateIpField(index, e.target.value)}
437-
placeholder="e.g., 192.168.1.1 or 10.0.0.0/24"
438-
disabled={isPending}
439-
/>
440-
{allowedIps.length > 1 && (
441-
<Button
442-
type="button"
443-
variant="outline"
444-
size="icon"
445-
onClick={() => removeIpField(index)}
446-
disabled={isPending}
447-
>
448-
<Trash2 className="h-4 w-4" />
449-
</Button>
510+
<div key={index} className="space-y-1">
511+
<div className="flex gap-2">
512+
<div className="relative flex-1">
513+
<Input
514+
value={ip}
515+
onChange={(e) => updateIpField(index, e.target.value)}
516+
placeholder={getAccessListExample('ip')}
517+
disabled={isPending}
518+
className={ipValidations[index] && !ipValidations[index].valid ? 'border-red-500' : ipValidations[index]?.valid ? 'border-green-500' : ''}
519+
/>
520+
{ipValidations[index]?.valid && ip.trim().length > 0 && (
521+
<CheckCircle2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
522+
)}
523+
{ipValidations[index] && !ipValidations[index].valid && (
524+
<AlertCircle className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-red-500" />
525+
)}
526+
</div>
527+
{allowedIps.length > 1 && (
528+
<Button
529+
type="button"
530+
variant="outline"
531+
size="icon"
532+
onClick={() => removeIpField(index)}
533+
disabled={isPending}
534+
>
535+
<Trash2 className="h-4 w-4" />
536+
</Button>
537+
)}
538+
</div>
539+
{ipValidations[index] && !ipValidations[index].valid && ipValidations[index].error && (
540+
<p className="text-xs text-red-500">{ipValidations[index].error}</p>
450541
)}
451542
</div>
452543
))}
453-
<p className="text-xs text-muted-foreground">
454-
Enter IP addresses or CIDR notation (e.g., 192.168.1.0/24)
455-
</p>
544+
<Alert>
545+
<Info className="h-4 w-4" />
546+
<AlertDescription>
547+
<strong>Hint:</strong> {getAccessListHints('ip')}
548+
</AlertDescription>
549+
</Alert>
456550
</div>
457551
)}
458552

@@ -496,15 +590,27 @@ export function AccessListFormDialog({
496590
<div className="grid grid-cols-2 gap-2">
497591
<div>
498592
<Label className="text-xs">Username (min 3 chars)</Label>
499-
<Input
500-
value={user.username}
501-
onChange={(e) =>
502-
updateAuthUser(index, 'username', e.target.value)
503-
}
504-
placeholder="username"
505-
disabled={isPending}
506-
minLength={3}
507-
/>
593+
<div className="relative">
594+
<Input
595+
value={user.username}
596+
onChange={(e) =>
597+
updateAuthUser(index, 'username', e.target.value)
598+
}
599+
placeholder={getAccessListExample('username')}
600+
disabled={isPending}
601+
minLength={3}
602+
className={userValidations[index]?.username && !userValidations[index].username.valid ? 'border-red-500' : userValidations[index]?.username?.valid ? 'border-green-500' : ''}
603+
/>
604+
{userValidations[index]?.username?.valid && user.username.trim().length > 0 && (
605+
<CheckCircle2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
606+
)}
607+
{userValidations[index]?.username && !userValidations[index].username.valid && (
608+
<AlertCircle className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-red-500" />
609+
)}
610+
</div>
611+
{userValidations[index]?.username && !userValidations[index].username.valid && userValidations[index].username.error && (
612+
<p className="text-xs text-red-500 mt-1">{userValidations[index].username.error}</p>
613+
)}
508614
</div>
509615

510616
<div>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
2+
import { Button } from "@/components/ui/button";
3+
import { Alert, AlertDescription } from "@/components/ui/alert";
4+
import { Loader2, FileCode, Copy, CheckCircle } from "lucide-react";
5+
import { usePreviewAclConfig } from "@/queries";
6+
import { useState } from "react";
7+
import { useToast } from "@/hooks/use-toast";
8+
9+
interface PreviewConfigDialogProps {
10+
open: boolean;
11+
onOpenChange: (open: boolean) => void;
12+
}
13+
14+
export function PreviewConfigDialog({ open, onOpenChange }: PreviewConfigDialogProps) {
15+
const { toast } = useToast();
16+
const { data, isLoading, error } = usePreviewAclConfig();
17+
const [copied, setCopied] = useState(false);
18+
19+
const handleCopy = () => {
20+
if (data?.config) {
21+
navigator.clipboard.writeText(data.config);
22+
setCopied(true);
23+
toast({
24+
title: "Copied!",
25+
description: "Configuration copied to clipboard"
26+
});
27+
setTimeout(() => setCopied(false), 2000);
28+
}
29+
};
30+
31+
return (
32+
<Dialog open={open} onOpenChange={onOpenChange}>
33+
<DialogContent className="max-w-4xl max-h-[80vh]">
34+
<DialogHeader>
35+
<DialogTitle className="flex items-center gap-2">
36+
<FileCode className="h-5 w-5" />
37+
Preview Nginx ACL Configuration
38+
</DialogTitle>
39+
<DialogDescription>
40+
Review the generated nginx configuration before applying
41+
</DialogDescription>
42+
</DialogHeader>
43+
44+
<div className="space-y-4">
45+
{isLoading && (
46+
<div className="flex items-center justify-center py-8">
47+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
48+
</div>
49+
)}
50+
51+
{error && (
52+
<Alert variant="destructive">
53+
<AlertDescription>
54+
Failed to load configuration preview. Please try again.
55+
</AlertDescription>
56+
</Alert>
57+
)}
58+
59+
{data && (
60+
<>
61+
<Alert>
62+
<AlertDescription>
63+
<strong>{data.rulesCount}</strong> enabled rule{data.rulesCount !== 1 ? 's' : ''} will be applied to nginx configuration
64+
</AlertDescription>
65+
</Alert>
66+
67+
<div className="relative">
68+
<div className="absolute right-2 top-2 z-10">
69+
<Button
70+
size="sm"
71+
variant="secondary"
72+
onClick={handleCopy}
73+
className="gap-2"
74+
>
75+
{copied ? (
76+
<>
77+
<CheckCircle className="h-4 w-4" />
78+
Copied
79+
</>
80+
) : (
81+
<>
82+
<Copy className="h-4 w-4" />
83+
Copy
84+
</>
85+
)}
86+
</Button>
87+
</div>
88+
<pre className="bg-muted p-4 rounded-lg overflow-auto max-h-[50vh] text-sm">
89+
<code>{data.config}</code>
90+
</pre>
91+
</div>
92+
</>
93+
)}
94+
</div>
95+
96+
<div className="flex justify-end gap-2 pt-4">
97+
<Button variant="outline" onClick={() => onOpenChange(false)}>
98+
Close
99+
</Button>
100+
</div>
101+
</DialogContent>
102+
</Dialog>
103+
);
104+
}

src/components/domains/DomainDialog.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ interface DomainDialogProps {
4141
}
4242

4343
export function DomainDialog({ open, onOpenChange, domain, onSave }: DomainDialogProps) {
44-
// const { t } = useTranslation();
44+
const { t } = useTranslation();
4545
const [formData, setFormData] = useState({
4646
name: '',
4747
status: 'active' as 'active' | 'inactive' | 'error',
@@ -181,7 +181,7 @@ export function DomainDialog({ open, onOpenChange, domain, onSave }: DomainDialo
181181
};
182182

183183
onSave(domainData);
184-
onOpenChange(false);
184+
// Do not close dialog here - let parent component handle it after successful save
185185
};
186186

187187
const addUpstream = () => {
@@ -190,7 +190,7 @@ export function DomainDialog({ open, onOpenChange, domain, onSave }: DomainDialo
190190
{
191191
host: '',
192192
port: 80,
193-
protocol: 'http' as 'http' | 'https',
193+
protocol: 'http',
194194
sslVerify: true,
195195
weight: 1,
196196
maxFails: 3,

0 commit comments

Comments
 (0)