Skip to content

Commit 541d58e

Browse files
committed
feat: improve Allowed IPs field in API Keys with visual CIDR tag input
1 parent 45d450c commit 541d58e

5 files changed

Lines changed: 216 additions & 18 deletions

File tree

frontend/src/components/ApiKeys/AddApiKey.tsx

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import {
2222
} from "@chakra-ui/react"
2323
import { useMutation, useQueryClient } from "@tanstack/react-query"
2424
import { useState } from "react"
25-
import { type SubmitHandler, useForm } from "react-hook-form"
25+
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
26+
27+
import { CidrInput } from "../Common/CidrInput"
2628

2729
import {
2830
type ApiError,
@@ -43,6 +45,7 @@ const AddApiKey = ({ isOpen, onClose }: AddApiKeyProps) => {
4345
const [created, setCreated] = useState<ApiKeyCreateResponse | null>(null)
4446
const {
4547
register,
48+
control,
4649
handleSubmit,
4750
reset,
4851
formState: { errors, isSubmitting },
@@ -180,20 +183,32 @@ const AddApiKey = ({ isOpen, onClose }: AddApiKeyProps) => {
180183
</FormControl>
181184

182185
<FormControl isInvalid={!!errors.allowed_ips}>
183-
<FormLabel htmlFor="allowed_ips" fontSize="sm" fontWeight="medium">
186+
<FormLabel
187+
htmlFor="allowed_ips"
188+
fontSize="sm"
189+
fontWeight="medium"
190+
>
184191
Allowed IPs{" "}
185192
<Text as="span" fontSize="xs" color="gray.400">
186193
comma-separated CIDRs
187194
</Text>
188195
</FormLabel>
189-
<Input
190-
id="allowed_ips"
191-
{...register("allowed_ips")}
192-
placeholder="e.g. 10.0.0.0/24,203.0.113.5/32 (default: 0.0.0.0/0 = allow all)"
193-
type="text"
196+
<Controller
197+
name="allowed_ips"
198+
control={control}
199+
render={({ field }) => (
200+
<CidrInput
201+
value={field.value || ""}
202+
onChange={field.onChange}
203+
placeholder="e.g. 10.0.0.0/24, 203.0.113.5/32 (default: 0.0.0.0/0 = allow all)"
204+
isInvalid={!!errors.allowed_ips}
205+
/>
206+
)}
194207
/>
195208
{errors.allowed_ips && (
196-
<FormErrorMessage>{errors.allowed_ips.message}</FormErrorMessage>
209+
<FormErrorMessage>
210+
{errors.allowed_ips.message}
211+
</FormErrorMessage>
197212
)}
198213
</FormControl>
199214
</Stack>

frontend/src/components/ApiKeys/EditApiKey.tsx

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import {
1616
Text,
1717
} from "@chakra-ui/react"
1818
import { useMutation, useQueryClient } from "@tanstack/react-query"
19-
import { type SubmitHandler, useForm } from "react-hook-form"
19+
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
20+
21+
import { CidrInput } from "../Common/CidrInput"
2022

2123
import {
2224
type ApiError,
@@ -37,6 +39,7 @@ const EditApiKey = ({ item, isOpen, onClose }: EditApiKeyProps) => {
3739
const showToast = useCustomToast()
3840
const {
3941
register,
42+
control,
4043
handleSubmit,
4144
reset,
4245
formState: { isSubmitting, errors, isDirty },
@@ -118,20 +121,32 @@ const EditApiKey = ({ item, isOpen, onClose }: EditApiKeyProps) => {
118121
</FormControl>
119122

120123
<FormControl isInvalid={!!errors.allowed_ips}>
121-
<FormLabel htmlFor="allowed_ips" fontSize="sm" fontWeight="medium">
124+
<FormLabel
125+
htmlFor="allowed_ips"
126+
fontSize="sm"
127+
fontWeight="medium"
128+
>
122129
Allowed IPs{" "}
123130
<Text as="span" fontSize="xs" color="gray.400">
124131
comma-separated CIDRs
125132
</Text>
126133
</FormLabel>
127-
<Input
128-
id="allowed_ips"
129-
{...register("allowed_ips")}
130-
placeholder="e.g. 10.0.0.0/24,203.0.113.5/32 (default: 0.0.0.0/0 = allow all)"
131-
type="text"
134+
<Controller
135+
name="allowed_ips"
136+
control={control}
137+
render={({ field }) => (
138+
<CidrInput
139+
value={field.value || ""}
140+
onChange={field.onChange}
141+
placeholder="e.g. 10.0.0.0/24, 203.0.113.5/32 (default: 0.0.0.0/0 = allow all)"
142+
isInvalid={!!errors.allowed_ips}
143+
/>
144+
)}
132145
/>
133146
{errors.allowed_ips && (
134-
<FormErrorMessage>{errors.allowed_ips.message}</FormErrorMessage>
147+
<FormErrorMessage>
148+
{errors.allowed_ips.message}
149+
</FormErrorMessage>
135150
)}
136151
</FormControl>
137152
</Stack>
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import {
2+
Box,
3+
Input,
4+
Tag,
5+
TagCloseButton,
6+
TagLabel,
7+
Wrap,
8+
WrapItem,
9+
} from "@chakra-ui/react"
10+
import {
11+
type ClipboardEvent,
12+
type KeyboardEvent,
13+
useRef,
14+
useState,
15+
} from "react"
16+
17+
interface CidrInputProps {
18+
value: string
19+
onChange: (value: string) => void
20+
placeholder?: string
21+
isInvalid?: boolean
22+
}
23+
24+
export function parseCidrInput(input: string): string[] {
25+
// split by comma, space, semicolon or newlines
26+
const rawParts = input.split(/[\s,;\n]+/)
27+
const parsed: string[] = []
28+
29+
for (let part of rawParts) {
30+
part = part.trim()
31+
if (!part) continue
32+
33+
// Check if it already has a slash
34+
if (part.includes("/")) {
35+
parsed.push(part)
36+
} else {
37+
// Check if it looks like IPv4 or IPv6
38+
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(part)) {
39+
parsed.push(`${part}/32`)
40+
} else if (/^[0-9a-fA-F:]+$/.test(part) && part.includes(":")) {
41+
parsed.push(`${part}/128`)
42+
} else {
43+
parsed.push(part)
44+
}
45+
}
46+
}
47+
return parsed
48+
}
49+
50+
export const CidrInput = ({
51+
value,
52+
onChange,
53+
placeholder,
54+
isInvalid,
55+
}: CidrInputProps) => {
56+
const [inputValue, setInputValue] = useState("")
57+
const inputRef = useRef<HTMLInputElement>(null)
58+
59+
// Convert comma-separated string to array
60+
const cidrs = value
61+
? value
62+
.split(",")
63+
.map((c) => c.trim())
64+
.filter(Boolean)
65+
: []
66+
67+
const updateCidrs = (newCidrs: string[]) => {
68+
// deduplicate
69+
const unique = Array.from(new Set(newCidrs))
70+
onChange(unique.join(","))
71+
}
72+
73+
const addCidrs = (rawInput: string) => {
74+
const parsed = parseCidrInput(rawInput)
75+
if (parsed.length > 0) {
76+
updateCidrs([...cidrs, ...parsed])
77+
setInputValue("")
78+
}
79+
}
80+
81+
const removeCidr = (indexToRemove: number) => {
82+
const updated = cidrs.filter((_, idx) => idx !== indexToRemove)
83+
updateCidrs(updated)
84+
}
85+
86+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
87+
if (e.key === "Enter" || e.key === ",") {
88+
e.preventDefault()
89+
addCidrs(inputValue)
90+
} else if (e.key === "Backspace" && !inputValue && cidrs.length > 0) {
91+
removeCidr(cidrs.length - 1)
92+
}
93+
}
94+
95+
const handleBlur = () => {
96+
if (inputValue.trim()) {
97+
addCidrs(inputValue)
98+
}
99+
}
100+
101+
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
102+
e.preventDefault()
103+
const pastedText = e.clipboardData.getData("text")
104+
addCidrs(pastedText)
105+
}
106+
107+
const handleContainerClick = () => {
108+
if (inputRef.current) {
109+
inputRef.current.focus()
110+
}
111+
}
112+
113+
return (
114+
<Box
115+
border="1px solid"
116+
borderColor={isInvalid ? "red.500" : "gray.300"}
117+
borderRadius="md"
118+
p={2}
119+
minH="40px"
120+
bg="white"
121+
_hover={{ borderColor: isInvalid ? "red.600" : "gray.400" }}
122+
_focusWithin={{
123+
borderColor: isInvalid ? "red.500" : "ui.main",
124+
boxShadow: isInvalid ? "0 0 0 1px #e53e3e" : "0 0 0 1px #009688",
125+
}}
126+
cursor="text"
127+
onClick={handleContainerClick}
128+
>
129+
<Wrap spacing={2} align="center">
130+
{cidrs.map((cidr, index) => (
131+
<WrapItem key={`${cidr}-${index}`}>
132+
<Tag
133+
size="sm"
134+
borderRadius="full"
135+
variant="solid"
136+
colorScheme="teal"
137+
backgroundColor="ui.main"
138+
color="white"
139+
>
140+
<TagLabel>{cidr}</TagLabel>
141+
<TagCloseButton
142+
onClick={(e) => {
143+
e.stopPropagation()
144+
removeCidr(index)
145+
}}
146+
/>
147+
</Tag>
148+
</WrapItem>
149+
))}
150+
<WrapItem flexGrow={1}>
151+
<Input
152+
ref={inputRef}
153+
value={inputValue}
154+
onChange={(e) => setInputValue(e.target.value)}
155+
onKeyDown={handleKeyDown}
156+
onBlur={handleBlur}
157+
onPaste={handlePaste}
158+
placeholder={cidrs.length === 0 ? placeholder : ""}
159+
size="sm"
160+
variant="unstyled"
161+
minW="120px"
162+
p={0}
163+
_focus={{ border: "none" }}
164+
/>
165+
</WrapItem>
166+
</Wrap>
167+
</Box>
168+
)
169+
}

frontend/src/routes/_layout/settings.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ const tabsConfig = [
2323
{ title: "Appearance", component: Appearance },
2424
]
2525

26-
2726
export const Route = createFileRoute("/_layout/settings")({
2827
component: UserSettings,
2928
})

frontend/src/routes/login.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ import { BsMicrosoft } from "react-icons/bs"
2828
import { FaKey } from "react-icons/fa"
2929
import { FcGoogle } from "react-icons/fc"
3030

31-
import { version } from "../../package.json"
3231
import Logo from "/assets/images/netconsole-logo.svg"
32+
import { version } from "../../package.json"
3333
import type { Body_login_login_access_token as AccessToken } from "../client"
3434
import PasskeyLogin from "../components/Auth/PasskeyLogin"
3535
import useAuth, { isLoggedIn } from "../hooks/useAuth"

0 commit comments

Comments
 (0)