Compare commits

...

82 Commits

Author SHA1 Message Date
Mauricio Siu
790894ab93 refactor: migrate admin API calls to user router 2025-02-20 23:02:02 -06:00
Mauricio Siu
5a1145996d feat: add backup code authentication for 2FA login 2025-02-20 01:50:01 -06:00
Mauricio Siu
a9e12c2b18 refactor: update organization context in API routers 2025-02-20 01:42:35 -06:00
Mauricio Siu
b73e4102dd feat: add organizations and members 2025-02-17 02:48:42 -06:00
Mauricio Siu
c7d47a6003 refactor: update database foreign key constraints and user management 2025-02-17 00:30:15 -06:00
Mauricio Siu
8c28223343 refactor: remove 2fa migration 2025-02-17 00:10:34 -06:00
Mauricio Siu
7abe060fcf feat: enhance two-factor authentication and auth client implementation 2025-02-17 00:07:36 -06:00
Mauricio Siu
0e8e92c715 refactor: add 2fa 2025-02-16 20:56:50 -06:00
Mauricio Siu
e1632cbdb3 refactor: update user and authentication schema with two-factor support 2025-02-16 15:32:57 -06:00
Mauricio Siu
90156da570 refactor: remove tables 2025-02-16 14:11:47 -06:00
Mauricio Siu
9856502ece refactor: remove old references 2025-02-16 13:55:27 -06:00
Mauricio Siu
a8d1471b16 refactor: update 2025-02-16 13:28:29 -06:00
Mauricio Siu
27736c7c97 refactor: update role and validation handling across multiple pages 2025-02-16 03:06:22 -06:00
Mauricio Siu
e7db0ccb70 refactor: update invitation 2025-02-16 02:57:49 -06:00
Mauricio Siu
4a1a14aeb4 refactor: update 2025-02-15 23:24:45 -06:00
Mauricio Siu
ed62b4e1a3 refactor: lint 2025-02-15 23:01:44 -06:00
Mauricio Siu
515d65d993 refactor: adjust queries 2025-02-15 23:01:36 -06:00
Mauricio Siu
78c72b6337 refactor: update 2025-02-15 20:49:10 -06:00
Mauricio Siu
e3e35ce792 refactor: update to use organization resources 2025-02-15 20:43:23 -06:00
Mauricio Siu
6d0e195a4d refactor: update 2025-02-15 20:26:05 -06:00
Mauricio Siu
53ce5e57fa refactor: update organization 2025-02-15 20:25:58 -06:00
Mauricio Siu
87b12ff6e9 refactor: update 2025-02-15 20:06:33 -06:00
Mauricio Siu
8b71f963cc refactor: update logic 2025-02-15 19:35:22 -06:00
Mauricio Siu
1c5cc5a0db refactor: update roles 2025-02-15 19:23:08 -06:00
Mauricio Siu
d233f2c764 feat: adjust roles 2025-02-15 19:12:44 -06:00
Mauricio Siu
1bbb4c9b64 refactor: update migration 2025-02-15 18:13:20 -06:00
Mauricio Siu
6ec60b6bab refactor: update validation 2025-02-15 13:14:48 -06:00
Mauricio Siu
55abac3f2f refactor: migrate endpoints 2025-02-14 02:52:37 -06:00
Mauricio Siu
b6c29ccf05 refactor: update 2025-02-14 02:40:11 -06:00
Mauricio Siu
ca217affe6 feat: update references 2025-02-14 02:18:53 -06:00
Mauricio Siu
5c24281f72 refactor: return correct information 2025-02-13 02:45:33 -06:00
Mauricio Siu
bc901bcb25 refactor: update 2025-02-13 02:36:08 -06:00
Mauricio Siu
7c0d223e17 refactor: add fields 2025-02-13 01:42:58 -06:00
Mauricio Siu
74ee024cf9 refactor: update temps 2025-02-13 01:24:25 -06:00
Mauricio Siu
140a871275 refactor: update 2025-02-13 01:21:49 -06:00
Mauricio Siu
d1f72a2e20 refactor: update migration 2025-02-13 00:57:22 -06:00
Mauricio Siu
0d525398a8 feat: migrate rest schemas 2025-02-13 00:45:29 -06:00
Mauricio Siu
7c62408070 refactor: delete 2025-02-13 00:38:39 -06:00
Mauricio Siu
23f1ce17de refactor: add migration 2025-02-13 00:38:22 -06:00
Mauricio Siu
60eee55f2d refactor: test migrastion 2025-02-12 23:41:04 -06:00
Mauricio Siu
8f562eefc1 Merge branch 'canary' into feat/better-auth 2025-02-12 20:56:23 -06:00
Mauricio Siu
6179cef1ee refactor: update name 2025-02-10 02:13:52 -06:00
Mauricio Siu
b7112b89fd refactor: add migration 2025-02-10 00:39:46 -06:00
Mauricio Siu
030c8a312d Update package.json 2025-02-10 00:24:58 -06:00
Mauricio Siu
1db6ba94f4 refactor: remove 2025-02-09 21:36:36 -06:00
Mauricio Siu
afd3d2eea3 refactor: lint 2025-02-09 20:53:14 -06:00
Mauricio Siu
8bd72a8a34 refactor: add organizations system 2025-02-09 20:53:06 -06:00
Mauricio Siu
fafc238e70 refactor: migration 2025-02-09 18:56:17 -06:00
Mauricio Siu
c04bf3c7e0 feat: add migration 2025-02-09 18:19:21 -06:00
Mauricio Siu
6b9fd596e5 feat: add openalternative 2025-02-09 03:17:13 -06:00
Mauricio Siu
7e36433144 Merge pull request #1282 from wish-oss/feat/bulk-actions
feat: added bulk actions for services start and stop and added service status for domain dropdown
2025-02-09 03:07:01 -06:00
Mauricio Siu
0a6554c275 refactor: add loading action 2025-02-09 03:06:18 -06:00
Mauricio Siu
fcc55355f2 refactor: add catch to prevent throw error 2025-02-09 03:02:39 -06:00
Mauricio Siu
78e606876a Merge pull request #1297 from mohabgabber/canary
Update unsend version to v1.3.2
2025-02-09 02:37:31 -06:00
Mauricio Siu
7e99baa267 Merge branch 'canary' into canary 2025-02-09 02:37:23 -06:00
Mauricio Siu
92c03bb7cc Merge pull request #1276 from Dokploy/1004-network-conflict
1004 network conflict
2025-02-09 02:36:17 -06:00
Mauricio Siu
3a5ecb2f64 refactor: remove unused imports 2025-02-09 02:33:30 -06:00
Mauricio Siu
c0a00f4957 refactor: remove dokploy-network 2025-02-09 02:31:01 -06:00
Mauricio Siu
a8f94540f9 refactor: lint 2025-02-09 02:20:40 -06:00
Mauricio Siu
3e2cfe6eb8 refactor: agroupate utilities 2025-02-09 02:20:28 -06:00
Mohab Gabber
b2d5090b36 Merge branch 'canary' of https://github.com/mohabgabber/dokploy into canary 2025-02-09 03:22:27 +02:00
Mohab Gabber
0a0f53e9de chore: update unsend version to v1.3.2 2025-02-09 03:22:23 +02:00
Vishal kadam
17ce03e529 Merge branch 'Dokploy:canary' into feat/bulk-actions 2025-02-09 01:47:55 +05:30
Mauricio Siu
f44512a437 refactor: add condition to deploy on remote servers 2025-02-06 01:52:53 -06:00
Mauricio Siu
8379068fe3 refactor: remove services 2025-02-06 00:40:03 -06:00
Mauricio Siu
a71de72a3c refactor: remove services 2025-02-06 00:39:42 -06:00
Mauricio Siu
b024060eed refactor: delete unneeded container_name 2025-02-06 00:38:04 -06:00
Mauricio Siu
56b26ce0d5 refactor: use appname in network connect 2025-02-06 00:19:34 -06:00
Mauricio Siu
a9e3a65782 Merge branch 'canary' into 1004-network-conflict 2025-02-06 00:17:26 -06:00
Mauricio Siu
7a472df753 Merge pull request #1239 from NagariaHussain/template-frappe-hr
feat(template): frappe HR, open source HR & Payroll software
2025-02-06 00:14:59 -06:00
vishalkadam47
bd809c8dca feat: added bulk actions for services start and stop and added service status for domain dropdown 2025-02-05 08:17:15 +05:30
Hussain Nagaria
48642979c5 chore: make erpnext template more configurable 2025-02-04 17:17:43 +05:30
Hussain Nagaria
46411a5f4e fix: create site should use configured db 2025-02-04 14:30:55 +05:30
Hussain Nagaria
82cf0643d7 fix: site volume configurable 2025-02-04 14:15:47 +05:30
Hussain Nagaria
65780ee852 feat: make db configurable 2025-02-04 13:57:49 +05:30
Mauricio Siu
9d988c9a9b Update package.json 2025-02-03 21:49:20 -06:00
Mauricio Siu
eb211b933e Merge pull request #1277 from Blueshadow58/revert-1259-pocketbase
revert "feat<templates>: Updated PocketBase version to 0.25.0" #1259
2025-02-03 21:47:59 -06:00
Franco Gamonal
20eb6d7985 Revert "feat<templates>: Updated PocketBase version to 0.25.0" 2025-02-03 10:27:35 -03:00
Mauricio Siu
d424524d69 refactor: lint 2025-02-03 00:57:27 -06:00
Mauricio Siu
6f2148c060 feat: add deployable option to randomize and prevent colission in duplicate templates 2025-02-03 00:57:18 -06:00
Mauricio Siu
79fca72d06 Merge branch 'canary' into template-frappe-hr 2025-01-30 23:32:56 -06:00
Hussain Nagaria
62a3707c10 feat(template): frappe HR, open source HR & Payroll software 2025-01-29 18:49:27 +05:30
269 changed files with 68897 additions and 3314 deletions

BIN
.github/sponsors/openalternative.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -93,6 +93,7 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<a href="https://cloudblast.io/?ref=dokploy "><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
<a href="https://startupfa.me/?ref=dokploy "><img src=".github/sponsors/startupfame.png" width="65px" alt="Startupfame"/></a>
<a href="https://itsdb-center.com?ref=dokploy "><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
<a href="https://openalternative.co/?ref=dokploy "><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
</div>
### Community Backers 🤝

View File

@@ -45,7 +45,7 @@ const baseApp: ApplicationNested = {
previewWildcard: "",
project: {
env: "",
adminId: "",
organizationId: "",
name: "",
description: "",
createdAt: "",

View File

@@ -5,7 +5,7 @@ vi.mock("node:fs", () => ({
default: fs,
}));
import type { Admin, FileConfig } from "@dokploy/server";
import type { FileConfig, User } from "@dokploy/server";
import {
createDefaultServerTraefikConfig,
loadOrCreateConfig,
@@ -13,7 +13,7 @@ import {
} from "@dokploy/server";
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: Admin = {
const baseAdmin: User = {
enablePaidFeatures: false,
metricsConfig: {
containers: {
@@ -40,9 +40,7 @@ const baseAdmin: Admin = {
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
createdAt: "",
authId: "",
adminId: "string",
createdAt: new Date(),
serverIp: null,
certificateType: "none",
host: null,
@@ -53,6 +51,31 @@ const baseAdmin: Admin = {
serversQuantity: 0,
stripeCustomerId: "",
stripeSubscriptionId: "",
accessedProjects: [],
accessedServices: [],
banExpires: new Date(),
banned: true,
banReason: "",
canAccessToAPI: false,
canCreateProjects: false,
canDeleteProjects: false,
canDeleteServices: false,
canAccessToDocker: false,
canAccessToSSHKeys: false,
canCreateServices: false,
canAccessToTraefikFiles: false,
canAccessToGitProviders: false,
email: "",
expirationDate: "",
id: "",
isRegistered: false,
name: "",
createdAt2: new Date().toISOString(),
emailVerified: false,
image: "",
token: "",
updatedAt: new Date(),
twoFactorEnabled: false,
};
beforeEach(() => {

View File

@@ -26,7 +26,7 @@ const baseApp: ApplicationNested = {
previewWildcard: "",
project: {
env: "",
adminId: "",
organizationId: "",
name: "",
description: "",
createdAt: "",

View File

@@ -14,7 +14,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config";
import { RandomizeCompose } from "./randomize-compose";
import { ShowUtilities } from "./show-utilities";
interface Props {
composeId: string;
@@ -125,7 +125,7 @@ services:
</Form>
<div className="flex justify-between flex-col lg:flex-row gap-2">
<div className="w-full flex flex-col lg:flex-row gap-4 items-end">
<RandomizeCompose composeId={composeId} />
<ShowUtilities composeId={composeId} />
</div>
<Button
type="submit"

View File

@@ -0,0 +1,191 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
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";
interface Props {
composeId: string;
}
const schema = z.object({
isolatedDeployment: z.boolean().optional(),
});
type Schema = z.infer<typeof schema>;
export const IsolatedDeployment = ({ composeId }: Props) => {
const utils = api.useUtils();
const [compose, setCompose] = useState<string>("");
const { mutateAsync, error, isError } =
api.compose.isolatedDeployment.useMutation();
const { mutateAsync: updateCompose } = api.compose.update.useMutation();
const { data, refetch } = api.compose.one.useQuery(
{ composeId },
{ enabled: !!composeId },
);
console.log(data);
const form = useForm<Schema>({
defaultValues: {
isolatedDeployment: false,
},
resolver: zodResolver(schema),
});
useEffect(() => {
randomizeCompose();
if (data) {
form.reset({
isolatedDeployment: data?.isolatedDeployment || false,
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (formData: Schema) => {
await updateCompose({
composeId,
isolatedDeployment: formData?.isolatedDeployment || false,
})
.then(async (data) => {
randomizeCompose();
refetch();
toast.success("Compose updated");
})
.catch(() => {
toast.error("Error updating the compose");
});
};
const randomizeCompose = async () => {
await mutateAsync({
composeId,
suffix: data?.appName || "",
})
.then(async (data) => {
await utils.project.all.invalidate();
setCompose(data);
toast.success("Compose Isolated");
})
.catch(() => {
toast.error("Error isolating the compose");
});
};
return (
<>
<DialogHeader>
<DialogTitle>Isolate Deployment</DialogTitle>
<DialogDescription>
Use this option to isolate the deployment of this compose file.
</DialogDescription>
</DialogHeader>
<div className="text-sm text-muted-foreground flex flex-col gap-2">
<span>
This feature creates an isolated environment for your deployment by
adding unique prefixes to all resources. It establishes a dedicated
network based on your compose file's name, ensuring your services run
in isolation. This prevents conflicts when running multiple instances
of the same template or services with identical names.
</span>
<div className="space-y-4">
<div>
<h4 className="font-medium mb-2">
Resources that will be isolated:
</h4>
<ul className="list-disc list-inside">
<li>Docker volumes</li>
<li>Docker networks</li>
</ul>
</div>
</div>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-add-project"
className="grid w-full gap-4"
>
{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>
)}
<div className="flex flex-col lg:flex-col gap-4 w-full ">
<div>
<FormField
control={form.control}
name="isolatedDeployment"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Enable Randomize ({data?.appName})</FormLabel>
<FormDescription>
Enable randomize to the compose file.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-col lg:flex-row gap-4 w-full items-end justify-end">
<Button
form="hook-form-add-project"
type="submit"
className="lg:w-fit"
>
Save
</Button>
</div>
</div>
<div className="flex flex-col gap-4">
<Label>Preview</Label>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
/>
</pre>
</div>
</form>
</Form>
</>
);
};

View File

@@ -1,14 +1,10 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
@@ -20,11 +16,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -70,6 +61,7 @@ export const RandomizeCompose = ({ composeId }: Props) => {
const suffix = form.watch("suffix");
useEffect(() => {
randomizeCompose();
if (data) {
form.reset({
suffix: data?.suffix || "",
@@ -110,126 +102,117 @@ export const RandomizeCompose = ({ composeId }: Props) => {
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild onClick={() => randomizeCompose()}>
<Button className="max-lg:w-full" variant="outline">
<Dices className="h-4 w-4" />
Randomize Compose
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-[50rem] overflow-y-auto">
<DialogHeader>
<DialogTitle>Randomize Compose (Experimental)</DialogTitle>
<DialogDescription>
Use this in case you want to deploy the same compose file and you
have conflicts with some property like volumes, networks, etc.
</DialogDescription>
</DialogHeader>
<div className="text-sm text-muted-foreground flex flex-col gap-2">
<span>
This will randomize the compose file and will add a suffix to the
property to avoid conflicts
</span>
<ul className="list-disc list-inside">
<li>volumes</li>
<li>networks</li>
<li>services</li>
<li>configs</li>
<li>secrets</li>
</ul>
<AlertBlock type="info">
When you activate this option, we will include a env
`COMPOSE_PREFIX` variable to the compose file so you can use it in
your compose file.
</AlertBlock>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-add-project"
className="grid w-full gap-4"
>
{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>
)}
<div className="flex flex-col lg:flex-col gap-4 w-full ">
<div>
<FormField
control={form.control}
name="suffix"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center w-full">
<FormLabel>Suffix</FormLabel>
<FormControl>
<Input
placeholder="Enter a suffix (Optional, example: prod)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="randomize"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Apply Randomize</FormLabel>
<FormDescription>
Apply randomize to the compose file.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-col lg:flex-row gap-4 w-full items-end justify-end">
<Button
form="hook-form-add-project"
type="submit"
className="lg:w-fit"
>
Save
</Button>
<Button
type="button"
variant="secondary"
onClick={async () => {
await randomizeCompose();
}}
className="lg:w-fit"
>
Random
</Button>
</div>
<div className="w-full">
<DialogHeader>
<DialogTitle>Randomize Compose (Experimental)</DialogTitle>
<DialogDescription>
Use this in case you want to deploy the same compose file and you have
conflicts with some property like volumes, networks, etc.
</DialogDescription>
</DialogHeader>
<div className="text-sm text-muted-foreground flex flex-col gap-2">
<span>
This will randomize the compose file and will add a suffix to the
property to avoid conflicts
</span>
<ul className="list-disc list-inside">
<li>volumes</li>
<li>networks</li>
<li>services</li>
<li>configs</li>
<li>secrets</li>
</ul>
<AlertBlock type="info">
When you activate this option, we will include a env `COMPOSE_PREFIX`
variable to the compose file so you can use it in your compose file.
</AlertBlock>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-add-project"
className="grid w-full gap-4"
>
{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>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
)}
<div className="flex flex-col lg:flex-col gap-4 w-full ">
<div>
<FormField
control={form.control}
name="suffix"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center w-full mt-4">
<FormLabel>Suffix</FormLabel>
<FormControl>
<Input
placeholder="Enter a suffix (Optional, example: prod)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</pre>
</form>
</Form>
</DialogContent>
</Dialog>
<FormField
control={form.control}
name="randomize"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Apply Randomize</FormLabel>
<FormDescription>
Apply randomize to the compose file.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-col lg:flex-row gap-4 w-full items-end justify-end">
<Button
form="hook-form-add-project"
type="submit"
className="lg:w-fit"
>
Save
</Button>
<Button
type="button"
variant="secondary"
onClick={async () => {
await randomizeCompose();
}}
className="lg:w-fit"
>
Random
</Button>
</div>
</div>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
/>
</pre>
</form>
</Form>
</div>
);
};

View File

@@ -0,0 +1,46 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useState } from "react";
import { IsolatedDeployment } from "./isolated-deployment";
import { RandomizeCompose } from "./randomize-compose";
interface Props {
composeId: string;
}
export const ShowUtilities = ({ composeId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">Show Utilities</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Utilities </DialogTitle>
<DialogDescription>Modify the application data</DialogDescription>
</DialogHeader>
<Tabs defaultValue="isolated">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="isolated">Isolated Deployment</TabsTrigger>
<TabsTrigger value="randomize">Randomize Compose</TabsTrigger>
</TabsList>
<TabsContent value="randomize" className="pt-5">
<RandomizeCompose composeId={composeId} />
</TabsContent>
<TabsContent value="isolated" className="pt-5">
<IsolatedDeployment composeId={composeId} />
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@@ -79,7 +79,7 @@ export const ContainerPaidMonitoring = ({ appName, baseUrl, token }: Props) => {
data,
isLoading,
error: queryError,
} = api.admin.getContainerMetrics.useQuery(
} = api.user.getContainerMetrics.useQuery(
{
url: baseUrl,
token,

View File

@@ -73,7 +73,7 @@ export const ShowPaidMonitoring = ({
data,
isLoading,
error: queryError,
} = api.admin.getServerMetrics.useQuery(
} = api.user.getServerMetrics.useQuery(
{
url: BASE_URL,
token,

View File

@@ -0,0 +1,119 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { PenBoxIcon, Plus, SquarePen } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
interface Props {
organizationId?: string;
children?: React.ReactNode;
}
export function AddOrganization({ organizationId, children }: Props) {
const utils = api.useUtils();
const { data: organization } = api.organization.one.useQuery(
{
organizationId: organizationId ?? "",
},
{
enabled: !!organizationId,
},
);
const { mutateAsync, isLoading } = organizationId
? api.organization.update.useMutation()
: api.organization.create.useMutation();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
useEffect(() => {
if (organization) {
setName(organization.name);
}
}, [organization]);
const handleSubmit = async () => {
await mutateAsync({ name, organizationId: organizationId ?? "" })
.then(() => {
setOpen(false);
toast.success(
`Organization ${organizationId ? "updated" : "created"} successfully`,
);
utils.organization.all.invalidate();
})
.catch((error) => {
console.error(error);
toast.error(
`Failed to ${organizationId ? "update" : "create"} organization`,
);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{organizationId ? (
<DropdownMenuItem
className="group cursor-pointer hover:bg-blue-500/10"
onSelect={(e) => e.preventDefault()}
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="gap-2 p-2"
onClick={() => {
setOpen(true);
}}
onSelect={(e) => e.preventDefault()}
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
</div>
<div className="font-medium text-muted-foreground">
Add organization
</div>
</DropdownMenuItem>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{organizationId ? "Update organization" : "Add organization"}
</DialogTitle>
<DialogDescription>
{organizationId
? "Update the organization name"
: "Create a new organization to manage your projects."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button type="submit" onClick={handleSubmit} isLoading={isLoading}>
{organizationId ? "Update organization" : "Create organization"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -21,6 +21,7 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, SquarePen } from "lucide-react";
@@ -97,6 +98,18 @@ export const HandleProject = ({ projectId }: Props) => {
);
});
};
// useEffect(() => {
// const getUsers = async () => {
// const users = await authClient.admin.listUsers({
// query: {
// limit: 100,
// },
// });
// console.log(users);
// };
// getUsers();
// });
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>

View File

@@ -1,5 +1,6 @@
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import {
AlertDialog,
AlertDialogAction,
@@ -50,15 +51,7 @@ import { ProjectEnvironment } from "./project-environment";
export const ShowProjects = () => {
const utils = api.useUtils();
const { data, isLoading } = api.project.all.useQuery();
const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { data: auth } = api.user.get.useQuery();
const { mutateAsync } = api.project.remove.useMutation();
const [searchQuery, setSearchQuery] = useState("");
@@ -90,7 +83,7 @@ export const ShowProjects = () => {
</CardDescription>
</CardHeader>
{(auth?.rol === "admin" || user?.canCreateProjects) && (
{(auth?.role === "owner" || auth?.user?.canCreateProjects) && (
<div className="">
<HandleProject />
</div>
@@ -176,8 +169,11 @@ export const ShowProjects = () => {
<div key={app.applicationId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs">
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{app.name}
<StatusTooltip
status={app.applicationStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{app.domains.map((domain) => (
@@ -209,8 +205,11 @@ export const ShowProjects = () => {
<div key={comp.composeId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs">
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
@@ -286,8 +285,8 @@ export const ShowProjects = () => {
<div
onClick={(e) => e.stopPropagation()}
>
{(auth?.rol === "admin" ||
user?.canDeleteProjects) && (
{(auth?.role === "owner" ||
auth?.user?.canDeleteProjects) && (
<AlertDialog>
<AlertDialogTrigger className="w-full">
<DropdownMenuItem

View File

@@ -18,6 +18,7 @@ import {
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { authClient } from "@/lib/auth-client";
import {
type Services,
extractServices,
@@ -35,8 +36,10 @@ export const SearchCommand = () => {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const { data } = api.project.all.useQuery();
const { data: session } = authClient.useSession();
const { data } = api.project.all.useQuery(undefined, {
enabled: !!session,
});
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
React.useEffect(() => {

View File

@@ -39,7 +39,7 @@ export const calculatePrice = (count: number, isAnnual = false) => {
};
export const ShowBilling = () => {
const { data: servers } = api.server.all.useQuery(undefined);
const { data: admin } = api.admin.one.useQuery();
const { data: admin } = api.user.get.useQuery();
const { data, isLoading } = api.stripe.getProducts.useQuery();
const { mutateAsync: createCheckoutSession } =
api.stripe.createCheckoutSession.useMutation();
@@ -70,7 +70,7 @@ export const ShowBilling = () => {
return isAnnual ? interval === "year" : interval === "month";
});
const maxServers = admin?.serversQuantity ?? 1;
const maxServers = admin?.user.serversQuantity ?? 1;
const percentage = ((servers?.length ?? 0) / maxServers) * 100;
const safePercentage = Math.min(percentage, 100);
@@ -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
{admin?.user.serversQuantity} servers
</p>
<div>
<Progress value={safePercentage} className="max-w-lg" />
</div>
{admin && admin.serversQuantity! <= servers?.length! && (
{admin && admin.user.serversQuantity! <= servers?.length! && (
<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"

View File

@@ -10,12 +10,12 @@ 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 +24,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

View File

@@ -86,6 +86,7 @@ export const AddCertificate = () => {
privateKey: data.privateKey,
autoRenew: data.autoRenew,
serverId: data.serverId,
organizationId: "",
})
.then(async () => {
toast.success("Certificate Created");

View File

@@ -53,7 +53,7 @@ export const AddBitbucketProvider = () => {
const [isOpen, setIsOpen] = useState(false);
const url = useUrl();
const { mutateAsync, error, isError } = api.bitbucket.create.useMutation();
const { data: auth } = api.auth.get.useQuery();
const { data: auth } = api.user.get.useQuery();
const router = useRouter();
const form = useForm<Schema>({
defaultValues: {

View File

@@ -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"
>

View File

@@ -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`;

View File

@@ -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.auth.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>
);

View File

@@ -17,144 +17,315 @@ 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 PasswordForm = z.infer<typeof PasswordSchema>;
type PinForm = z.infer<typeof PinSchema>;
type TwoFactorEnableResponse = {
totpURI: string;
backupCodes: string[];
};
type TwoFactorSetupData = {
qrCodeUrl: string;
secret: string;
totpURI: string;
};
export const Enable2FA = () => {
const utils = api.useUtils();
const { data: session } = authClient.useSession();
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.auth.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>
);

View File

@@ -14,7 +14,7 @@ import Link from "next/link";
import { toast } from "sonner";
export const GenerateToken = () => {
const { data, refetch } = api.auth.get.useQuery();
const { data, refetch } = api.user.get.useQuery();
const { mutateAsync: generateToken, isLoading: isLoadingToken } =
api.auth.generateToken.useMutation();
@@ -51,7 +51,7 @@ export const GenerateToken = () => {
<Label>Token</Label>
<ToggleVisibilityInput
placeholder="Token"
value={data?.token || ""}
value={data?.user?.token || ""}
disabled
/>
</div>

View File

@@ -1,4 +1,5 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -17,6 +18,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { authClient } from "@/lib/auth-client";
import { generateSHA256Hash } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -54,7 +56,10 @@ const randomImages = [
];
export const ProfileForm = () => {
const { data, refetch, isLoading } = api.auth.get.useQuery();
const utils = api.useUtils();
const { mutateAsync: disable2FA, isLoading: isDisabling } =
api.auth.disable2FA.useMutation();
const { data, refetch, isLoading } = api.user.get.useQuery();
const {
mutateAsync,
isLoading: isUpdating,
@@ -73,9 +78,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 +89,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);
});
}
@@ -130,7 +135,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">

View File

@@ -35,7 +35,7 @@ const profileSchema = z.object({
type Profile = z.infer<typeof profileSchema>;
export const RemoveSelfAccount = () => {
const { data } = api.auth.get.useQuery();
const { data } = api.user.get.useQuery();
const { mutateAsync, isLoading, error, isError } =
api.auth.removeSelfAccount.useMutation();
const { t } = useTranslation("settings");

View File

@@ -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();

View File

@@ -91,7 +91,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
enabled: !!serverId,
},
)
: api.admin.one.useQuery();
: api.user.get.useQuery();
const url = useUrl();

View File

@@ -35,6 +35,7 @@ export const CreateSSHKey = () => {
description: "Used on Dokploy Cloud",
privateKey: keys.privateKey,
publicKey: keys.publicKey,
organizationId: "",
});
await refetch();
} catch (error) {

View File

@@ -78,6 +78,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
const onSubmit = async (data: SSHKey) => {
await mutateAsync({
...data,
organizationId: "",
sshKeyId: sshKeyId || "",
})
.then(async () => {

View File

@@ -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

View File

@@ -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.auth.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,

View File

@@ -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>
);
};

View File

@@ -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, isLoading: isRemoving } = api.user.remove.useMutation();
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,63 @@ 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()}
{member.role !== "owner" && (
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
onClick={async () => {
if (isCloud) {
const { error } =
await authClient.organization.removeMember(
{
memberIdOrEmail: member.id,
},
);
if (!error) {
toast.success(
"User deleted successfully",
);
refetch();
} else {
toast.error(
"Error deleting user",
);
}
} else {
await mutateAsync({
userId: member.user.id,
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting destination",
);
});
}
}}
>
Delete User
</DropdownMenuItem>
</DialogAction>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Delete User
</DropdownMenuItem>
</DialogAction>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -189,10 +191,6 @@ export const ShowUsers = () => {
})}
</TableBody>
</Table>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddUser />
</div>
</div>
)}
</>

View File

@@ -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({

View File

@@ -21,7 +21,7 @@ interface Props {
}
export const WebServer = ({ className }: Props) => {
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 +58,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}

View File

@@ -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,
@@ -47,15 +46,15 @@ interface Props {
export const UpdateServerIp = ({ children, serverId }: 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]);

View File

@@ -1,6 +1,7 @@
"use client";
import {
Activity,
AudioWaveform,
BarChartHorizontalBigIcon,
Bell,
BlocksIcon,
@@ -8,6 +9,7 @@ import {
Boxes,
ChevronRight,
CircleHelp,
Command,
CreditCard,
Database,
Folder,
@@ -16,11 +18,13 @@ import {
GitBranch,
HeartIcon,
KeyRound,
Loader2,
type LucideIcon,
Package,
PieChart,
Server,
ShieldCheck,
Trash2,
User,
Users,
} from "lucide-react";
@@ -74,7 +78,6 @@ import { UserNav } from "./user-nav";
// The types of the queries we are going to use
type AuthQueryOutput = inferRouterOutputs<AppRouter>["auth"]["get"];
type UserQueryOutput = inferRouterOutputs<AppRouter>["user"]["byAuthId"];
type SingleNavItem = {
isSingle?: true;
@@ -83,7 +86,6 @@ type SingleNavItem = {
icon?: LucideIcon;
isEnabled?: (opts: {
auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean;
}) => boolean;
};
@@ -101,7 +103,6 @@ type NavItem =
items: SingleNavItem[];
isEnabled?: (opts: {
auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean;
}) => boolean;
};
@@ -114,7 +115,6 @@ type ExternalLink = {
icon: React.ComponentType<{ className?: string }>;
isEnabled?: (opts: {
auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean;
}) => boolean;
};
@@ -145,7 +145,7 @@ const MENU: Menu = {
url: "/dashboard/monitoring",
icon: BarChartHorizontalBigIcon,
// Only enabled in non-cloud environments
isEnabled: ({ auth, user, isCloud }) => !isCloud,
isEnabled: ({ auth, isCloud }) => !isCloud,
},
{
isSingle: true,
@@ -153,9 +153,9 @@ const MENU: Menu = {
url: "/dashboard/traefik",
icon: GalleryVerticalEnd,
// Only enabled for admins and users with access to Traefik files in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.rol === "admin" || user?.canAccessToTraefikFiles) &&
(auth?.role === "owner" || auth?.user?.canAccessToTraefikFiles) &&
!isCloud
),
},
@@ -165,8 +165,11 @@ const MENU: Menu = {
url: "/dashboard/docker",
icon: BlocksIcon,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud),
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" || auth?.user?.canAccessToDocker) &&
!isCloud
),
},
{
isSingle: true,
@@ -174,8 +177,11 @@ const MENU: Menu = {
url: "/dashboard/swarm",
icon: PieChart,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud),
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" || auth?.user?.canAccessToDocker) &&
!isCloud
),
},
{
isSingle: true,
@@ -183,8 +189,11 @@ const MENU: Menu = {
url: "/dashboard/requests",
icon: Forward,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud),
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" || auth?.user?.canAccessToDocker) &&
!isCloud
),
},
// Legacy unused menu, adjusted to the new structure
@@ -251,8 +260,7 @@ const MENU: Menu = {
url: "/dashboard/settings/server",
icon: Activity,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!(auth?.rol === "admin" && !isCloud),
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
},
{
isSingle: true,
@@ -266,7 +274,7 @@ const MENU: Menu = {
url: "/dashboard/settings/servers",
icon: Server,
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -274,7 +282,7 @@ const MENU: Menu = {
icon: Users,
url: "/dashboard/settings/users",
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -282,8 +290,8 @@ const MENU: Menu = {
icon: KeyRound,
url: "/dashboard/settings/ssh-keys",
// Only enabled for admins and users with access to SSH keys
isEnabled: ({ auth, user }) =>
!!(auth?.rol === "admin" || user?.canAccessToSSHKeys),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.user?.canAccessToSSHKeys),
},
{
isSingle: true,
@@ -291,8 +299,8 @@ const MENU: Menu = {
url: "/dashboard/settings/git-providers",
icon: GitBranch,
// Only enabled for admins and users with access to Git providers
isEnabled: ({ auth, user }) =>
!!(auth?.rol === "admin" || user?.canAccessToGitProviders),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.user?.canAccessToGitProviders),
},
{
isSingle: true,
@@ -300,7 +308,7 @@ const MENU: Menu = {
url: "/dashboard/settings/registry",
icon: Package,
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -308,7 +316,7 @@ const MENU: Menu = {
url: "/dashboard/settings/destinations",
icon: Database,
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
@@ -317,7 +325,7 @@ const MENU: Menu = {
url: "/dashboard/settings/certificates",
icon: ShieldCheck,
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -325,8 +333,7 @@ const MENU: Menu = {
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!(auth?.rol === "admin" && !isCloud),
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
},
{
isSingle: true,
@@ -334,7 +341,7 @@ const MENU: Menu = {
url: "/dashboard/settings/notifications",
icon: Bell,
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -342,8 +349,7 @@ const MENU: Menu = {
url: "/dashboard/settings/billing",
icon: CreditCard,
// Only enabled for admins in cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!(auth?.rol === "admin" && isCloud),
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
},
],
@@ -379,7 +385,6 @@ const MENU: Menu = {
*/
function createMenuForAuthUser(opts: {
auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean;
}): Menu {
return {
@@ -390,7 +395,6 @@ function createMenuForAuthUser(opts: {
? true
: item.isEnabled({
auth: opts.auth,
user: opts.user,
isCloud: opts.isCloud,
}),
),
@@ -401,7 +405,6 @@ function createMenuForAuthUser(opts: {
? true
: item.isEnabled({
auth: opts.auth,
user: opts.user,
isCloud: opts.isCloud,
}),
),
@@ -412,7 +415,6 @@ function createMenuForAuthUser(opts: {
? true
: item.isEnabled({
auth: opts.auth,
user: opts.user,
isCloud: opts.isCloud,
}),
),
@@ -480,37 +482,218 @@ interface Props {
function LogoWrapper() {
return <SidebarLogo />;
}
import { ChevronsUpDown, Plus } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { authClient } from "@/lib/auth-client";
import { toast } from "sonner";
import { AddOrganization } from "../dashboard/organization/handle-organization";
import { DialogAction } from "../shared/dialog-action";
import { Button } from "../ui/button";
const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "/avatars/shadcn.jpg",
},
teams: [
{
name: "Acme Inc",
logo: GalleryVerticalEnd,
plan: "Enterprise",
},
{
name: "Acme Corp.",
logo: AudioWaveform,
plan: "Startup",
},
{
name: "Evil Corp.",
logo: Command,
plan: "Free",
},
],
};
function SidebarLogo() {
const { state } = useSidebar();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: session } = authClient.useSession();
const {
data: organizations,
refetch,
isLoading,
} = api.organization.all.useQuery();
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
api.organization.delete.useMutation();
const { isMobile } = useSidebar();
const { data: activeOrganization } = authClient.useActiveOrganization();
const [activeTeam, setActiveTeam] = useState<
typeof activeOrganization | null
>(null);
useEffect(() => {
if (activeOrganization) {
setActiveTeam(activeOrganization);
}
}, [activeOrganization]);
return (
<Link
href="/dashboard/projects"
className="flex items-center gap-2 p-1 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]/35 rounded-lg "
>
<div
className={cn(
"flex aspect-square items-center justify-center rounded-lg transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
<>
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[5vh] pt-4">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
{/* <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"> */}
<div
className={cn(
"flex aspect-square items-center justify-center rounded-lg transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
>
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
/>
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{activeTeam?.name}
</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Organizations
</DropdownMenuLabel>
{organizations?.map((org, index) => (
<div className="flex flex-row justify-between" key={org.name}>
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="w-full gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
/>
</div>
{org.name}
</DropdownMenuItem>
{(org.ownerId === session?.user?.id || isCloud) && (
<div className="flex items-center gap-2">
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
)}
</div>
))}
{!isCloud && user?.role === "owner" && (
<>
<DropdownMenuSeparator />
<AddOrganization />
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)}
{/* <Link
href="/dashboard/projects"
className="flex items-center gap-2 p-1 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]/35 rounded-lg "
>
<Logo
<div
className={cn(
"transition-all",
"flex aspect-square items-center justify-center rounded-lg transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
/>
</div>
>
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
/>
</div>
<div className="text-left text-sm leading-tight group-data-[state=open]/collapsible:rotate-90">
<p className="truncate font-semibold">Dokploy</p>
<p className="truncate text-xs text-muted-foreground">
{dokployVersion}
</p>
</div>
</Link>
<div className="text-left text-sm leading-tight group-data-[state=open]/collapsible:rotate-90">
<p className="truncate font-semibold">Dokploy</p>
<p className="truncate text-xs text-muted-foreground">
{dokployVersion}
</p>
</div>
</Link> */}
</>
);
}
@@ -531,15 +714,7 @@ export default function Page({ children }: Props) {
const router = useRouter();
const pathname = usePathname();
const currentPath = router.pathname;
const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { data: auth } = api.user.get.useQuery();
const includesProjects = pathname?.includes("/dashboard/project");
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
@@ -548,7 +723,7 @@ export default function Page({ children }: Props) {
home: filteredHome,
settings: filteredSettings,
help,
} = createMenuForAuthUser({ auth, user, isCloud: !!isCloud });
} = createMenuForAuthUser({ auth, isCloud: !!isCloud });
const activeItem = findActiveNavItem(
[...filteredHome, ...filteredSettings],
@@ -557,7 +732,7 @@ export default function Page({ children }: Props) {
// const showProjectsButton =
// currentPath === "/dashboard/projects" &&
// (auth?.rol === "admin" || user?.canCreateProjects);
// (auth?.rol === "owner" || user?.canCreateProjects);
return (
<SidebarProvider
@@ -577,12 +752,12 @@ export default function Page({ children }: Props) {
>
<Sidebar collapsible="icon" variant="floating">
<SidebarHeader>
<SidebarMenuButton
{/* <SidebarMenuButton
className="group-data-[collapsible=icon]:!p-0"
size="lg"
>
<LogoWrapper />
</SidebarMenuButton>
> */}
<LogoWrapper />
{/* </SidebarMenuButton> */}
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
@@ -783,7 +958,7 @@ export default function Page({ children }: Props) {
</SidebarMenuButton>
</SidebarMenuItem>
))}
{!isCloud && auth?.rol === "admin" && (
{!isCloud && auth?.role === "owner" && (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<UpdateServerButton />

View File

@@ -15,6 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { authClient } from "@/lib/auth-client";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
import useLocale from "@/utils/hooks/use-locale";
@@ -29,18 +30,11 @@ const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
export const UserNav = () => {
const router = useRouter();
const { data } = api.auth.get.useQuery();
const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: data?.id || "",
},
{
enabled: !!data?.id && data?.rol === "user",
},
);
const { locale, setLocale } = useLocale();
const { mutateAsync } = api.auth.logout.useMutation();
// const { mutateAsync } = api.auth.logout.useMutation();
return (
<DropdownMenu>
@@ -50,12 +44,15 @@ export const UserNav = () => {
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={data?.image || ""} alt={data?.image || ""} />
<AvatarImage
src={data?.user?.image || ""}
alt={data?.user?.image || ""}
/>
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Account</span>
<span className="truncate text-xs">{data?.email}</span>
<span className="truncate text-xs">{data?.user?.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
@@ -70,7 +67,7 @@ export const UserNav = () => {
<DropdownMenuLabel className="flex flex-col">
My Account
<span className="text-xs font-normal text-muted-foreground">
{data?.email}
{data?.user?.email}
</span>
</DropdownMenuLabel>
<ModeToggle />
@@ -95,7 +92,8 @@ export const UserNav = () => {
>
Monitoring
</DropdownMenuItem>
{(data?.rol === "admin" || user?.canAccessToTraefikFiles) && (
{(data?.role === "owner" ||
data?.user?.canAccessToTraefikFiles) && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -105,7 +103,7 @@ export const UserNav = () => {
Traefik
</DropdownMenuItem>
)}
{(data?.rol === "admin" || user?.canAccessToDocker) && (
{(data?.role === "owner" || data?.user?.canAccessToDocker) && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -118,7 +116,7 @@ export const UserNav = () => {
</DropdownMenuItem>
)}
{data?.rol === "admin" && (
{data?.role === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -139,7 +137,7 @@ export const UserNav = () => {
>
Profile
</DropdownMenuItem>
{data?.rol === "admin" && (
{data?.role === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -150,7 +148,7 @@ export const UserNav = () => {
</DropdownMenuItem>
)}
{data?.rol === "admin" && (
{data?.role === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -163,7 +161,7 @@ export const UserNav = () => {
</>
)}
</DropdownMenuGroup>
{isCloud && data?.rol === "admin" && (
{isCloud && data?.role === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -178,9 +176,12 @@ export const UserNav = () => {
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await mutateAsync().then(() => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out

View File

@@ -0,0 +1 @@
ALTER TABLE "compose" ADD COLUMN "deployable" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "compose" RENAME COLUMN "deployable" TO "isolatedDeployment";

View File

@@ -0,0 +1,136 @@
CREATE TABLE "user_temp" (
"id" text PRIMARY KEY NOT NULL,
"name" text DEFAULT '' NOT NULL,
"token" text NOT NULL,
"isRegistered" boolean DEFAULT false NOT NULL,
"expirationDate" text NOT NULL,
"createdAt" text NOT NULL,
"canCreateProjects" boolean DEFAULT false NOT NULL,
"canAccessToSSHKeys" boolean DEFAULT false NOT NULL,
"canCreateServices" boolean DEFAULT false NOT NULL,
"canDeleteProjects" boolean DEFAULT false NOT NULL,
"canDeleteServices" boolean DEFAULT false NOT NULL,
"canAccessToDocker" boolean DEFAULT false NOT NULL,
"canAccessToAPI" boolean DEFAULT false NOT NULL,
"canAccessToGitProviders" boolean DEFAULT false NOT NULL,
"canAccessToTraefikFiles" boolean DEFAULT false NOT NULL,
"accesedProjects" text[] DEFAULT ARRAY[]::text[] NOT NULL,
"accesedServices" text[] DEFAULT ARRAY[]::text[] NOT NULL,
"two_factor_enabled" boolean DEFAULT false NOT NULL,
"email" text NOT NULL,
"email_verified" boolean NOT NULL,
"image" text,
"banned" boolean,
"ban_reason" text,
"ban_expires" timestamp,
"updated_at" timestamp NOT NULL,
"serverIp" text,
"certificateType" "certificateType" DEFAULT 'none' NOT NULL,
"host" text,
"letsEncryptEmail" text,
"sshPrivateKey" text,
"enableDockerCleanup" boolean DEFAULT false NOT NULL,
"enableLogRotation" boolean DEFAULT false NOT NULL,
"enablePaidFeatures" boolean DEFAULT false NOT NULL,
"metricsConfig" jsonb DEFAULT '{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb NOT NULL,
"cleanupCacheApplications" boolean DEFAULT false NOT NULL,
"cleanupCacheOnPreviews" boolean DEFAULT false NOT NULL,
"cleanupCacheOnCompose" boolean DEFAULT false NOT NULL,
"stripeCustomerId" text,
"stripeSubscriptionId" text,
"serversQuantity" integer DEFAULT 0 NOT NULL,
CONSTRAINT "user_temp_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "session_temp" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
"impersonated_by" text,
"active_organization_id" text,
CONSTRAINT "session_temp_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"is2FAEnabled" boolean DEFAULT false NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"resetPasswordToken" text,
"resetPasswordExpiresAt" text,
"confirmationToken" text,
"confirmationExpiresAt" text
);
--> statement-breakpoint
CREATE TABLE "invitation" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"email" text NOT NULL,
"role" text,
"status" text NOT NULL,
"expires_at" timestamp NOT NULL,
"inviter_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "member" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"user_id" text NOT NULL,
"role" text NOT NULL,
"created_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"slug" text,
"logo" text,
"created_at" timestamp NOT NULL,
"metadata" text,
"owner_id" text NOT NULL,
CONSTRAINT "organization_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp,
"updated_at" timestamp
);
CREATE TABLE "two_factor" (
"id" text PRIMARY KEY NOT NULL,
"secret" text NOT NULL,
"backup_codes" text NOT NULL,
"user_id" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "certificate" ALTER COLUMN "adminId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "notification" ALTER COLUMN "adminId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "ssh-key" ALTER COLUMN "adminId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "git_provider" ALTER COLUMN "adminId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "session_temp" ADD CONSTRAINT "session_temp_user_id_user_temp_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user_temp"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_temp_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user_temp"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_temp_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user_temp"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_temp_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user_temp"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization" ADD CONSTRAINT "organization_owner_id_user_temp_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user_temp"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_user_temp_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint

View File

@@ -0,0 +1,211 @@
-- Custom SQL migration file, put your code below! --
WITH inserted_users AS (
-- Insertar usuarios desde admins
INSERT INTO user_temp (
id,
email,
token,
"email_verified",
"updated_at",
"serverIp",
image,
"certificateType",
host,
"letsEncryptEmail",
"sshPrivateKey",
"enableDockerCleanup",
"enableLogRotation",
"enablePaidFeatures",
"metricsConfig",
"cleanupCacheApplications",
"cleanupCacheOnPreviews",
"cleanupCacheOnCompose",
"stripeCustomerId",
"stripeSubscriptionId",
"serversQuantity",
"expirationDate",
"createdAt",
"isRegistered"
)
SELECT
a."adminId",
auth.email,
COALESCE(auth.token, ''),
true,
CURRENT_TIMESTAMP,
a."serverIp",
auth.image,
a."certificateType",
a.host,
a."letsEncryptEmail",
a."sshPrivateKey",
a."enableDockerCleanup",
a."enableLogRotation",
a."enablePaidFeatures",
a."metricsConfig",
a."cleanupCacheApplications",
a."cleanupCacheOnPreviews",
a."cleanupCacheOnCompose",
a."stripeCustomerId",
a."stripeSubscriptionId",
a."serversQuantity",
NOW() + INTERVAL '1 year',
NOW(),
true
FROM admin a
JOIN auth ON auth.id = a."authId"
RETURNING *
),
inserted_accounts AS (
-- Insertar cuentas para los admins
INSERT INTO account (
id,
"account_id",
"provider_id",
"user_id",
password,
"created_at",
"updated_at"
)
SELECT
gen_random_uuid(),
gen_random_uuid(),
'credential',
a."adminId",
auth.password,
NOW(),
NOW()
FROM admin a
JOIN auth ON auth.id = a."authId"
RETURNING *
),
inserted_orgs AS (
-- Crear organizaciones para cada admin
INSERT INTO organization (
id,
name,
slug,
"owner_id",
"created_at"
)
SELECT
gen_random_uuid(),
'My Organization',
-- Generamos un slug único usando una función de hash
encode(sha256((a."adminId" || CURRENT_TIMESTAMP)::bytea), 'hex'),
a."adminId",
NOW()
FROM admin a
RETURNING *
),
inserted_members AS (
-- Insertar usuarios miembros
INSERT INTO user_temp (
id,
email,
token,
"email_verified",
"updated_at",
image,
"createdAt",
"canAccessToAPI",
"canAccessToDocker",
"canAccessToGitProviders",
"canAccessToSSHKeys",
"canAccessToTraefikFiles",
"canCreateProjects",
"canCreateServices",
"canDeleteProjects",
"canDeleteServices",
"accesedProjects",
"accesedServices",
"expirationDate",
"isRegistered"
)
SELECT
u."userId",
auth.email,
COALESCE(u.token, ''),
true,
CURRENT_TIMESTAMP,
auth.image,
NOW(),
COALESCE(u."canAccessToAPI", false),
COALESCE(u."canAccessToDocker", false),
COALESCE(u."canAccessToGitProviders", false),
COALESCE(u."canAccessToSSHKeys", false),
COALESCE(u."canAccessToTraefikFiles", false),
COALESCE(u."canCreateProjects", false),
COALESCE(u."canCreateServices", false),
COALESCE(u."canDeleteProjects", false),
COALESCE(u."canDeleteServices", false),
COALESCE(u."accesedProjects", '{}'),
COALESCE(u."accesedServices", '{}'),
NOW() + INTERVAL '1 year',
COALESCE(u."isRegistered", false)
FROM "user" u
JOIN admin a ON u."adminId" = a."adminId"
JOIN auth ON auth.id = u."authId"
RETURNING *
),
inserted_member_accounts AS (
-- Insertar cuentas para los usuarios miembros
INSERT INTO account (
id,
"account_id",
"provider_id",
"user_id",
password,
"created_at",
"updated_at"
)
SELECT
gen_random_uuid(),
gen_random_uuid(),
'credential',
u."userId",
auth.password,
NOW(),
NOW()
FROM "user" u
JOIN admin a ON u."adminId" = a."adminId"
JOIN auth ON auth.id = u."authId"
RETURNING *
),
inserted_admin_members AS (
-- Insertar miembros en las organizaciones (admins como owners)
INSERT INTO member (
id,
"organization_id",
"user_id",
role,
"created_at"
)
SELECT
gen_random_uuid(),
o.id,
a."adminId",
'owner',
NOW()
FROM admin a
JOIN inserted_orgs o ON o."owner_id" = a."adminId"
RETURNING *
)
-- Insertar miembros regulares en las organizaciones
INSERT INTO member (
id,
"organization_id",
"user_id",
role,
"created_at"
)
SELECT
gen_random_uuid(),
o.id,
u."userId",
'member',
NOW()
FROM "user" u
JOIN admin a ON u."adminId" = a."adminId"
JOIN inserted_orgs o ON o."owner_id" = a."adminId";

View File

@@ -0,0 +1,32 @@
ALTER TABLE "project" RENAME COLUMN "adminId" TO "userId";--> statement-breakpoint
ALTER TABLE "destination" RENAME COLUMN "adminId" TO "userId";--> statement-breakpoint
ALTER TABLE "certificate" RENAME COLUMN "adminId" TO "userId";--> statement-breakpoint
ALTER TABLE "registry" RENAME COLUMN "adminId" TO "userId";--> statement-breakpoint
ALTER TABLE "notification" RENAME COLUMN "adminId" TO "userId";--> statement-breakpoint
ALTER TABLE "ssh-key" RENAME COLUMN "adminId" TO "userId";--> statement-breakpoint
ALTER TABLE "git_provider" RENAME COLUMN "adminId" TO "userId";--> statement-breakpoint
ALTER TABLE "server" RENAME COLUMN "adminId" TO "userId";--> statement-breakpoint
ALTER TABLE "project" DROP CONSTRAINT "project_adminId_admin_adminId_fk";
--> statement-breakpoint
ALTER TABLE "destination" DROP CONSTRAINT "destination_adminId_admin_adminId_fk";
--> statement-breakpoint
ALTER TABLE "certificate" DROP CONSTRAINT "certificate_adminId_admin_adminId_fk";
--> statement-breakpoint
ALTER TABLE "registry" DROP CONSTRAINT "registry_adminId_admin_adminId_fk";
--> statement-breakpoint
ALTER TABLE "notification" DROP CONSTRAINT "notification_adminId_admin_adminId_fk";
--> statement-breakpoint
ALTER TABLE "ssh-key" DROP CONSTRAINT "ssh-key_adminId_admin_adminId_fk";
--> statement-breakpoint
ALTER TABLE "git_provider" DROP CONSTRAINT "git_provider_adminId_admin_adminId_fk";
--> statement-breakpoint
ALTER TABLE "server" DROP CONSTRAINT "server_adminId_admin_adminId_fk";
--> statement-breakpoint
ALTER TABLE "project" ADD CONSTRAINT "project_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "destination" ADD CONSTRAINT "destination_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "certificate" ADD CONSTRAINT "certificate_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "registry" ADD CONSTRAINT "registry_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ssh-key" ADD CONSTRAINT "ssh-key_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "git_provider" ADD CONSTRAINT "git_provider_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "server" ADD CONSTRAINT "server_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "user_temp" ALTER COLUMN "token" SET DEFAULT '';--> statement-breakpoint
ALTER TABLE "user_temp" ADD COLUMN "created_at" timestamp DEFAULT now();

View File

@@ -0,0 +1,16 @@
ALTER TABLE "project" ADD COLUMN "organizationId" text;--> statement-breakpoint
ALTER TABLE "destination" ADD COLUMN "organizationId" text;--> statement-breakpoint
ALTER TABLE "certificate" ADD COLUMN "organizationId" text;--> statement-breakpoint
ALTER TABLE "registry" ADD COLUMN "organizationId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "organizationId" text;--> statement-breakpoint
ALTER TABLE "ssh-key" ADD COLUMN "organizationId" text;--> statement-breakpoint
ALTER TABLE "git_provider" ADD COLUMN "organizationId" text;--> statement-breakpoint
ALTER TABLE "server" ADD COLUMN "organizationId" text;--> statement-breakpoint
ALTER TABLE "project" ADD CONSTRAINT "project_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "destination" ADD CONSTRAINT "destination_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "certificate" ADD CONSTRAINT "certificate_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "registry" ADD CONSTRAINT "registry_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ssh-key" ADD CONSTRAINT "ssh-key_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "git_provider" ADD CONSTRAINT "git_provider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "server" ADD CONSTRAINT "server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,142 @@
-- Custom SQL migration file
-- Actualizar projects
UPDATE "project" p
SET "organizationId" = (
SELECT m."organization_id"
FROM "member" m
WHERE m."user_id" = p."userId"
AND m."role" = 'owner'
LIMIT 1
)
WHERE p."organizationId" IS NULL;
-- Actualizar servers
UPDATE "server" s
SET "organizationId" = (
SELECT m."organization_id"
FROM "member" m
WHERE m."user_id" = s."userId"
AND m."role" = 'owner'
LIMIT 1
)
WHERE s."organizationId" IS NULL;
-- Actualizar ssh-keys
UPDATE "ssh-key" k
SET "organizationId" = (
SELECT m."organization_id"
FROM "member" m
WHERE m."user_id" = k."userId"
AND m."role" = 'owner'
LIMIT 1
)
WHERE k."organizationId" IS NULL;
-- Actualizar destinations
UPDATE "destination" d
SET "organizationId" = (
SELECT m."organization_id"
FROM "member" m
WHERE m."user_id" = d."userId"
AND m."role" = 'owner'
LIMIT 1
)
WHERE d."organizationId" IS NULL;
-- Actualizar registry
UPDATE "registry" r
SET "organizationId" = (
SELECT m."organization_id"
FROM "member" m
WHERE m."user_id" = r."userId"
AND m."role" = 'owner'
LIMIT 1
)
WHERE r."organizationId" IS NULL;
-- Actualizar notifications
UPDATE "notification" n
SET "organizationId" = (
SELECT m."organization_id"
FROM "member" m
WHERE m."user_id" = n."userId"
AND m."role" = 'owner'
LIMIT 1
)
WHERE n."organizationId" IS NULL;
-- Actualizar certificates
UPDATE "certificate" c
SET "organizationId" = (
SELECT m."organization_id"
FROM "member" m
WHERE m."user_id" = c."userId"
AND m."role" = 'owner'
LIMIT 1
)
WHERE c."organizationId" IS NULL;
-- Actualizar git_provider
UPDATE "git_provider" g
SET "organizationId" = (
SELECT m."organization_id"
FROM "member" m
WHERE m."user_id" = g."userId"
AND m."role" = 'owner'
LIMIT 1
)
WHERE g."organizationId" IS NULL;
-- Verificar que todos los recursos tengan una organización
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "project" WHERE "organizationId" IS NULL
UNION ALL
SELECT 1 FROM "server" WHERE "organizationId" IS NULL
UNION ALL
SELECT 1 FROM "ssh-key" WHERE "organizationId" IS NULL
UNION ALL
SELECT 1 FROM "destination" WHERE "organizationId" IS NULL
UNION ALL
SELECT 1 FROM "registry" WHERE "organizationId" IS NULL
UNION ALL
SELECT 1 FROM "notification" WHERE "organizationId" IS NULL
UNION ALL
SELECT 1 FROM "certificate" WHERE "organizationId" IS NULL
UNION ALL
SELECT 1 FROM "git_provider" WHERE "organizationId" IS NULL
) THEN
RAISE EXCEPTION 'Hay recursos sin organización asignada';
END IF;
END $$;
-- Hacer organization_id NOT NULL en todas las tablas
ALTER TABLE "project" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "server" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "ssh-key" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "destination" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "registry" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "notification" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "certificate" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "git_provider" ALTER COLUMN "organizationId" SET NOT NULL;
-- Crear índices para mejorar el rendimiento de búsquedas por organización
CREATE INDEX IF NOT EXISTS "idx_project_organization" ON "project" ("organizationId");
CREATE INDEX IF NOT EXISTS "idx_server_organization" ON "server" ("organizationId");
CREATE INDEX IF NOT EXISTS "idx_sshkey_organization" ON "ssh-key" ("organizationId");
CREATE INDEX IF NOT EXISTS "idx_destination_organization" ON "destination" ("organizationId");
CREATE INDEX IF NOT EXISTS "idx_registry_organization" ON "registry" ("organizationId");
CREATE INDEX IF NOT EXISTS "idx_notification_organization" ON "notification" ("organizationId");
CREATE INDEX IF NOT EXISTS "idx_certificate_organization" ON "certificate" ("organizationId");
CREATE INDEX IF NOT EXISTS "idx_git_provider_organization" ON "git_provider" ("organizationId");

View File

@@ -0,0 +1,32 @@
ALTER TABLE "project" DROP CONSTRAINT "project_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "destination" DROP CONSTRAINT "destination_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "certificate" DROP CONSTRAINT "certificate_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "registry" DROP CONSTRAINT "registry_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "notification" DROP CONSTRAINT "notification_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "ssh-key" DROP CONSTRAINT "ssh-key_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "git_provider" DROP CONSTRAINT "git_provider_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "server" DROP CONSTRAINT "server_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "project" ALTER COLUMN "organizationId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "destination" ALTER COLUMN "organizationId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "certificate" ALTER COLUMN "organizationId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "registry" ALTER COLUMN "organizationId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "notification" ALTER COLUMN "organizationId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "ssh-key" ALTER COLUMN "organizationId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "git_provider" ALTER COLUMN "organizationId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "server" ALTER COLUMN "organizationId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "project" DROP COLUMN "userId";--> statement-breakpoint
ALTER TABLE "destination" DROP COLUMN "userId";--> statement-breakpoint
ALTER TABLE "certificate" DROP COLUMN "userId";--> statement-breakpoint
ALTER TABLE "registry" DROP COLUMN "userId";--> statement-breakpoint
ALTER TABLE "notification" DROP COLUMN "userId";--> statement-breakpoint
ALTER TABLE "ssh-key" DROP COLUMN "userId";--> statement-breakpoint
ALTER TABLE "git_provider" DROP COLUMN "userId";--> statement-breakpoint
ALTER TABLE "server" DROP COLUMN "userId";

View File

@@ -0,0 +1,6 @@
--> statement-breakpoint
DROP TABLE "user" CASCADE;--> statement-breakpoint
DROP TABLE "admin" CASCADE;--> statement-breakpoint
DROP TABLE "auth" CASCADE;--> statement-breakpoint
DROP TABLE "session" CASCADE;--> statement-breakpoint
DROP TYPE "public"."Roles";

View File

@@ -0,0 +1,18 @@
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "invitation" DROP CONSTRAINT "invitation_organization_id_organization_id_fk";
--> statement-breakpoint
ALTER TABLE "invitation" DROP CONSTRAINT "invitation_inviter_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "member" DROP CONSTRAINT "member_organization_id_organization_id_fk";
--> statement-breakpoint
ALTER TABLE "member" DROP CONSTRAINT "member_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "organization" DROP CONSTRAINT "organization_owner_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_temp_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_temp_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_temp_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization" ADD CONSTRAINT "organization_owner_id_user_temp_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "session_temp" DROP CONSTRAINT "session_temp_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "session_temp" ADD CONSTRAINT "session_temp_user_id_user_temp_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -449,6 +449,90 @@
"when": 1738522845992,
"tag": "0063_panoramic_dreadnoughts",
"breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1738564387043,
"tag": "0064_previous_agent_brand",
"breakpoints": true
},
{
"idx": 65,
"version": "7",
"when": 1739087857244,
"tag": "0065_daily_zaladane",
"breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1739426913392,
"tag": "0066_yielding_echo",
"breakpoints": true
},
{
"idx": 67,
"version": "7",
"when": 1739427057545,
"tag": "0067_migrate-data",
"breakpoints": true
},
{
"idx": 68,
"version": "7",
"when": 1739428942964,
"tag": "0068_sour_professor_monster",
"breakpoints": true
},
{
"idx": 69,
"version": "7",
"when": 1739664410814,
"tag": "0069_broad_ken_ellis",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1739671869809,
"tag": "0070_nervous_vivisector",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1739671878698,
"tag": "0071_migrate-data-projects",
"breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1739672367223,
"tag": "0072_lazy_pixie",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1739740193879,
"tag": "0073_polite_miss_america",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1739773539709,
"tag": "0074_lowly_jack_power",
"breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1739781534192,
"tag": "0075_heavy_metal_master",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,8 @@
import { organizationClient } from "better-auth/client/plugins";
import { twoFactorClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
// baseURL: "http://localhost:3000", // the base url of your auth server
plugins: [organizationClient(), twoFactorClient()],
});

150
apps/dokploy/migrate.ts Normal file
View File

@@ -0,0 +1,150 @@
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { nanoid } from "nanoid";
import postgres from "postgres";
import * as schema from "./server/db/schema";
const connectionString = process.env.DATABASE_URL!;
const sql = postgres(connectionString, { max: 1 });
const db = drizzle(sql, {
schema,
});
await db
.transaction(async (db) => {
const admins = await db.query.admins.findMany({
with: {
auth: true,
users: {
with: {
auth: true,
},
},
},
});
for (const admin of admins) {
const user = await db
.insert(schema.users_temp)
.values({
id: admin.adminId,
email: admin.auth.email,
token: admin.auth.token || "",
emailVerified: true,
updatedAt: new Date(),
role: "admin",
serverIp: admin.serverIp,
image: admin.auth.image,
certificateType: admin.certificateType,
host: admin.host,
letsEncryptEmail: admin.letsEncryptEmail,
sshPrivateKey: admin.sshPrivateKey,
enableDockerCleanup: admin.enableDockerCleanup,
enableLogRotation: admin.enableLogRotation,
enablePaidFeatures: admin.enablePaidFeatures,
metricsConfig: admin.metricsConfig,
cleanupCacheApplications: admin.cleanupCacheApplications,
cleanupCacheOnPreviews: admin.cleanupCacheOnPreviews,
cleanupCacheOnCompose: admin.cleanupCacheOnCompose,
stripeCustomerId: admin.stripeCustomerId,
stripeSubscriptionId: admin.stripeSubscriptionId,
serversQuantity: admin.serversQuantity,
})
.returning()
.then((user) => user[0]);
await db.insert(schema.account).values({
providerId: "credential",
userId: user?.id || "",
password: admin.auth.password,
is2FAEnabled: admin.auth.is2FAEnabled || false,
createdAt: new Date(admin.auth.createdAt) || new Date(),
updatedAt: new Date(admin.auth.createdAt) || new Date(),
});
const organization = await db
.insert(schema.organization)
.values({
name: "My Organization",
slug: nanoid(),
ownerId: user?.id || "",
createdAt: new Date(admin.createdAt) || new Date(),
})
.returning()
.then((organization) => organization[0]);
for (const member of admin.users) {
const userTemp = await db
.insert(schema.users_temp)
.values({
id: member.userId,
email: member.auth.email,
token: member.token || "",
emailVerified: true,
updatedAt: new Date(admin.createdAt) || new Date(),
role: "user",
image: member.auth.image,
createdAt: admin.createdAt,
canAccessToAPI: member.canAccessToAPI || false,
canAccessToDocker: member.canAccessToDocker || false,
canAccessToGitProviders: member.canAccessToGitProviders || false,
canAccessToSSHKeys: member.canAccessToSSHKeys || false,
canAccessToTraefikFiles: member.canAccessToTraefikFiles || false,
canCreateProjects: member.canCreateProjects || false,
canCreateServices: member.canCreateServices || false,
canDeleteProjects: member.canDeleteProjects || false,
canDeleteServices: member.canDeleteServices || false,
accessedProjects: member.accessedProjects || [],
accessedServices: member.accessedServices || [],
})
.returning()
.then((userTemp) => userTemp[0]);
await db.insert(schema.account).values({
providerId: "credential",
userId: member?.userId || "",
password: member.auth.password,
is2FAEnabled: member.auth.is2FAEnabled || false,
createdAt: new Date(member.auth.createdAt) || new Date(),
updatedAt: new Date(member.auth.createdAt) || new Date(),
});
await db.insert(schema.member).values({
organizationId: organization?.id || "",
userId: userTemp?.id || "",
role: "admin",
createdAt: new Date(member.createdAt) || new Date(),
});
}
}
})
.then(() => {
console.log("Migration finished");
})
.catch((error) => {
console.error(error);
});
await db
.transaction(async (db) => {
const projects = await db.query.projects.findMany({
with: {
user: {
with: {
organizations: true,
},
},
},
});
for (const project of projects) {
const user = await db.update(schema.projects).set({
organizationId: project.user.organizations[0]?.id || "",
});
}
})
.then(() => {
console.log("Migration finished");
})
.catch((error) => {
console.error(error);
});

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.18.1",
"version": "v0.18.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -16,6 +16,7 @@
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
"migration:run": "tsx -r dotenv/config migration.ts",
"manual-migration:run": "tsx -r dotenv/config migrate.ts",
"migration:up": "drizzle-kit up --config ./server/db/drizzle.config.ts",
"migration:drop": "drizzle-kit drop --config ./server/db/drizzle.config.ts",
"db:push": "drizzle-kit push --config ./server/db/drizzle.config.ts",
@@ -35,6 +36,7 @@
"test": "vitest --config __test__/vitest.config.ts"
},
"dependencies": {
"better-auth": "1.1.16",
"bl": "6.0.11",
"rotating-file-stream": "3.2.3",
"qrcode": "^1.5.3",

View File

@@ -0,0 +1,30 @@
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/router";
export const AcceptInvitation = () => {
const { query } = useRouter();
const invitationId = query["accept-invitation"];
// const { data: organization } = api.organization.getById.useQuery({
// id: id as string
// })
return (
<div>
<Button
onClick={async () => {
const result = await authClient.organization.acceptInvitation({
invitationId: invitationId as string,
});
console.log(result);
}}
>
Accept Invitation
</Button>
</div>
);
};
export default AcceptInvitation;

View File

@@ -0,0 +1,7 @@
import { auth } from "@dokploy/server/index";
import { toNodeHandler } from "better-auth/node";
// Disallow body parsing, we will parse it manually
export const config = { api: { bodyParser: false } };
export default toNodeHandler(auth.handler);

View File

@@ -1,10 +1,12 @@
import { db } from "@/server/db";
import { github } from "@/server/db/schema";
import {
auth,
createGithub,
findAdminByAuthId,
findAuthById,
findUserByAuthId,
findUserById,
} from "@dokploy/server";
import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -28,7 +30,7 @@ export default async function handler(
return res.status(400).json({ error: "Missing code parameter" });
}
const [action, value] = state?.split(":");
// Value could be the authId or the githubProviderId
// Value could be the organizationId or the githubProviderId
if (action === "gh_init") {
const octokit = new Octokit({});
@@ -39,17 +41,6 @@ export default async function handler(
},
);
const auth = await findAuthById(value as string);
let adminId = "";
if (auth.rol === "admin") {
const admin = await findAdminByAuthId(auth.id);
adminId = admin.adminId;
} else {
const user = await findUserByAuthId(auth.id);
adminId = user.adminId;
}
await createGithub(
{
name: data.name,
@@ -60,7 +51,7 @@ export default async function handler(
githubWebhookSecret: data.webhook_secret,
githubPrivateKey: data.pem,
},
adminId,
value as string,
);
} else if (action === "gh_setup") {
await db

View File

@@ -1,7 +1,7 @@
import { buffer } from "node:stream/consumers";
import { db } from "@/server/db";
import { admins, server } from "@/server/db/schema";
import { findAdminById } from "@dokploy/server";
import { server, users_temp } from "@/server/db/schema";
import { findAdminById, findUserById } from "@dokploy/server";
import { asc, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
@@ -64,33 +64,35 @@ export default async function handler(
session.subscription as string,
);
await db
.update(admins)
.update(users_temp)
.set({
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
serversQuantity: subscription?.items?.data?.[0]?.quantity ?? 0,
})
.where(eq(admins.adminId, adminId))
.where(eq(users_temp.id, adminId))
.returning();
const admin = await findAdminById(adminId);
const admin = await findUserById(adminId);
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
const newServersQuantity = admin.serversQuantity;
await updateServersBasedOnQuantity(admin.adminId, newServersQuantity);
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
break;
}
case "customer.subscription.created": {
const newSubscription = event.data.object as Stripe.Subscription;
await db
.update(admins)
.update(users_temp)
.set({
stripeSubscriptionId: newSubscription.id,
stripeCustomerId: newSubscription.customer as string,
})
.where(eq(admins.stripeCustomerId, newSubscription.customer as string))
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
)
.returning();
break;
@@ -100,14 +102,16 @@ export default async function handler(
const newSubscription = event.data.object as Stripe.Subscription;
await db
.update(admins)
.update(users_temp)
.set({
stripeSubscriptionId: null,
serversQuantity: 0,
})
.where(eq(admins.stripeCustomerId, newSubscription.customer as string));
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
);
const admin = await findAdminByStripeCustomerId(
const admin = await findUserByStripeCustomerId(
newSubscription.customer as string,
);
@@ -115,13 +119,13 @@ export default async function handler(
return res.status(400).send("Webhook Error: Admin not found");
}
await disableServers(admin.adminId);
await disableServers(admin.id);
break;
}
case "customer.subscription.updated": {
const newSubscription = event.data.object as Stripe.Subscription;
const admin = await findAdminByStripeCustomerId(
const admin = await findUserByStripeCustomerId(
newSubscription.customer as string,
);
@@ -131,23 +135,23 @@ export default async function handler(
if (newSubscription.status === "active") {
await db
.update(admins)
.update(users_temp)
.set({
serversQuantity: newSubscription?.items?.data?.[0]?.quantity ?? 0,
})
.where(
eq(admins.stripeCustomerId, newSubscription.customer as string),
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
);
const newServersQuantity = admin.serversQuantity;
await updateServersBasedOnQuantity(admin.adminId, newServersQuantity);
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
} else {
await disableServers(admin.adminId);
await disableServers(admin.id);
await db
.update(admins)
.update(users_temp)
.set({ serversQuantity: 0 })
.where(
eq(admins.stripeCustomerId, newSubscription.customer as string),
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
);
}
@@ -174,7 +178,7 @@ export default async function handler(
})
.where(eq(admins.stripeCustomerId, suscription.customer as string));
const admin = await findAdminByStripeCustomerId(
const admin = await findUserByStripeCustomerId(
suscription.customer as string,
);
@@ -182,7 +186,7 @@ export default async function handler(
return res.status(400).send("Webhook Error: Admin not found");
}
const newServersQuantity = admin.serversQuantity;
await updateServersBasedOnQuantity(admin.adminId, newServersQuantity);
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
break;
}
case "invoice.payment_failed": {
@@ -193,7 +197,7 @@ export default async function handler(
);
if (subscription.status !== "active") {
const admin = await findAdminByStripeCustomerId(
const admin = await findUserByStripeCustomerId(
newInvoice.customer as string,
);
@@ -207,7 +211,7 @@ export default async function handler(
})
.where(eq(admins.stripeCustomerId, newInvoice.customer as string));
await disableServers(admin.adminId);
await disableServers(admin.id);
}
break;
@@ -216,20 +220,20 @@ export default async function handler(
case "customer.deleted": {
const customer = event.data.object as Stripe.Customer;
const admin = await findAdminByStripeCustomerId(customer.id);
const admin = await findUserByStripeCustomerId(customer.id);
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
await disableServers(admin.adminId);
await disableServers(admin.id);
await db
.update(admins)
.update(users_temp)
.set({
stripeCustomerId: null,
stripeSubscriptionId: null,
serversQuantity: 0,
})
.where(eq(admins.stripeCustomerId, customer.id));
.where(eq(users_temp.stripeCustomerId, customer.id));
break;
}
@@ -240,20 +244,20 @@ export default async function handler(
return res.status(200).json({ received: true });
}
const disableServers = async (adminId: string) => {
const disableServers = async (userId: string) => {
await db
.update(server)
.set({
serverStatus: "inactive",
})
.where(eq(server.adminId, adminId));
.where(eq(server.userId, userId));
};
const findAdminByStripeCustomerId = async (stripeCustomerId: string) => {
const admin = db.query.admins.findFirst({
where: eq(admins.stripeCustomerId, stripeCustomerId),
const findUserByStripeCustomerId = async (stripeCustomerId: string) => {
const user = db.query.users_temp.findFirst({
where: eq(users_temp.stripeCustomerId, stripeCustomerId),
});
return admin;
return user;
};
const activateServer = async (serverId: string) => {
@@ -270,19 +274,19 @@ const deactivateServer = async (serverId: string) => {
.where(eq(server.serverId, serverId));
};
export const findServersByAdminIdSorted = async (adminId: string) => {
export const findServersByUserIdSorted = async (userId: string) => {
const servers = await db.query.server.findMany({
where: eq(server.adminId, adminId),
where: eq(server.userId, userId),
orderBy: asc(server.createdAt),
});
return servers;
};
export const updateServersBasedOnQuantity = async (
adminId: string,
userId: string,
newServersQuantity: number,
) => {
const servers = await findServersByAdminIdSorted(adminId);
const servers = await findServersByUserIdSorted(userId);
if (servers.length > newServersQuantity) {
for (const [index, server] of servers.entries()) {

View File

@@ -1,7 +1,8 @@
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import React, { type ReactElement } from "react";
@@ -27,7 +28,7 @@ export async function getServerSideProps(
},
};
}
const { user, session } = await validateRequest(ctx.req, ctx.res);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
@@ -44,21 +45,20 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});
try {
await helpers.project.all.prefetch();
const auth = await helpers.auth.get.fetch();
if (auth.rol === "user") {
const user = await helpers.user.byAuthId.fetch({
authId: auth.id,
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!user.canAccessToDocker) {
if (!userR.canAccessToDocker) {
return {
redirect: {
permanent: true,

View File

@@ -8,7 +8,7 @@ import { Switch } from "@/components/ui/switch";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { api } from "@/utils/api";
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/index";
import { validateRequest } from "@dokploy/server/lib/auth";
import { Loader2 } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import type React from "react";
@@ -25,7 +25,7 @@ const Dashboard = () => {
false,
);
const { data: monitoring, isLoading } = api.admin.getMetricsToken.useQuery();
const { data: monitoring, isLoading } = api.user.getMetricsToken.useQuery();
return (
<div className="space-y-4 pb-10">
{/* <AlertBlock>
@@ -104,7 +104,7 @@ export async function getServerSideProps(
},
};
}
const { user } = await validateRequest(ctx.req, ctx.res);
const { user } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {

View File

@@ -13,6 +13,7 @@ import {
import { ProjectLayout } from "@/components/layouts/project-layout";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
@@ -23,6 +24,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
@@ -47,24 +49,29 @@ import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import type { findProjectById } from "@dokploy/server";
import { validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import {
Ban,
Check,
CheckCircle2,
ChevronsUpDown,
CircuitBoard,
FolderInput,
GlobeIcon,
Loader2,
PlusIcon,
Search,
X,
} from "lucide-react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import React, { useMemo, useState, type ReactElement } from "react";
import { type ReactElement, useMemo, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
export type Services = {
@@ -191,17 +198,11 @@ export const extractServices = (data: Project | undefined) => {
const Project = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
) => {
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
const { projectId } = props;
const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { data, isLoading } = api.project.one.useQuery({ projectId });
const { data: auth } = api.user.get.useQuery();
const { data, isLoading, refetch } = api.project.one.useQuery({ projectId });
const router = useRouter();
const emptyServices =
@@ -228,6 +229,70 @@ const Project = (
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
const [openCombobox, setOpenCombobox] = useState(false);
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const handleSelectAll = () => {
if (selectedServices.length === filteredServices.length) {
setSelectedServices([]);
} else {
setSelectedServices(filteredServices.map((service) => service.id));
}
};
const handleServiceSelect = (serviceId: string, event: React.MouseEvent) => {
event.stopPropagation();
setSelectedServices((prev) =>
prev.includes(serviceId)
? prev.filter((id) => id !== serviceId)
: [...prev, serviceId],
);
};
const composeActions = {
start: api.compose.start.useMutation(),
stop: api.compose.stop.useMutation(),
};
const handleBulkStart = async () => {
let success = 0;
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
try {
await composeActions.start.mutateAsync({ composeId: serviceId });
success++;
} catch (error) {
toast.error(`Error starting service ${serviceId}`);
}
}
if (success > 0) {
toast.success(`${success} services started successfully`);
refetch();
}
setIsBulkActionLoading(false);
setSelectedServices([]);
setIsDropdownOpen(false);
};
const handleBulkStop = async () => {
let success = 0;
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
try {
await composeActions.stop.mutateAsync({ composeId: serviceId });
success++;
} catch (error) {
toast.error(`Error stopping service ${serviceId}`);
}
}
if (success > 0) {
toast.success(`${success} services stopped successfully`);
refetch();
}
setSelectedServices([]);
setIsDropdownOpen(false);
setIsBulkActionLoading(false);
};
const filteredServices = useMemo(() => {
if (!applications) return [];
@@ -263,7 +328,7 @@ const Project = (
</CardTitle>
<CardDescription>{data?.description}</CardDescription>
</CardHeader>
{(auth?.rol === "admin" || user?.canCreateServices) && (
{(auth?.role === "owner" || auth?.user?.canCreateServices) && (
<div className="flex flex-row gap-4 flex-wrap">
<ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button>
@@ -309,78 +374,151 @@ const Project = (
</div>
) : (
<>
<div className="flex flex-row gap-2 items-center">
<div className="w-full relative">
<Input
placeholder="Filter services..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-10"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedServices.length > 0}
className={cn(
"data-[state=checked]:bg-primary",
selectedServices.length > 0 &&
selectedServices.length <
filteredServices.length &&
"bg-primary/50",
)}
onCheckedChange={handleSelectAll}
/>
<span className="text-sm">
Select All{" "}
{selectedServices.length > 0 &&
`(${selectedServices.length}/${filteredServices.length})`}
</span>
</div>
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
disabled={selectedServices.length === 0}
isLoading={isBulkActionLoading}
>
Bulk Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DialogAction
title="Start Services"
description={`Are you sure you want to start ${selectedServices.length} services?`}
type="default"
onClick={handleBulkStart}
>
<Button
variant="ghost"
className="w-full justify-start"
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Start
</Button>
</DialogAction>
<DialogAction
title="Stop Services"
description={`Are you sure you want to stop ${selectedServices.length} services?`}
type="destructive"
onClick={handleBulkStop}
>
<Button
variant="ghost"
className="w-full justify-start text-destructive"
>
<Ban className="mr-2 h-4 w-4" />
Stop
</Button>
</DialogAction>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Popover open={openCombobox} onOpenChange={setOpenCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={openCombobox}
className="min-w-[200px] justify-between"
>
{selectedTypes.length === 0
? "Select types..."
: `${selectedTypes.length} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search type..." />
<CommandEmpty>No type found.</CommandEmpty>
<CommandGroup>
{serviceTypes.map((type) => (
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center">
<div className="w-full relative">
<Input
placeholder="Filter services..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-10"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<Popover
open={openCombobox}
onOpenChange={setOpenCombobox}
>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={openCombobox}
className="min-w-[200px] justify-between"
>
{selectedTypes.length === 0
? "Select types..."
: `${selectedTypes.length} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search type..." />
<CommandEmpty>No type found.</CommandEmpty>
<CommandGroup>
{serviceTypes.map((type) => (
<CommandItem
key={type.value}
onSelect={() => {
setSelectedTypes((prev) =>
prev.includes(type.value)
? prev.filter((t) => t !== type.value)
: [...prev, type.value],
);
setOpenCombobox(false);
}}
>
<div className="flex flex-row">
<Check
className={cn(
"mr-2 h-4 w-4",
selectedTypes.includes(type.value)
? "opacity-100"
: "opacity-0",
)}
/>
{type.icon && (
<type.icon className="mr-2 h-4 w-4" />
)}
{type.label}
</div>
</CommandItem>
))}
<CommandItem
key={type.value}
onSelect={() => {
setSelectedTypes((prev) =>
prev.includes(type.value)
? prev.filter((t) => t !== type.value)
: [...prev, type.value],
);
setSelectedTypes([]);
setOpenCombobox(false);
}}
className="border-t"
>
<div className="flex flex-row">
<Check
className={cn(
"mr-2 h-4 w-4",
selectedTypes.includes(type.value)
? "opacity-100"
: "opacity-0",
)}
/>
{type.icon && (
<type.icon className="mr-2 h-4 w-4" />
)}
{type.label}
<div className="flex flex-row items-center">
<X className="mr-2 h-4 w-4" />
Clear filters
</div>
</CommandItem>
))}
<CommandItem
onSelect={() => {
setSelectedTypes([]);
setOpenCombobox(false);
}}
className="border-t"
>
<div className="flex flex-row items-center">
<X className="mr-2 h-4 w-4" />
Clear filters
</div>
</CommandItem>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex w-full gap-8">
@@ -418,6 +556,27 @@ const Project = (
<StatusTooltip status={service.status} />
</div>
<div
className={cn(
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
selectedServices.includes(service.id)
? "opacity-100 translate-y-0"
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
)}
onClick={(e) =>
handleServiceSelect(service.id, e)
}
>
<div className="h-full w-full flex items-center justify-center">
<Checkbox
checked={selectedServices.includes(
service.id,
)}
className="data-[state=checked]:bg-primary"
/>
</div>
</div>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex flex-row items-center gap-2 justify-between w-full">
@@ -492,7 +651,7 @@ export async function getServerSideProps(
const { params } = ctx;
const { req, res } = ctx;
const { user, session } = await validateRequest(req, res);
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
@@ -508,8 +667,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -39,7 +39,7 @@ import {
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { GlobeIcon, HelpCircle, ServerOff, Trash2 } from "lucide-react";
@@ -86,16 +86,8 @@ const Service = (
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.auth.get.useQuery();
const { data: monitoring } = api.admin.getMetricsToken.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { data: auth } = api.user.get.useQuery();
const { data: monitoring } = api.user.getMetricsToken.useQuery();
return (
<div className="pb-10">
@@ -186,7 +178,8 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateApplication applicationId={applicationId} />
{(auth?.rol === "admin" || user?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.user?.canDeleteServices) && (
<DeleteService id={applicationId} type="application" />
)}
</div>
@@ -370,7 +363,7 @@ export async function getServerSideProps(
const { query, params, req, res } = ctx;
const activeTab = query.tab;
const { user, session } = await validateRequest(req, res);
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
@@ -386,8 +379,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -33,7 +33,7 @@ import {
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { CircuitBoard, ServerOff } from "lucide-react";
@@ -79,17 +79,9 @@ const Service = (
},
);
const { data: auth } = api.auth.get.useQuery();
const { data: monitoring } = api.admin.getMetricsToken.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: monitoring } = api.user.getMetricsToken.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
return (
<div className="pb-10">
@@ -181,7 +173,8 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateCompose composeId={composeId} />
{(auth?.rol === "admin" || user?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.user?.canDeleteServices) && (
<DeleteService id={composeId} type="compose" />
)}
</div>
@@ -366,7 +359,7 @@ export async function getServerSideProps(
const { query, params, req, res } = ctx;
const activeTab = query.tab;
const { user, session } = await validateRequest(req, res);
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
@@ -382,8 +375,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -35,7 +35,7 @@ import {
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { HelpCircle, ServerOff, Trash2 } from "lucide-react";
import type {
@@ -61,16 +61,9 @@ const Mariadb = (
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mariadb.one.useQuery({ mariadbId });
const { data: auth } = api.auth.get.useQuery();
const { data: monitoring } = api.admin.getMetricsToken.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { data: auth } = api.user.get.useQuery();
const { data: monitoring } = api.user.getMetricsToken.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
@@ -154,7 +147,8 @@ const Mariadb = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateMariadb mariadbId={mariadbId} />
{(auth?.rol === "admin" || user?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.user?.canDeleteServices) && (
<DeleteService id={mariadbId} type="mariadb" />
)}
</div>
@@ -316,7 +310,7 @@ export async function getServerSideProps(
const { query, params, req, res } = ctx;
const activeTab = query.tab;
const { user, session } = await validateRequest(req, res);
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
@@ -332,8 +326,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -35,7 +35,7 @@ import {
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { HelpCircle, ServerOff, Trash2 } from "lucide-react";
import type {
@@ -61,16 +61,8 @@ const Mongo = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mongo.one.useQuery({ mongoId });
const { data: auth } = api.auth.get.useQuery();
const { data: monitoring } = api.admin.getMetricsToken.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { data: auth } = api.user.get.useQuery();
const { data: monitoring } = api.user.getMetricsToken.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -156,7 +148,8 @@ const Mongo = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMongo mongoId={mongoId} />
{(auth?.rol === "admin" || user?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.user?.canDeleteServices) && (
<DeleteService id={mongoId} type="mongo" />
)}
</div>
@@ -318,7 +311,7 @@ export async function getServerSideProps(
const { query, params, req, res } = ctx;
const activeTab = query.tab;
const { user, session } = await validateRequest(req, res);
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
@@ -334,8 +327,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -35,7 +35,7 @@ import {
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { HelpCircle, ServerOff, Trash2 } from "lucide-react";
import type {
@@ -60,16 +60,8 @@ const MySql = (
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mysql.one.useQuery({ mysqlId });
const { data: auth } = api.auth.get.useQuery();
const { data: monitoring } = api.admin.getMetricsToken.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { data: auth } = api.user.get.useQuery();
const { data: monitoring } = api.user.getMetricsToken.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -156,7 +148,8 @@ const MySql = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMysql mysqlId={mysqlId} />
{(auth?.rol === "admin" || user?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.user?.canDeleteServices) && (
<DeleteService id={mysqlId} type="mysql" />
)}
</div>
@@ -323,7 +316,7 @@ export async function getServerSideProps(
const { query, params, req, res } = ctx;
const activeTab = query.tab;
const { user, session } = await validateRequest(req, res);
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
@@ -339,8 +332,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -35,7 +35,7 @@ import {
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { HelpCircle, ServerOff, Trash2 } from "lucide-react";
import type {
@@ -60,16 +60,9 @@ const Postgresql = (
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.postgres.one.useQuery({ postgresId });
const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { data: monitoring } = api.admin.getMetricsToken.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: monitoring } = api.user.getMetricsToken.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
@@ -154,7 +147,8 @@ const Postgresql = (
<div className="flex flex-row gap-2 justify-end">
<UpdatePostgres postgresId={postgresId} />
{(auth?.rol === "admin" || user?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.user?.canDeleteServices) && (
<DeleteService id={postgresId} type="postgres" />
)}
</div>
@@ -319,7 +313,7 @@ export async function getServerSideProps(
) {
const { query, params, req, res } = ctx;
const activeTab = query.tab;
const { user, session } = await validateRequest(req, res);
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
@@ -335,8 +329,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -34,7 +34,7 @@ import {
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
@@ -60,16 +60,8 @@ const Redis = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.redis.one.useQuery({ redisId });
const { data: auth } = api.auth.get.useQuery();
const { data: monitoring } = api.admin.getMetricsToken.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { data: auth } = api.user.get.useQuery();
const { data: monitoring } = api.user.getMetricsToken.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -155,7 +147,8 @@ const Redis = (
<div className="flex flex-row gap-2 justify-end">
<UpdateRedis redisId={redisId} />
{(auth?.rol === "admin" || user?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.user?.canDeleteServices) && (
<DeleteService id={redisId} type="redis" />
)}
</div>
@@ -311,7 +304,7 @@ export async function getServerSideProps(
const { query, params, req, res } = ctx;
const activeTab = query.tab;
const { user, session } = await validateRequest(req, res);
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
@@ -327,8 +320,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -2,7 +2,7 @@ import { ShowProjects } from "@/components/dashboard/projects/show";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import dynamic from "next/dynamic";
@@ -38,7 +38,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req, res);
const { user, session } = await validateRequest(req);
const helpers = createServerSideHelpers({
router: appRouter,
@@ -46,8 +46,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -1,6 +1,7 @@
import { ShowRequests } from "@/components/dashboard/requests/show-requests";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import * as React from "react";
@@ -22,7 +23,7 @@ export async function getServerSideProps(
},
};
}
const { user } = await validateRequest(ctx.req, ctx.res);
const { user } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {

View File

@@ -2,7 +2,8 @@ import { ShowBilling } from "@/components/dashboard/settings/billing/show-billin
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import React, { type ReactElement } from "react";
@@ -29,8 +30,8 @@ export async function getServerSideProps(
};
}
const { req, res } = ctx;
const { user, session } = await validateRequest(req, res);
if (!user || user.rol === "user") {
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,
@@ -45,8 +46,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -24,8 +24,8 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req, res);
if (!user || user.rol === "user") {
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,
@@ -40,8 +40,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -33,8 +33,8 @@ export async function getServerSideProps(
},
};
}
const { user, session } = await validateRequest(ctx.req, ctx.res);
if (!user || user.rol === "user") {
const { user, session } = await validateRequest(ctx.req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,
@@ -48,8 +48,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -25,8 +25,8 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req, res);
if (!user || user.rol === "user") {
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,
@@ -41,8 +41,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -24,7 +24,7 @@ Page.getLayout = (page: ReactElement) => {
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { user, session } = await validateRequest(ctx.req, ctx.res);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
@@ -40,8 +40,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});
@@ -49,14 +49,12 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
await helpers.settings.isCloud.prefetch();
const auth = await helpers.auth.get.fetch();
if (auth.rol === "user") {
const user = await helpers.user.byAuthId.fetch({
authId: auth.id,
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!user.canAccessToGitProviders) {
if (!userR.canAccessToGitProviders) {
return {
redirect: {
permanent: true,

View File

@@ -42,9 +42,9 @@ const settings = z.object({
type SettingsType = z.infer<typeof settings>;
const Page = () => {
const { data, refetch } = api.admin.one.useQuery();
const { data, refetch } = api.user.get.useQuery();
const { mutateAsync, isLoading, isError, error } =
api.admin.update.useMutation();
api.user.update.useMutation();
const form = useForm<SettingsType>({
defaultValues: {
cleanCacheOnApplications: false,
@@ -55,9 +55,9 @@ const Page = () => {
});
useEffect(() => {
form.reset({
cleanCacheOnApplications: data?.cleanupCacheApplications || false,
cleanCacheOnCompose: data?.cleanupCacheOnCompose || false,
cleanCacheOnPreviews: data?.cleanupCacheOnPreviews || false,
cleanCacheOnApplications: data?.user.cleanupCacheApplications || false,
cleanCacheOnCompose: data?.user.cleanupCacheOnCompose || false,
cleanCacheOnPreviews: data?.user.cleanupCacheOnPreviews || false,
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
@@ -181,7 +181,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(ctx.req, ctx.res);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
@@ -190,7 +190,7 @@ export async function getServerSideProps(
},
};
}
if (user.rol === "user") {
if (user.role === "member") {
return {
redirect: {
permanent: true,
@@ -205,8 +205,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -25,8 +25,8 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req, res);
if (!user || user.rol === "user") {
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,
@@ -41,8 +41,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -13,22 +13,16 @@ import React, { type ReactElement } from "react";
import superjson from "superjson";
const Page = () => {
const { data } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: data?.id || "",
},
{
enabled: !!data?.id && data?.rol === "user",
},
);
const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<ProfileForm />
{(user?.canAccessToAPI || data?.rol === "admin") && <GenerateToken />}
{(data?.user?.canAccessToAPI || data?.role === "owner") && (
<GenerateToken />
)}
{isCloud && <RemoveSelfAccount />}
</div>
@@ -46,7 +40,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const locale = getLocale(req.cookies);
const { user, session } = await validateRequest(req, res);
const { user, session } = await validateRequest(req);
const helpers = createServerSideHelpers({
router: appRouter,
@@ -54,18 +48,21 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.settings.isCloud.prefetch();
await helpers.auth.get.prefetch();
if (user?.rol === "user") {
await helpers.user.byAuthId.prefetch({
authId: user.authId,
});
if (user?.role === "member") {
// const userR = await helpers.user.one.fetch({
// userId: user.id,
// });
// await helpers.user.byAuthId.prefetch({
// authId: user.authId,
// });
}
if (!user) {

View File

@@ -25,8 +25,8 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req, res);
if (!user || user.rol === "user") {
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,
@@ -40,8 +40,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -2,19 +2,7 @@ import { SetupMonitoring } from "@/components/dashboard/settings/servers/setup-m
import { WebDomain } from "@/components/dashboard/settings/web-domain";
import { WebServer } from "@/components/dashboard/settings/web-server";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
@@ -25,8 +13,6 @@ import { toast } from "sonner";
import superjson from "superjson";
const Page = () => {
const { data, refetch } = api.admin.one.useQuery();
const { mutateAsync: update } = api.admin.update.useMutation();
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
@@ -98,7 +84,7 @@ export async function getServerSideProps(
},
};
}
const { user, session } = await validateRequest(ctx.req, ctx.res);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
@@ -107,7 +93,7 @@ export async function getServerSideProps(
},
};
}
if (user.rol === "user") {
if (user.role === "member") {
return {
redirect: {
permanent: true,
@@ -122,8 +108,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -27,7 +27,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(req, res);
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
@@ -36,7 +36,7 @@ export async function getServerSideProps(
},
};
}
if (user.rol === "user") {
if (user.role === "member") {
return {
redirect: {
permanent: true,
@@ -51,8 +51,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -24,7 +24,7 @@ Page.getLayout = (page: ReactElement) => {
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { user, session } = await validateRequest(ctx.req, ctx.res);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
@@ -40,23 +40,22 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});
try {
await helpers.project.all.prefetch();
const auth = await helpers.auth.get.fetch();
await helpers.settings.isCloud.prefetch();
if (auth.rol === "user") {
const user = await helpers.user.byAuthId.fetch({
authId: auth.id,
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!user.canAccessToSSHKeys) {
if (!userR.canAccessToSSHKeys) {
return {
redirect: {
permanent: true,

View File

@@ -1,3 +1,4 @@
import { ShowInvitations } from "@/components/dashboard/settings/users/show-invitations";
import { ShowUsers } from "@/components/dashboard/settings/users/show-users";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
@@ -12,6 +13,7 @@ const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowUsers />
<ShowInvitations />
</div>
);
};
@@ -25,8 +27,10 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req, res);
if (!user || user.rol === "user") {
const { user, session } = await validateRequest(req);
console.log("user", user, session);
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,
@@ -41,8 +45,8 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});

View File

@@ -1,7 +1,8 @@
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
@@ -27,7 +28,7 @@ export async function getServerSideProps(
},
};
}
const { user, session } = await validateRequest(ctx.req, ctx.res);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
@@ -44,21 +45,20 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});
try {
await helpers.project.all.prefetch();
const auth = await helpers.auth.get.fetch();
if (auth.rol === "user") {
const user = await helpers.user.byAuthId.fetch({
authId: auth.id,
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!user.canAccessToDocker) {
if (!userR.canAccessToDocker) {
return {
redirect: {
permanent: true,

View File

@@ -1,7 +1,8 @@
import { ShowTraefikSystem } from "@/components/dashboard/file-system/show-traefik-system";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import React, { type ReactElement } from "react";
@@ -27,7 +28,7 @@ export async function getServerSideProps(
},
};
}
const { user, session } = await validateRequest(ctx.req, ctx.res);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
@@ -44,21 +45,20 @@ export async function getServerSideProps(
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
session: session as any,
user: user as any,
},
transformer: superjson,
});
try {
await helpers.project.all.prefetch();
const auth = await helpers.auth.get.fetch();
if (auth.rol === "user") {
const user = await helpers.user.byAuthId.fetch({
authId: auth.id,
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!user.canAccessToTraefikFiles) {
if (!userR.canAccessToTraefikFiles) {
return {
redirect: {
permanent: true,

View File

@@ -3,99 +3,177 @@ import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import { Logo } from "@/components/shared/logo";
import { Button, buttonVariants } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import { CardContent, CardDescription } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
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 {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
import { IS_CLOUD, auth, isAdminPresent } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import base32 from "hi-base32";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { TOTP } from "otpauth";
import { type ReactElement, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const loginSchema = z.object({
email: z
.string()
.min(1, {
message: "Email is required",
})
.email({
message: "Email must be a valid email",
}),
password: z
.string()
.min(1, {
message: "Password is required",
})
.min(8, {
message: "Password must be at least 8 characters",
}),
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type Login = z.infer<typeof loginSchema>;
const TwoFactorSchema = z.object({
code: z.string().min(6),
});
type AuthResponse = {
is2FAEnabled: boolean;
authId: string;
};
const BackupCodeSchema = z.object({
code: z.string().min(8, {
message: "Backup code must be at least 8 characters",
}),
});
type LoginForm = z.infer<typeof LoginSchema>;
type BackupCodeForm = z.infer<typeof BackupCodeSchema>;
interface Props {
IS_CLOUD: boolean;
}
export default function Home({ IS_CLOUD }: Props) {
const [temp, setTemp] = useState<AuthResponse>({
is2FAEnabled: false,
authId: "",
});
const { mutateAsync, isLoading, error, isError } =
api.auth.login.useMutation();
const router = useRouter();
const form = useForm<Login>({
const [isLoginLoading, setIsLoginLoading] = useState(false);
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
const [isTwoFactor, setIsTwoFactor] = useState(false);
const [error, setError] = useState<string | null>(null);
const [twoFactorCode, setTwoFactorCode] = useState("");
const [isBackupCodeModalOpen, setIsBackupCodeModalOpen] = useState(false);
const [backupCode, setBackupCode] = useState("");
const loginForm = useForm<LoginForm>({
resolver: zodResolver(LoginSchema),
defaultValues: {
email: "",
password: "",
email: "siumauricio@hotmail.com",
password: "Password123",
},
resolver: zodResolver(loginSchema),
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (values: Login) => {
await mutateAsync({
email: values.email.toLowerCase(),
password: values.password,
})
.then((data) => {
if (data.is2FAEnabled) {
setTemp(data);
} else {
toast.success("Successfully signed in", {
duration: 2000,
});
router.push("/dashboard/projects");
}
})
.catch(() => {
toast.error("Signin failed", {
duration: 2000,
});
const onSubmit = async (values: LoginForm) => {
setIsLoginLoading(true);
try {
const { data, error } = await authClient.signIn.email({
email: values.email,
password: values.password,
});
if (error) {
toast.error(error.message);
setError(error.message || "An error occurred while logging in");
return;
}
if (data?.twoFactorRedirect as boolean) {
setTwoFactorCode("");
setIsTwoFactor(true);
toast.info("Please enter your 2FA code");
return;
}
toast.success("Logged in successfully");
router.push("/dashboard/projects");
} catch (error) {
toast.error("An error occurred while logging in");
} finally {
setIsLoginLoading(false);
}
};
const onTwoFactorSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (twoFactorCode.length !== 6) {
toast.error("Please enter a valid 6-digit code");
return;
}
setIsTwoFactorLoading(true);
try {
const { data, error } = await authClient.twoFactor.verifyTotp({
code: twoFactorCode.replace(/\s/g, ""),
});
if (error) {
toast.error(error.message);
setError(error.message || "An error occurred while verifying 2FA code");
return;
}
toast.success("Logged in successfully");
router.push("/dashboard/projects");
} catch (error) {
toast.error("An error occurred while verifying 2FA code");
} finally {
setIsTwoFactorLoading(false);
}
};
const onBackupCodeSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (backupCode.length < 8) {
toast.error("Please enter a valid backup code");
return;
}
setIsBackupCodeLoading(true);
try {
const { data, error } = await authClient.twoFactor.verifyBackupCode({
code: backupCode.trim(),
});
if (error) {
toast.error(error.message);
setError(
error.message || "An error occurred while verifying backup code",
);
return;
}
toast.success("Logged in successfully");
router.push("/dashboard/projects");
} catch (error) {
toast.error("An error occurred while verifying backup code");
} finally {
setIsBackupCodeLoading(false);
}
};
return (
<>
<div className="flex flex-col space-y-2 text-center">
@@ -109,55 +187,169 @@ export default function Home({ IS_CLOUD }: Props) {
Enter your email and password to sign in
</p>
</div>
{isError && (
{error && (
<AlertBlock type="error" className="my-2">
<span>{error?.message}</span>
<span>{error}</span>
</AlertBlock>
)}
<CardContent className="p-0">
{!temp.is2FAEnabled ? (
<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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" isLoading={isLoading} className="w-full">
Login
</Button>
</div>
{!isTwoFactor ? (
<Form {...loginForm}>
<form
onSubmit={loginForm.handleSubmit(onSubmit)}
className="space-y-4"
id="login-form"
>
<FormField
control={loginForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={loginForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
className="w-full"
type="submit"
isLoading={isLoginLoading}
>
Login
</Button>
</form>
</Form>
) : (
<Login2FA authId={temp.authId} />
<>
<form
onSubmit={onTwoFactorSubmit}
className="space-y-4"
id="two-factor-form"
autoComplete="off"
>
<div className="flex flex-col gap-2">
<Label>2FA Code</Label>
<InputOTP
value={twoFactorCode}
onChange={setTwoFactorCode}
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
autoComplete="off"
>
<InputOTPGroup>
<InputOTPSlot index={0} className="border-border" />
<InputOTPSlot index={1} className="border-border" />
<InputOTPSlot index={2} className="border-border" />
<InputOTPSlot index={3} className="border-border" />
<InputOTPSlot index={4} className="border-border" />
<InputOTPSlot index={5} className="border-border" />
</InputOTPGroup>
</InputOTP>
<CardDescription>
Enter the 6-digit code from your authenticator app
</CardDescription>
<button
type="button"
onClick={() => setIsBackupCodeModalOpen(true)}
className="text-sm text-muted-foreground hover:underline self-start mt-2"
>
Lost access to your authenticator app?
</button>
</div>
<div className="flex gap-4">
<Button
variant="outline"
className="w-full"
type="button"
onClick={() => {
setIsTwoFactor(false);
setTwoFactorCode("");
}}
>
Back
</Button>
<Button
className="w-full"
type="submit"
isLoading={isTwoFactorLoading}
>
Verify
</Button>
</div>
</form>
<Dialog
open={isBackupCodeModalOpen}
onOpenChange={setIsBackupCodeModalOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Enter Backup Code</DialogTitle>
<DialogDescription>
Enter one of your backup codes to access your account
</DialogDescription>
</DialogHeader>
<form onSubmit={onBackupCodeSubmit} className="space-y-4">
<div className="flex flex-col gap-2">
<Label>Backup Code</Label>
<Input
value={backupCode}
onChange={(e) => setBackupCode(e.target.value)}
placeholder="Enter your backup code"
className="font-mono"
/>
<CardDescription>
Enter one of the backup codes you received when setting up
2FA
</CardDescription>
</div>
<div className="flex gap-4">
<Button
variant="outline"
className="w-full"
type="button"
onClick={() => {
setIsBackupCodeModalOpen(false);
setBackupCode("");
}}
>
Cancel
</Button>
<Button
className="w-full"
type="submit"
isLoading={isBackupCodeLoading}
>
Verify
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
)}
<div className="flex flex-row justify-between flex-wrap">
@@ -203,8 +395,7 @@ Home.getLayout = (page: ReactElement) => {
export async function getServerSideProps(context: GetServerSidePropsContext) {
if (IS_CLOUD) {
try {
const { user } = await validateRequest(context.req, context.res);
const { user } = await validateRequest(context.req);
if (user) {
return {
redirect: {
@@ -232,7 +423,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const { user } = await validateRequest(context.req, context.res);
const { user } = await validateRequest(context.req);
if (user) {
return {

View File

@@ -1,4 +1,5 @@
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
import {
@@ -16,10 +17,11 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { IS_CLOUD, getUserByToken } from "@dokploy/server";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
import { AlertCircle, AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -30,6 +32,9 @@ import { z } from "zod";
const registerSchema = z
.object({
name: z.string().min(1, {
message: "Name is required",
}),
email: z
.string()
.min(1, {
@@ -38,7 +43,6 @@ const registerSchema = z
.email({
message: "Email must be a valid email",
}),
password: z
.string()
.min(1, {
@@ -71,11 +75,17 @@ interface Props {
token: string;
invitation: Awaited<ReturnType<typeof getUserByToken>>;
isCloud: boolean;
userAlreadyExists: boolean;
}
const Invitation = ({ token, invitation, isCloud }: Props) => {
const Invitation = ({
token,
invitation,
isCloud,
userAlreadyExists,
}: Props) => {
const router = useRouter();
const { data } = api.admin.getUserByToken.useQuery(
const { data } = api.user.getUserByToken.useQuery(
{
token,
},
@@ -90,6 +100,7 @@ const Invitation = ({ token, invitation, isCloud }: Props) => {
const form = useForm<Register>({
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
@@ -98,9 +109,9 @@ const Invitation = ({ token, invitation, isCloud }: Props) => {
});
useEffect(() => {
if (data?.auth?.email) {
if (data?.email) {
form.reset({
email: data?.auth?.email || "",
email: data?.email || "",
password: "",
confirmPassword: "",
});
@@ -108,20 +119,32 @@ const Invitation = ({ token, invitation, isCloud }: Props) => {
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (values: Register) => {
await mutateAsync({
id: data?.authId,
password: values.password,
token: token,
})
.then(() => {
toast.success("User registered successfuly", {
description:
"Please check your inbox or spam folder to confirm your account.",
duration: 100000,
});
router.push("/dashboard/projects");
})
.catch((e) => e);
try {
const { data, error } = await authClient.signUp.email({
email: values.email,
password: values.password,
name: values.name,
fetchOptions: {
headers: {
"x-dokploy-token": token,
},
},
});
if (error) {
toast.error(error.message);
return;
}
const result = await authClient.organization.acceptInvitation({
invitationId: token,
});
toast.success("Account created successfully");
router.push("/dashboard/projects");
} catch (error) {
toast.error("An error occurred while creating your account");
}
};
return (
@@ -138,114 +161,155 @@ const Invitation = ({ token, invitation, isCloud }: Props) => {
</Link>
Invitation
</CardTitle>
<CardDescription>
Fill the form below to create your account
</CardDescription>
<div className="w-full">
<div className="p-3" />
{userAlreadyExists ? (
<div className="flex flex-col gap-4 justify-center items-center">
<AlertBlock type="success">
<div className="flex flex-col gap-2">
<span className="font-medium">Valid Invitation!</span>
<span className="text-sm text-green-600 dark:text-green-400">
We detected that you already have an account with this
email. Please sign in to accept the invitation.
</span>
</div>
</AlertBlock>
{isError && (
<div className="mx-5 my-2 flex flex-row items-center gap-2 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>
)}
<Button asChild variant="default" className="w-full">
<Link href="/">Sign In</Link>
</Button>
</div>
) : (
<>
<CardDescription>
Fill the form below to create your account
</CardDescription>
<div className="w-full">
<div className="p-3" />
<CardContent className="p-0">
<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 disabled 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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isError && (
<div className="mx-5 my-2 flex flex-row items-center gap-2 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>
)}
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
isLoading={form.formState.isSubmitting}
className="w-full"
<CardContent className="p-0">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid gap-4"
>
Register
</Button>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Enter your name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
disabled
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4 text-sm flex flex-row justify-between gap-2 w-full">
{isCloud && (
<>
<Link
className="hover:underline text-muted-foreground"
href="/"
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
isLoading={form.formState.isSubmitting}
className="w-full"
>
Login
</Link>
<Link
className="hover:underline text-muted-foreground"
href="/send-reset-password"
>
Lost your password?
</Link>
</>
)}
</div>
</form>
</Form>
</CardContent>
</div>
Register
</Button>
</div>
<div className="mt-4 text-sm flex flex-row justify-between gap-2 w-full">
{isCloud && (
<>
<Link
className="hover:underline text-muted-foreground"
href="/"
>
Login
</Link>
<Link
className="hover:underline text-muted-foreground"
href="/send-reset-password"
>
Lost your password?
</Link>
</>
)}
</div>
</form>
</Form>
</CardContent>
</div>
</>
)}
</div>
</div>
</div>
);
};
// http://localhost:3000/invitation?token=CZK4BLrUdMa32RVkAdZiLsPDdvnPiAgZ
// /f7af93acc1a99eae864972ab4c92fee089f0d83473d415ede8e821e5dbabe79c
export default Invitation;
Invitation.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
@@ -254,6 +318,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { query } = ctx;
const token = query.token;
console.log("query", query);
if (typeof token !== "string") {
return {
@@ -267,6 +332,17 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
try {
const invitation = await getUserByToken(token);
if (invitation.userAlreadyExists) {
return {
props: {
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
userAlreadyExists: true,
},
};
}
if (invitation.isExpired) {
return {
redirect: {
@@ -284,6 +360,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
},
};
} catch (error) {
console.log("error", error);
return {
redirect: {
permanent: true,

Some files were not shown because too many files have changed in this diff Show More