mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-10 05:55:18 +00:00
Merge branch 'canary' into vicke4/canary
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
prefix: z.string().optional(),
|
||||
expiresIn: z.number().nullable(),
|
||||
organizationId: z.string().min(1, "Organization is required"),
|
||||
// Rate limiting fields
|
||||
rateLimitEnabled: z.boolean().optional(),
|
||||
rateLimitTimeWindow: z.number().nullable(),
|
||||
rateLimitMax: z.number().nullable(),
|
||||
// Request limiting fields
|
||||
remaining: z.number().nullable().optional(),
|
||||
refillAmount: z.number().nullable().optional(),
|
||||
refillInterval: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const EXPIRATION_OPTIONS = [
|
||||
{ label: "Never", value: "0" },
|
||||
{ label: "1 day", value: String(60 * 60 * 24) },
|
||||
{ label: "7 days", value: String(60 * 60 * 24 * 7) },
|
||||
{ label: "30 days", value: String(60 * 60 * 24 * 30) },
|
||||
{ label: "90 days", value: String(60 * 60 * 24 * 90) },
|
||||
{ label: "1 year", value: String(60 * 60 * 24 * 365) },
|
||||
];
|
||||
|
||||
const TIME_WINDOW_OPTIONS = [
|
||||
{ label: "1 minute", value: String(60 * 1000) },
|
||||
{ label: "5 minutes", value: String(5 * 60 * 1000) },
|
||||
{ label: "15 minutes", value: String(15 * 60 * 1000) },
|
||||
{ label: "30 minutes", value: String(30 * 60 * 1000) },
|
||||
{ label: "1 hour", value: String(60 * 60 * 1000) },
|
||||
{ label: "1 day", value: String(24 * 60 * 60 * 1000) },
|
||||
];
|
||||
|
||||
const REFILL_INTERVAL_OPTIONS = [
|
||||
{ label: "1 hour", value: String(60 * 60 * 1000) },
|
||||
{ label: "6 hours", value: String(6 * 60 * 60 * 1000) },
|
||||
{ label: "12 hours", value: String(12 * 60 * 60 * 1000) },
|
||||
{ label: "1 day", value: String(24 * 60 * 60 * 1000) },
|
||||
{ label: "7 days", value: String(7 * 24 * 60 * 60 * 1000) },
|
||||
{ label: "30 days", value: String(30 * 24 * 60 * 60 * 1000) },
|
||||
];
|
||||
|
||||
export const AddApiKey = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [newApiKey, setNewApiKey] = useState("");
|
||||
const { refetch } = api.user.get.useQuery();
|
||||
const { data: organizations } = api.organization.all.useQuery();
|
||||
const createApiKey = api.user.createApiKey.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (!data) return;
|
||||
|
||||
setNewApiKey(data.key);
|
||||
setOpen(false);
|
||||
setShowSuccessModal(true);
|
||||
form.reset();
|
||||
void refetch();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to generate API key");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
prefix: "",
|
||||
expiresIn: null,
|
||||
organizationId: "",
|
||||
rateLimitEnabled: false,
|
||||
rateLimitTimeWindow: null,
|
||||
rateLimitMax: null,
|
||||
remaining: null,
|
||||
refillAmount: null,
|
||||
refillInterval: null,
|
||||
},
|
||||
});
|
||||
|
||||
const rateLimitEnabled = form.watch("rateLimitEnabled");
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
createApiKey.mutate({
|
||||
name: values.name,
|
||||
expiresIn: values.expiresIn || undefined,
|
||||
prefix: values.prefix || undefined,
|
||||
metadata: {
|
||||
organizationId: values.organizationId,
|
||||
},
|
||||
// Rate limiting
|
||||
rateLimitEnabled: values.rateLimitEnabled,
|
||||
rateLimitTimeWindow: values.rateLimitTimeWindow || undefined,
|
||||
rateLimitMax: values.rateLimitMax || undefined,
|
||||
// Request limiting
|
||||
remaining: values.remaining || undefined,
|
||||
refillAmount: values.refillAmount || undefined,
|
||||
refillInterval: values.refillInterval || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Generate New Key</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key for accessing the API. You can set an
|
||||
expiration date and a custom prefix for better organization.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My API Key" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prefix"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Prefix</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my_app" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresIn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Expiration</FormLabel>
|
||||
<Select
|
||||
value={field.value?.toString() || "0"}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Number.parseInt(value, 10))
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select expiration time" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{EXPIRATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organizationId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select organization" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{organizations?.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Rate Limiting Section */}
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<h3 className="text-lg font-medium">Rate Limiting</h3>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rateLimitEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Enable Rate Limiting</FormLabel>
|
||||
<FormDescription>
|
||||
Limit the number of requests within a time window
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{rateLimitEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rateLimitTimeWindow"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Time Window</FormLabel>
|
||||
<Select
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Number.parseInt(value, 10))
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select time window" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{TIME_WINDOW_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
The duration in which requests are counted
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rateLimitMax"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Maximum Requests</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="100"
|
||||
value={field.value?.toString() ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value
|
||||
? Number.parseInt(e.target.value, 10)
|
||||
: null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of requests allowed within the time
|
||||
window
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Request Limiting Section */}
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<h3 className="text-lg font-medium">Request Limiting</h3>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remaining"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Total Request Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Leave empty for unlimited"
|
||||
value={field.value?.toString() ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value
|
||||
? Number.parseInt(e.target.value, 10)
|
||||
: null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Total number of requests allowed (leave empty for
|
||||
unlimited)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="refillAmount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Refill Amount</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Amount to refill"
|
||||
value={field.value?.toString() ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value
|
||||
? Number.parseInt(e.target.value, 10)
|
||||
: null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Number of requests to add on each refill
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="refillInterval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Refill Interval</FormLabel>
|
||||
<Select
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Number.parseInt(value, 10))
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select refill interval" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{REFILL_INTERVAL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
How often to refill the request limit
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Generate</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key Generated Successfully</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please copy your API key now. You won't be able to see it again!
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded-md bg-muted p-4 font-mono text-sm break-all">
|
||||
{newApiKey}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(newApiKey);
|
||||
toast.success("API key copied to clipboard");
|
||||
}}
|
||||
>
|
||||
Copy to Clipboard
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowSuccessModal(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { ExternalLinkIcon, KeyIcon, Trash2, Clock, Tag } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { AddApiKey } from "./add-api-key";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const ShowApiKeys = () => {
|
||||
const { data, refetch } = api.user.get.useQuery();
|
||||
const { mutateAsync: deleteApiKey, isLoading: isLoadingDelete } =
|
||||
api.user.deleteApiKey.useMutation();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<KeyIcon className="size-5" />
|
||||
API/CLI Keys
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Generate and manage API keys to access the API/CLI
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 max-sm:flex-wrap items-end">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Swagger API:
|
||||
</span>
|
||||
<Link
|
||||
href="/swagger"
|
||||
target="_blank"
|
||||
className="flex flex-row gap-2 items-center"
|
||||
>
|
||||
<span className="text-sm font-medium">View</span>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{data?.user.apiKeys && data.user.apiKeys.length > 0 ? (
|
||||
data.user.apiKeys.map((apiKey) => (
|
||||
<div
|
||||
key={apiKey.id}
|
||||
className="flex flex-col gap-2 p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">{apiKey.name}</span>
|
||||
<div className="flex flex-wrap gap-2 items-center text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3.5" />
|
||||
Created{" "}
|
||||
{formatDistanceToNow(new Date(apiKey.createdAt))}{" "}
|
||||
ago
|
||||
</span>
|
||||
{apiKey.prefix && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Tag className="size-3.5" />
|
||||
{apiKey.prefix}
|
||||
</Badge>
|
||||
)}
|
||||
{apiKey.expiresAt && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clock className="size-3.5" />
|
||||
Expires in{" "}
|
||||
{formatDistanceToNow(
|
||||
new Date(apiKey.expiresAt),
|
||||
)}{" "}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogAction
|
||||
title="Delete API Key"
|
||||
description="Are you sure you want to delete this API key? This action cannot be undone."
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteApiKey({
|
||||
apiKeyId: apiKey.id,
|
||||
});
|
||||
await refetch();
|
||||
toast.success("API key deleted successfully");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error deleting API key",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
isLoading={isLoadingDelete}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<KeyIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No API keys found
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generate new API key */}
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<AddApiKey />
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
const stripePromise = loadStripe(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
@@ -38,8 +38,8 @@ export const calculatePrice = (count: number, isAnnual = false) => {
|
||||
return count * 3.5;
|
||||
};
|
||||
export const ShowBilling = () => {
|
||||
const { data: servers } = api.server.all.useQuery(undefined);
|
||||
const { data: admin } = api.admin.one.useQuery();
|
||||
const { data: servers } = api.server.count.useQuery();
|
||||
const { data: admin } = api.user.get.useQuery();
|
||||
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
||||
const { mutateAsync: createCheckoutSession } =
|
||||
api.stripe.createCheckoutSession.useMutation();
|
||||
@@ -70,8 +70,8 @@ export const ShowBilling = () => {
|
||||
return isAnnual ? interval === "year" : interval === "month";
|
||||
});
|
||||
|
||||
const maxServers = admin?.serversQuantity ?? 1;
|
||||
const percentage = ((servers?.length ?? 0) / maxServers) * 100;
|
||||
const maxServers = admin?.user.serversQuantity ?? 1;
|
||||
const percentage = ((servers ?? 0) / maxServers) * 100;
|
||||
const safePercentage = Math.min(percentage, 100);
|
||||
|
||||
return (
|
||||
@@ -98,17 +98,17 @@ export const ShowBilling = () => {
|
||||
<TabsTrigger value="annual">Annual</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{admin?.stripeSubscriptionId && (
|
||||
{admin?.user.stripeSubscriptionId && (
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<h3 className="text-lg font-medium">Servers Plan</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have {servers?.length} server on your plan of{" "}
|
||||
{admin?.serversQuantity} servers
|
||||
You have {servers} server on your plan of{" "}
|
||||
{admin?.user.serversQuantity} servers
|
||||
</p>
|
||||
<div>
|
||||
<Progress value={safePercentage} className="max-w-lg" />
|
||||
</div>
|
||||
{admin && admin.serversQuantity! <= servers?.length! && (
|
||||
{admin && admin.user.serversQuantity! <= (servers ?? 0) && (
|
||||
<div className="flex flex-row gap-4 p-2 bg-yellow-50 dark:bg-yellow-950 rounded-lg items-center">
|
||||
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
@@ -279,7 +279,7 @@ export const ShowBilling = () => {
|
||||
"flex flex-row items-center gap-2 mt-4",
|
||||
)}
|
||||
>
|
||||
{admin?.stripeCustomerId && (
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
|
||||
@@ -6,16 +6,15 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const ShowWelcomeDokploy = () => {
|
||||
const { data } = api.auth.get.useQuery();
|
||||
const { data } = api.user.get.useQuery();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
|
||||
|
||||
if (!isCloud || data?.rol !== "admin") {
|
||||
if (!isCloud || data?.role !== "admin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -24,14 +23,14 @@ export const ShowWelcomeDokploy = () => {
|
||||
!isLoading &&
|
||||
isCloud &&
|
||||
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
|
||||
data?.rol === "admin"
|
||||
data?.role === "owner"
|
||||
) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [isCloud, isLoading]);
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (data?.rol === "admin") {
|
||||
if (data?.role === "owner") {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
localStorage.setItem("hasSeenCloudWelcomeModal", "true"); // Establece el flag al cerrar el modal
|
||||
|
||||
@@ -86,6 +86,7 @@ export const AddCertificate = () => {
|
||||
privateKey: data.privateKey,
|
||||
autoRenew: data.autoRenew,
|
||||
serverId: data.serverId,
|
||||
organizationId: "",
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Certificate Created");
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { Boxes, HelpCircle, LockIcon, MoreHorizontal } from "lucide-react";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AddNode } from "./add-node";
|
||||
import { ShowNodeData } from "./show-node-data";
|
||||
@@ -42,8 +41,7 @@ export const ShowNodes = () => {
|
||||
const { data, isLoading, refetch } = api.cluster.getNodes.useQuery();
|
||||
const { data: registry } = api.registry.all.useQuery();
|
||||
|
||||
const { mutateAsync: deleteNode, isLoading: isRemoving } =
|
||||
api.cluster.removeWorker.useMutation();
|
||||
const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation();
|
||||
|
||||
const haveAtLeastOneRegistry = !!(registry && registry?.length > 0);
|
||||
return (
|
||||
|
||||
@@ -131,7 +131,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
serverId: data.serverId,
|
||||
registryId: registryId || "",
|
||||
})
|
||||
.then(async (data) => {
|
||||
.then(async (_data) => {
|
||||
await utils.registry.all.invalidate();
|
||||
toast.success(registryId ? "Registry updated" : "Registry added");
|
||||
setIsOpen(false);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
BitbucketIcon,
|
||||
GithubIcon,
|
||||
GitlabIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
@@ -51,10 +47,10 @@ type Schema = z.infer<typeof Schema>;
|
||||
export const AddBitbucketProvider = () => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const url = useUrl();
|
||||
const _url = useUrl();
|
||||
const { mutateAsync, error, isError } = api.bitbucket.create.useMutation();
|
||||
const { data: auth } = api.auth.get.useQuery();
|
||||
const router = useRouter();
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const _router = useRouter();
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
username: "",
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Edit, PenBoxIcon } from "lucide-react";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -10,13 +10,15 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { format } from "date-fns";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const AddGithubProvider = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data } = api.auth.get.useQuery();
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data } = api.user.get.useQuery();
|
||||
const [manifest, setManifest] = useState("");
|
||||
const [isOrganization, setIsOrganization] = useState(false);
|
||||
const [organizationName, setOrganization] = useState("");
|
||||
@@ -25,7 +27,7 @@ export const AddGithubProvider = () => {
|
||||
const url = document.location.origin;
|
||||
const manifest = JSON.stringify(
|
||||
{
|
||||
redirect_url: `${origin}/api/providers/github/setup?authId=${data?.id}`,
|
||||
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}`,
|
||||
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}`,
|
||||
url: origin,
|
||||
hook_attributes: {
|
||||
@@ -93,8 +95,8 @@ export const AddGithubProvider = () => {
|
||||
<form
|
||||
action={
|
||||
isOrganization
|
||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${data?.id}`
|
||||
: `https://github.com/settings/apps/new?state=gh_init:${data?.id}`
|
||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
||||
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
||||
}
|
||||
method="post"
|
||||
>
|
||||
|
||||
@@ -55,7 +55,7 @@ export const AddGitlabProvider = () => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const url = useUrl();
|
||||
const { data: auth } = api.auth.get.useQuery();
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { mutateAsync, error, isError } = api.gitlab.create.useMutation();
|
||||
const webhookUrl = `${url}/api/providers/gitlab/callback`;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Edit, PenBoxIcon } from "lucide-react";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -102,7 +102,7 @@ export const ShowGitProviders = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-lg ">
|
||||
{data?.map((gitProvider, index) => {
|
||||
{data?.map((gitProvider, _index) => {
|
||||
const isGithub = gitProvider.providerType === "github";
|
||||
const isGitlab = gitProvider.providerType === "gitlab";
|
||||
const isBitbucket =
|
||||
|
||||
@@ -137,7 +137,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
const { data: notification, refetch } = api.notification.one.useQuery(
|
||||
const { data: notification } = api.notification.one.useQuery(
|
||||
{
|
||||
notificationId: notificationId || "",
|
||||
},
|
||||
@@ -1060,7 +1060,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error("Error testing the provider");
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -56,7 +56,7 @@ export const ShowNotifications = () => {
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="flex flex-col gap-4 rounded-lg ">
|
||||
{data?.map((notification, index) => (
|
||||
{data?.map((notification, _index) => (
|
||||
<div
|
||||
key={notification.notificationId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
|
||||
@@ -1,52 +1,134 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
password: z.string().min(8, {
|
||||
message: "Password is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type PasswordForm = z.infer<typeof PasswordSchema>;
|
||||
|
||||
export const Disable2FA = () => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, isLoading } = api.auth.disable2FA.useMutation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const form = useForm<PasswordForm>({
|
||||
resolver: zodResolver(PasswordSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (formData: PasswordForm) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await authClient.twoFactor.disable({
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
form.setError("password", {
|
||||
message: result.error.message,
|
||||
});
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("2FA disabled successfully");
|
||||
utils.user.get.invalidate();
|
||||
setIsOpen(false);
|
||||
} catch (_error) {
|
||||
form.setError("password", {
|
||||
message: "Connection error. Please try again.",
|
||||
});
|
||||
toast.error("Connection error. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" isLoading={isLoading}>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
<Button variant="destructive">Disable 2FA</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the 2FA
|
||||
This action cannot be undone. This will permanently disable
|
||||
Two-Factor Authentication for your account.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync()
|
||||
.then(() => {
|
||||
utils.auth.get.invalidate();
|
||||
toast.success("2FA Disabled");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error disabling 2FA");
|
||||
});
|
||||
}}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your password to disable 2FA
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" isLoading={isLoading}>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -17,144 +16,309 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, Fingerprint } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Fingerprint, QrCode } from "lucide-react";
|
||||
import QRCode from "qrcode";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const Enable2FASchema = z.object({
|
||||
const PasswordSchema = z.object({
|
||||
password: z.string().min(8, {
|
||||
message: "Password is required",
|
||||
}),
|
||||
});
|
||||
|
||||
const PinSchema = z.object({
|
||||
pin: z.string().min(6, {
|
||||
message: "Pin is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type Enable2FA = z.infer<typeof Enable2FASchema>;
|
||||
type TwoFactorSetupData = {
|
||||
qrCodeUrl: string;
|
||||
secret: string;
|
||||
totpURI: string;
|
||||
};
|
||||
|
||||
type PasswordForm = z.infer<typeof PasswordSchema>;
|
||||
type PinForm = z.infer<typeof PinSchema>;
|
||||
|
||||
export const Enable2FA = () => {
|
||||
const utils = api.useUtils();
|
||||
const [data, setData] = useState<TwoFactorSetupData | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [step, setStep] = useState<"password" | "verify">("password");
|
||||
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||
|
||||
const { data } = api.auth.generate2FASecret.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
const handlePasswordSubmit = async (formData: PasswordForm) => {
|
||||
setIsPasswordLoading(true);
|
||||
try {
|
||||
const { data: enableData } = await authClient.twoFactor.enable({
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
if (!enableData) {
|
||||
throw new Error("No data received from server");
|
||||
}
|
||||
|
||||
if (enableData.backupCodes) {
|
||||
setBackupCodes(enableData.backupCodes);
|
||||
}
|
||||
|
||||
if (enableData.totpURI) {
|
||||
const qrCodeUrl = await QRCode.toDataURL(enableData.totpURI);
|
||||
|
||||
setData({
|
||||
qrCodeUrl,
|
||||
secret: enableData.totpURI.split("secret=")[1]?.split("&")[0] || "",
|
||||
totpURI: enableData.totpURI,
|
||||
});
|
||||
|
||||
setStep("verify");
|
||||
toast.success("Scan the QR code with your authenticator app");
|
||||
} else {
|
||||
throw new Error("No TOTP URI received from server");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error setting up 2FA",
|
||||
);
|
||||
passwordForm.setError("password", {
|
||||
message: "Error verifying password",
|
||||
});
|
||||
} finally {
|
||||
setIsPasswordLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifySubmit = async (formData: PinForm) => {
|
||||
try {
|
||||
const result = await authClient.twoFactor.verifyTotp({
|
||||
code: formData.pin,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
|
||||
pinForm.setError("pin", {
|
||||
message: "Invalid code. Please try again.",
|
||||
});
|
||||
toast.error("Invalid verification code");
|
||||
return;
|
||||
}
|
||||
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error("No response received from server");
|
||||
}
|
||||
|
||||
toast.success("2FA configured successfully");
|
||||
utils.user.get.invalidate();
|
||||
setIsDialogOpen(false);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
const errorMessage =
|
||||
error.message === "Failed to fetch"
|
||||
? "Connection error. Please check your internet connection."
|
||||
: error.message;
|
||||
|
||||
pinForm.setError("pin", {
|
||||
message: errorMessage,
|
||||
});
|
||||
toast.error(errorMessage);
|
||||
} else {
|
||||
pinForm.setError("pin", {
|
||||
message: "Error verifying code",
|
||||
});
|
||||
toast.error("Error verifying 2FA code");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const passwordForm = useForm<PasswordForm>({
|
||||
resolver: zodResolver(PasswordSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.auth.verify2FASetup.useMutation();
|
||||
|
||||
const form = useForm<Enable2FA>({
|
||||
const pinForm = useForm<PinForm>({
|
||||
resolver: zodResolver(PinSchema),
|
||||
defaultValues: {
|
||||
pin: "",
|
||||
},
|
||||
resolver: zodResolver(Enable2FASchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
pin: "",
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
if (!isDialogOpen) {
|
||||
setStep("password");
|
||||
setData(null);
|
||||
setBackupCodes([]);
|
||||
passwordForm.reset();
|
||||
pinForm.reset();
|
||||
}
|
||||
}, [isDialogOpen, passwordForm, pinForm]);
|
||||
|
||||
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 verifying the 2FA");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<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 ">
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>2FA Setup</DialogTitle>
|
||||
<DialogDescription>Add a 2FA to your account</DialogDescription>
|
||||
<DialogDescription>
|
||||
{step === "password"
|
||||
? "Enter your password to begin 2FA setup"
|
||||
: "Scan the QR code and verify with your authenticator app"}
|
||||
</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"
|
||||
{step === "password" ? (
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
id="password-form"
|
||||
onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
Submit 2FA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your password to enable 2FA
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isPasswordLoading}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<Form {...pinForm}>
|
||||
<form
|
||||
id="pin-form"
|
||||
onSubmit={pinForm.handleSubmit(handleVerifySubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="flex flex-col gap-6 justify-center items-center">
|
||||
{data?.qrCodeUrl ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4 p-6 border rounded-lg">
|
||||
<QrCode className="size-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
Scan this QR code with your authenticator app
|
||||
</span>
|
||||
<img
|
||||
src={data.qrCodeUrl}
|
||||
alt="2FA QR Code"
|
||||
className="rounded-lg w-48 h-48"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Can't scan the QR code?
|
||||
</span>
|
||||
<span className="text-xs font-mono bg-muted p-2 rounded">
|
||||
{data.secret}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className="w-full space-y-3 border rounded-lg p-4">
|
||||
<h4 className="font-medium">Backup Codes</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<code
|
||||
key={index}
|
||||
className="bg-muted p-2 rounded text-sm font-mono"
|
||||
>
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Save these backup codes in a secure place. You can use
|
||||
them to access your account if you lose access to your
|
||||
authenticator device.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-48 bg-muted rounded-lg">
|
||||
<QrCode className="size-8 text-muted-foreground animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={pinForm.control}
|
||||
name="pin"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-center items-center">
|
||||
<FormLabel>Verification Code</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>
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isPasswordLoading}
|
||||
>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const GenerateToken = () => {
|
||||
const { data, refetch } = api.auth.get.useQuery();
|
||||
|
||||
const { mutateAsync: generateToken, isLoading: isLoadingToken } =
|
||||
api.auth.generateToken.useMutation();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl">API/CLI</CardTitle>
|
||||
<CardDescription>
|
||||
Generate a token to access the API/CLI
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 max-sm:flex-wrap items-end">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Swagger API:
|
||||
</span>
|
||||
<Link
|
||||
href="/swagger"
|
||||
target="_blank"
|
||||
className="flex flex-row gap-2 items-center"
|
||||
>
|
||||
<span className="text-sm font-medium">View</span>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex flex-row gap-2 max-sm:flex-wrap justify-end items-end">
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Token</Label>
|
||||
<ToggleVisibilityInput
|
||||
placeholder="Token"
|
||||
value={data?.token || ""}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
isLoading={isLoadingToken}
|
||||
onClick={async () => {
|
||||
await generateToken().then(() => {
|
||||
refetch();
|
||||
toast.success("Token generated");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -54,13 +54,14 @@ const randomImages = [
|
||||
];
|
||||
|
||||
export const ProfileForm = () => {
|
||||
const { data, refetch, isLoading } = api.auth.get.useQuery();
|
||||
const _utils = api.useUtils();
|
||||
const { data, refetch, isLoading } = api.user.get.useQuery();
|
||||
const {
|
||||
mutateAsync,
|
||||
isLoading: isUpdating,
|
||||
isError,
|
||||
error,
|
||||
} = api.auth.update.useMutation();
|
||||
} = api.user.update.useMutation();
|
||||
const { t } = useTranslation("settings");
|
||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||
|
||||
@@ -73,9 +74,9 @@ export const ProfileForm = () => {
|
||||
|
||||
const form = useForm<Profile>({
|
||||
defaultValues: {
|
||||
email: data?.email || "",
|
||||
email: data?.user?.email || "",
|
||||
password: "",
|
||||
image: data?.image || "",
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: "",
|
||||
},
|
||||
resolver: zodResolver(profileSchema),
|
||||
@@ -84,14 +85,14 @@ export const ProfileForm = () => {
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
email: data?.email || "",
|
||||
email: data?.user?.email || "",
|
||||
password: "",
|
||||
image: data?.image || "",
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: "",
|
||||
});
|
||||
|
||||
if (data.email) {
|
||||
generateSHA256Hash(data.email).then((hash) => {
|
||||
if (data.user.email) {
|
||||
generateSHA256Hash(data.user.email).then((hash) => {
|
||||
setGravatarHash(hash);
|
||||
});
|
||||
}
|
||||
@@ -102,9 +103,9 @@ export const ProfileForm = () => {
|
||||
const onSubmit = async (values: Profile) => {
|
||||
await mutateAsync({
|
||||
email: values.email.toLowerCase(),
|
||||
password: values.password,
|
||||
password: values.password || undefined,
|
||||
image: values.image,
|
||||
currentPassword: values.currentPassword,
|
||||
currentPassword: values.currentPassword || undefined,
|
||||
})
|
||||
.then(async () => {
|
||||
await refetch();
|
||||
@@ -130,7 +131,7 @@ export const ProfileForm = () => {
|
||||
{t("settings.profile.description")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
|
||||
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Disable2FA />}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
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 { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const profileSchema = z.object({
|
||||
password: z.string().min(1, {
|
||||
message: "Password is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type Profile = z.infer<typeof profileSchema>;
|
||||
|
||||
export const RemoveSelfAccount = () => {
|
||||
const { data } = api.auth.get.useQuery();
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.auth.removeSelfAccount.useMutation();
|
||||
const { t } = useTranslation("settings");
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<Profile>({
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
resolver: zodResolver(profileSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
password: "",
|
||||
});
|
||||
}
|
||||
form.reset();
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (values: Profile) => {
|
||||
await mutateAsync({
|
||||
password: values.password,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Profile Deleted");
|
||||
router.push("/");
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Remove Self Account</CardTitle>
|
||||
<CardDescription>
|
||||
If you want to remove your account, you can do it here
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("settings.profile.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("settings.profile.password")}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
<div className="flex justify-end pt-2">
|
||||
<DialogAction
|
||||
title="Are you sure you want to remove your account?"
|
||||
description="This action cannot be undone, all your projects/servers will be deleted."
|
||||
onClick={() => form.handleSubmit(onSubmit)()}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
isLoading={isLoading}
|
||||
variant="destructive"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
|
||||
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -27,7 +26,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
isLoading: cleanDockerBuilderIsLoading,
|
||||
} = api.settings.cleanDockerBuilder.useMutation();
|
||||
|
||||
const { mutateAsync: cleanMonitoring, isLoading: cleanMonitoringIsLoading } =
|
||||
const { mutateAsync: cleanMonitoring } =
|
||||
api.settings.cleanMonitoring.useMutation();
|
||||
const {
|
||||
mutateAsync: cleanUnusedImages,
|
||||
|
||||
+1
-13
@@ -1,14 +1,4 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,10 +10,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
|
||||
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
|
||||
import { ShowModalLogs } from "../../web-server/show-modal-logs";
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
const { data, refetch } = api.admin.one.useQuery(undefined, {
|
||||
const { data, refetch } = api.user.get.useQuery(undefined, {
|
||||
enabled: !serverId,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const enabled = data?.enableDockerCleanup || server?.enableDockerCleanup;
|
||||
const enabled = data?.user.enableDockerCleanup || server?.enableDockerCleanup;
|
||||
|
||||
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
|
||||
|
||||
@@ -36,7 +36,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
await refetch();
|
||||
}
|
||||
toast.success("Docker Cleanup updated");
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
toast.error("Docker Cleanup Error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,7 +82,7 @@ export const EditScript = ({ serverId }: Props) => {
|
||||
command: formData.command || "",
|
||||
serverId,
|
||||
})
|
||||
.then((data) => {
|
||||
.then((_data) => {
|
||||
toast.success("Script modified successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
import { CheckCircle2, Cpu, Loader2, RefreshCw, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -57,7 +56,7 @@ export function GPUSupport({ serverId }: GPUSupportProps) {
|
||||
try {
|
||||
await utils.settings.checkGPUStatus.invalidate({ serverId });
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
toast.error("Failed to refresh GPU status");
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
@@ -75,7 +74,7 @@ export function GPUSupport({ serverId }: GPUSupportProps) {
|
||||
|
||||
try {
|
||||
await setupGPU.mutateAsync({ serverId });
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Error handling is done in mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,7 +118,7 @@ export const HandleServers = ({ serverId }: Props) => {
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
serverId: serverId || "",
|
||||
})
|
||||
.then(async (data) => {
|
||||
.then(async (_data) => {
|
||||
await utils.server.all.invalidate();
|
||||
refetchServer();
|
||||
toast.success(serverId ? "Server Updated" : "Server Created");
|
||||
|
||||
@@ -25,7 +25,7 @@ export const SecurityAudit = ({ serverId }: Props) => {
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
const utils = api.useUtils();
|
||||
const _utils = api.useUtils();
|
||||
return (
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -145,15 +145,6 @@ export const SecurityAudit = ({ serverId }: Props) => {
|
||||
: "Enabled (Password Authentication should be disabled)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Permit Root Login"
|
||||
isEnabled={data?.ssh.permitRootLogin === "no"}
|
||||
description={
|
||||
data?.ssh.permitRootLogin === "no"
|
||||
? "Disabled (Recommended)"
|
||||
: `Enabled: ${data?.ssh.permitRootLogin} (Root Login should be disabled)`
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Use PAM"
|
||||
isEnabled={data?.ssh.usePam === "no"}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { extractServices } from "@/pages/dashboard/project/[projectId]";
|
||||
import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
@@ -82,7 +80,7 @@ const Schema = z.object({
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
export const SetupMonitoring = ({ serverId }: Props) => {
|
||||
const { data, isLoading } = serverId
|
||||
const { data } = serverId
|
||||
? api.server.one.useQuery(
|
||||
{
|
||||
serverId: serverId || "",
|
||||
@@ -91,7 +89,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
|
||||
enabled: !!serverId,
|
||||
},
|
||||
)
|
||||
: api.admin.one.useQuery();
|
||||
: api.user.getServerMetrics.useQuery();
|
||||
|
||||
const url = useUrl();
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { useState } from "react";
|
||||
import { ShowContainers } from "../../docker/show/show-containers";
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { ContainerIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import SwarmMonitorCard from "../../swarm/monitoring-card";
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ValidateServer = ({ serverId }: Props) => {
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
const utils = api.useUtils();
|
||||
const _utils = api.useUtils();
|
||||
return (
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -52,10 +52,10 @@ interface Props {
|
||||
|
||||
export const CreateServer = ({ stepper }: Props) => {
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, _setIsOpen] = useState(false);
|
||||
const { data: canCreateMoreServers, refetch } =
|
||||
api.stripe.canCreateMoreServers.useQuery();
|
||||
const { mutateAsync, error, isError } = api.server.create.useMutation();
|
||||
const { mutateAsync } = api.server.create.useMutation();
|
||||
const cloudSSHKey = sshKeys?.find(
|
||||
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
|
||||
);
|
||||
@@ -96,7 +96,7 @@ export const CreateServer = ({ stepper }: Props) => {
|
||||
username: data.username || "root",
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
})
|
||||
.then(async (data) => {
|
||||
.then(async (_data) => {
|
||||
toast.success("Server Created");
|
||||
stepper.next();
|
||||
})
|
||||
|
||||
@@ -35,6 +35,7 @@ export const CreateSSHKey = () => {
|
||||
description: "Used on Dokploy Cloud",
|
||||
privateKey: keys.privateKey,
|
||||
publicKey: keys.publicKey,
|
||||
organizationId: "",
|
||||
});
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
|
||||
@@ -37,15 +37,6 @@ export const Verify = () => {
|
||||
);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const { data: server } = api.server.one.useQuery(
|
||||
{
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -78,6 +78,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
|
||||
const onSubmit = async (data: SSHKey) => {
|
||||
await mutateAsync({
|
||||
...data,
|
||||
organizationId: "",
|
||||
sshKeyId: sshKeyId || "",
|
||||
})
|
||||
.then(async () => {
|
||||
|
||||
+69
-24
@@ -19,6 +19,14 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
@@ -27,62 +35,70 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const addUser = z.object({
|
||||
const addInvitation = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "Email is required")
|
||||
.email({ message: "Invalid email" }),
|
||||
role: z.enum(["member", "admin"]),
|
||||
});
|
||||
|
||||
type AddUser = z.infer<typeof addUser>;
|
||||
type AddInvitation = z.infer<typeof addInvitation>;
|
||||
|
||||
export const AddUser = () => {
|
||||
export const AddInvitation = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } =
|
||||
api.admin.createUserInvitation.useMutation();
|
||||
|
||||
const form = useForm<AddUser>({
|
||||
const form = useForm<AddInvitation>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
role: "member",
|
||||
},
|
||||
resolver: zodResolver(addUser),
|
||||
resolver: zodResolver(addInvitation),
|
||||
});
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
||||
|
||||
const onSubmit = async (data: AddUser) => {
|
||||
await mutateAsync({
|
||||
const onSubmit = async (data: AddInvitation) => {
|
||||
setIsLoading(true);
|
||||
const result = await authClient.organization.inviteMember({
|
||||
email: data.email.toLowerCase(),
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Invitation created");
|
||||
await utils.user.all.invalidate();
|
||||
setOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error creating the invitation");
|
||||
});
|
||||
role: data.role,
|
||||
organizationId: activeOrganization?.id,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message || "");
|
||||
} else {
|
||||
toast.success("Invitation created");
|
||||
setError(null);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
utils.organization.allInvitations.invalidate();
|
||||
setIsLoading(false);
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button>
|
||||
<PlusIcon className="h-4 w-4" /> Add User
|
||||
<PlusIcon className="h-4 w-4" /> Add Invitation
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add User</DialogTitle>
|
||||
<DialogTitle>Add Invitation</DialogTitle>
|
||||
<DialogDescription>Invite a new user</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
{error && <AlertBlock type="error">{error}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-user"
|
||||
id="hook-form-add-invitation"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
@@ -104,10 +120,39 @@ export const AddUser = () => {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the role for the new user
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<DialogFooter className="flex w-full flex-row">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-add-user"
|
||||
form="hook-form-add-invitation"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
@@ -52,7 +52,7 @@ interface Props {
|
||||
export const AddUserPermissions = ({ userId }: Props) => {
|
||||
const { data: projects } = api.project.all.useQuery();
|
||||
|
||||
const { data, refetch } = api.user.byUserId.useQuery(
|
||||
const { data, refetch } = api.user.one.useQuery(
|
||||
{
|
||||
userId,
|
||||
},
|
||||
@@ -62,7 +62,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
|
||||
);
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } =
|
||||
api.admin.assignPermissions.useMutation();
|
||||
api.user.assignPermissions.useMutation();
|
||||
|
||||
const form = useForm<AddPermissions>({
|
||||
defaultValues: {
|
||||
@@ -92,7 +92,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
|
||||
|
||||
const onSubmit = async (data: AddPermissions) => {
|
||||
await mutateAsync({
|
||||
userId,
|
||||
id: userId,
|
||||
canCreateServices: data.canCreateServices,
|
||||
canCreateProjects: data.canCreateProjects,
|
||||
canDeleteServices: data.canDeleteServices,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { format, isPast } from "date-fns";
|
||||
import { Mail, MoreHorizontal, Users } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AddInvitation } from "./add-invitation";
|
||||
|
||||
export const ShowInvitations = () => {
|
||||
const { data, isLoading, refetch } =
|
||||
api.organization.allInvitations.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Mail className="size-6 text-muted-foreground self-center" />
|
||||
Invitations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Create invitations to your organization.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<Users className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
Invite users to your organization
|
||||
</span>
|
||||
<AddInvitation />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<Table>
|
||||
<TableCaption>See all invitations</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Email</TableHead>
|
||||
<TableHead className="text-center">Role</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
<TableHead className="text-center">
|
||||
Expires At
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((invitation) => {
|
||||
const isExpired = isPast(
|
||||
new Date(invitation.expiresAt),
|
||||
);
|
||||
return (
|
||||
<TableRow key={invitation.id}>
|
||||
<TableCell className="w-[100px]">
|
||||
{invitation.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={
|
||||
invitation.role === "owner"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{invitation.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={
|
||||
invitation.status === "pending"
|
||||
? "secondary"
|
||||
: invitation.status === "canceled"
|
||||
? "destructive"
|
||||
: "default"
|
||||
}
|
||||
>
|
||||
{invitation.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{format(new Date(invitation.expiresAt), "PPpp")}{" "}
|
||||
{isExpired ? (
|
||||
<span className="text-muted-foreground">
|
||||
(Expired)
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
{!isExpired && (
|
||||
<>
|
||||
{invitation.status === "pending" && (
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(_e) => {
|
||||
copy(
|
||||
`${origin}/invitation?token=${invitation.id}`,
|
||||
);
|
||||
toast.success(
|
||||
"Invitation Copied to clipboard",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Copy Invitation
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{invitation.status === "pending" && (
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={async (_e) => {
|
||||
const result =
|
||||
await authClient.organization.cancelInvitation(
|
||||
{
|
||||
invitationId: invitation.id,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
result.error.message,
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
"Invitation deleted",
|
||||
);
|
||||
refetch();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel Invitation
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<AddInvitation />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -23,22 +24,19 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { format } from "date-fns";
|
||||
import { MoreHorizontal, Users } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AddUserPermissions } from "./add-permissions";
|
||||
import { AddUser } from "./add-user";
|
||||
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export const ShowUsers = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isLoading, refetch } = api.user.all.useQuery();
|
||||
const { mutateAsync, isLoading: isRemoving } =
|
||||
api.admin.removeUser.useMutation();
|
||||
const { mutateAsync } = api.user.remove.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -67,7 +65,6 @@ export const ShowUsers = () => {
|
||||
<span className="text-base text-muted-foreground">
|
||||
Invite users to your Dokploy account
|
||||
</span>
|
||||
<AddUser />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
@@ -76,43 +73,41 @@ export const ShowUsers = () => {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Email</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
<TableHead className="text-center">Role</TableHead>
|
||||
<TableHead className="text-center">2FA</TableHead>
|
||||
|
||||
<TableHead className="text-center">
|
||||
Expiration
|
||||
Created At
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((user) => {
|
||||
{data?.map((member) => {
|
||||
return (
|
||||
<TableRow key={user.userId}>
|
||||
<TableRow key={member.id}>
|
||||
<TableCell className="w-[100px]">
|
||||
{user.auth.email}
|
||||
{member.user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={
|
||||
user.isRegistered ? "default" : "secondary"
|
||||
member.role === "owner"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{user.isRegistered
|
||||
? "Registered"
|
||||
: "Not Registered"}
|
||||
{member.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{user.auth.is2FAEnabled
|
||||
? "2FA Enabled"
|
||||
: "2FA Not Enabled"}
|
||||
{member.user.twoFactorEnabled
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{format(
|
||||
new Date(user.expirationDate),
|
||||
"PPpp",
|
||||
)}
|
||||
{format(new Date(member.createdAt), "PPpp")}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -131,56 +126,110 @@ export const ShowUsers = () => {
|
||||
<DropdownMenuLabel>
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
{!user.isRegistered && (
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => {
|
||||
copy(
|
||||
`${origin}/invitation?token=${user.token}`,
|
||||
);
|
||||
toast.success(
|
||||
"Invitation Copied to clipboard",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Copy Invitation
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{user.isRegistered && (
|
||||
{member.role !== "owner" && (
|
||||
<AddUserPermissions
|
||||
userId={user.userId}
|
||||
userId={member.user.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogAction
|
||||
title="Delete User"
|
||||
description="Are you sure you want to delete this user?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
authId: user.authId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"User deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting destination",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Delete User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
{member.role !== "owner" && (
|
||||
<>
|
||||
{!isCloud && (
|
||||
<DialogAction
|
||||
title="Delete User"
|
||||
description="Are you sure you want to delete this user?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"User deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting destination",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
Delete User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
<DialogAction
|
||||
title="Unlink User"
|
||||
description="Are you sure you want to unlink this user?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
if (!isCloud) {
|
||||
const orgCount =
|
||||
await utils.user.checkUserOrganizations.fetch(
|
||||
{
|
||||
userId: member.user.id,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(orgCount);
|
||||
|
||||
if (orgCount === 1) {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"User deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting user",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { error } =
|
||||
await authClient.organization.removeMember(
|
||||
{
|
||||
memberIdOrEmail: member.id,
|
||||
},
|
||||
);
|
||||
|
||||
if (!error) {
|
||||
toast.success(
|
||||
"User unlinked successfully",
|
||||
);
|
||||
refetch();
|
||||
} else {
|
||||
toast.error(
|
||||
"Error unlinking user",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Unlink User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
@@ -189,10 +238,6 @@ export const ShowUsers = () => {
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<AddUser />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { GlobeIcon, ServerIcon, User } from "lucide-react";
|
||||
import { GlobeIcon } from "lucide-react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -52,7 +52,7 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
|
||||
|
||||
export const WebDomain = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { data: user, refetch } = api.admin.one.useQuery();
|
||||
const { data, refetch } = api.user.get.useQuery();
|
||||
const { mutateAsync, isLoading } =
|
||||
api.settings.assignDomainServer.useMutation();
|
||||
|
||||
@@ -65,14 +65,14 @@ export const WebDomain = () => {
|
||||
resolver: zodResolver(addServerDomain),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
if (data) {
|
||||
form.reset({
|
||||
domain: user?.host || "",
|
||||
certificateType: user?.certificateType,
|
||||
letsEncryptEmail: user?.letsEncryptEmail || "",
|
||||
domain: data?.user?.host || "",
|
||||
certificateType: data?.user?.certificateType,
|
||||
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, user]);
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: AddServerDomain) => {
|
||||
await mutateAsync({
|
||||
|
||||
@@ -5,23 +5,18 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { ServerIcon } from "lucide-react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import React from "react";
|
||||
import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
|
||||
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
|
||||
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";
|
||||
import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
|
||||
import { UpdateServer } from "./web-server/update-server";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export const WebServer = ({ className }: Props) => {
|
||||
export const WebServer = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { data } = api.admin.one.useQuery();
|
||||
const { data } = api.user.get.useQuery();
|
||||
|
||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||
|
||||
@@ -58,7 +53,7 @@ export const WebServer = ({ className }: Props) => {
|
||||
|
||||
<div className="flex items-center flex-wrap justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Server IP: {data?.serverIp}
|
||||
Server IP: {data?.user.serverIp}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Version: {dokployVersion}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Settings } from "lucide-react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
});
|
||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
toast.error(t("settings.server.webServer.traefik.portsUpdateError"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -44,18 +43,18 @@ interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const UpdateServerIp = ({ children, serverId }: Props) => {
|
||||
export const UpdateServerIp = ({ children }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data } = api.admin.one.useQuery();
|
||||
const { data } = api.user.get.useQuery();
|
||||
const { data: ip } = api.server.publicIp.useQuery();
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.admin.update.useMutation();
|
||||
api.user.update.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
serverIp: data?.serverIp || "",
|
||||
serverIp: data?.user.serverIp || "",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
@@ -63,7 +62,7 @@ export const UpdateServerIp = ({ children, serverId }: Props) => {
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
serverIp: data.serverIp || "",
|
||||
serverIp: data.user.serverIp || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
@@ -81,7 +80,7 @@ export const UpdateServerIp = ({ children, serverId }: Props) => {
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Server IP Updated");
|
||||
await utils.admin.one.invalidate();
|
||||
await utils.user.get.invalidate();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
Info,
|
||||
RefreshCcw,
|
||||
Server,
|
||||
ServerCrash,
|
||||
Sparkles,
|
||||
Stars,
|
||||
} from "lucide-react";
|
||||
|
||||
Reference in New Issue
Block a user