diff --git a/src/pages/routing/Create.tsx b/src/pages/routing/Create.tsx index afadf5dddf0a3aadf7c1403a8c260c19a2200296..8011001f83f1625cf6c1e32d25072edfe61000fc 100644 --- a/src/pages/routing/Create.tsx +++ b/src/pages/routing/Create.tsx @@ -103,8 +103,13 @@ export default function RoutingCreate() { }); if (!response.ok) { - console.error(response); - toast.error('라우팅 설정을 등록할 수 없습니다'); + const { code } = await response.json(); + if (code == 'DUPLICATED_DOMAIN_NAME') { + form.setError('domain', { type: 'custom', message: '이미 사용중인 도메인입니다' }); + toast.error('이미 사용중인 도메인입니다'); + } else { + toast.error('라우팅 설정을 등록할 수 없습니다'); + } } else { toast.success('라우팅 설정을 등록합니다'); navigate('/routing'); diff --git a/src/pages/routing/Edit.tsx b/src/pages/routing/Edit.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6a40926efb01c373ac5d727843d7b638c280af72 --- /dev/null +++ b/src/pages/routing/Edit.tsx @@ -0,0 +1,351 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router'; +import { useForm, useWatch } from 'react-hook-form'; +import { Check, X } from 'lucide-react'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Separator } from '@/components/ui/separator'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Certificate } from '@/types/certificate'; +import { useAuthStore } from '@/stores/authStore'; +import useDebounce from '@/hooks/useDebounce'; + +const formSchema = z + .object({ + name: z.string({ required_error: '서버 이름을 입력해주세요' }).min(1, { message: '서버 이름을 입력해주세요' }), + domain: z + .string({ required_error: '도메인 주소를 입력해주세요' }) + .regex(/^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, { + message: '올바른 도메인 주소를 입력해주세요', + }), + ip: z + .string({ required_error: '인스턴스 IP를 입력해주세요' }) + .regex(/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/, { + message: '올바른 IP 주소를 입력해주세요', + }) + .startsWith('10.16.', { message: '인스턴스 IP는 10.16.0.0/16 대역을 사용해야 합니다' }), + port: z.coerce + .number({ invalid_type_error: '포트 번호를 입력해주세요' }) + .min(1, { message: '포트 번호를 입력해주세요' }) + .max(65535, { message: '올바른 포트 번호를 입력해주세요' }), + enableSSL: z.boolean(), + certificateId: z.coerce.number().optional(), + caching: z.boolean(), + }) + .refine((data) => !data.enableSSL || (data.enableSSL && data.certificateId !== undefined), { + message: 'SSL 인증서를 선택해주세요', + path: ['certificateId'], + }); + +export default function RoutingEdit() { + const navigate = useNavigate(); + const { id } = useParams(); + const { authFetch, selectedProject } = useAuthStore(); + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: undefined, + domain: undefined, + ip: undefined, + port: undefined, + enableSSL: false, + certificateId: undefined, + caching: false, + }, + }); + const domain = useWatch({ control: form.control, name: 'domain' }); + const enableSSL = useWatch({ control: form.control, name: 'enableSSL' }); + const debouncedDomain = useDebounce(domain, 500); + const [certificates, setCertificates] = useState<Certificate[]>([]); + const initData = useRef<z.infer<typeof formSchema> | null>(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + authFetch(`/api/routing?routingId=${id}`) + .then((response) => { + if (!response.ok) { + console.error(response); + throw Error(); + } + + return response.json(); + }) + .then(({ name, domain, ip, port, certificateId, caching }) => { + initData.current = { + name, + domain, + ip, + port: parseInt(port), + enableSSL: certificateId ? true : false, + certificateId: certificateId || -1, + caching, + }; + + form.setValue('name', initData.current.name); + form.setValue('domain', initData.current.domain); + form.setValue('ip', initData.current.ip); + form.setValue('port', initData.current.port); + form.setValue('enableSSL', initData.current.enableSSL); + form.setValue('certificateId', initData.current.certificateId); + form.setValue('caching', initData.current.caching); + + setIsLoading(false); + }) + .catch((error) => { + console.error(error); + toast.error('라우팅 설정 정보를 조회할 수 없습니다.'); + }); + }, []); + + useEffect(() => { + if (enableSSL) { + authFetch(`/api/certificates?projectId=${selectedProject?.id}&domain=${debouncedDomain}`) + .then((response) => { + if (!response.ok) { + toast.error('SSL 인증서 목록을 불러올 수 없습니다'); + return { contents: [] }; + } + return response.json(); + }) + .then(({ contents }) => { + setCertificates(contents); + }); + } + }, [authFetch, enableSSL, selectedProject, debouncedDomain]); + + async function onSubmit(values: z.infer<typeof formSchema>) { + if (!initData.current) return; + + const payload: Partial<z.infer<typeof formSchema>> = {}; + if (values.name !== initData.current.name) payload.name = values.name; + if (values.domain !== initData.current.domain) payload.domain = values.domain; + if (values.ip !== initData.current.ip) payload.ip = values.ip; + if (values.port !== initData.current.port) payload.port = values.port; + if (values.caching !== initData.current.caching) payload.caching = values.caching; + if (!values.enableSSL && values.enableSSL !== initData.current.enableSSL) payload.certificateId = -1; + if (values.enableSSL && values.certificateId !== initData.current.certificateId) + payload.certificateId = values.certificateId; + + const response = await authFetch(`/api/routing?routingId=${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const { code } = await response.json(); + if (code == 'DUPLICATED_DOMAIN_NAME') { + form.setError('domain', { type: 'custom' }); + toast.error('이미 사용중인 도메인입니다'); + } else { + toast.error('라우팅 설정 수정에 실패하였습니다'); + } + } else { + toast.success('라우팅 설정이 수정되었습니다'); + navigate('/routing'); + } + } + + return ( + <div className="flex flex-1 flex-col gap-4 p-6"> + <div className="mb-2"> + <h1 className="scroll-m-20 text-3xl font-semibold first:mt-0">라우팅 설정 수정</h1> + <p className="mt-1 text-base text-gray-500">웹 프록시 서버의 기존 라우팅 설정을 수정합니다.</p> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <Card className="mb-4"> + <CardHeader> + <CardTitle className="text-xl">라우팅 기본 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {isLoading ? ( + <Skeleton className="h-[24rem] w-full" /> + ) : ( + <div className="grid grid-cols-1 gap-4"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel required>서버 이름</FormLabel> + <FormControl> + <Input placeholder="웹 서버 이름" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="domain" + render={({ field }) => ( + <FormItem> + <FormLabel required>도메인</FormLabel> + <FormControl> + <Input placeholder="example.ajou.app" {...field} /> + </FormControl> + <FormDescription> + *.ajou.app 도메인은 별도 설정 없이 자유롭게 사용할 수 있습니다 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="ip" + render={({ field }) => ( + <FormItem> + <FormLabel required>인스턴스 IP</FormLabel> + <FormControl> + <Input placeholder="10.16.x.x" {...field} /> + </FormControl> + <FormDescription>인스턴스 IP는 10.16.0.0/16 대역을 사용합니다</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="port" + render={({ field }) => ( + <FormItem> + <FormLabel required>인스턴스 포트</FormLabel> + <FormControl> + <Input type="number" placeholder="8080" {...field} /> + </FormControl> + <FormDescription>연결할 웹 서비스의 포트 번호를 입력해주세요</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </div> + )} + </CardContent> + </Card> + + <Card className="mb-6"> + <CardHeader> + <CardTitle className="text-xl">추가 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {isLoading ? ( + <Skeleton className="h-[8rem] w-full" /> + ) : ( + <> + <FormField + control={form.control} + name="enableSSL" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className="text-base font-medium">SSL 보안 연결</FormLabel> + <FormDescription className="text-sm text-gray-500"> + HTTPS 프로토콜을 사용하여 보안 연결을 활성화합니다 + </FormDescription> + </div> + <FormControl> + <div className="flex items-center space-x-2"> + {!field.value && <X className="h-4 w-4 text-gray-500" />} + {field.value && <Check className="h-4 w-4" />} + <Switch checked={field.value} onCheckedChange={field.onChange} /> + </div> + </FormControl> + </FormItem> + )} + /> + + {form.watch('enableSSL') && ( + <FormField + control={form.control} + name="certificateId" + render={({ field }) => ( + <FormItem> + <Select onValueChange={field.onChange}> + <FormControl> + <SelectTrigger className="w-full"> + <SelectValue placeholder="SSL 인증서 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + {certificates.length === 0 ? ( + <SelectLabel className="text-muted-foreground"> + 사용 가능한 SSL 인증서가 없습니다. + <Link to="/certificate/create" className="font-medium underline underline-offset-4"> + 추가하기 + </Link> + </SelectLabel> + ) : ( + certificates.map((certificate) => ( + <SelectItem value={certificate.id.toString()}>{certificate.domain}</SelectItem> + )) + )} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + )} + + <Separator /> + + <FormField + control={form.control} + name="caching" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className="text-base font-medium">정적 파일 캐싱 활성화</FormLabel> + <FormDescription className="text-sm text-gray-500"> + 빠른 응답을 위해 정적 파일에 대한 캐싱을 활성화합니다 + </FormDescription> + </div> + <FormControl> + <div className="flex items-center space-x-2"> + {!field.value && <X className="h-4 w-4 text-gray-500" />} + {field.value && <Check className="h-4 w-4" />} + <Switch checked={field.value} onCheckedChange={field.onChange} /> + </div> + </FormControl> + </FormItem> + )} + /> + </> + )} + </CardContent> + </Card> + + <div className="flex justify-end gap-2"> + <Button variant="outline" asChild> + <Link to="..">취소</Link> + </Button> + <Button type="submit">저장</Button> + </div> + </form> + </Form> + </div> + ); +} diff --git a/src/routes.tsx b/src/routes.tsx index 22bf2419cc730017b230de187ccaf13b6e39721c..1880f94bc398cf3355a267f991457a9419d9acc0 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -5,6 +5,7 @@ import Home from '@/pages/Home'; import Login from '@/pages/Login'; import RoutingList from '@/pages/routing/List'; import RoutingCreate from '@/pages/routing/Create'; +import RoutingEdit from '@/pages/routing/Edit'; import CertificateList from './pages/certificate/List'; import CertificateCreate from './pages/certificate/Create'; import ForwardingList from '@/pages/forwarding/List'; @@ -20,6 +21,7 @@ export default function AppRoutes() { <Route path="routing"> <Route index element={<RoutingList />} /> <Route path="create" element={<RoutingCreate />} /> + <Route path="edit/:id" element={<RoutingEdit />} /> </Route> <Route path="certificate"> <Route index element={<CertificateList />} />