feat: link field with application

This commit is contained in:
Lorenzo Migliorero
2024-07-25 19:32:02 +02:00
parent f866250c25
commit d243470029
10 changed files with 3218 additions and 225 deletions

View File

@@ -1,13 +1,5 @@
import { AddSSHKey } from "@/components/dashboard/settings/ssh-keys/add-ssh-key";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { import {
Form, Form,
FormControl, FormControl,
@@ -17,12 +9,24 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard"; import { SelectSeparator } from "@radix-ui/react-select";
import { CopyIcon, LockIcon } from "lucide-react"; import { KeyRoundIcon, LockIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { flushSync } from "react-dom";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
@@ -33,6 +37,7 @@ const GitProviderSchema = z.object({
}), }),
branch: z.string().min(1, "Branch required"), branch: z.string().min(1, "Branch required"),
buildPath: z.string().min(1, "Build Path required"), buildPath: z.string().min(1, "Build Path required"),
sshKey: z.string(),
}); });
type GitProvider = z.infer<typeof GitProviderSchema>; type GitProvider = z.infer<typeof GitProviderSchema>;
@@ -43,19 +48,22 @@ interface Props {
export const SaveGitProvider = ({ applicationId }: Props) => { export const SaveGitProvider = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isLoading } = const { mutateAsync, isLoading } =
api.application.saveGitProdiver.useMutation(); api.application.saveGitProdiver.useMutation();
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } = // const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
api.application.generateSSHKey.useMutation(); // api.application.generateSSHKey.useMutation();
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } = // const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
api.application.removeSSHKey.useMutation(); // api.application.removeSSHKey.useMutation();
const form = useForm<GitProvider>({ const form = useForm<GitProvider>({
defaultValues: { defaultValues: {
branch: "", branch: "",
buildPath: "/", buildPath: "/",
repositoryURL: "", repositoryURL: "",
sshKey: "",
}, },
resolver: zodResolver(GitProviderSchema), resolver: zodResolver(GitProviderSchema),
}); });
@@ -63,6 +71,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset({
sshKey: data.customGitSSHKeyId || "",
branch: data.customGitBranch || "", branch: data.customGitBranch || "",
buildPath: data.customGitBuildPath || "/", buildPath: data.customGitBuildPath || "/",
repositoryURL: data.customGitUrl || "", repositoryURL: data.customGitUrl || "",
@@ -75,6 +84,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
customGitBranch: values.branch, customGitBranch: values.branch,
customGitBuildPath: values.buildPath, customGitBuildPath: values.buildPath,
customGitUrl: values.repositoryURL, customGitUrl: values.repositoryURL,
customGitSSHKeyId: values.sshKey,
applicationId, applicationId,
}) })
.then(async () => { .then(async () => {
@@ -92,160 +102,103 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid md:grid-cols-2 gap-4 "> <div className="grid md:grid-cols-2 gap-4">
<div className="md:col-span-2 space-y-4"> <div className="flex items-end col-span-2 gap-4">
<FormField <div className="grow">
control={form.control} <FormField
name="repositoryURL" control={form.control}
render={({ field }) => ( name="repositoryURL"
<FormItem> render={({ field }) => (
<FormLabel className="flex flex-row justify-between"> <FormItem>
Repository URL <FormLabel>Repository URL</FormLabel>
<div className="flex gap-2"> <FormControl>
<Dialog> <Input placeholder="git@bitbucket.org" {...field} />
<DialogTrigger className="flex flex-row gap-2"> </FormControl>
<LockIcon className="size-4 text-muted-foreground" />? <FormMessage />
</DialogTrigger> </FormItem>
<DialogContent className="sm:max-w-[425px]"> )}
<DialogHeader> />
<DialogTitle>Private Repository</DialogTitle> </div>
<DialogDescription> {sshKeys && sshKeys.length > 0 ? (
If your repository is private is necessary to <FormField
generate SSH Keys to add to your git provider. control={form.control}
</DialogDescription> name="sshKey"
</DialogHeader> render={({ field }) => (
<div className="grid gap-4 py-4"> <FormItem className="basis-40">
<div className="relative"> <FormLabel className="w-full inline-flex justify-between">
<Textarea SSH Key
placeholder="Please click on Generate SSH Key" <LockIcon className="size-4 text-muted-foreground" />
className="no-scrollbar h-64 text-muted-foreground" </FormLabel>
disabled={!data?.customGitSSHKey} <FormControl>
contentEditable={false} <Select
value={ key={field.value}
data?.customGitSSHKey || onValueChange={field.onChange}
"Please click on Generate SSH Key" defaultValue={field.value}
} value={field.value}
/> >
<button <SelectTrigger>
type="button" <SelectValue placeholder="Select a key" />
className="absolute right-2 top-2" </SelectTrigger>
onClick={() => { <SelectContent>
copy( <SelectGroup>
data?.customGitSSHKey || {sshKeys?.map((sshKey) => (
"Generate a SSH Key", <SelectItem
); key={sshKey.sshKeyId}
toast.success("SSH Copied to clipboard"); value={sshKey.sshKeyId}
}}
> >
<CopyIcon className="size-4" /> {sshKey.name}
</button> </SelectItem>
</div> ))}
</div> <SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full"> </SelectGroup>
<div className="flex flex-row gap-2 w-full justify-between flex-wrap"> <SelectSeparator />
{data?.customGitSSHKey && ( </SelectContent>
<Button </Select>
variant="destructive" </FormControl>
isLoading={ </FormItem>
isGeneratingSSHKey || isRemovingSSHKey )}
} />
className="max-sm:w-full" ) : (
onClick={async () => { <Button
await removeSSHKey({ variant="secondary"
applicationId, onClick={() => router.push("/dashboard/settings/ssh-keys")}
}) type="button"
.then(async () => { >
toast.success("SSH Key Removed"); <KeyRoundIcon className="size-4" /> Add SSH Key
await refetch(); </Button>
}) )}
.catch(() => {
toast.error(
"Error to remove the SSH Key",
);
});
}}
type="button"
>
Remove SSH Key
</Button>
)}
<Button
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await generateSSHKey({
applicationId,
})
.then(async () => {
toast.success("SSH Key Generated");
await refetch();
})
.catch(() => {
toast.error(
"Error to generate the SSH Key",
);
});
}}
type="button"
>
Generate SSH Key
</Button>
</div>
<span className="text-sm text-muted-foreground">
Is recommended to remove the SSH Key if you want
to deploy a public repository.
</span>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem>
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem>
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}> <Button type="submit" className="w-fit" isLoading={isLoading}>
Save{" "} Save
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -127,6 +127,7 @@ export const AddSSHKey = ({ children }: Props) => {
<FormControl> <FormControl>
<Textarea <Textarea
placeholder={"-----BEGIN RSA PRIVATE KEY-----"} placeholder={"-----BEGIN RSA PRIVATE KEY-----"}
rows={5}
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -143,16 +144,13 @@ export const AddSSHKey = ({ children }: Props) => {
<FormLabel>Public Key</FormLabel> <FormLabel>Public Key</FormLabel>
</div> </div>
<FormControl> <FormControl>
<Textarea <Input placeholder={"ssh-rsa AAAAB3NzaC1yc2E"} {...field} />
placeholder={"ssh-rsa AAAAB3NzaC1yc2E"}
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<DialogFooter className="flex w-full flex-row !justify-between pt-3"> <DialogFooter>
<Button isLoading={isLoading} type="submit"> <Button isLoading={isLoading} type="submit">
Create Create
</Button> </Button>

View File

@@ -39,54 +39,54 @@ export const ShowDestinations = () => {
</AddSSHKey> </AddSSHKey>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="space-y-8">
<div className="flex gap-4 text-xs px-3.5"> <div className="flex flex-col gap-4">
<div className="col-span-2 basis-4/12">Key</div> <div className="flex gap-4 text-xs px-3.5">
<div className="basis-3/12">Added</div> <div className="col-span-2 basis-4/12">Key</div>
<div>Last Used</div> <div className="basis-3/12">Added</div>
</div> <div>Last Used</div>
{data?.map((sshKey) => (
<div
key={sshKey.sshKeyId}
className="flex gap-4 items-center border p-3.5 rounded-lg text-sm"
>
<div className="flex flex-col basis-4/12">
<span>{sshKey.name}</span>
{sshKey.description && (
<span className="text-xs text-muted-foreground">
{sshKey.description}
</span>
)}
</div>
<div className="basis-3/12">
{formatDistanceToNow(new Date(sshKey.createdAt), {
addSuffix: true,
})}
</div>
<div className="grow">
{sshKey.lastUsedAt
? formatDistanceToNow(new Date(sshKey.lastUsedAt), {
addSuffix: true,
})
: "Never"}
</div>
<div className="flex flex-row gap-1">
<UpdateSSHKey sshKeyId={sshKey.sshKeyId}>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</UpdateSSHKey>
<DeleteSSHKey sshKeyId={sshKey.sshKeyId} />
</div>
</div> </div>
))} {data?.map((sshKey) => (
<div> <div
<AddSSHKey> key={sshKey.sshKeyId}
<Button> className="flex gap-4 items-center border p-3.5 rounded-lg text-sm"
<KeyRoundIcon className="size-4" /> Add SSH Key >
</Button> <div className="flex flex-col basis-4/12">
</AddSSHKey> <span>{sshKey.name}</span>
{sshKey.description && (
<span className="text-xs text-muted-foreground">
{sshKey.description}
</span>
)}
</div>
<div className="basis-3/12">
{formatDistanceToNow(new Date(sshKey.createdAt), {
addSuffix: true,
})}
</div>
<div className="grow">
{sshKey.lastUsedAt
? formatDistanceToNow(new Date(sshKey.lastUsedAt), {
addSuffix: true,
})
: "Never"}
</div>
<div className="flex flex-row gap-1">
<UpdateSSHKey sshKeyId={sshKey.sshKeyId}>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</UpdateSSHKey>
<DeleteSSHKey sshKeyId={sshKey.sshKeyId} />
</div>
</div>
))}
</div> </div>
<AddSSHKey>
<Button>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
</AddSSHKey>
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -1,3 +1,4 @@
import { DeleteSSHKey } from "@/components/dashboard/settings/ssh-keys/delete-ssh-key";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -135,7 +136,7 @@ export const UpdateSSHKey = ({ children, sshKeyId = "" }: Props) => {
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
<DialogFooter className="flex w-full flex-row !justify-between pt-3"> <DialogFooter>
<Button isLoading={isLoading} type="submit"> <Button isLoading={isLoading} type="submit">
Update Update
</Button> </Button>

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS "ssh-key" (
"sshKeyId" text PRIMARY KEY NOT NULL,
"publicKey" text NOT NULL,
"name" text NOT NULL,
"description" text,
"createdAt" text NOT NULL,
"lastUsedAt" text
);
--> statement-breakpoint
ALTER TABLE "application" RENAME COLUMN "customGitSSHKey" TO "customGitSSHKeyId";--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "application" ADD CONSTRAINT "application_customGitSSHKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("customGitSSHKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -183,6 +183,13 @@
"when": 1721633853118, "when": 1721633853118,
"tag": "0025_lying_mephisto", "tag": "0025_lying_mephisto",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1721928706816,
"tag": "0026_yielding_king_cobra",
"breakpoints": true
} }
] ]
} }

View File

@@ -235,6 +235,7 @@ export const applicationRouter = createTRPCRouter({
customGitBranch: input.customGitBranch, customGitBranch: input.customGitBranch,
customGitBuildPath: input.customGitBuildPath, customGitBuildPath: input.customGitBuildPath,
customGitUrl: input.customGitUrl, customGitUrl: input.customGitUrl,
customGitSSHKeyId: input.customGitSSHKeyId,
sourceType: "git", sourceType: "git",
applicationStatus: "idle", applicationStatus: "idle",
}); });
@@ -249,9 +250,9 @@ export const applicationRouter = createTRPCRouter({
await generateSSHKey(application.appName); await generateSSHKey(application.appName);
const file = await readRSAFile(application.appName); const file = await readRSAFile(application.appName);
await updateApplication(input.applicationId, { // await updateApplication(input.applicationId, {
customGitSSHKey: file, // customGitSSHKey: file,
}); // });
} catch (error) {} } catch (error) {}
return true; return true;
@@ -261,9 +262,9 @@ export const applicationRouter = createTRPCRouter({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const application = await findApplicationById(input.applicationId); const application = await findApplicationById(input.applicationId);
await removeRSAFiles(application.appName); await removeRSAFiles(application.appName);
await updateApplication(input.applicationId, { // await updateApplication(input.applicationId, {
customGitSSHKey: null, // customGitSSHKey: null,
}); // });
return true; return true;
}), }),

View File

@@ -20,6 +20,7 @@ import { redirects } from "./redirects";
import { registry } from "./registry"; import { registry } from "./registry";
import { security } from "./security"; import { security } from "./security";
import { applicationStatus } from "./shared"; import { applicationStatus } from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils"; import { generateAppName } from "./utils";
export const sourceType = pgEnum("sourceType", [ export const sourceType = pgEnum("sourceType", [
@@ -132,7 +133,12 @@ export const applications = pgTable("application", {
customGitUrl: text("customGitUrl"), customGitUrl: text("customGitUrl"),
customGitBranch: text("customGitBranch"), customGitBranch: text("customGitBranch"),
customGitBuildPath: text("customGitBuildPath"), customGitBuildPath: text("customGitBuildPath"),
customGitSSHKey: text("customGitSSHKey"), customGitSSHKeyId: text("customGitSSHKeyId").references(
() => sshKeys.sshKeyId,
{
onDelete: "set null",
},
),
dockerfile: text("dockerfile"), dockerfile: text("dockerfile"),
// Drop // Drop
dropBuildPath: text("dropBuildPath"), dropBuildPath: text("dropBuildPath"),
@@ -170,6 +176,10 @@ export const applicationsRelations = relations(
references: [projects.projectId], references: [projects.projectId],
}), }),
deployments: many(deployments), deployments: many(deployments),
customGitSSHKey: one(sshKeys, {
fields: [applications.customGitSSHKeyId],
references: [sshKeys.sshKeyId],
}),
domains: many(domains), domains: many(domains),
mounts: many(mounts), mounts: many(mounts),
redirects: many(redirects), redirects: many(redirects),
@@ -289,7 +299,7 @@ const createSchema = createInsertSchema(applications, {
dockerImage: z.string().optional(), dockerImage: z.string().optional(),
username: z.string().optional(), username: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
customGitSSHKey: z.string().optional(), customGitSSHKeyId: z.string().optional(),
repository: z.string().optional(), repository: z.string().optional(),
dockerfile: z.string().optional(), dockerfile: z.string().optional(),
branch: z.string().optional(), branch: z.string().optional(),
@@ -371,7 +381,12 @@ export const apiSaveGitProvider = createSchema
customGitBuildPath: true, customGitBuildPath: true,
customGitUrl: true, customGitUrl: true,
}) })
.required(); .required()
.merge(
createSchema.pick({
customGitSSHKeyId: true,
}),
);
export const apiSaveEnvironmentVariables = createSchema export const apiSaveEnvironmentVariables = createSchema
.pick({ .pick({

View File

@@ -1,4 +1,6 @@
import { applications } from "@/server/db/schema/application";
import { sshKeyCreate } from "@/server/db/validations"; import { sshKeyCreate } from "@/server/db/validations";
import { relations } from "drizzle-orm";
import { pgTable, text, time } from "drizzle-orm/pg-core"; import { pgTable, text, time } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -17,6 +19,10 @@ export const sshKeys = pgTable("ssh-key", {
lastUsedAt: text("lastUsedAt"), lastUsedAt: text("lastUsedAt"),
}); });
export const sshKeysRelations = relations(sshKeys, ({ many }) => ({
applications: many(applications),
}));
const createSchema = createInsertSchema( const createSchema = createInsertSchema(
sshKeys, sshKeys,
/* Private key is not stored in the DB */ /* Private key is not stored in the DB */