mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: add organizations and members
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -132,19 +133,22 @@ export const ShowInvitations = () => {
|
|||||||
Actions
|
Actions
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
{/* <DropdownMenuItem
|
{invitation.status === "pending" && (
|
||||||
className="w-full cursor-pointer"
|
<DropdownMenuItem
|
||||||
onSelect={(e) => {
|
className="w-full cursor-pointer"
|
||||||
copy(
|
onSelect={(e) => {
|
||||||
`${origin}/invitation?token=${user.user.token}`,
|
copy(
|
||||||
);
|
`${origin}/invitation?token=${invitation.id}`,
|
||||||
toast.success(
|
);
|
||||||
"Invitation Copied to clipboard",
|
toast.success(
|
||||||
);
|
"Invitation Copied to clipboard",
|
||||||
}}
|
);
|
||||||
>
|
}}
|
||||||
Copy Invitation
|
>
|
||||||
</DropdownMenuItem> */}
|
Copy Invitation
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{invitation.status === "pending" && (
|
{invitation.status === "pending" && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -23,15 +24,14 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { MoreHorizontal, Users } from "lucide-react";
|
import { MoreHorizontal, Users } from "lucide-react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddUserPermissions } from "./add-permissions";
|
import { AddUserPermissions } from "./add-permissions";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
|
|
||||||
export const ShowUsers = () => {
|
export const ShowUsers = () => {
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
@@ -167,8 +167,7 @@ export const ShowUsers = () => {
|
|||||||
const { error } =
|
const { error } =
|
||||||
await authClient.organization.removeMember(
|
await authClient.organization.removeMember(
|
||||||
{
|
{
|
||||||
memberIdOrEmail:
|
memberIdOrEmail: member.id,
|
||||||
member.user.id,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ import { UserNav } from "./user-nav";
|
|||||||
|
|
||||||
// The types of the queries we are going to use
|
// The types of the queries we are going to use
|
||||||
type AuthQueryOutput = inferRouterOutputs<AppRouter>["auth"]["get"];
|
type AuthQueryOutput = inferRouterOutputs<AppRouter>["auth"]["get"];
|
||||||
type UserQueryOutput = inferRouterOutputs<AppRouter>["user"]["byAuthId"];
|
|
||||||
|
|
||||||
type SingleNavItem = {
|
type SingleNavItem = {
|
||||||
isSingle?: true;
|
isSingle?: true;
|
||||||
@@ -87,7 +86,6 @@ type SingleNavItem = {
|
|||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
isEnabled?: (opts: {
|
isEnabled?: (opts: {
|
||||||
auth?: AuthQueryOutput;
|
auth?: AuthQueryOutput;
|
||||||
user?: UserQueryOutput;
|
|
||||||
isCloud: boolean;
|
isCloud: boolean;
|
||||||
}) => boolean;
|
}) => boolean;
|
||||||
};
|
};
|
||||||
@@ -105,7 +103,6 @@ type NavItem =
|
|||||||
items: SingleNavItem[];
|
items: SingleNavItem[];
|
||||||
isEnabled?: (opts: {
|
isEnabled?: (opts: {
|
||||||
auth?: AuthQueryOutput;
|
auth?: AuthQueryOutput;
|
||||||
user?: UserQueryOutput;
|
|
||||||
isCloud: boolean;
|
isCloud: boolean;
|
||||||
}) => boolean;
|
}) => boolean;
|
||||||
};
|
};
|
||||||
@@ -118,7 +115,6 @@ type ExternalLink = {
|
|||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
isEnabled?: (opts: {
|
isEnabled?: (opts: {
|
||||||
auth?: AuthQueryOutput;
|
auth?: AuthQueryOutput;
|
||||||
user?: UserQueryOutput;
|
|
||||||
isCloud: boolean;
|
isCloud: boolean;
|
||||||
}) => boolean;
|
}) => boolean;
|
||||||
};
|
};
|
||||||
@@ -149,7 +145,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/monitoring",
|
url: "/dashboard/monitoring",
|
||||||
icon: BarChartHorizontalBigIcon,
|
icon: BarChartHorizontalBigIcon,
|
||||||
// Only enabled in non-cloud environments
|
// Only enabled in non-cloud environments
|
||||||
isEnabled: ({ auth, user, isCloud }) => !isCloud,
|
isEnabled: ({ auth, isCloud }) => !isCloud,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -157,9 +153,9 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/traefik",
|
url: "/dashboard/traefik",
|
||||||
icon: GalleryVerticalEnd,
|
icon: GalleryVerticalEnd,
|
||||||
// Only enabled for admins and users with access to Traefik files in non-cloud environments
|
// Only enabled for admins and users with access to Traefik files in non-cloud environments
|
||||||
isEnabled: ({ auth, user, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!(
|
!!(
|
||||||
(auth?.role === "owner" || user?.canAccessToTraefikFiles) &&
|
(auth?.role === "owner" || auth?.user?.canAccessToTraefikFiles) &&
|
||||||
!isCloud
|
!isCloud
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -169,8 +165,11 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/docker",
|
url: "/dashboard/docker",
|
||||||
icon: BlocksIcon,
|
icon: BlocksIcon,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, user, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!((auth?.role === "owner" || user?.canAccessToDocker) && !isCloud),
|
!!(
|
||||||
|
(auth?.role === "owner" || auth?.user?.canAccessToDocker) &&
|
||||||
|
!isCloud
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -178,8 +177,11 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/swarm",
|
url: "/dashboard/swarm",
|
||||||
icon: PieChart,
|
icon: PieChart,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, user, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!((auth?.role === "owner" || user?.canAccessToDocker) && !isCloud),
|
!!(
|
||||||
|
(auth?.role === "owner" || auth?.user?.canAccessToDocker) &&
|
||||||
|
!isCloud
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -187,8 +189,11 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/requests",
|
url: "/dashboard/requests",
|
||||||
icon: Forward,
|
icon: Forward,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, user, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!((auth?.role === "owner" || user?.canAccessToDocker) && !isCloud),
|
!!(
|
||||||
|
(auth?.role === "owner" || auth?.user?.canAccessToDocker) &&
|
||||||
|
!isCloud
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Legacy unused menu, adjusted to the new structure
|
// Legacy unused menu, adjusted to the new structure
|
||||||
@@ -255,8 +260,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/server",
|
url: "/dashboard/settings/server",
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
// Only enabled for admins in non-cloud environments
|
// Only enabled for admins in non-cloud environments
|
||||||
isEnabled: ({ auth, user, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
||||||
!!(auth?.role === "owner" && !isCloud),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -270,7 +274,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/servers",
|
url: "/dashboard/settings/servers",
|
||||||
icon: Server,
|
icon: Server,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -278,7 +282,7 @@ const MENU: Menu = {
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
url: "/dashboard/settings/users",
|
url: "/dashboard/settings/users",
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -286,8 +290,8 @@ const MENU: Menu = {
|
|||||||
icon: KeyRound,
|
icon: KeyRound,
|
||||||
url: "/dashboard/settings/ssh-keys",
|
url: "/dashboard/settings/ssh-keys",
|
||||||
// Only enabled for admins and users with access to SSH keys
|
// Only enabled for admins and users with access to SSH keys
|
||||||
isEnabled: ({ auth, user }) =>
|
isEnabled: ({ auth }) =>
|
||||||
!!(auth?.role === "owner" || user?.canAccessToSSHKeys),
|
!!(auth?.role === "owner" || auth?.user?.canAccessToSSHKeys),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -295,8 +299,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/git-providers",
|
url: "/dashboard/settings/git-providers",
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
// Only enabled for admins and users with access to Git providers
|
// Only enabled for admins and users with access to Git providers
|
||||||
isEnabled: ({ auth, user }) =>
|
isEnabled: ({ auth }) =>
|
||||||
!!(auth?.role === "owner" || user?.canAccessToGitProviders),
|
!!(auth?.role === "owner" || auth?.user?.canAccessToGitProviders),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -304,7 +308,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/registry",
|
url: "/dashboard/settings/registry",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -312,7 +316,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/destinations",
|
url: "/dashboard/settings/destinations",
|
||||||
icon: Database,
|
icon: Database,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -321,7 +325,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/certificates",
|
url: "/dashboard/settings/certificates",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -329,8 +333,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/cluster",
|
url: "/dashboard/settings/cluster",
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
// Only enabled for admins in non-cloud environments
|
// Only enabled for admins in non-cloud environments
|
||||||
isEnabled: ({ auth, user, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
||||||
!!(auth?.role === "owner" && !isCloud),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -338,7 +341,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/notifications",
|
url: "/dashboard/settings/notifications",
|
||||||
icon: Bell,
|
icon: Bell,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -346,8 +349,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/billing",
|
url: "/dashboard/settings/billing",
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
// Only enabled for admins in cloud environments
|
// Only enabled for admins in cloud environments
|
||||||
isEnabled: ({ auth, user, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
|
||||||
!!(auth?.role === "owner" && isCloud),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -383,7 +385,6 @@ const MENU: Menu = {
|
|||||||
*/
|
*/
|
||||||
function createMenuForAuthUser(opts: {
|
function createMenuForAuthUser(opts: {
|
||||||
auth?: AuthQueryOutput;
|
auth?: AuthQueryOutput;
|
||||||
user?: UserQueryOutput;
|
|
||||||
isCloud: boolean;
|
isCloud: boolean;
|
||||||
}): Menu {
|
}): Menu {
|
||||||
return {
|
return {
|
||||||
@@ -394,7 +395,6 @@ function createMenuForAuthUser(opts: {
|
|||||||
? true
|
? true
|
||||||
: item.isEnabled({
|
: item.isEnabled({
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
user: opts.user,
|
|
||||||
isCloud: opts.isCloud,
|
isCloud: opts.isCloud,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -405,7 +405,6 @@ function createMenuForAuthUser(opts: {
|
|||||||
? true
|
? true
|
||||||
: item.isEnabled({
|
: item.isEnabled({
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
user: opts.user,
|
|
||||||
isCloud: opts.isCloud,
|
isCloud: opts.isCloud,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -416,7 +415,6 @@ function createMenuForAuthUser(opts: {
|
|||||||
? true
|
? true
|
||||||
: item.isEnabled({
|
: item.isEnabled({
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
user: opts.user,
|
|
||||||
isCloud: opts.isCloud,
|
isCloud: opts.isCloud,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -525,10 +523,12 @@ const data = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const teams = data.teams;
|
|
||||||
function SidebarLogo() {
|
function SidebarLogo() {
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: user } = api.auth.get.useQuery();
|
||||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
const {
|
const {
|
||||||
data: organizations,
|
data: organizations,
|
||||||
refetch,
|
refetch,
|
||||||
@@ -617,42 +617,51 @@ function SidebarLogo() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{org.name}
|
{org.name}
|
||||||
{/* <DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut> */}
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{/* <DropdownMenuSeparator /> */}
|
{(org.ownerId === session?.user?.id || isCloud) && (
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AddOrganization organizationId={org.id} />
|
<AddOrganization organizationId={org.id} />
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete Organization"
|
title="Delete Organization"
|
||||||
description="Are you sure you want to delete this organization?"
|
description="Are you sure you want to delete this organization?"
|
||||||
type="destructive"
|
type="destructive"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await deleteOrganization({
|
await deleteOrganization({
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
refetch();
|
|
||||||
toast.success("Port deleted successfully");
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error deleting port");
|
refetch();
|
||||||
});
|
toast.success(
|
||||||
}}
|
"Organization deleted successfully",
|
||||||
>
|
);
|
||||||
<Button
|
})
|
||||||
variant="ghost"
|
.catch((error) => {
|
||||||
size="icon"
|
toast.error(
|
||||||
className="group hover:bg-red-500/10 "
|
error?.message ||
|
||||||
isLoading={isRemoving}
|
"Error deleting organization",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DialogAction>
|
size="icon"
|
||||||
</div>
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<DropdownMenuSeparator />
|
{!isCloud && user?.role === "owner" && (
|
||||||
<AddOrganization />
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<AddOrganization />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
@@ -706,14 +715,6 @@ export default function Page({ children }: Props) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const currentPath = router.pathname;
|
const currentPath = router.pathname;
|
||||||
const { data: auth } = api.auth.get.useQuery();
|
const { data: auth } = api.auth.get.useQuery();
|
||||||
const { data: user } = api.user.byAuthId.useQuery(
|
|
||||||
{
|
|
||||||
authId: auth?.id || "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!auth?.id && auth?.role === "member",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const includesProjects = pathname?.includes("/dashboard/project");
|
const includesProjects = pathname?.includes("/dashboard/project");
|
||||||
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
|
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
|
||||||
@@ -722,7 +723,7 @@ export default function Page({ children }: Props) {
|
|||||||
home: filteredHome,
|
home: filteredHome,
|
||||||
settings: filteredSettings,
|
settings: filteredSettings,
|
||||||
help,
|
help,
|
||||||
} = createMenuForAuthUser({ auth, user, isCloud: !!isCloud });
|
} = createMenuForAuthUser({ auth, isCloud: !!isCloud });
|
||||||
|
|
||||||
const activeItem = findActiveNavItem(
|
const activeItem = findActiveNavItem(
|
||||||
[...filteredHome, ...filteredSettings],
|
[...filteredHome, ...filteredSettings],
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ export const UserNav = () => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data } = api.auth.get.useQuery();
|
const { data } = api.auth.get.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data: user } = api.user.byAuthId.useQuery(
|
|
||||||
{
|
|
||||||
authId: data?.id || "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!data?.id && data?.role === "member",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const { locale, setLocale } = useLocale();
|
const { locale, setLocale } = useLocale();
|
||||||
// const { mutateAsync } = api.auth.logout.useMutation();
|
// const { mutateAsync } = api.auth.logout.useMutation();
|
||||||
|
|
||||||
@@ -99,7 +92,8 @@ export const UserNav = () => {
|
|||||||
>
|
>
|
||||||
Monitoring
|
Monitoring
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{(data?.role === "owner" || user?.canAccessToTraefikFiles) && (
|
{(data?.role === "owner" ||
|
||||||
|
data?.user?.canAccessToTraefikFiles) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -109,7 +103,7 @@ export const UserNav = () => {
|
|||||||
Traefik
|
Traefik
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{(data?.role === "owner" || user?.canAccessToDocker) && (
|
{(data?.role === "owner" || data?.user?.canAccessToDocker) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
3
apps/dokploy/drizzle/0075_heavy_metal_master.sql
Normal file
3
apps/dokploy/drizzle/0075_heavy_metal_master.sql
Normal 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;
|
||||||
4878
apps/dokploy/drizzle/meta/0075_snapshot.json
Normal file
4878
apps/dokploy/drizzle/meta/0075_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -526,6 +526,13 @@
|
|||||||
"when": 1739773539709,
|
"when": 1739773539709,
|
||||||
"tag": "0074_lowly_jack_power",
|
"tag": "0074_lowly_jack_power",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 75,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1739781534192,
|
||||||
|
"tag": "0075_heavy_metal_master",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,8 @@ export async function getServerSideProps(
|
|||||||
) {
|
) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
|
|
||||||
|
console.log("user", user, session);
|
||||||
if (!user || user.role === "member") {
|
if (!user || user.role === "member") {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
|
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Logo } from "@/components/shared/logo";
|
import { Logo } from "@/components/shared/logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -16,21 +17,24 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { IS_CLOUD, getUserByToken } from "@dokploy/server";
|
import { IS_CLOUD, getUserByToken } from "@dokploy/server";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertCircle, AlertTriangle } from "lucide-react";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { type ReactElement, useEffect } from "react";
|
import { type ReactElement, useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import superjson from "superjson";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
name: z.string().min(1, {
|
||||||
|
message: "Name is required",
|
||||||
|
}),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, {
|
.min(1, {
|
||||||
@@ -39,7 +43,6 @@ const registerSchema = z
|
|||||||
.email({
|
.email({
|
||||||
message: "Email must be a valid email",
|
message: "Email must be a valid email",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, {
|
.min(1, {
|
||||||
@@ -72,9 +75,15 @@ interface Props {
|
|||||||
token: string;
|
token: string;
|
||||||
invitation: Awaited<ReturnType<typeof getUserByToken>>;
|
invitation: Awaited<ReturnType<typeof getUserByToken>>;
|
||||||
isCloud: boolean;
|
isCloud: boolean;
|
||||||
|
userAlreadyExists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Invitation = ({ token, invitation, isCloud }: Props) => {
|
const Invitation = ({
|
||||||
|
token,
|
||||||
|
invitation,
|
||||||
|
isCloud,
|
||||||
|
userAlreadyExists,
|
||||||
|
}: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data } = api.admin.getUserByToken.useQuery(
|
const { data } = api.admin.getUserByToken.useQuery(
|
||||||
{
|
{
|
||||||
@@ -91,6 +100,7 @@ const Invitation = ({ token, invitation, isCloud }: Props) => {
|
|||||||
|
|
||||||
const form = useForm<Register>({
|
const form = useForm<Register>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
@@ -109,20 +119,32 @@ const Invitation = ({ token, invitation, isCloud }: Props) => {
|
|||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||||
|
|
||||||
const onSubmit = async (values: Register) => {
|
const onSubmit = async (values: Register) => {
|
||||||
await mutateAsync({
|
try {
|
||||||
id: data?.id,
|
const { data, error } = await authClient.signUp.email({
|
||||||
password: values.password,
|
email: values.email,
|
||||||
token: token,
|
password: values.password,
|
||||||
})
|
name: values.name,
|
||||||
.then(() => {
|
fetchOptions: {
|
||||||
toast.success("User registered successfuly", {
|
headers: {
|
||||||
description:
|
"x-dokploy-token": token,
|
||||||
"Please check your inbox or spam folder to confirm your account.",
|
},
|
||||||
duration: 100000,
|
},
|
||||||
});
|
});
|
||||||
router.push("/dashboard/projects");
|
|
||||||
})
|
if (error) {
|
||||||
.catch((e) => e);
|
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 (
|
return (
|
||||||
@@ -139,114 +161,155 @@ const Invitation = ({ token, invitation, isCloud }: Props) => {
|
|||||||
</Link>
|
</Link>
|
||||||
Invitation
|
Invitation
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
{userAlreadyExists ? (
|
||||||
Fill the form below to create your account
|
<div className="flex flex-col gap-4 justify-center items-center">
|
||||||
</CardDescription>
|
<AlertBlock type="success">
|
||||||
<div className="w-full">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="p-3" />
|
<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 && (
|
<Button asChild variant="default" className="w-full">
|
||||||
<div className="mx-5 my-2 flex flex-row items-center gap-2 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
<Link href="/">Sign In</Link>
|
||||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
</Button>
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
</div>
|
||||||
{error?.message}
|
) : (
|
||||||
</span>
|
<>
|
||||||
</div>
|
<CardDescription>
|
||||||
)}
|
Fill the form below to create your account
|
||||||
|
</CardDescription>
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="p-3" />
|
||||||
|
|
||||||
<CardContent className="p-0">
|
{isError && (
|
||||||
<Form {...form}>
|
<div className="mx-5 my-2 flex flex-row items-center gap-2 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||||
<form
|
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
className="grid gap-4"
|
{error?.message}
|
||||||
>
|
</span>
|
||||||
<div className="space-y-4">
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<CardContent className="p-0">
|
||||||
control={form.control}
|
<Form {...form}>
|
||||||
name="confirmPassword"
|
<form
|
||||||
render={({ field }) => (
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
<FormItem>
|
className="grid gap-4"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
Register
|
<div className="space-y-4">
|
||||||
</Button>
|
<FormField
|
||||||
</div>
|
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">
|
<FormField
|
||||||
{isCloud && (
|
control={form.control}
|
||||||
<>
|
name="confirmPassword"
|
||||||
<Link
|
render={({ field }) => (
|
||||||
className="hover:underline text-muted-foreground"
|
<FormItem>
|
||||||
href="/"
|
<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
|
Register
|
||||||
</Link>
|
</Button>
|
||||||
<Link
|
</div>
|
||||||
className="hover:underline text-muted-foreground"
|
|
||||||
href="/send-reset-password"
|
<div className="mt-4 text-sm flex flex-row justify-between gap-2 w-full">
|
||||||
>
|
{isCloud && (
|
||||||
Lost your password?
|
<>
|
||||||
</Link>
|
<Link
|
||||||
</>
|
className="hover:underline text-muted-foreground"
|
||||||
)}
|
href="/"
|
||||||
</div>
|
>
|
||||||
</form>
|
Login
|
||||||
</Form>
|
</Link>
|
||||||
</CardContent>
|
<Link
|
||||||
</div>
|
className="hover:underline text-muted-foreground"
|
||||||
|
href="/send-reset-password"
|
||||||
|
>
|
||||||
|
Lost your password?
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
// http://localhost:3000/invitation?token=CZK4BLrUdMa32RVkAdZiLsPDdvnPiAgZ
|
||||||
|
// /f7af93acc1a99eae864972ab4c92fee089f0d83473d415ede8e821e5dbabe79c
|
||||||
export default Invitation;
|
export default Invitation;
|
||||||
Invitation.getLayout = (page: ReactElement) => {
|
Invitation.getLayout = (page: ReactElement) => {
|
||||||
return <OnboardingLayout>{page}</OnboardingLayout>;
|
return <OnboardingLayout>{page}</OnboardingLayout>;
|
||||||
@@ -268,7 +331,17 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const invitation = await getUserByToken(token);
|
const invitation = await getUserByToken(token);
|
||||||
console.log("invitation", invitation);
|
|
||||||
|
if (invitation.userAlreadyExists) {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
isCloud: IS_CLOUD,
|
||||||
|
token: token,
|
||||||
|
invitation: invitation,
|
||||||
|
userAlreadyExists: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (invitation.isExpired) {
|
if (invitation.isExpired) {
|
||||||
return {
|
return {
|
||||||
@@ -287,6 +360,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log("error", error);
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
createInvitation,
|
createInvitation,
|
||||||
findUserByAuthId,
|
findOrganizationById,
|
||||||
findUserById,
|
findUserById,
|
||||||
getUserByToken,
|
getUserByToken,
|
||||||
removeUserById,
|
removeUserById,
|
||||||
@@ -98,21 +98,20 @@ export const adminRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
const user = await findUserById(input.id);
|
const user = await findUserById(input.id);
|
||||||
|
|
||||||
if (user.id !== ctx.user.ownerId) {
|
const organization = await findOrganizationById(
|
||||||
|
ctx.session?.activeOrganizationId || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (organization?.ownerId !== ctx.user.ownerId) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You are not allowed to assign permissions",
|
message: "You are not allowed to assign permissions",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateUser(user.id, {
|
await updateUser(user.id, {
|
||||||
...input,
|
...input,
|
||||||
});
|
});
|
||||||
// await db
|
|
||||||
// .update(users)
|
|
||||||
// .set({
|
|
||||||
// ...input,
|
|
||||||
// })
|
|
||||||
// .where(eq(users.userId, input.userId));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,43 @@
|
|||||||
import { db } from "@/server/db";
|
import { db } from "@/server/db";
|
||||||
import { invitation, member, organization } from "@/server/db/schema";
|
import {
|
||||||
|
invitation,
|
||||||
|
member,
|
||||||
|
organization,
|
||||||
|
users_temp,
|
||||||
|
} from "@/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { and, desc, eq, exists } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { adminProcedure, createTRPCRouter } from "../trpc";
|
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { auth, IS_CLOUD } from "@dokploy/server/index";
|
||||||
export const organizationRouter = createTRPCRouter({
|
export const organizationRouter = createTRPCRouter({
|
||||||
create: adminProcedure
|
create: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (ctx.user.rol !== "owner" && !IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only the organization owner can create an organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
const result = await db
|
const result = await db
|
||||||
.insert(organization)
|
.insert(organization)
|
||||||
.values({
|
.values({
|
||||||
...input,
|
...input,
|
||||||
slug: nanoid(),
|
slug: nanoid(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
ownerId: ctx.user.ownerId,
|
ownerId: ctx.user.id,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
.then((res) => res[0]);
|
.then((res) => res[0]);
|
||||||
|
|
||||||
|
console.log("result", result);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
@@ -39,13 +53,24 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
all: adminProcedure.query(async ({ ctx }) => {
|
all: protectedProcedure.query(async ({ ctx }) => {
|
||||||
return await db.query.organization.findMany({
|
const memberResult = await db.query.organization.findMany({
|
||||||
where: eq(organization.ownerId, ctx.user.ownerId),
|
where: (organization) =>
|
||||||
orderBy: [desc(organization.createdAt)],
|
exists(
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(member.organizationId, organization.id),
|
||||||
|
eq(member.userId, ctx.user.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
return memberResult;
|
||||||
}),
|
}),
|
||||||
one: adminProcedure
|
one: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
@@ -56,7 +81,7 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
where: eq(organization.id, input.organizationId),
|
where: eq(organization.id, input.organizationId),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
update: adminProcedure
|
update: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
@@ -64,6 +89,12 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (ctx.user.rol !== "owner" && !IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only the organization owner can update it",
|
||||||
|
});
|
||||||
|
}
|
||||||
const result = await db
|
const result = await db
|
||||||
.update(organization)
|
.update(organization)
|
||||||
.set({ name: input.name })
|
.set({ name: input.name })
|
||||||
@@ -71,16 +102,41 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
.returning();
|
.returning();
|
||||||
return result[0];
|
return result[0];
|
||||||
}),
|
}),
|
||||||
delete: adminProcedure
|
delete: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (ctx.user.rol !== "owner" && !IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only the organization owner can delete it",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const org = await db.query.organization.findFirst({
|
||||||
|
where: eq(organization.id, input.organizationId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Organization not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org.ownerId !== ctx.user.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only the organization owner can delete it",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.delete(organization)
|
.delete(organization)
|
||||||
.where(eq(organization.id, input.organizationId));
|
.where(eq(organization.id, input.organizationId));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
allInvitations: adminProcedure.query(async ({ ctx }) => {
|
allInvitations: adminProcedure.query(async ({ ctx }) => {
|
||||||
@@ -89,4 +145,13 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
orderBy: [desc(invitation.status)],
|
orderBy: [desc(invitation.status)],
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
acceptInvitation: adminProcedure
|
||||||
|
.input(z.object({ invitationId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const result = await auth.api.acceptInvitation({
|
||||||
|
invitationId: input.invitationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { apiFindOneUser, apiFindOneUserByAuth } from "@/server/db/schema";
|
import { apiFindOneUser, apiFindOneUserByAuth } from "@/server/db/schema";
|
||||||
import {
|
import {
|
||||||
|
IS_CLOUD,
|
||||||
findUserByAuthId,
|
findUserByAuthId,
|
||||||
findUserById,
|
findUserById,
|
||||||
IS_CLOUD,
|
|
||||||
removeUserById,
|
removeUserById,
|
||||||
updateUser,
|
updateUser,
|
||||||
verify2FA,
|
verify2FA,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const organizationRelations = relations(
|
|||||||
}),
|
}),
|
||||||
servers: many(server),
|
servers: many(server),
|
||||||
projects: many(projects),
|
projects: many(projects),
|
||||||
|
members: many(member),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const session = pgTable("session_temp", {
|
|||||||
userAgent: text("user_agent"),
|
userAgent: text("user_agent"),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users_temp.id),
|
.references(() => users_temp.id, { onDelete: "cascade" }),
|
||||||
impersonatedBy: text("impersonated_by"),
|
impersonatedBy: text("impersonated_by"),
|
||||||
activeOrganizationId: text("active_organization_id"),
|
activeOrganizationId: text("active_organization_id"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
organization,
|
organization,
|
||||||
twoFactor,
|
twoFactor,
|
||||||
} from "better-auth/plugins";
|
} from "better-auth/plugins";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import * as schema from "../db/schema";
|
import * as schema from "../db/schema";
|
||||||
|
|
||||||
@@ -43,22 +43,25 @@ export const auth = betterAuth({
|
|||||||
after: createAuthMiddleware(async (ctx) => {
|
after: createAuthMiddleware(async (ctx) => {
|
||||||
if (ctx.path.startsWith("/sign-up")) {
|
if (ctx.path.startsWith("/sign-up")) {
|
||||||
const newSession = ctx.context.newSession;
|
const newSession = ctx.context.newSession;
|
||||||
const organization = await db
|
if (ctx.headers?.get("x-dokploy-token")) {
|
||||||
.insert(schema.organization)
|
} else {
|
||||||
.values({
|
const organization = await db
|
||||||
name: "My Organization",
|
.insert(schema.organization)
|
||||||
ownerId: newSession?.user?.id || "",
|
.values({
|
||||||
createdAt: new Date(),
|
name: "My Organization",
|
||||||
})
|
ownerId: newSession?.user?.id || "",
|
||||||
.returning()
|
createdAt: new Date(),
|
||||||
.then((res) => res[0]);
|
})
|
||||||
|
.returning()
|
||||||
|
.then((res) => res[0]);
|
||||||
|
|
||||||
await db.insert(schema.member).values({
|
await db.insert(schema.member).values({
|
||||||
userId: newSession?.user?.id || "",
|
userId: newSession?.user?.id || "",
|
||||||
organizationId: organization?.id || "",
|
organizationId: organization?.id || "",
|
||||||
role: "owner",
|
role: "owner",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -89,11 +92,13 @@ export const auth = betterAuth({
|
|||||||
additionalFields: {
|
additionalFields: {
|
||||||
role: {
|
role: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
// required: true,
|
||||||
|
input: false,
|
||||||
},
|
},
|
||||||
ownerId: {
|
ownerId: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
// required: true,
|
||||||
|
input: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -133,7 +138,13 @@ export const validateRequest = async (request: IncomingMessage) => {
|
|||||||
|
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
const member = await db.query.member.findFirst({
|
const member = await db.query.member.findFirst({
|
||||||
where: eq(schema.member.userId, session.user.id),
|
where: and(
|
||||||
|
eq(schema.member.userId, session.user.id),
|
||||||
|
eq(
|
||||||
|
schema.member.organizationId,
|
||||||
|
session.session.activeOrganizationId || "",
|
||||||
|
),
|
||||||
|
),
|
||||||
with: {
|
with: {
|
||||||
organization: true,
|
organization: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from "@dokploy/server/db";
|
|||||||
import {
|
import {
|
||||||
account,
|
account,
|
||||||
type apiCreateUserInvitation,
|
type apiCreateUserInvitation,
|
||||||
|
invitation,
|
||||||
member,
|
member,
|
||||||
organization,
|
organization,
|
||||||
users_temp,
|
users_temp,
|
||||||
@@ -64,6 +65,13 @@ export const findUserById = async (userId: string) => {
|
|||||||
return user;
|
return user;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const findOrganizationById = async (organizationId: string) => {
|
||||||
|
const organizationResult = await db.query.organization.findFirst({
|
||||||
|
where: eq(organization.id, organizationId),
|
||||||
|
});
|
||||||
|
return organizationResult;
|
||||||
|
};
|
||||||
|
|
||||||
export const updateUser = async (userId: string, userData: Partial<User>) => {
|
export const updateUser = async (userId: string, userData: Partial<User>) => {
|
||||||
const user = await db
|
const user = await db
|
||||||
.update(users_temp)
|
.update(users_temp)
|
||||||
@@ -106,24 +114,34 @@ export const isAdminPresent = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getUserByToken = async (token: string) => {
|
export const getUserByToken = async (token: string) => {
|
||||||
const user = await db.query.users_temp.findFirst({
|
const user = await db.query.invitation.findFirst({
|
||||||
where: eq(users_temp.token, token),
|
where: eq(invitation.id, token),
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
token: true,
|
status: true,
|
||||||
isRegistered: true,
|
expiresAt: true,
|
||||||
|
role: true,
|
||||||
|
inviterId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Invitation not found",
|
message: "Invitation not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userAlreadyExists = await db.query.users_temp.findFirst({
|
||||||
|
where: eq(users_temp.email, user?.email || ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { expiresAt, ...rest } = user;
|
||||||
return {
|
return {
|
||||||
...user,
|
...rest,
|
||||||
isExpired: user.isRegistered,
|
isExpired: user.expiresAt < new Date(),
|
||||||
|
userAlreadyExists: !!userAlreadyExists,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -137,76 +137,76 @@ export const symmetricDecrypt = async ({ key, data }) => {
|
|||||||
const chacha = managedNonce(xchacha20poly1305)(new Uint8Array(keyAsBytes));
|
const chacha = managedNonce(xchacha20poly1305)(new Uint8Array(keyAsBytes));
|
||||||
return new TextDecoder().decode(chacha.decrypt(dataAsBytes));
|
return new TextDecoder().decode(chacha.decrypt(dataAsBytes));
|
||||||
};
|
};
|
||||||
export const migrateExistingSecret = async (
|
// export const migrateExistingSecret = async (
|
||||||
existingBase32Secret: string,
|
// existingBase32Secret: string,
|
||||||
encryptionKey: string,
|
// encryptionKey: string,
|
||||||
) => {
|
// ) => {
|
||||||
try {
|
// try {
|
||||||
// 1. Primero asegurarnos que el secreto base32 tenga el padding correcto
|
// // 1. Primero asegurarnos que el secreto base32 tenga el padding correcto
|
||||||
let paddedSecret = existingBase32Secret;
|
// let paddedSecret = existingBase32Secret;
|
||||||
while (paddedSecret.length % 8 !== 0) {
|
// while (paddedSecret.length % 8 !== 0) {
|
||||||
paddedSecret += "=";
|
// paddedSecret += "=";
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 2. Decodificar el base32 a bytes usando hi-base32
|
// // 2. Decodificar el base32 a bytes usando hi-base32
|
||||||
const bytes = encode.decode.asBytes(paddedSecret.toUpperCase());
|
// const bytes = encode.decode.asBytes(paddedSecret.toUpperCase());
|
||||||
|
|
||||||
// 3. Convertir los bytes a hex
|
// // 3. Convertir los bytes a hex
|
||||||
const hexSecret = Buffer.from(bytes).toString("hex");
|
// const hexSecret = Buffer.from(bytes).toString("hex");
|
||||||
|
|
||||||
// 4. Encriptar el secreto hex usando Better Auth
|
// // 4. Encriptar el secreto hex usando Better Auth
|
||||||
const encryptedSecret = await symmetricEncrypt({
|
// const encryptedSecret = await symmetricEncrypt({
|
||||||
key: encryptionKey,
|
// key: encryptionKey,
|
||||||
data: hexSecret,
|
// data: hexSecret,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 5. Crear TOTP con el secreto original para validación
|
// // 5. Crear TOTP con el secreto original para validación
|
||||||
const originalTotp = new TOTP({
|
// const originalTotp = new TOTP({
|
||||||
issuer: "Dokploy",
|
// issuer: "Dokploy",
|
||||||
label: "migration-test",
|
// label: "migration-test",
|
||||||
algorithm: "SHA1",
|
// algorithm: "SHA1",
|
||||||
digits: 6,
|
// digits: 6,
|
||||||
secret: existingBase32Secret,
|
// secret: existingBase32Secret,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 6. Generar un código de prueba con el secreto original
|
// // 6. Generar un código de prueba con el secreto original
|
||||||
const testCode = originalTotp.generate();
|
// const testCode = originalTotp.generate();
|
||||||
|
|
||||||
// 7. Validar que el código funcione con el secreto original
|
// // 7. Validar que el código funcione con el secreto original
|
||||||
const isValid = originalTotp.validate({ token: testCode }) !== null;
|
// const isValid = originalTotp.validate({ token: testCode }) !== null;
|
||||||
|
|
||||||
return {
|
// return {
|
||||||
originalSecret: existingBase32Secret,
|
// originalSecret: existingBase32Secret,
|
||||||
hexSecret,
|
// hexSecret,
|
||||||
encryptedSecret, // Este es el valor que debes guardar en la base de datos
|
// encryptedSecret, // Este es el valor que debes guardar en la base de datos
|
||||||
isValid,
|
// isValid,
|
||||||
testCode,
|
// testCode,
|
||||||
secretLength: hexSecret.length,
|
// secretLength: hexSecret.length,
|
||||||
};
|
// };
|
||||||
} catch (error: unknown) {
|
// } catch (error: unknown) {
|
||||||
const errorMessage =
|
// const errorMessage =
|
||||||
error instanceof Error ? error.message : "Unknown error";
|
// error instanceof Error ? error.message : "Unknown error";
|
||||||
console.error("Error durante la migración:", errorMessage);
|
// console.error("Error durante la migración:", errorMessage);
|
||||||
throw new Error(`Error al migrar el secreto: ${errorMessage}`);
|
// throw new Error(`Error al migrar el secreto: ${errorMessage}`);
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
// // Ejemplo de uso con el secreto de prueba
|
// // // Ejemplo de uso con el secreto de prueba
|
||||||
// const testMigration = await migrateExistingSecret(
|
// // const testMigration = await migrateExistingSecret(
|
||||||
// "46JMUCG4NJ3CIU6LQAIVFWUW",
|
// // "46JMUCG4NJ3CIU6LQAIVFWUW",
|
||||||
// process.env.BETTER_AUTH_SECRET || "your-encryption-key",
|
// // process.env.BETTER_AUTH_SECRET || "your-encryption-key",
|
||||||
// );
|
// // );
|
||||||
// console.log("\nPrueba de migración:");
|
// // console.log("\nPrueba de migración:");
|
||||||
// console.log("Secreto original (base32):", testMigration.originalSecret);
|
// // console.log("Secreto original (base32):", testMigration.originalSecret);
|
||||||
// console.log("Secreto convertido (hex):", testMigration.hexSecret);
|
// // console.log("Secreto convertido (hex):", testMigration.hexSecret);
|
||||||
// console.log("Secreto encriptado:", testMigration.encryptedSecret);
|
// // console.log("Secreto encriptado:", testMigration.encryptedSecret);
|
||||||
// console.log("Longitud del secreto hex:", testMigration.secretLength);
|
// // console.log("Longitud del secreto hex:", testMigration.secretLength);
|
||||||
// console.log("¿Conversión válida?:", testMigration.isValid);
|
// // console.log("¿Conversión válida?:", testMigration.isValid);
|
||||||
// console.log("Código de prueba:", testMigration.testCode);
|
// // console.log("Código de prueba:", testMigration.testCode);
|
||||||
const secret = "46JMUCG4NJ3CIU6LQAIVFWUW";
|
// const secret = "46JMUCG4NJ3CIU6LQAIVFWUW";
|
||||||
const isValid = createOTP(secret, {
|
// const isValid = createOTP(secret, {
|
||||||
digits: 6,
|
// digits: 6,
|
||||||
period: 30,
|
// period: 30,
|
||||||
}).verify("123456");
|
// }).verify("123456");
|
||||||
|
|
||||||
console.log(isValid.then((isValid) => console.log(isValid)));
|
// console.log(isValid.then((isValid) => console.log(isValid)));
|
||||||
|
|||||||
Reference in New Issue
Block a user