feat: add organization invitation system and update user profile management

This commit is contained in:
Mauricio Siu
2025-02-22 02:31:04 -06:00
parent 5ae103e779
commit b02195db17
11 changed files with 169 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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