Merge pull request #267 from lorenzomigliorero/feat/condition-certificate

feat: condition domain certificate field
This commit is contained in:
Mauricio Siu
2024-07-26 01:13:43 -06:00
committed by GitHub
5 changed files with 147 additions and 357 deletions

View File

@@ -28,84 +28,103 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
// const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/;
// .regex(hostnameRegex
const addDomain = z.object({
host: z.string().min(1, "Hostname is required"),
path: z.string().min(1),
port: z.number(),
https: z.boolean(),
certificateType: z.enum(["letsencrypt", "none"]),
});
import { domain } from "@/server/db/validations";
import { zodResolver } from "@hookform/resolvers/zod";
import type z from "zod";
type AddDomain = z.infer<typeof addDomain>;
type Domain = z.infer<typeof domain>;
interface Props {
applicationId: string;
children?: React.ReactNode;
domainId?: string;
children: React.ReactNode;
}
export const AddDomain = ({
applicationId,
children = <PlusIcon className="h-4 w-4" />,
domainId = "",
children,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isError, error } = api.domain.create.useMutation();
const form = useForm<AddDomain>({
defaultValues: {
host: "",
https: false,
path: "/",
port: 3000,
certificateType: "none",
const { data, refetch } = api.domain.one.useQuery(
{
domainId,
},
resolver: zodResolver(addDomain),
{
enabled: !!domainId,
},
);
const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const form = useForm<Domain>({
resolver: zodResolver(domain),
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
if (data) {
form.reset({
...data,
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
});
}
const onSubmit = async (data: AddDomain) => {
if (!domainId) {
form.reset({});
}
}, [form, form.reset, data, isLoading]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId
? "Error to update the domain"
: "Error to create the domain",
submit: domainId ? "Update" : "Create",
dialogDescription: domainId
? "In this section you can edit a domain"
: "In this section you can add domains",
};
const onSubmit = async (data: Domain) => {
await mutateAsync({
domainId,
applicationId,
host: data.host,
https: data.https,
path: data.path,
port: data.port,
certificateType: data.certificateType,
...data,
})
.then(async () => {
toast.success("Domain Created");
toast.success(dictionary.success);
await utils.domain.byApplicationId.invalidate({
applicationId,
});
await utils.application.readTraefikConfig.invalidate({ applicationId });
if (domainId) {
refetch();
}
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the domain");
toast.error(dictionary.error);
});
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<Button>{children}</Button>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>
In this section you can add custom domains
</DialogDescription>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
@@ -169,33 +188,36 @@ export const AddDomain = ({
);
}}
/>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
{form.getValues().https && (
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="https"
@@ -206,6 +228,7 @@ export const AddDomain = ({
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
@@ -226,7 +249,7 @@ export const AddDomain = ({
form="hook-form"
type="submit"
>
Create
{dictionary.submit}
</Button>
</DialogFooter>
</Form>

View File

@@ -8,13 +8,11 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { ExternalLink, GlobeIcon, RefreshCcw } from "lucide-react";
import { ExternalLink, GlobeIcon, PenBoxIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
import { AddDomain } from "./add-domain";
import { DeleteDomain } from "./delete-domain";
import { GenerateDomain } from "./generate-domain";
import { UpdateDomain } from "./update-domain";
interface Props {
applicationId: string;
@@ -43,7 +41,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<AddDomain applicationId={applicationId}>
<GlobeIcon className="size-4" /> Add Domain
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
)}
{data && data?.length > 0 && (
@@ -61,7 +61,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain applicationId={applicationId}>
<GlobeIcon className="size-4" /> Add Domain
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
<GenerateDomain applicationId={applicationId} />
@@ -90,7 +92,14 @@ export const ShowDomains = ({ applicationId }: Props) => {
{item.https ? "HTTPS" : "HTTP"}
</Button>
<div className="flex flex-row gap-1">
<UpdateDomain domainId={item.domainId} />
<AddDomain
applicationId={applicationId}
domainId={item.domainId}
>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</AddDomain>
<DeleteDomain domainId={item.domainId} />
</div>
</div>

View File

@@ -1,254 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/;
const updateDomain = z.object({
host: z.string().regex(hostnameRegex, { message: "Invalid hostname" }),
path: z.string().min(1),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" }),
https: z.boolean(),
certificateType: z.enum(["letsencrypt", "none"]),
});
type UpdateDomain = z.infer<typeof updateDomain>;
interface Props {
domainId: string;
}
export const UpdateDomain = ({ domainId }: Props) => {
const utils = api.useUtils();
const { data, refetch } = api.domain.one.useQuery(
{
domainId,
},
{
enabled: !!domainId,
},
);
const { mutateAsync, isError, error } = api.domain.update.useMutation();
const form = useForm<UpdateDomain>({
defaultValues: {
host: "",
https: true,
path: "/",
port: 3000,
certificateType: "none",
},
resolver: zodResolver(updateDomain),
});
useEffect(() => {
if (data) {
form.reset({
host: data.host || "",
port: data.port || 3000,
path: data.path || "/",
https: data.https,
certificateType: data.certificateType,
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateDomain) => {
await mutateAsync({
domainId,
host: data.host,
https: data.https,
path: data.path,
port: data.port,
certificateType: data.certificateType,
})
.then(async (data) => {
toast.success("Domain Updated");
await refetch();
await utils.domain.byApplicationId.invalidate({
applicationId: data?.applicationId,
});
await utils.application.readTraefikConfig.invalidate({
applicationId: data?.applicationId,
});
})
.catch(() => {
toast.error("Error to update the domain");
});
};
return (
<Dialog>
<DialogTrigger className="" asChild>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>
In this section you can add custom domains
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<FormControl>
<Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormControl>
<Input
placeholder={"3000"}
{...field}
onChange={(e) => {
field.onChange(Number.parseInt(e.target.value));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</form>
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,8 +1,8 @@
import { domain } from "@/server/db/validations";
import { relations } from "drizzle-orm";
import { boolean, integer, pgTable, serial, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application";
import { certificateType } from "./shared";
@@ -31,27 +31,17 @@ export const domainsRelations = relations(domains, ({ one }) => ({
references: [applications.applicationId],
}),
}));
const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/;
const createSchema = createInsertSchema(domains, {
domainId: z.string().min(1),
host: z.string().min(1),
path: z.string().min(1),
port: z.number(),
https: z.boolean(),
applicationId: z.string(),
certificateType: z.enum(["letsencrypt", "none"]),
});
export const apiCreateDomain = createSchema
.pick({
host: true,
path: true,
port: true,
https: true,
applicationId: true,
certificateType: true,
})
.required();
const createSchema = createInsertSchema(domains, domain._def.schema.shape);
export const apiCreateDomain = createSchema.pick({
host: true,
path: true,
port: true,
https: true,
applicationId: true,
certificateType: true,
});
export const apiFindDomain = createSchema
.pick({
@@ -59,19 +49,16 @@ export const apiFindDomain = createSchema
})
.required();
export const apiFindDomainByApplication = createSchema
.pick({
applicationId: true,
})
.required();
export const apiFindDomainByApplication = createSchema.pick({
applicationId: true,
});
export const apiUpdateDomain = createSchema
.pick({
domainId: true,
host: true,
path: true,
port: true,
https: true,
certificateType: true,
})
.required();
.merge(createSchema.pick({ domainId: true }).required());

View File

@@ -0,0 +1,25 @@
import { z } from "zod";
export const domain = z
.object({
host: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/, {
message: "Invalid hostname",
}),
path: z.string().min(1).optional(),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" })
.optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
message: "Required",
});
}
});