mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: add organization invitation system and update user profile management
This commit is contained in:
@@ -65,7 +65,7 @@ export const ProfileForm = () => {
|
|||||||
isLoading: isUpdating,
|
isLoading: isUpdating,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = api.auth.update.useMutation();
|
} = api.user.update.useMutation();
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
@@ -75,6 +77,20 @@ import { useRouter } from "next/router";
|
|||||||
import { Logo } from "../shared/logo";
|
import { Logo } from "../shared/logo";
|
||||||
import { UpdateServerButton } from "./update-server";
|
import { UpdateServerButton } from "./update-server";
|
||||||
import { UserNav } from "./user-nav";
|
import { UserNav } from "./user-nav";
|
||||||
|
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";
|
||||||
|
|
||||||
// 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"];
|
||||||
@@ -473,46 +489,6 @@ interface Props {
|
|||||||
function LogoWrapper() {
|
function LogoWrapper() {
|
||||||
return <SidebarLogo />;
|
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() {
|
function SidebarLogo() {
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
@@ -529,6 +505,10 @@ function SidebarLogo() {
|
|||||||
api.organization.delete.useMutation();
|
api.organization.delete.useMutation();
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { data: invitations, refetch: refetchInvitations } =
|
||||||
|
api.user.getInvitations.useQuery();
|
||||||
|
|
||||||
const [activeTeam, setActiveTeam] = useState<
|
const [activeTeam, setActiveTeam] = useState<
|
||||||
typeof activeOrganization | null
|
typeof activeOrganization | null
|
||||||
@@ -549,31 +529,27 @@ function SidebarLogo() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem className="flex items-center gap-2">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
size="lg"
|
size="lg"
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
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="flex items-center gap-2">
|
||||||
<div
|
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||||
className={cn(
|
<Logo
|
||||||
"flex aspect-square items-center justify-center rounded-lg transition-all",
|
className={cn(
|
||||||
state === "collapsed" ? "size-6" : "size-10",
|
"transition-all",
|
||||||
)}
|
state === "collapsed" ? "size-6" : "size-10",
|
||||||
>
|
)}
|
||||||
<Logo
|
/>
|
||||||
className={cn(
|
</div>
|
||||||
"transition-all",
|
<div className="flex flex-col items-start">
|
||||||
state === "collapsed" ? "size-6" : "size-10",
|
<p className="text-sm font-medium leading-none">
|
||||||
)}
|
{activeOrganization?.name}
|
||||||
/>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span className="truncate font-semibold">
|
|
||||||
{activeTeam?.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown className="ml-auto" />
|
<ChevronsUpDown className="ml-auto" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -587,14 +563,13 @@ function SidebarLogo() {
|
|||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
Organizations
|
Organizations
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{organizations?.map((org, index) => (
|
{organizations?.map((org) => (
|
||||||
<div className="flex flex-row justify-between" key={org.name}>
|
<div className="flex flex-row justify-between" key={org.name}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await authClient.organization.setActive({
|
await authClient.organization.setActive({
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}}
|
}}
|
||||||
className="w-full gap-2 p-2"
|
className="w-full gap-2 p-2"
|
||||||
@@ -655,35 +630,76 @@ function SidebarLogo() {
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{invitations && invitations.length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 text-xs text-white">
|
||||||
|
{invitations.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
side={"right"}
|
||||||
|
className="w-80"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>Pending Invitations</DropdownMenuLabel>
|
||||||
|
{invitations && invitations.length > 0 ? (
|
||||||
|
invitations.map((invitation) => (
|
||||||
|
<div key={invitation.id} className="flex flex-col gap-2">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex flex-col items-start gap-1 p-3"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{invitation.email}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Expires:{" "}
|
||||||
|
{new Date(invitation.expiresAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Role: {invitation.role}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DialogAction
|
||||||
|
title="Accept Invitation"
|
||||||
|
description="Are you sure you want to accept this invitation?"
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
const { error } =
|
||||||
|
await authClient.organization.acceptInvitation({
|
||||||
|
invitationId: invitation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(
|
||||||
|
error.message || "Error accepting invitation",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success("Invitation accepted successfully");
|
||||||
|
await refetchInvitations();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button size="sm" variant="secondary">
|
||||||
|
Accept Invitation
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
No pending invitations
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</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 "
|
|
||||||
>
|
|
||||||
<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="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> */}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
const newApplication = await createApplication(input);
|
const newApplication = await createApplication(input);
|
||||||
|
|
||||||
if (ctx.user.rol === "member") {
|
if (ctx.user.rol === "member") {
|
||||||
await addNewService(ctx.user.id, newApplication.applicationId);
|
await addNewService(
|
||||||
|
ctx.user.id,
|
||||||
|
newApplication.applicationId,
|
||||||
|
project.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return newApplication;
|
return newApplication;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -80,7 +80,11 @@ export const composeRouter = createTRPCRouter({
|
|||||||
const newService = await createCompose(input);
|
const newService = await createCompose(input);
|
||||||
|
|
||||||
if (ctx.user.rol === "member") {
|
if (ctx.user.rol === "member") {
|
||||||
await addNewService(ctx.user.id, newService.composeId);
|
await addNewService(
|
||||||
|
ctx.user.id,
|
||||||
|
newService.composeId,
|
||||||
|
project.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newService;
|
return newService;
|
||||||
@@ -424,7 +428,11 @@ export const composeRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (ctx.user.rol === "member") {
|
if (ctx.user.rol === "member") {
|
||||||
await addNewService(ctx.user.id, compose.composeId);
|
await addNewService(
|
||||||
|
ctx.user.id,
|
||||||
|
compose.composeId,
|
||||||
|
project.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounts && mounts?.length > 0) {
|
if (mounts && mounts?.length > 0) {
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ export const mariadbRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
const newMariadb = await createMariadb(input);
|
const newMariadb = await createMariadb(input);
|
||||||
if (ctx.user.rol === "member") {
|
if (ctx.user.rol === "member") {
|
||||||
await addNewService(ctx.user.id, newMariadb.mariadbId);
|
await addNewService(
|
||||||
|
ctx.user.id,
|
||||||
|
newMariadb.mariadbId,
|
||||||
|
project.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await createMount({
|
await createMount({
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ export const mongoRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
const newMongo = await createMongo(input);
|
const newMongo = await createMongo(input);
|
||||||
if (ctx.user.rol === "member") {
|
if (ctx.user.rol === "member") {
|
||||||
await addNewService(ctx.user.id, newMongo.mongoId);
|
await addNewService(
|
||||||
|
ctx.user.id,
|
||||||
|
newMongo.mongoId,
|
||||||
|
project.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await createMount({
|
await createMount({
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ export const mysqlRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const newMysql = await createMysql(input);
|
const newMysql = await createMysql(input);
|
||||||
if (ctx.user.rol === "member") {
|
if (ctx.user.rol === "member") {
|
||||||
await addNewService(ctx.user.id, newMysql.mysqlId);
|
await addNewService(
|
||||||
|
ctx.user.id,
|
||||||
|
newMysql.mysqlId,
|
||||||
|
project.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await createMount({
|
await createMount({
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ export const postgresRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
const newPostgres = await createPostgres(input);
|
const newPostgres = await createPostgres(input);
|
||||||
if (ctx.user.rol === "member") {
|
if (ctx.user.rol === "member") {
|
||||||
await addNewService(ctx.user.id, newPostgres.postgresId);
|
await addNewService(
|
||||||
|
ctx.user.id,
|
||||||
|
newPostgres.postgresId,
|
||||||
|
project.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await createMount({
|
await createMount({
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ export const redisRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
const newRedis = await createRedis(input);
|
const newRedis = await createRedis(input);
|
||||||
if (ctx.user.rol === "member") {
|
if (ctx.user.rol === "member") {
|
||||||
await addNewService(ctx.user.id, newRedis.redisId);
|
await addNewService(
|
||||||
|
ctx.user.id,
|
||||||
|
newRedis.redisId,
|
||||||
|
project.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await createMount({
|
await createMount({
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
|
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { db } from "@/server/db";
|
||||||
export const registryRouter = createTRPCRouter({
|
export const registryRouter = createTRPCRouter({
|
||||||
create: adminProcedure
|
create: adminProcedure
|
||||||
.input(apiCreateRegistry)
|
.input(apiCreateRegistry)
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import {
|
|||||||
apiAssignPermissions,
|
apiAssignPermissions,
|
||||||
apiFindOneToken,
|
apiFindOneToken,
|
||||||
apiUpdateUser,
|
apiUpdateUser,
|
||||||
|
invitation,
|
||||||
member,
|
member,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, asc, desc, eq } from "drizzle-orm";
|
import { and, asc, desc, eq, gt } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
adminProcedure,
|
adminProcedure,
|
||||||
@@ -115,14 +116,34 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { id, ...rest } = input;
|
||||||
|
|
||||||
|
console.log(rest);
|
||||||
await db
|
await db
|
||||||
.update(member)
|
.update(member)
|
||||||
.set({
|
.set({
|
||||||
...input,
|
...rest,
|
||||||
})
|
})
|
||||||
.where(eq(member.userId, input.id));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(member.userId, input.id),
|
||||||
|
eq(
|
||||||
|
member.organizationId,
|
||||||
|
ctx.session?.activeOrganizationId || "",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
getInvitations: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return await db.query.invitation.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(invitation.email, ctx.user.email),
|
||||||
|
gt(invitation.expiresAt, new Date()),
|
||||||
|
eq(invitation.status, "pending"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user