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,
isError,
error,
} = api.auth.update.useMutation();
} = api.user.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);

View File

@ -27,6 +27,8 @@ import {
Trash2,
User,
Users,
ChevronsUpDown,
Plus,
} from "lucide-react";
import { usePathname } from "next/navigation";
import type * as React from "react";
@ -75,6 +77,20 @@ import { useRouter } from "next/router";
import { Logo } from "../shared/logo";
import { UpdateServerButton } from "./update-server";
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
type AuthQueryOutput = inferRouterOutputs<AppRouter>["auth"]["get"];
@ -473,46 +489,6 @@ 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();
@ -529,6 +505,10 @@ function SidebarLogo() {
api.organization.delete.useMutation();
const { isMobile } = useSidebar();
const { data: activeOrganization } = authClient.useActiveOrganization();
const utils = api.useUtils();
const { data: invitations, refetch: refetchInvitations } =
api.user.getInvitations.useQuery();
const [activeTeam, setActiveTeam] = useState<
typeof activeOrganization | null
@ -549,31 +529,27 @@ function SidebarLogo() {
</div>
) : (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuItem className="flex items-center gap-2">
<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 className="flex items-center gap-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>
<div className="flex flex-col items-start">
<p className="text-sm font-medium leading-none">
{activeOrganization?.name}
</p>
</div>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
@ -587,14 +563,13 @@ function SidebarLogo() {
<DropdownMenuLabel className="text-xs text-muted-foreground">
Organizations
</DropdownMenuLabel>
{organizations?.map((org, index) => (
{organizations?.map((org) => (
<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"
@ -655,35 +630,76 @@ function SidebarLogo() {
)}
</DropdownMenuContent>
</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>
</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);
if (ctx.user.rol === "member") {
await addNewService(ctx.user.id, newApplication.applicationId);
await addNewService(
ctx.user.id,
newApplication.applicationId,
project.organizationId,
);
}
return newApplication;
} catch (error: unknown) {

View File

@ -80,7 +80,11 @@ export const composeRouter = createTRPCRouter({
const newService = await createCompose(input);
if (ctx.user.rol === "member") {
await addNewService(ctx.user.id, newService.composeId);
await addNewService(
ctx.user.id,
newService.composeId,
project.organizationId,
);
}
return newService;
@ -424,7 +428,11 @@ export const composeRouter = createTRPCRouter({
});
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) {

View File

@ -57,7 +57,11 @@ export const mariadbRouter = createTRPCRouter({
}
const newMariadb = await createMariadb(input);
if (ctx.user.rol === "member") {
await addNewService(ctx.user.id, newMariadb.mariadbId);
await addNewService(
ctx.user.id,
newMariadb.mariadbId,
project.organizationId,
);
}
await createMount({

View File

@ -56,7 +56,11 @@ export const mongoRouter = createTRPCRouter({
}
const newMongo = await createMongo(input);
if (ctx.user.rol === "member") {
await addNewService(ctx.user.id, newMongo.mongoId);
await addNewService(
ctx.user.id,
newMongo.mongoId,
project.organizationId,
);
}
await createMount({

View File

@ -59,7 +59,11 @@ export const mysqlRouter = createTRPCRouter({
const newMysql = await createMysql(input);
if (ctx.user.rol === "member") {
await addNewService(ctx.user.id, newMysql.mysqlId);
await addNewService(
ctx.user.id,
newMysql.mysqlId,
project.organizationId,
);
}
await createMount({

View File

@ -64,7 +64,11 @@ export const postgresRouter = createTRPCRouter({
}
const newPostgres = await createPostgres(input);
if (ctx.user.rol === "member") {
await addNewService(ctx.user.id, newPostgres.postgresId);
await addNewService(
ctx.user.id,
newPostgres.postgresId,
project.organizationId,
);
}
await createMount({

View File

@ -56,7 +56,11 @@ export const redisRouter = createTRPCRouter({
}
const newRedis = await createRedis(input);
if (ctx.user.rol === "member") {
await addNewService(ctx.user.id, newRedis.redisId);
await addNewService(
ctx.user.id,
newRedis.redisId,
project.organizationId,
);
}
await createMount({

View File

@ -18,7 +18,7 @@ import {
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
import { db } from "@/server/db";
export const registryRouter = createTRPCRouter({
create: adminProcedure
.input(apiCreateRegistry)

View File

@ -15,10 +15,11 @@ import {
apiAssignPermissions,
apiFindOneToken,
apiUpdateUser,
invitation,
member,
} from "@dokploy/server/db/schema";
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 {
adminProcedure,
@ -115,14 +116,34 @@ export const userRouter = createTRPCRouter({
});
}
const { id, ...rest } = input;
console.log(rest);
await db
.update(member)
.set({
...input,
...rest,
})
.where(eq(member.userId, input.id));
.where(
and(
eq(member.userId, input.id),
eq(
member.organizationId,
ctx.session?.activeOrganizationId || "",
),
),
);
} catch (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"),
),
});
}),
});