feat: initial commit

This commit is contained in:
Mauricio Siu
2024-04-28 23:57:52 -06:00
parent 8857a20344
commit be56ba046c
412 changed files with 60777 additions and 1 deletions

View File

@@ -0,0 +1,150 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useTheme } from "next-themes";
import { useEffect } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const appearanceFormSchema = z.object({
theme: z.enum(["light", "dark"], {
required_error: "Please select a theme.",
}),
});
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
// This can come from your database or API.
const defaultValues: Partial<AppearanceFormValues> = {
theme: "light",
};
export function AppearanceForm() {
const { setTheme, theme } = useTheme();
const form = useForm<AppearanceFormValues>({
resolver: zodResolver(appearanceFormSchema),
defaultValues,
});
useEffect(() => {
form.reset({
theme: theme === "light" ? "light" : "dark",
});
}, [form, theme]);
function onSubmit(data: AppearanceFormValues) {
setTheme(data.theme);
toast.success("Preferences Updated");
}
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="text-xl">Appareance</CardTitle>
<CardDescription>
Customize the theme of your dashboard.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="theme"
defaultValue={form.control._defaultValues.theme}
render={({ field }) => {
return (
<FormItem className="space-y-1 ">
<FormLabel>Theme</FormLabel>
<FormDescription>
Select a theme for your dashboard
</FormDescription>
<FormMessage />
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
className="grid max-w-md grid-cols-1 sm:grid-cols-2 gap-8 pt-2"
>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
<FormControl>
<RadioGroupItem value="light" className="sr-only" />
</FormControl>
<div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
</div>
</div>
<span className="block w-full p-2 text-center font-normal">
Light
</span>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
<FormControl>
<RadioGroupItem value="dark" className="sr-only" />
</FormControl>
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
<div className="space-y-2 rounded-sm bg-slate-950 p-2">
<div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
</div>
</div>
<span className="block w-full p-2 text-center font-normal">
Dark
</span>
</FormLabel>
</FormItem>
</RadioGroup>
</FormItem>
);
}}
/>
<Button type="submit">Save</Button>
</form>
</Form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,165 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addCertificate = z.object({
name: z.string().min(1, "Name is required"),
certificateData: z.string().min(1, "Certificate data is required"),
privateKey: z.string().min(1, "Private key is required"),
autoRenew: z.boolean().optional(),
});
type AddCertificate = z.infer<typeof addCertificate>;
export const AddCertificate = () => {
const utils = api.useUtils();
const { mutateAsync, isError, error, isLoading } =
api.certificates.create.useMutation();
const form = useForm<AddCertificate>({
defaultValues: {
name: "",
certificateData: "",
privateKey: "",
autoRenew: false,
},
resolver: zodResolver(addCertificate),
});
useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddCertificate) => {
await mutateAsync({
name: data.name,
certificateData: data.certificateData,
privateKey: data.privateKey,
autoRenew: data.autoRenew,
})
.then(async () => {
toast.success("Certificate Created");
await utils.certificates.all.invalidate();
})
.catch(() => {
toast.error("Error to create the Certificate");
});
};
return (
<Dialog>
<DialogTrigger className="" asChild>
<Button>Add Certificate</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add Certificate</DialogTitle>
<DialogDescription>Add a new certificate</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<Form {...form}>
<form
id="hook-form-add-certificate"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Certificate Name</FormLabel>
<FormControl>
<Input placeholder={"My Certificate"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="certificateData"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Certificate Data</FormLabel>
</div>
<FormControl>
<Textarea
className="h-32"
placeholder={`-----BEGIN CERTIFICATE-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n------END CERTIFICATE-----`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="privateKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Private Key</FormLabel>
</div>
<FormControl>
<Textarea
className="h-32"
placeholder={`-----BEGIN CERTIFICATE-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n------END CERTIFICATE-----`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
<DialogFooter className="flex w-full flex-row !justify-between pt-3">
<Button
isLoading={isLoading}
form="hook-form-add-certificate"
type="submit"
>
Create
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,62 @@
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import { toast } from "sonner";
interface Props {
certificateId: string;
}
export const DeleteCertificate = ({ certificateId }: Props) => {
const { mutateAsync, isLoading } = api.certificates.remove.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
certificate.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
certificateId,
})
.then(async () => {
utils.certificates.all.invalidate();
utils.auth.get.invalidate();
toast.success("Certificate delete succesfully");
})
.catch(() => {
toast.error("Error to delete the Certificate");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,60 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { ShieldCheck } from "lucide-react";
import { AddCertificate } from "./add-certificate";
import { DeleteCertificate } from "./delete-certificate";
export const ShowCertificates = () => {
const { data } = api.certificates.all.useQuery();
return (
<div className="h-full">
<Card className="bg-transparent h-full">
<CardHeader>
<CardTitle className="text-xl">Certificates</CardTitle>
<CardDescription>
Add custom certificates to your application.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-4 h-full">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<ShieldCheck className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
To create a certificate is required to upload your certificate
</span>
<AddCertificate />
</div>
) : (
<div className="flex flex-col gap-6">
{data?.map((destination, index) => (
<div
key={destination.certificateId}
className="flex items-center justify-between"
>
<span className="text-sm text-muted-foreground">
{index + 1}. {destination.name}
</span>
<div className="flex flex-row gap-3">
<DeleteCertificate
certificateId={destination.certificateId}
/>
</div>
</div>
))}
<div>
<AddCertificate />
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,235 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addDestination = z.object({
name: z.string().min(1, "Name is required"),
accessKeyId: z.string(),
secretAccessKey: z.string(),
bucket: z.string(),
region: z.string(),
endpoint: z.string(),
});
type AddDestination = z.infer<typeof addDestination>;
export const AddDestination = () => {
const utils = api.useUtils();
const { mutateAsync, isError, error, isLoading } =
api.destination.create.useMutation();
const { mutateAsync: testConnection, isLoading: isLoadingConnection } =
api.destination.testConnection.useMutation();
const form = useForm<AddDestination>({
defaultValues: {
accessKeyId: "",
bucket: "",
name: "",
region: "",
secretAccessKey: "",
endpoint: "",
},
resolver: zodResolver(addDestination),
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: AddDestination) => {
await mutateAsync({
accessKey: data.accessKeyId,
bucket: data.bucket,
endpoint: data.endpoint,
name: data.name,
region: data.region,
secretAccessKey: data.secretAccessKey,
})
.then(async () => {
toast.success("Destination Created");
await utils.destination.all.invalidate();
})
.catch(() => {
toast.error("Error to create the Destination");
});
};
return (
<Dialog>
<DialogTrigger className="" asChild>
<Button>Add Destination</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add Destination</DialogTitle>
<DialogDescription>
In this section you can add destinations for your backups.
</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<Form {...form}>
<form
id="hook-form-destination-add"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={"S3 Bucket"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Access Key Id</FormLabel>
<FormControl>
<Input placeholder={"xcas41dasde"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Secret Access Key</FormLabel>
</div>
<FormControl>
<Input placeholder={"asd123asdasw"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Bucket</FormLabel>
</div>
<FormControl>
<Input placeholder={"dokploy-bucket"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="region"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Region</FormLabel>
</div>
<FormControl>
<Input placeholder={"us-east-1"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input
placeholder={"https://us.bucket.aws/s3"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
<DialogFooter className="flex w-full flex-row !justify-between pt-3">
<Button
isLoading={isLoadingConnection}
type="button"
variant="secondary"
onClick={async () => {
await testConnection({
accessKey: form.getValues("accessKeyId"),
bucket: form.getValues("bucket"),
endpoint: form.getValues("endpoint"),
name: "Test",
region: form.getValues("region"),
secretAccessKey: form.getValues("secretAccessKey"),
})
.then(async () => {
toast.success("Connection Success");
})
.catch(() => {
toast.error("Error to connect the provider");
});
}}
>
Test connection
</Button>
<Button
isLoading={isLoading}
form="hook-form-destination-add"
type="submit"
>
Create
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,61 @@
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import { toast } from "sonner";
interface Props {
destinationId: string;
}
export const DeleteDestination = ({ destinationId }: Props) => {
const { mutateAsync, isLoading } = api.destination.remove.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
destination
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
destinationId,
})
.then(() => {
utils.destination.all.invalidate();
toast.success("Destination delete succesfully");
})
.catch(() => {
toast.error("Error to delete destination");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,65 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { FolderUp } from "lucide-react";
import { AddDestination } from "./add-destination";
import { UpdateDestination } from "./update-destination";
import { DeleteDestination } from "./delete-destination";
export const ShowDestinations = () => {
const { data } = api.destination.all.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-transparent">
<CardHeader>
<CardTitle className="text-xl">S3 Destinations</CardTitle>
<CardDescription>
Add your providers like AWS S3, Cloudflare R2, Wasabi, DigitalOcean
Spaces etc.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-4">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<FolderUp className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
To create a backup is required to set at least 1 provider.
</span>
<AddDestination />
</div>
) : (
<div className="flex flex-col gap-6">
{data?.map((destination, index) => (
<div
key={destination.destinationId}
className="flex items-center justify-between"
>
<span className="text-sm text-muted-foreground">
{index + 1}. {destination.name}
</span>
<div className="flex flex-row gap-3">
<UpdateDestination
destinationId={destination.destinationId}
/>
<DeleteDestination
destinationId={destination.destinationId}
/>
</div>
</div>
))}
<div>
<AddDestination />
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,263 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const updateDestination = z.object({
name: z.string().min(1, "Name is required"),
accessKeyId: z.string(),
secretAccessKey: z.string(),
bucket: z.string(),
region: z.string(),
endpoint: z.string(),
});
type UpdateDestination = z.infer<typeof updateDestination>;
interface Props {
destinationId: string;
}
export const UpdateDestination = ({ destinationId }: Props) => {
const utils = api.useUtils();
const { data, refetch } = api.destination.one.useQuery(
{
destinationId,
},
{
enabled: !!destinationId,
},
);
const { mutateAsync, isError, error } = api.destination.update.useMutation();
const { mutateAsync: testConnection, isLoading: isLoadingConnection } =
api.destination.testConnection.useMutation();
const form = useForm<UpdateDestination>({
defaultValues: {
accessKeyId: "",
bucket: "",
name: "",
region: "",
secretAccessKey: "",
endpoint: "",
},
resolver: zodResolver(updateDestination),
});
useEffect(() => {
if (data) {
form.reset({
accessKeyId: data.accessKey,
bucket: data.bucket,
endpoint: data.endpoint,
name: data.name,
region: data.region,
secretAccessKey: data.secretAccessKey,
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateDestination) => {
await mutateAsync({
accessKey: data.accessKeyId,
bucket: data.bucket,
endpoint: data.endpoint,
name: data.name,
region: data.region,
secretAccessKey: data.secretAccessKey,
destinationId,
})
.then(async () => {
toast.success("Destination Updated");
await refetch();
await utils.destination.all.invalidate();
})
.catch(() => {
toast.error("Error to update the Destination");
});
};
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>Update Destination</DialogTitle>
<DialogDescription>
Update the current destination config
</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<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="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={"S3 Bucket"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Access Key Id</FormLabel>
<FormControl>
<Input placeholder={"xcas41dasde"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Secret Access Key</FormLabel>
</div>
<FormControl>
<Input placeholder={"asd123asdasw"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Bucket</FormLabel>
</div>
<FormControl>
<Input placeholder={"dokploy-bucket"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="region"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Region</FormLabel>
</div>
<FormControl>
<Input placeholder={"us-east-1"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input
placeholder={"https://us.bucket.aws/s3"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</form>
<DialogFooter className="flex w-full flex-row !justify-between pt-3">
<Button
isLoading={isLoadingConnection}
type="button"
variant="secondary"
onClick={async () => {
await testConnection({
accessKey: form.getValues("accessKeyId"),
bucket: form.getValues("bucket"),
endpoint: form.getValues("endpoint"),
name: "Test",
region: form.getValues("region"),
secretAccessKey: form.getValues("secretAccessKey"),
})
.then(async () => {
toast.success("Connection Success");
})
.catch(() => {
toast.error("Error to connect the provider");
});
}}
>
Test connection
</Button>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,168 @@
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { BadgeCheck } from "lucide-react";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import { RemoveGithubApp } from "./remove-github-app";
export const generateName = () => {
const n1 = ["Blue", "Green", "Red", "Orange", "Violet", "Indigo", "Yellow"];
const n2 = [
"One",
"Two",
"Three",
"Four",
"Five",
"Six",
"Seven",
"Eight",
"Nine",
"Zero",
];
return `Dokploy-${n1[Math.round(Math.random() * (n1.length - 1))]}-${
n2[Math.round(Math.random() * (n2.length - 1))]
}`;
};
export const GithubSetup = () => {
const [isOrganization, setIsOrganization] = useState(false);
const { data: haveGithubConfigured } =
api.admin.haveGithubConfigured.useQuery();
const [manifest, setManifest] = useState<string>("");
const [organizationName, setOrganization] = useState<string>("");
const { data } = api.admin.one.useQuery();
useEffect(() => {
const manifest = JSON.stringify(
{
redirect_url: `${origin}/api/redirect?authId=${data?.authId}`,
name: generateName(),
url: origin,
hook_attributes: {
// JUST FOR TESTING
url: "https://webhook.site/b6a167c0-ceb5-4f0c-a257-97c0fd163977",
// url: `${origin}/api/webhook`, // Aquí especificas la URL del endpoint de tu webhook
},
callback_urls: [`${origin}/api/redirect`], // Los URLs de callback para procesos de autenticación
public: false,
request_oauth_on_install: true,
default_permissions: {
contents: "read",
metadata: "read",
emails: "read",
pull_requests: "write",
},
default_events: ["pull_request", "push"],
},
null,
4,
);
setManifest(manifest);
}, [data?.authId]);
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="text-xl">Configure Github </CardTitle>
<CardDescription>
Setup your github account to access to your repositories.
</CardDescription>
</CardHeader>
<CardContent className="h-full space-y-2">
{haveGithubConfigured ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-4">
<span className="text-muted-foreground">
Github account configured succesfully.
</span>
<BadgeCheck className="size-5 text-green-700" />
</div>
<div className="flex items-end">
<RemoveGithubApp />
</div>
</div>
) : (
<>
{data?.githubAppName ? (
<div className="flex w-fit flex-col gap-4">
<span className="text-muted-foreground">
Youve successfully created a GitHub app named
{data.githubAppName}! The next step is to install this app in
your GitHub account.
</span>
<Link
href={`https://github.com/apps/${data.githubAppName}/installations/new?state=gh_setup:${data?.authId}`}
className={buttonVariants({ className: "w-fit" })}
>
Install Github App
</Link>
</div>
) : (
<div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
To integrate your GitHub account with our services, youll
need to create and install a GitHub app. This process is
straightforward and only takes a few minutes. Click the
button below to get started.
</span>
</div>
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-row gap-4">
<span>Organization?</span>
<Switch
checked={isOrganization}
onCheckedChange={(checked) => setIsOrganization(checked)}
/>
</div>
{isOrganization && (
<Input
required
placeholder="Organization name"
onChange={(e) => setOrganization(e.target.value)}
/>
)}
</div>
<form
action={
isOrganization
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${data?.authId}`
: `https://github.com/settings/apps/new?state=gh_init:${data?.authId}`
}
method="post"
>
<input
type="text"
name="manifest"
id="manifest"
defaultValue={manifest}
className="invisible"
/>
<br />
<Button
disabled={isOrganization && organizationName.length < 1}
type="submit"
>
Create GitHub App
</Button>
</form>
</div>
)}
</>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,56 @@
import { api } from "@/utils/api";
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
export const RemoveGithubApp = () => {
const { refetch } = api.auth.get.useQuery();
const utils = api.useUtils();
const { mutateAsync } = api.admin.cleanGithubApp.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Remove Current Github App</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
associated github application
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync()
.then(async () => {
await refetch();
await utils.admin.haveGithubConfigured.invalidate();
toast.success("Github application deleted succesfully.");
})
.catch(() => {
toast.error("Error to delete your github application.");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,53 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { toast } from "sonner";
export const Disable2FA = () => {
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.auth.disable2FA.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Disable 2FA
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the 2FA
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync()
.then(() => {
utils.auth.get.invalidate();
toast.success("2FA Disabled");
})
.catch(() => {
toast.error("Error to disable 2FA");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,161 @@
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 { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Fingerprint } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
const Enable2FASchema = z.object({
pin: z.string().min(6, {
message: "Pin is required",
}),
});
type Enable2FA = z.infer<typeof Enable2FASchema>;
export const Enable2FA = () => {
const utils = api.useUtils();
const { data } = api.auth.generate2FASecret.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const { mutateAsync, isLoading, error, isError } =
api.auth.verify2FASetup.useMutation();
const form = useForm<Enable2FA>({
defaultValues: {
pin: "",
},
resolver: zodResolver(Enable2FASchema),
});
useEffect(() => {
form.reset({
pin: "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (formData: Enable2FA) => {
await mutateAsync({
pin: formData.pin,
secret: data?.secret || "",
})
.then(async () => {
toast.success("2FA Verified");
utils.auth.get.invalidate();
})
.catch(() => {
toast.error("Error to verify the 2FA");
});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost">
<Fingerprint className="size-4 text-muted-foreground" />
Enable 2FA
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen max-sm:overflow-y-auto sm:max-w-xl ">
<DialogHeader>
<DialogTitle>2FA Setup</DialogTitle>
<DialogDescription>Add a 2FA to your account</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg items-center bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<Form {...form}>
<form
id="hook-form-add-2FA"
onSubmit={form.handleSubmit(onSubmit)}
className="grid sm:grid-cols-2 w-full gap-4"
>
<div className="flex flex-col gap-4 justify-center items-center">
<span className="text-sm text-muted-foreground">
{data?.qrCodeUrl ? "Scan the QR code to add 2FA" : ""}
</span>
<img
src={data?.qrCodeUrl}
alt="qrCode"
className="rounded-lg w-fit"
/>
<div className="flex flex-col gap-2">
<span className="text-sm text-muted-foreground text-center">
{data?.secret ? `Secret: ${data?.secret}` : ""}
</span>
</div>
</div>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center">
<FormLabel>Pin</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription className="max-md:text-center">
Please enter the 6 digits code provided by your
authenticator app.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-add-2FA"
type="submit"
>
Submit 2FA
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,188 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useEffect } from "react";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Enable2FA } from "./enable-2fa";
import { Disable2FA } from "./disable-2fa";
const profileSchema = z.object({
email: z.string(),
password: z.string().nullable(),
image: z.string().optional(),
});
type Profile = z.infer<typeof profileSchema>;
const randomImages = [
"/avatars/avatar-1.png",
"/avatars/avatar-2.png",
"/avatars/avatar-3.png",
"/avatars/avatar-4.png",
"/avatars/avatar-5.png",
"/avatars/avatar-6.png",
"/avatars/avatar-7.png",
"/avatars/avatar-8.png",
"/avatars/avatar-9.png",
"/avatars/avatar-10.png",
"/avatars/avatar-11.png",
"/avatars/avatar-12.png",
];
export const ProfileForm = () => {
const { data, refetch } = api.auth.get.useQuery();
const { mutateAsync, isLoading } = api.auth.update.useMutation();
const form = useForm<Profile>({
defaultValues: {
email: data?.email || "",
password: "",
image: data?.image || "",
},
resolver: zodResolver(profileSchema),
});
useEffect(() => {
if (data) {
form.reset({
email: data?.email || "",
password: "",
image: data?.image || "",
});
}
form.reset();
}, [form, form.reset, data]);
const onSubmit = async (values: Profile) => {
await mutateAsync({
email: values.email,
password: values.password,
image: values.image,
})
.then(async () => {
await refetch();
toast.success("Profile Updated");
})
.catch(() => {
toast.error("Error to Update the profile");
});
};
return (
<Card className="bg-transparent">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<CardTitle className="text-xl">Account</CardTitle>
<CardDescription>
Change your details of your profile here.
</CardDescription>
</div>
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
</CardHeader>
<CardContent className="space-y-2">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
<div className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="Email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(e) => {
console.log(e);
field.onChange(e);
}}
defaultValue={field.value}
value={field.value}
className="flex flex-row flex-wrap gap-2 max-xl:justify-cente"
>
{randomImages.map((image) => (
<FormItem key={image}>
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px">
<FormControl>
<RadioGroupItem
value={image}
className="sr-only"
/>
</FormControl>
<img
key={image}
src={image}
alt="avatar"
className="h-12 w-12 rounded-full border transition-transform"
/>
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
<Button type="submit" isLoading={isLoading}>
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,41 @@
import React from "react";
import { ProfileForm } from "./profile/profile-form";
import { GithubSetup } from "./github/github-setup";
import { AppearanceForm } from "./appearance-form";
import { ShowDestinations } from "./destination/show-destinations";
import { ShowCertificates } from "./certificates/show-certificates";
import { WebDomain } from "./web-domain";
import { WebServer } from "./web-server";
import { api } from "@/utils/api";
import { ShowUsers } from "./users/show-users";
import { cn } from "@/lib/utils";
export const ShowSettings = () => {
const { data } = api.auth.get.useQuery();
return (
<div
className={cn(
"mt-6 md:grid flex flex-col gap-4 pb-20 md:grid-cols-2",
data?.rol === "user" && "col-span-2",
)}
>
<div className={cn(data?.rol === "user" && "col-span-2")}>
<ProfileForm />
</div>
{data?.rol === "admin" && (
<>
<GithubSetup />
<AppearanceForm />
<ShowDestinations />
<ShowCertificates />
<WebDomain />
<WebServer />
<ShowUsers />
</>
)}
</div>
);
};

View File

@@ -0,0 +1,378 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
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 { Switch } from "@/components/ui/switch";
import { extractServices } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, ListTodo } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addPermissions = z.object({
accesedProjects: z.array(z.string()).optional(),
accesedServices: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional().default(false),
canCreateServices: z.boolean().optional().default(false),
canDeleteProjects: z.boolean().optional().default(false),
canDeleteServices: z.boolean().optional().default(false),
canAccessToTraefikFiles: z.boolean().optional().default(false),
canAccessToDocker: z.boolean().optional().default(false),
});
type AddPermissions = z.infer<typeof addPermissions>;
interface Props {
userId: string;
}
export const AddUserPermissions = ({ userId }: Props) => {
const { data: projects } = api.project.all.useQuery();
const { data, refetch } = api.user.byUserId.useQuery(
{
userId,
},
{
enabled: !!userId,
},
);
const { mutateAsync, isError, error, isLoading } =
api.admin.assignPermissions.useMutation();
const form = useForm<AddPermissions>({
defaultValues: {
accesedProjects: [],
accesedServices: [],
},
resolver: zodResolver(addPermissions),
});
useEffect(() => {
if (data) {
form.reset({
accesedProjects: data.accesedProjects || [],
accesedServices: data.accesedServices || [],
canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices,
canDeleteProjects: data.canDeleteProjects,
canDeleteServices: data.canDeleteServices,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
canAccessToDocker: data.canAccessToDocker,
});
}
}, [form, form.formState.isSubmitSuccessful, form.reset, data]);
const onSubmit = async (data: AddPermissions) => {
await mutateAsync({
userId,
canCreateServices: data.canCreateServices,
canCreateProjects: data.canCreateProjects,
canDeleteServices: data.canDeleteServices,
canDeleteProjects: data.canDeleteProjects,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
accesedProjects: data.accesedProjects || [],
accesedServices: data.accesedServices || [],
canAccessToDocker: data.canAccessToDocker,
})
.then(async () => {
toast.success("Permissions updated");
refetch();
})
.catch(() => {
toast.error("Error to update the permissions");
});
};
return (
<Dialog>
<DialogTrigger className="" asChild>
<Button variant="ghost">
<ListTodo className="size-4 text-muted-foreground " />
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Permissions</DialogTitle>
<DialogDescription>Add or remove permissions</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<Form {...form}>
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
>
<FormField
control={form.control}
name="canCreateProjects"
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Projects</FormLabel>
<FormDescription>
Allow the user to create projects
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteProjects"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Projects</FormLabel>
<FormDescription>
Allow the user to delete projects
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canCreateServices"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Services</FormLabel>
<FormDescription>
Allow the user to create services
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteServices"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Services</FormLabel>
<FormDescription>
Allow the user to delete services
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToTraefikFiles"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Traefik Files</FormLabel>
<FormDescription>
Allow the user to access to the Traefik Tab Files
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToDocker"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Docker</FormLabel>
<FormDescription>
Allow the user to access to the Docker Tab
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="accesedProjects"
render={() => (
<FormItem className="md:col-span-2">
<div className="mb-4">
<FormLabel className="text-base">Projects</FormLabel>
<FormDescription>
Select the Projects that the user can access
</FormDescription>
</div>
{projects?.length === 0 && (
<p className="text-sm text-muted-foreground">
No projects found
</p>
)}
<div className="grid md:grid-cols-2 gap-4">
{projects?.map((item, index) => {
const applications = extractServices(item);
return (
<FormField
key={`project-${index}`}
control={form.control}
name="accesedProjects"
render={({ field }) => {
return (
<FormItem
key={item.projectId}
className="flex flex-col items-start space-x-4 rounded-lg p-4 border"
>
<div className="flex flex-row gap-4">
<FormControl>
<Checkbox
checked={field.value?.includes(
item.projectId,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.projectId,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== item.projectId,
),
);
}}
/>
</FormControl>
<FormLabel className="text-sm font-medium text-primary">
{item.name}
</FormLabel>
</div>
{applications.length === 0 && (
<p className="text-sm text-muted-foreground">
No services found
</p>
)}
{applications?.map((item, index) => (
<FormField
key={`project-${index}`}
control={form.control}
name="accesedServices"
render={({ field }) => {
return (
<FormItem
key={item.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(
item.id,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.id,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== item.id,
),
);
}}
/>
</FormControl>
<FormLabel className="text-sm text-muted-foreground">
{item.name}
</FormLabel>
</FormItem>
);
}}
/>
))}
</FormItem>
);
}}
/>
);
})}
</div>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2">
<Button
isLoading={isLoading}
form="hook-form-add-permissions"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,123 @@
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 { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addUser = z.object({
email: z
.string()
.min(1, "Email is required")
.email({ message: "Invalid email" }),
});
type AddUser = z.infer<typeof addUser>;
export const AddUser = () => {
const utils = api.useUtils();
const { mutateAsync, isError, error, isLoading } =
api.admin.createUserInvitation.useMutation();
const form = useForm<AddUser>({
defaultValues: {
email: "",
},
resolver: zodResolver(addUser),
});
useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddUser) => {
await mutateAsync({
email: data.email,
})
.then(async () => {
toast.success("Invitation created");
await utils.user.all.invalidate();
})
.catch(() => {
toast.error("Error to create the invitation");
});
};
return (
<Dialog>
<DialogTrigger className="" asChild>
<Button>Add User</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
<DialogDescription>Invite a new user</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<Form {...form}>
<form
id="hook-form-add-user"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="email"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder={"email@dokploy.com"} {...field} />
</FormControl>
<FormDescription>
This will be the email of the new user
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<DialogFooter className="flex w-full flex-row">
<Button
isLoading={isLoading}
form="hook-form-add-user"
type="submit"
>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,60 @@
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import { toast } from "sonner";
interface Props {
authId: string;
}
export const DeleteUser = ({ authId }: Props) => {
const { mutateAsync, isLoading } = api.admin.removeUser.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the user.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
authId,
})
.then(async () => {
utils.user.all.invalidate();
toast.success("User delete succesfully");
})
.catch(() => {
toast.error("Error to delete the user");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,110 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { CopyIcon, Users } from "lucide-react";
import { AddUser } from "./add-user";
import { DeleteUser } from "./delete-user";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import { AddUserPermissions } from "./add-permissions";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
import { UpdateUser } from "./update-user";
export const ShowUsers = () => {
const { data } = api.user.all.useQuery();
const [url, setUrl] = useState("");
useEffect(() => {
setUrl(document.location.origin);
}, []);
return (
<div className="h-full col-span-2">
<Card className="bg-transparent h-full border-none">
<CardHeader>
<CardTitle className="text-xl">Users</CardTitle>
<CardDescription>Add, manage and delete users.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 h-full">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<Users className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
To create a user is required to add
</span>
<AddUser />
</div>
) : (
<div className="flex flex-col gap-6">
{data?.map((user) => {
return (
<div
key={user.userId}
className="flex gap-2 flex-col justify-start border p-4 rounded-lg"
>
<span className="text-sm text-muted-foreground">
{user.auth.email}
</span>
{!user.isRegistered && (
<span className="text-sm text-muted-foreground">
Expire In{" "}
{format(new Date(user.expirationDate), "PPpp")}
</span>
)}
<span className="text-sm text-muted-foreground">
{user.isRegistered ? "Registered" : "Not Registered"}
</span>
{user.auth.is2FAEnabled && (
<span className="text-sm text-muted-foreground">
{user.auth.is2FAEnabled
? "2FA Enabled"
: "2FA Not Enabled"}
</span>
)}
<div className="flex flex-wrap flex-row gap-3">
{!user.isRegistered && (
<div className="overflow-x-auto flex flex-row gap-4 items-center">
<div className="overflow-x-auto">
<span className="text-sm text-muted-foreground ">
{`${url}/invitation?token=${user.token}`}
</span>
</div>
<button
type="button"
// className="absolute right-2 top-2"
onClick={() => {
copy(`${url}/invitation?token=${user.token}`);
toast.success("Invitation Copied to clipboard");
}}
>
<CopyIcon className="size-4 text-muted-foreground" />
</button>
</div>
)}
{user.isRegistered && (
<AddUserPermissions userId={user.userId} />
)}
{user.isRegistered && <UpdateUser authId={user.authId} />}
<DeleteUser authId={user.authId} />
</div>
</div>
);
})}
<div className="flex flex-col justify-end gap-3 w-full items-end">
<AddUser />
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,168 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { AlertTriangle, SquarePen } from "lucide-react";
import { api } from "@/utils/api";
const updateUserSchema = z.object({
email: z
.string()
.min(1, "Email is required")
.email({ message: "Invalid email" }),
password: z.string(),
});
type UpdateUser = z.infer<typeof updateUserSchema>;
interface Props {
authId: string;
}
export const UpdateUser = ({ authId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
api.auth.updateByAdmin.useMutation();
const { data } = api.auth.one.useQuery(
{
id: authId,
},
{
enabled: !!authId,
},
);
const form = useForm<UpdateUser>({
defaultValues: {
email: "",
password: "",
},
resolver: zodResolver(updateUserSchema),
});
useEffect(() => {
if (data) {
form.reset({
email: data.email || "",
password: "",
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: UpdateUser) => {
await mutateAsync({
email: formData.email === data?.email ? null : formData.email,
password: formData.password,
id: authId,
})
.then(() => {
toast.success("User updated succesfully");
utils.user.all.invalidate();
})
.catch(() => {
toast.error("Error to update the user");
})
.finally(() => {});
};
return (
<Dialog>
<DialogTrigger asChild className="w-fit">
<Button
variant="ghost"
className=" cursor-pointer space-x-3 w-fit"
onSelect={(e) => e.preventDefault()}
>
<SquarePen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update User</DialogTitle>
<DialogDescription>Update the user</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error.message}
</span>
</div>
)}
<div className="grid gap-4">
<div className="grid items-center gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-update-user"
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="XNl5C@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="*******"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
form="hook-form-update-user"
type="submit"
isLoading={isLoading}
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,170 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
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 { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addServerDomain = z.object({
domain: z.string().min(1, { message: "URL is required" }),
letsEncryptEmail: z.string().min(1, "Email is required").email(),
certificateType: z.enum(["letsencrypt", "none"]),
});
type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { data: user, refetch } = api.admin.one.useQuery();
const { mutateAsync, isLoading } =
api.settings.assignDomainServer.useMutation();
const form = useForm<AddServerDomain>({
defaultValues: {
domain: "",
certificateType: "none",
letsEncryptEmail: "",
},
resolver: zodResolver(addServerDomain),
});
useEffect(() => {
if (user) {
form.reset({
domain: user?.host || "",
certificateType: user?.certificateType,
letsEncryptEmail: user?.letsEncryptEmail || "",
});
}
}, [form, form.reset, user]);
const onSubmit = async (data: AddServerDomain) => {
await mutateAsync({
host: data.domain,
letsEncryptEmail: data.letsEncryptEmail,
certificateType: data.certificateType,
})
.then(async () => {
await refetch();
toast.success("Domain Assigned");
})
.catch(() => {
toast.error("Error to assign the domain");
});
};
return (
<div className="w-full">
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="text-xl">Server Domain</CardTitle>
<CardDescription>
Add your server domain to your application
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 md:grid-cols-2"
>
<FormField
control={form.control}
name="domain"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder={"dokploy.com"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="letsEncryptEmail"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Letsencrypt Email</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder={"Dp4kz@example.com"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => {
return (
<FormItem className="md: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 (Default)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
<div>
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,315 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { ShowModalLogs } from "./web-server/show-modal-logs";
import { TerminalModal } from "./web-server/terminal-modal";
import { DockerTerminalModal } from "./web-server/docker-terminal-modal";
import { ShowMainTraefikConfig } from "./web-server/show-main-traefik-config";
import { ShowServerTraefikConfig } from "./web-server/show-server-traefik-config";
import { ShowServerMiddlewareConfig } from "./web-server/show-server-middleware-config";
import { UpdateWebServer } from "./web-server/update-webserver";
export const WebServer = () => {
const [fetchAfterFirstRender, setFetchAfterFirstRender] = useState(false);
const { data, refetch } = api.admin.one.useQuery();
const { mutateAsync: reloadServer, isLoading } =
api.settings.reloadServer.useMutation();
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
api.settings.reloadTraefik.useMutation();
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
api.settings.cleanAll.useMutation();
const {
mutateAsync: cleanDockerBuilder,
isLoading: cleanDockerBuilderIsLoading,
} = api.settings.cleanDockerBuilder.useMutation();
const {
mutateAsync: cleanUnusedImages,
isLoading: cleanUnusedImagesIsLoading,
} = api.settings.cleanUnusedImages.useMutation();
const {
mutateAsync: cleanUnusedVolumes,
isLoading: cleanUnusedVolumesIsLoading,
} = api.settings.cleanUnusedVolumes.useMutation();
const {
mutateAsync: cleanStoppedContainers,
isLoading: cleanStoppedContainersIsLoading,
} = api.settings.cleanStoppedContainers.useMutation();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { mutateAsync: updateDockerCleanup } =
api.settings.updateDockerCleanup.useMutation();
const { data: query } = api.settings.checkAndUpdateImage.useQuery(void 0, {
enabled: fetchAfterFirstRender,
});
useEffect(() => {
setFetchAfterFirstRender(true);
}, []);
return (
<Card className="rounded-lg w-full bg-transparent">
<CardHeader>
<CardTitle className="text-xl">Web server settings</CardTitle>
<CardDescription>Reload or clean the web server.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid md:grid-cols-2 gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={isLoading}>
<Button isLoading={isLoading} variant="outline">
Server
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={async () => {
await reloadServer()
.then(async () => {
toast.success("Server Reloaded");
})
.catch(() => {
toast.success("Server Reloaded");
});
}}
>
<span>Reload</span>
</DropdownMenuItem>
<ShowModalLogs appName="dokploy">
<span>Watch logs</span>
</ShowModalLogs>
<ShowServerTraefikConfig>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="w-full cursor-pointer space-x-3"
>
<span>View Traefik config</span>
</DropdownMenuItem>
</ShowServerTraefikConfig>
<ShowServerMiddlewareConfig>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="w-full cursor-pointer space-x-3"
>
<span>View middlewares config</span>
</DropdownMenuItem>
</ShowServerMiddlewareConfig>
<TerminalModal>
<span>Enter the terminal</span>
</TerminalModal>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={reloadTraefikIsLoading}>
<Button isLoading={reloadTraefikIsLoading} variant="outline">
Traefik
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={async () => {
await reloadTraefik()
.then(async () => {
toast.success("Traefik Reloaded");
})
.catch(() => {
toast.error("Error to reload the traefik");
});
}}
>
<span>Reload</span>
</DropdownMenuItem>
<ShowModalLogs appName="dokploy-traefik">
<span>Watch logs</span>
</ShowModalLogs>
<ShowMainTraefikConfig>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="w-full cursor-pointer space-x-3"
>
<span>View Traefik config</span>
</DropdownMenuItem>
</ShowMainTraefikConfig>
<DockerTerminalModal appName="dokploy-traefik">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<span>Enter the terminal</span>
</DropdownMenuItem>
</DockerTerminalModal>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
asChild
disabled={
cleanAllIsLoading ||
cleanDockerBuilderIsLoading ||
cleanUnusedImagesIsLoading ||
cleanUnusedVolumesIsLoading ||
cleanStoppedContainersIsLoading
}
>
<Button
isLoading={
cleanAllIsLoading ||
cleanDockerBuilderIsLoading ||
cleanUnusedImagesIsLoading ||
cleanUnusedVolumesIsLoading ||
cleanStoppedContainersIsLoading
}
variant="outline"
>
Space
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanUnusedImages()
.then(async () => {
toast.success("Cleaned images");
})
.catch(() => {
toast.error("Error to clean images");
});
}}
>
<span>Clean unused images</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanUnusedVolumes()
.then(async () => {
toast.success("Cleaned volumes");
})
.catch(() => {
toast.error("Error to clean volumes");
});
}}
>
<span>Clean unused volumes</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanStoppedContainers()
.then(async () => {
toast.success("Stopped containers cleaned");
})
.catch(() => {
toast.error("Error to clean stopped containers");
});
}}
>
<span>Clean stopped containers</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanDockerBuilder()
.then(async () => {
toast.success("Cleaned Docker Builder");
})
.catch(() => {
toast.error("Error to clean Docker Builder");
});
}}
>
<span>Clean Docker Builder & System</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanAll()
.then(async () => {
toast.success("Cleaned all");
})
.catch(() => {
toast.error("Error to clean all");
});
}}
>
<span>Clean all</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{query ? <UpdateWebServer /> : null}
</div>
<div className="flex items-center flex-wrap justify-between gap-4">
<span className="text-sm text-muted-foreground">
Server IP: {data?.serverIp}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}
</span>
<div className="flex items-center gap-4">
<Switch
checked={data?.enableDockerCleanup}
onCheckedChange={async (e) => {
await updateDockerCleanup({
enableDockerCleanup: e,
})
.then(async () => {
toast.success("Docker Cleanup Enabled");
})
.catch(() => {
toast.error("Docker Cleanup Error");
});
refetch();
}}
/>
<Label className="text-primary">Daily Docker Cleanup</Label>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,91 @@
import type React from "react";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import dynamic from "next/dynamic";
import { api } from "@/utils/api";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
const Terminal = dynamic(
() =>
import("@/components/dashboard/docker/terminal/docker-terminal").then(
(e) => e.DockerTerminal,
),
{
ssr: false,
},
);
interface Props {
appName: string;
children?: React.ReactNode;
}
export const DockerTerminalModal = ({ children, appName }: Props) => {
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
},
{
enabled: !!appName,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
useEffect(() => {
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
}
}, [data]);
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-7xl">
<DialogHeader>
<DialogTitle>Docker Terminal</DialogTitle>
<DialogDescription>
Easy way to access to docker container
</DialogDescription>
</DialogHeader>
<Label>Select a container to view logs</Label>
<Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger>
<SelectValue placeholder="Select a container" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{data?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}) {container.state}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<Terminal
id="terminal"
containerId={containerId || "select-a-container"}
/>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,55 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { toast } from "sonner";
export const RemoveSSHPrivateKey = () => {
const utils = api.useUtils();
const { mutateAsync, isLoading } =
api.settings.cleanSSHPrivateKey.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Remove Current SSH Private Key
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the ssh
private key.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync()
.then(() => {
toast.success("SSH private key deleted succesfully");
utils.auth.get.invalidate();
})
.catch(() => {
toast.error("Error to delete the ssh private key");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,169 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config";
const UpdateMainTraefikConfigSchema = z.object({
traefikConfig: z.string(),
});
type UpdateTraefikConfig = z.infer<typeof UpdateMainTraefikConfigSchema>;
interface Props {
children?: React.ReactNode;
}
export const ShowMainTraefikConfig = ({ children }: Props) => {
const { data, refetch } = api.settings.readTraefikConfig.useQuery();
const [canEdit, setCanEdit] = useState(true);
const { mutateAsync, isLoading, error, isError } =
api.settings.updateTraefikConfig.useMutation();
const form = useForm<UpdateTraefikConfig>({
defaultValues: {
traefikConfig: "",
},
disabled: canEdit,
resolver: zodResolver(UpdateMainTraefikConfigSchema),
});
useEffect(() => {
if (data) {
form.reset({
traefikConfig: data || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateTraefikConfig) => {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: error || "Invalid YAML",
});
return;
}
form.clearErrors("traefikConfig");
await mutateAsync({
traefikConfig: data.traefikConfig,
})
.then(async () => {
toast.success("Traefik config Updated");
refetch();
})
.catch(() => {
toast.error("Error to update the traefik config");
});
};
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Update traefik config</DialogTitle>
<DialogDescription>Update the traefik config</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<Form {...form}>
<form
id="hook-form-update-main-traefik-config"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full py-4 relative"
>
<div className="flex flex-col">
<FormField
control={form.control}
name="traefikConfig"
render={({ field }) => (
<FormItem className="relative">
<FormLabel>Traefik config</FormLabel>
<FormControl>
<Textarea
className="h-[35rem]"
placeholder={`providers:
docker:
defaultRule: 'Host('dokploy.com')'
file:
directory: /etc/dokploy/traefik
watch: true
entryPoints:
web:
address: ':80'
websecure:
address: ':443'
api:
insecure: true
`}
{...field}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
<div className="flex justify-end absolute z-50 right-6 top-10">
<Button
type="button"
onClick={async () => {
setCanEdit(!canEdit);
}}
>
{canEdit ? "Unlock" : "Lock"}
</Button>
</div>
</FormItem>
)}
/>
</div>
</form>
<DialogFooter>
<Button
isLoading={isLoading}
disabled={canEdit}
form="hook-form-update-main-traefik-config"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,95 @@
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import dynamic from "next/dynamic";
export const DockerLogsId = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
children?: React.ReactNode;
}
export const ShowModalLogs = ({ appName, children }: Props) => {
const { data } = api.docker.getContainersByAppLabel.useQuery(
{
appName,
},
{
enabled: !!appName,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
useEffect(() => {
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
}
}, [data]);
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>View the logs for {appName}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<Label>Select a container to view logs</Label>
<Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger>
<SelectValue placeholder="Select a container" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{data?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}) {container.state}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<DockerLogsId id="terminal" containerId={containerId || ""} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,167 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config";
const UpdateServerMiddlewareConfigSchema = z.object({
traefikConfig: z.string(),
});
type UpdateServerMiddlewareConfig = z.infer<
typeof UpdateServerMiddlewareConfigSchema
>;
interface Props {
children?: React.ReactNode;
}
export const ShowServerMiddlewareConfig = ({ children }: Props) => {
const { data, refetch } = api.settings.readMiddlewareTraefikConfig.useQuery();
const [canEdit, setCanEdit] = useState(true);
const { mutateAsync, isLoading, error, isError } =
api.settings.updateMiddlewareTraefikConfig.useMutation();
const form = useForm<UpdateServerMiddlewareConfig>({
defaultValues: {
traefikConfig: "",
},
disabled: canEdit,
resolver: zodResolver(UpdateServerMiddlewareConfigSchema),
});
useEffect(() => {
if (data) {
form.reset({
traefikConfig: data || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
console.log(error);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: error || "Invalid YAML",
});
return;
}
form.clearErrors("traefikConfig");
await mutateAsync({
traefikConfig: data.traefikConfig,
})
.then(async () => {
toast.success("Middleware config Updated");
refetch();
})
.catch(() => {
toast.error("Error to update the middleware traefik config");
});
};
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Update Middleware config</DialogTitle>
<DialogDescription>Update the middleware config</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<Form {...form}>
<form
id="hook-form-update-server-traefik-config"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full py-4 relative"
>
<div className="flex flex-col">
<FormField
control={form.control}
name="traefikConfig"
render={({ field }) => (
<FormItem className="relative">
<FormLabel>Traefik config</FormLabel>
<FormControl>
<Textarea
className="h-[35rem]"
placeholder={`http:
routers:
router-name:
rule: Host('domain.com')
service: container-name
entryPoints:
- web
tls: false
middlewares: []
`}
{...field}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
<div className="flex justify-end absolute z-50 right-6 top-10">
<Button
type="button"
onClick={async () => {
setCanEdit(!canEdit);
}}
>
{canEdit ? "Unlock" : "Lock"}
</Button>
</div>
</FormItem>
)}
/>
</div>
</form>
<DialogFooter>
<Button
isLoading={isLoading}
disabled={canEdit}
form="hook-form-update-server-traefik-config"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,167 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config";
const UpdateServerTraefikConfigSchema = z.object({
traefikConfig: z.string(),
});
type UpdateServerTraefikConfig = z.infer<
typeof UpdateServerTraefikConfigSchema
>;
interface Props {
children?: React.ReactNode;
}
export const ShowServerTraefikConfig = ({ children }: Props) => {
const { data, refetch } = api.settings.readWebServerTraefikConfig.useQuery();
const [canEdit, setCanEdit] = useState(true);
const { mutateAsync, isLoading, error, isError } =
api.settings.updateWebServerTraefikConfig.useMutation();
const form = useForm<UpdateServerTraefikConfig>({
defaultValues: {
traefikConfig: "",
},
disabled: canEdit,
resolver: zodResolver(UpdateServerTraefikConfigSchema),
});
useEffect(() => {
if (data) {
form.reset({
traefikConfig: data || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateServerTraefikConfig) => {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
console.log(error);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: error || "Invalid YAML",
});
return;
}
form.clearErrors("traefikConfig");
await mutateAsync({
traefikConfig: data.traefikConfig,
})
.then(async () => {
toast.success("Traefik config Updated");
refetch();
})
.catch(() => {
toast.error("Error to update the traefik config");
});
};
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Update traefik config</DialogTitle>
<DialogDescription>Update the traefik config</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<Form {...form}>
<form
id="hook-form-update-server-traefik-config"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full py-4 relative"
>
<div className="flex flex-col">
<FormField
control={form.control}
name="traefikConfig"
render={({ field }) => (
<FormItem className="relative">
<FormLabel>Traefik config</FormLabel>
<FormControl>
<Textarea
className="h-[35rem]"
placeholder={`http:
routers:
router-name:
rule: Host('domain.com')
service: container-name
entryPoints:
- web
tls: false
middlewares: []
`}
{...field}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
<div className="flex justify-end absolute z-50 right-6 top-10">
<Button
type="button"
onClick={async () => {
setCanEdit(!canEdit);
}}
>
{canEdit ? "Unlock" : "Lock"}
</Button>
</div>
</FormItem>
)}
/>
</div>
</form>
<DialogFooter>
<Button
isLoading={isLoading}
disabled={canEdit}
form="hook-form-update-server-traefik-config"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,167 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import dynamic from "next/dynamic";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { z } from "zod";
import { useEffect, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { RemoveSSHPrivateKey } from "./remove-ssh-private-key";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const Terminal = dynamic(() => import("./terminal").then((e) => e.Terminal), {
ssr: false,
});
const addSSHPrivateKey = z.object({
sshPrivateKey: z
.string({
required_error: "SSH private key is required",
})
.min(1, "SSH private key is required"),
});
type AddSSHPrivateKey = z.infer<typeof addSSHPrivateKey>;
interface Props {
children?: React.ReactNode;
}
export const TerminalModal = ({ children }: Props) => {
const { data, refetch } = api.admin.one.useQuery();
const [user, setUser] = useState("root");
const [terminalUser, setTerminalUser] = useState("root");
const { mutateAsync, isLoading } =
api.settings.saveSSHPrivateKey.useMutation();
const form = useForm<AddSSHPrivateKey>({
defaultValues: {
sshPrivateKey: "",
},
resolver: zodResolver(addSSHPrivateKey),
});
useEffect(() => {
if (data) {
form.reset({});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: AddSSHPrivateKey) => {
await mutateAsync({
sshPrivateKey: formData.sshPrivateKey,
})
.then(async () => {
toast.success("SSH Key Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the ssh key");
});
};
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl">
<DialogHeader className="flex flex-row justify-between pt-4">
<div>
<DialogTitle>Terminal</DialogTitle>
<DialogDescription>Easy way to access the server</DialogDescription>
</div>
{data?.haveSSH && (
<div>
<RemoveSSHPrivateKey />
</div>
)}
</DialogHeader>
{!data?.haveSSH ? (
<div>
<div className="flex flex-col gap-4">
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full">
<FormField
control={form.control}
name="sshPrivateKey"
render={({ field }) => {
return (
<FormItem>
<FormLabel>SSH Private Key</FormLabel>
<FormDescription>
In order to access the server you need to add an
ssh private key
</FormDescription>
<FormControl>
<Textarea
placeholder={
"-----BEGIN CERTIFICATE-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n------END CERTIFICATE-----"
}
className="h-32"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Log in as</Label>
<div className="flex flex-row gap-4">
<Input value={user} onChange={(e) => setUser(e.target.value)} />
<Button onClick={() => setTerminalUser(user)}>Login</Button>
</div>
</div>
<Terminal id="terminal" userSSH={terminalUser} />
</div>
)}
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,58 @@
import React from "react";
import { useEffect, useRef } from "react";
import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
interface Props {
id: string;
userSSH?: string;
}
export const Terminal: React.FC<Props> = ({ id, userSSH = "root" }) => {
const termRef = useRef(null);
useEffect(() => {
const container = document.getElementById(id);
if (container) {
container.innerHTML = "";
}
const term = new XTerm({
cursorBlink: true,
cols: 80,
rows: 30,
lineHeight: 1.4,
convertEol: true,
theme: {
cursor: "transparent",
background: "#19191A",
},
});
const addonFit = new FitAddon();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/terminal?userSSH=${userSSH}`;
const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
// @ts-ignore
term.open(termRef.current);
term.loadAddon(addonFit);
term.loadAddon(addonAttach);
addonFit.fit();
return () => {
ws.readyState === WebSocket.OPEN && ws.close();
};
}, [id, userSSH]);
return (
<div className="flex flex-col gap-4">
<div className="w-full h-full bg-input rounded-lg p-2 ">
<div id={id} ref={termRef} className="rounded-xl" />
</div>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { toast } from "sonner";
export const UpdateWebServer = () => {
const { mutateAsync: updateServer, isLoading } =
api.settings.updateServer.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="relative" variant="secondary" isLoading={isLoading}>
<span className="absolute -right-1 -top-2 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
Update Server
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will update the web server to the
new version.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await updateServer();
toast.success("Please reload the browser to see the changes");
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};