diff --git a/src/pages/organization/[orgid].tsx b/src/pages/organization/[orgid].tsx
new file mode 100644
index 00000000..babc4c9f
--- /dev/null
+++ b/src/pages/organization/[orgid].tsx
@@ -0,0 +1,310 @@
+import { useRouter } from "next/router";
+import { useEffect, type ReactElement, useState } from "react";
+import { LayoutOrganizationAuthenticated } from "~/components/layouts/layout";
+import { api } from "~/utils/api";
+import { GetServerSidePropsContext } from "next/types";
+import { withAuth } from "~/components/auth/withAuth";
+import { getSession } from "next-auth/react";
+import { OrganizationNetworkTable } from "~/components/organization/networkTable";
+import { stringToColor } from "~/utils/randomColor";
+import { useModalStore } from "~/utils/store";
+import TimeAgo from "react-timeago";
+import { ErrorData } from "~/types/errorHandling";
+import toast from "react-hot-toast";
+import EditOrganizationUserModal from "~/components/organization/editUserModal";
+import { useTranslations } from "next-intl";
+
+const OrganizationById = ({ user }) => {
+ const b = useTranslations("commonButtons");
+ const t = useTranslations("organization");
+ const [maxHeight, setMaxHeight] = useState("auto");
+ const { query, push } = useRouter();
+ const organizationId = query.orgid as string;
+ const { callModal } = useModalStore((state) => state);
+
+ const { data: meOrgRole } = api.org.getOrgUserRoleById.useQuery({
+ organizationId,
+ userId: user.id,
+ });
+
+ const { mutate: leaveOrg } = api.org.leave.useMutation({
+ onError: (error) => {
+ if ((error.data as ErrorData)?.zodError) {
+ const fieldErrors = (error.data as ErrorData)?.zodError.fieldErrors;
+ for (const field in fieldErrors) {
+ toast.error(`${fieldErrors[field].join(", ")}`);
+ }
+ } else if (error.message) {
+ toast.error(error.message);
+ } else {
+ toast.error("An unknown error occurred");
+ }
+ },
+ });
+
+ const {
+ data: orgData,
+ refetch: refecthOrg,
+ isLoading: orgLoading,
+ error: getOrgError,
+ } = api.org.getOrgById.useQuery({
+ organizationId,
+ });
+
+ const { data: orgUsers } = api.org.getOrgUsers.useQuery({
+ organizationId,
+ });
+ const { mutate: createNetwork } = api.org.createOrgNetwork.useMutation({
+ onError: (error) => {
+ if ((error.data as ErrorData)?.zodError) {
+ const fieldErrors = (error.data as ErrorData)?.zodError.fieldErrors;
+ for (const field in fieldErrors) {
+ toast.error(`${fieldErrors[field].join(", ")}`);
+ }
+ } else if (error.message) {
+ toast.error(error.message);
+ } else {
+ toast.error("An unknown error occurred");
+ }
+ },
+ onSuccess: () => {
+ refecthOrg();
+ },
+ });
+
+ useEffect(() => {
+ const calculateMaxHeight = () => {
+ const offset = 400;
+ const calculatedHeight = window.innerHeight - offset;
+ setMaxHeight(`${calculatedHeight}px`);
+ };
+
+ // Calculate on mount
+ calculateMaxHeight();
+
+ // Recalculate on window resize
+ window.addEventListener("resize", calculateMaxHeight);
+
+ // Cleanup listener
+ return () => window.removeEventListener("resize", calculateMaxHeight);
+ }, []);
+
+ if (getOrgError) {
+ return (
+ <>
+
+ >
+ );
+ }
+ if (orgLoading) {
+ // add loading progress bar to center of page, vertially and horizontally
+ return (
+ <>
+
+ );
+};
+
+OrganizationById.getLayout = function getLayout(page: ReactElement) {
+ return
;
+};
+
+export const getServerSideProps = withAuth(async (context: GetServerSidePropsContext) => {
+ const session = await getSession(context);
+ return {
+ props: {
+ session,
+ // You can get the messages from anywhere you like. The recommended
+ // pattern is to put them in JSON files separated by locale and read
+ // the desired one based on the `locale` received from Next.js.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+ messages: (await import(`../../locales/${context.locale}/common.json`)).default,
+ },
+ };
+});
+export default OrganizationById;
diff --git a/src/pages/organization/[orgid]/[id].tsx b/src/pages/organization/[orgid]/[id].tsx
new file mode 100644
index 00000000..f7e118ef
--- /dev/null
+++ b/src/pages/organization/[orgid]/[id].tsx
@@ -0,0 +1,347 @@
+import { useRouter } from "next/router";
+import { useState, type ReactElement } from "react";
+import { LayoutOrganizationAuthenticated } from "~/components/layouts/layout";
+import { NettworkRoutes } from "~/components/networkByIdPage/networkRoutes";
+import { NetworkMembersTable } from "~/components/networkByIdPage/table/networkMembersTable";
+import { api } from "~/utils/api";
+import { NetworkIpAssignment } from "~/components/networkByIdPage/networkIpAssignments";
+import { NetworkPrivatePublic } from "~/components/networkByIdPage/networkPrivatePublic";
+import { AddMemberById } from "~/components/networkByIdPage/addMemberById";
+import { CopyToClipboard } from "react-copy-to-clipboard";
+import CopyIcon from "~/icons/copy";
+import toast from "react-hot-toast";
+import { DeletedNetworkMembersTable } from "~/components/networkByIdPage/table/deletedNetworkMembersTable";
+import { useModalStore } from "~/utils/store";
+import { NetworkFlowRules } from "~/components/networkByIdPage/networkFlowRules";
+import { NetworkDns } from "~/components/networkByIdPage/networkDns";
+import { NetworkMulticast } from "~/components/networkByIdPage/networkMulticast";
+import cn from "classnames";
+import NetworkHelpText from "~/components/networkByIdPage/networkHelp";
+import { InviteMemberByMail } from "~/components/networkByIdPage/inviteMemberbyMail";
+import { useTranslations } from "next-intl";
+import { GetServerSidePropsContext } from "next/types";
+import NetworkName from "~/components/networkByIdPage/networkName";
+import NetworkDescription from "~/components/networkByIdPage/networkDescription";
+import { withAuth } from "~/components/auth/withAuth";
+import Head from "next/head";
+import { globalSiteTitle } from "~/utils/global";
+
+const HeadSection = ({ title }: { title: string }) => (
+
+
+
+);
+
+const OrganizationNetworkById = () => {
+ const t = useTranslations("networkById");
+ const [state, setState] = useState({
+ viewZombieTable: false,
+ isDebug: false,
+ });
+ const { callModal } = useModalStore((state) => state);
+ const { query, push: router } = useRouter();
+ const organizationId = query.orgid as string;
+
+ const { mutate: deleteNetwork } = api.network.deleteNetwork.useMutation();
+ const {
+ data: networkById,
+ isLoading: loadingNetwork,
+ error: errorNetwork,
+ } = api.network.getNetworkById.useQuery(
+ {
+ nwid: query.id as string,
+ },
+ { enabled: !!query.id, refetchInterval: 10000 },
+ );
+ const { network, members = [] } = networkById || {};
+ const pageTitle = `${globalSiteTitle} - ${network?.name}`;
+
+ if (errorNetwork) {
+ return (
+ <>
+
+ >
+ );
+ }
+ if (loadingNetwork) {
+ const pageTitleLoading = `${globalSiteTitle}`;
+ // add loading progress bar to center of page, vertially and horizontally
+ return (
+ <>
+
+ );
+};
+
+OrganizationNetworkById.getLayout = function getLayout(page: ReactElement) {
+ return
;
+};
+
+export const getServerSideProps = withAuth(async (context: GetServerSidePropsContext) => {
+ return {
+ props: {
+ // You can get the messages from anywhere you like. The recommended
+ // pattern is to put them in JSON files separated by locale and read
+ // the desired one based on the `locale` received from Next.js.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+ messages: (await import(`../../../locales/${context.locale}/common.json`)).default,
+ },
+ };
+});
+export default OrganizationNetworkById;
diff --git a/src/pages/user-settings/account/index.tsx b/src/pages/user-settings/account/index.tsx
index 434611ae..3305ae6d 100644
--- a/src/pages/user-settings/account/index.tsx
+++ b/src/pages/user-settings/account/index.tsx
@@ -190,7 +190,6 @@ const Account = () => {
{t("account.zerotierCentral.title").toUpperCase()}
-
BETA
diff --git a/src/server/api/__tests__/network/getNetworkById.test.ts b/src/server/api/__tests__/network/getNetworkById.test.ts
index 7602380e..3a441981 100644
--- a/src/server/api/__tests__/network/getNetworkById.test.ts
+++ b/src/server/api/__tests__/network/getNetworkById.test.ts
@@ -9,31 +9,30 @@ const mockSession: PartialDeep = {
expires: new Date().toISOString(),
update: { name: "test" },
user: {
- id: 1,
+ id: "userid",
name: "Bernt Christian",
email: "mail@gmail.com",
},
};
it("should throw an error if the user is not the author of the network", async () => {
- prisma.network.findFirst = jest.fn().mockRejectedValue(
+ prisma.network.findUnique = jest.fn().mockRejectedValue(
new TRPCError({
- message: "You are not the Author of this network!",
- code: "FORBIDDEN",
+ message: "Network not found!",
+ code: "BAD_REQUEST",
}),
);
const caller = appRouter.createCaller({
session: mockSession as Session,
+ wss: null,
prisma: prisma,
});
try {
await caller.network.getNetworkById({ nwid: "test_nw_id" });
} catch (error) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- expect(error.message).toBe("You are not the Author of this network!");
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- expect(error.code).toBe("FORBIDDEN");
+ expect(error.message).toBe("Network not found!");
+ expect(error.code).toBe("BAD_REQUEST");
}
});
diff --git a/src/server/api/__tests__/network/getUserNetworks.test.ts b/src/server/api/__tests__/network/getUserNetworks.test.ts
index 5343b93c..addc8e6b 100644
--- a/src/server/api/__tests__/network/getUserNetworks.test.ts
+++ b/src/server/api/__tests__/network/getUserNetworks.test.ts
@@ -8,7 +8,7 @@ const mockSession: PartialDeep = {
expires: new Date().toISOString(),
update: { name: "test" },
user: {
- id: 1,
+ id: "userid",
name: "Bernt Christian",
email: "mail@gmail.com",
},
@@ -53,6 +53,7 @@ test("getUserNetworks", async () => {
const caller = appRouter.createCaller({
session: mockSession as Session,
+ wss: null,
prisma: prismaMock,
});
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index 0f0e8b64..4b122cbb 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -4,6 +4,7 @@ import { networkMemberRouter } from "./routers/memberRouter";
import { networkRouter } from "./routers/networkRouter";
import { adminRouter } from "./routers/adminRoute";
import { settingsRouter } from "./routers/settingsRouter";
+import { organizationRouter } from "./routers/organizationRouter";
import { publicRouter } from "./routers/publicRouter";
/**
@@ -17,6 +18,7 @@ export const appRouter = createTRPCRouter({
auth: authRouter,
admin: adminRouter,
settings: settingsRouter,
+ org: organizationRouter,
public: publicRouter,
});
diff --git a/src/server/api/routers/adminRoute.ts b/src/server/api/routers/adminRoute.ts
index 543b73d3..ca17ae9b 100644
--- a/src/server/api/routers/adminRoute.ts
+++ b/src/server/api/routers/adminRoute.ts
@@ -4,6 +4,7 @@ import * as ztController from "~/utils/ztApi";
import ejs from "ejs";
import {
forgotPasswordTemplate,
+ inviteOrganizationTemplate,
inviteUserTemplate,
notificationTemplate,
} from "~/utils/mail";
@@ -343,6 +344,8 @@ export const adminRouter = createTRPCRouter({
return templates?.forgotPasswordTemplate ?? forgotPasswordTemplate();
case "notificationTemplate":
return templates?.notificationTemplate ?? notificationTemplate();
+ case "inviteOrganizationTemplate":
+ return templates?.inviteOrganizationTemplate ?? inviteOrganizationTemplate();
default:
return {};
}
@@ -417,6 +420,15 @@ export const adminRouter = createTRPCRouter({
notificationTemplate: templateObj,
},
});
+ case "inviteOrganizationTemplate":
+ return await ctx.prisma.globalOptions.update({
+ where: {
+ id: 1,
+ },
+ data: {
+ inviteOrganizationTemplate: templateObj,
+ },
+ });
default:
break;
}
diff --git a/src/server/api/routers/authRouter.ts b/src/server/api/routers/authRouter.ts
index 09170560..c43dacf6 100644
--- a/src/server/api/routers/authRouter.ts
+++ b/src/server/api/routers/authRouter.ts
@@ -55,30 +55,31 @@ export const authRouter = createTRPCRouter({
password: passwordSchema("password does not meet the requirements!"),
name: z.string().min(3).max(40),
expiresAt: z.string().optional(),
- code: z.string().optional(),
+ ztnetToken: z.string().optional(),
token: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
- const { email, password, name, code, token, expiresAt } = input;
+ const { email, password, name, ztnetToken, token, expiresAt } = input;
const settings = await ctx.prisma.globalOptions.findFirst({
where: {
id: 1,
},
});
- const invitationToken = code?.trim() || token?.trim();
+ const invitationToken = ztnetToken?.trim() || token?.trim();
+ // ztnet user invitation
const hasValidCode =
invitationToken &&
(await (async () => {
- if (!code.trim()) {
+ if (!ztnetToken.trim()) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No invitation code provided",
});
}
const invitation = await ctx.prisma.userInvitation.findUnique({
- where: { token: token.trim(), secret: code.trim() },
+ where: { token: token.trim(), secret: ztnetToken.trim() },
});
if (
@@ -261,6 +262,7 @@ export const authRouter = createTRPCRouter({
},
include: {
options: true,
+ memberOfOrgs: true,
},
})) as User & {
options?: UserOptions & {
@@ -268,8 +270,14 @@ export const authRouter = createTRPCRouter({
secretFromEnv?: boolean;
localControllerUrlPlaceholder?: string;
};
+ memberOfOrgs?: {
+ id: string;
+ ownerId: string;
+ orgName: string;
+ description: string | null;
+ isActive: boolean;
+ }[];
};
-
user.options.localControllerUrlPlaceholder = isRunningInDocker()
? "http://zerotier:9993"
: "http://127.0.0.1:9993";
diff --git a/src/server/api/routers/memberRouter.ts b/src/server/api/routers/memberRouter.ts
index 9d8074df..50f5032c 100644
--- a/src/server/api/routers/memberRouter.ts
+++ b/src/server/api/routers/memberRouter.ts
@@ -3,6 +3,8 @@ import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import * as ztController from "~/utils/ztApi";
import { TRPCError } from "@trpc/server";
import { type MemberEntity } from "~/types/local/member";
+import { checkUserOrganizationRole } from "~/utils/role";
+import { Role } from "@prisma/client";
const isValidZeroTierNetworkId = (id: string) => {
const hexRegex = /^[0-9a-fA-F]{10}$/;
@@ -51,6 +53,7 @@ export const networkMemberRouter = createTRPCRouter({
create: protectedProcedure
.input(
z.object({
+ organizationId: z.string().optional(),
id: z
.string({ required_error: "No member id provided!" })
.refine(isValidZeroTierNetworkId, {
@@ -62,6 +65,22 @@ export const networkMemberRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Created member ${input.id} in network ${input.nwid}`,
+ performedById: ctx.session.user.id,
+ organizationId: input.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
if (input.central) {
return await ztController.member_update({
ctx,
@@ -116,6 +135,7 @@ export const networkMemberRouter = createTRPCRouter({
nwid: z.string({ required_error: "No network id provided!" }),
memberId: z.string({ required_error: "No member id provided!" }),
central: z.boolean().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
activeBridge: z.boolean().optional(),
noAutoAssignIps: z.boolean().optional(),
@@ -131,6 +151,24 @@ export const networkMemberRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Updated member ${input.memberId} in network ${
+ input.nwid
+ }. ${JSON.stringify(input.updateParams)}`,
+ performedById: ctx.session.user.id,
+ organizationId: input.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
const payload: Partial = {};
// update capabilities
@@ -191,51 +229,6 @@ export const networkMemberRouter = createTRPCRouter({
});
if (input.central) return updatedMember;
-
- // const response = await ctx.prisma.network
- // .update({
- // where: {
- // nwid: input.nwid,
- // },
- // data: {
- // networkMembers: {
- // updateMany: {
- // where: { id: input.memberId, nwid: input.nwid },
- // data: {
- // // @ts-expect-error
- // tags: updatedMember.tags,
- // capabilities: updatedMember.capabilities,
- // },
- // },
- // },
- // },
- // include: {
- // networkMembers: {
- // where: {
- // id: input.memberId,
- // nwid: input.nwid,
- // },
- // },
- // },
- // })
- // // biome-ignore lint/suspicious/noConsoleLog:
- // .catch((err: string) => console.log(err));
- // if (!response) {
- // throw new TRPCError({
- // message: "Network database response is empty.",
- // code: "BAD_REQUEST",
- // });
- // }
-
- // if ("networkMembers" in response) {
- // // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- // return { member: response.networkMembers[0] };
- // } else {
- // throw new TRPCError({
- // message: "Response does not have network members.",
- // code: "BAD_REQUEST",
- // });
- // }
}),
Tags: protectedProcedure
.input(
@@ -243,6 +236,7 @@ export const networkMemberRouter = createTRPCRouter({
nwid: z.string({ required_error: "No network id provided!" }),
memberId: z.string({ required_error: "No member id provided!" }),
central: z.boolean().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
tags: z
.array(z.tuple([z.number(), z.number()]).or(z.array(z.never())))
@@ -251,6 +245,22 @@ export const networkMemberRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Updated tags for member ${input.memberId} in network ${input.nwid}`,
+ performedById: ctx.session.user.id,
+ organizationId: input.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
const tags = input.updateParams.tags;
const adjustedTags = tags && tags.length === 0 ? [] : tags;
@@ -285,16 +295,32 @@ export const networkMemberRouter = createTRPCRouter({
nwid: z.string(),
id: z.string(),
central: z.boolean().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
- // ipAssignments: z
- // .array(z.string({ required_error: "No Ip assignment provided!" }))
- // .optional(),
deleted: z.boolean().optional(),
name: z.string().optional(),
}),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Updated member ${input.id} in network ${input.nwid}. ${JSON.stringify(
+ input.updateParams,
+ )}`,
+ performedById: ctx.session.user.id,
+ organizationId: input.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
// if central is true, send the request to the central API and return the response
if (input.central && input?.updateParams?.name) {
return await ztController
@@ -348,15 +374,31 @@ export const networkMemberRouter = createTRPCRouter({
.input(
z
.object({
+ organizationId: z.string().optional(),
nwid: z.string({ required_error: "network ID not provided!" }),
id: z.string({ required_error: "id not provided!" }),
})
.required(),
)
.mutation(async ({ ctx, input }) => {
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Stashed member ${input.id} in network ${input.nwid}`,
+ performedById: ctx.session.user.id,
+ organizationId: input.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
const caller = networkMemberRouter.createCaller(ctx);
//user needs to be de-authorized before deleted.
-
// adding try catch to prevent error if user is not part of the network but still in the database.
try {
await caller.Update({
@@ -400,6 +442,7 @@ export const networkMemberRouter = createTRPCRouter({
.input(
z
.object({
+ organizationId: z.string().optional(),
central: z.boolean().default(false),
nwid: z.string({ required_error: "network ID not provided!" }),
id: z.string({ required_error: "memberId not provided!" }),
@@ -407,6 +450,22 @@ export const networkMemberRouter = createTRPCRouter({
.required(),
)
.mutation(async ({ ctx, input }) => {
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Deleted member ${input.id} in network ${input.nwid}`,
+ performedById: ctx.session.user.id,
+ organizationId: input.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
// remove member from controller
const deleted = await ztController.member_delete({
ctx,
@@ -446,11 +505,28 @@ export const networkMemberRouter = createTRPCRouter({
removeMemberAnotations: protectedProcedure
.input(
z.object({
+ organizationId: z.string().optional(),
notationId: z.number(),
nodeid: z.number(),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Removed notation ${input.notationId} from member ${input.nodeid}`,
+ performedById: ctx.session.user.id,
+ organizationId: input.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
await ctx.prisma.networkMemberNotation.delete({
where: {
notationId_nodeid: {
diff --git a/src/server/api/routers/networkRouter.ts b/src/server/api/routers/networkRouter.ts
index 790ce053..f39920c4 100644
--- a/src/server/api/routers/networkRouter.ts
+++ b/src/server/api/routers/networkRouter.ts
@@ -19,6 +19,8 @@ import { type TagsByName, type NetworkEntity } from "~/types/local/network";
import { type CapabilitiesByName } from "~/types/local/member";
import { type CentralNetwork } from "~/types/central/network";
import { createNetworkService } from "../services/networkService";
+import { checkUserOrganizationRole } from "~/utils/role";
+import { Role } from "@prisma/client";
export const customConfig: Config = {
dictionaries: [adjectives, animals],
@@ -107,18 +109,36 @@ export const networkRouter = createTRPCRouter({
return await ztController.central_network_detail(ctx, input.nwid, input.central);
}
- const psqlNetworkData = await ctx.prisma.network.findFirst({
+ // First, retrieve the network with organization details
+ const psqlNetworkData = await ctx.prisma.network.findUnique({
where: {
- AND: [
- {
- authorId: { equals: ctx.session.user.id },
- nwid: { equals: input.nwid },
- },
- ],
+ nwid: input.nwid,
+ },
+ include: {
+ organization: true,
},
});
- if (!psqlNetworkData) return throwError("You are not the Author of this network!");
+ if (!psqlNetworkData) {
+ return throwError("Network not found!");
+ }
+
+ // Check if the user is the author of the network or part of the associated organization
+ const isAuthor = psqlNetworkData.authorId === ctx.session.user.id;
+ const isMemberOfOrganization =
+ psqlNetworkData.organizationId &&
+ (await ctx.prisma.organization.findFirst({
+ where: {
+ id: psqlNetworkData.organizationId,
+ users: {
+ some: { id: ctx.session.user.id },
+ },
+ },
+ }));
+
+ if (!isAuthor && !isMemberOfOrganization) {
+ return throwError("You do not have access to this network!");
+ }
const ztControllerResponse = await ztController
.local_network_detail(ctx, psqlNetworkData.nwid, false)
@@ -194,13 +214,32 @@ export const networkRouter = createTRPCRouter({
deleteNetwork: protectedProcedure
.input(
z.object({
- nwid: z.string().nonempty(),
+ nwid: z.string(),
central: z.boolean().default(false),
+ organizationId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
+
try {
- // de-authorize all members before we delete network
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Deleted network ${input.nwid}`,
+ performedById: ctx.session.user.id,
+ organizationId: input.organizationId || null,
+ },
+ });
+
+ // De-authorize all members before deleting the network
const members = await ztController.network_members(
ctx,
input.nwid,
@@ -212,26 +251,32 @@ export const networkRouter = createTRPCRouter({
nwid: input.nwid,
central: input.central,
memberId: member,
- updateParams: {
- authorized: false,
- },
+ updateParams: { authorized: false },
});
}
// Delete ZT network
- const createCentralNw = await ztController
- .network_delete(ctx, input.nwid, input.central)
- .catch(() => []); // Ignore errors
+ await ztController.network_delete(ctx, input.nwid, input.central);
- if (input.central) return createCentralNw;
+ // If the network is not central, delete it from the organization
+ if (!input.central && input.organizationId) {
+ await ctx.prisma.network.deleteMany({
+ where: {
+ organizationId: input.organizationId,
+ nwid: input.nwid,
+ },
+ });
+ }
- // Delete network
- await ctx.prisma.network.deleteMany({
- where: {
- authorId: ctx.session.user.id,
- nwid: input.nwid,
- },
- });
+ // If no organizationId is provided, delete network owned by the user
+ if (!input.organizationId) {
+ await ctx.prisma.network.deleteMany({
+ where: {
+ authorId: ctx.session.user.id,
+ nwid: input.nwid,
+ },
+ });
+ }
} catch (error) {
if (error instanceof z.ZodError) {
return throwError(`Invalid routes provided ${error.message}`);
@@ -239,6 +284,7 @@ export const networkRouter = createTRPCRouter({
throw error;
}
}),
+
ipv6: protectedProcedure
.input(
z.object({
@@ -249,9 +295,29 @@ export const networkRouter = createTRPCRouter({
rfc4193: z.boolean().optional(),
zt: z.boolean().optional(),
}),
+ organizationId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Changed network ${input.nwid} IPv6 auto-assign to ${JSON.stringify(
+ input.v6AssignMode,
+ )}`,
+ performedById: ctx.session.user.id,
+ organizationId: input.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
const network = await ztController.get_network(ctx, input.nwid, input.central);
// prepare update params
const updateParams = input.central
@@ -278,6 +344,7 @@ export const networkRouter = createTRPCRouter({
z.object({
nwid: z.string().nonempty(),
central: z.boolean().optional().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
v4AssignMode: z.object({
zt: z.boolean(),
@@ -286,6 +353,23 @@ export const networkRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Changed network ${input.nwid} IPv4 auto-assign to ${input.updateParams.v4AssignMode.zt}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input?.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input?.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
// if central is true, send the request to the central API and return the response
const { v4AssignMode } = input.updateParams;
// prepare update params
@@ -306,12 +390,32 @@ export const networkRouter = createTRPCRouter({
z.object({
nwid: z.string(),
central: z.boolean().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
routes: RoutesArraySchema.optional(),
}),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Changed network ${input.nwid} managed routes to ${JSON.stringify(
+ input.updateParams.routes,
+ )}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input?.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input?.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
const { routes } = input.updateParams;
// prepare update params
const updateParams = input.central ? { config: { routes } } : { routes };
@@ -329,12 +433,31 @@ export const networkRouter = createTRPCRouter({
z.object({
nwid: z.string().nonempty(),
central: z.boolean().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
routes: RoutesArraySchema.optional(),
}),
}),
)
.mutation(async ({ ctx, input }) => {
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Changed network ${input.nwid} IP assignment to ${JSON.stringify(
+ input.updateParams.routes,
+ )})}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input?.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input?.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
// generate network params
const { ipAssignmentPools, routes, v4AssignMode } = IPv4gen(
input.updateParams.routes[0].target,
@@ -358,6 +481,7 @@ export const networkRouter = createTRPCRouter({
z.object({
nwid: z.string().nonempty(),
central: z.boolean().optional().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
ipAssignmentPools: z
.array(
@@ -371,6 +495,25 @@ export const networkRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Changed network ${input.nwid} IP assignment pools to ${JSON.stringify(
+ input.updateParams.ipAssignmentPools,
+ )}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input?.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input?.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
const { ipAssignmentPools } = input.updateParams;
// prepare update params
const updateParams = input.central
@@ -390,12 +533,29 @@ export const networkRouter = createTRPCRouter({
z.object({
nwid: z.string().nonempty(),
central: z.boolean().optional().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
- private: z.boolean().optional(),
+ private: z.boolean(),
}),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Changed network ${input.nwid} privacy to ${input.updateParams.private}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
const updateParams = input.central
? { config: { private: input.updateParams.private } }
: { private: input.updateParams.private };
@@ -417,14 +577,32 @@ export const networkRouter = createTRPCRouter({
networkName: protectedProcedure
.input(
z.object({
- nwid: z.string().nonempty(),
+ nwid: z.string(),
central: z.boolean().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
- name: z.string().nonempty(),
+ name: z.string(),
}),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Changed network ${input.nwid} name to ${input.updateParams.name}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
const updateParams = input.central
? { config: { ...input.updateParams } }
: { ...input.updateParams };
@@ -456,12 +634,30 @@ export const networkRouter = createTRPCRouter({
z.object({
nwid: z.string(),
central: z.boolean().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
description: z.string(),
}),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Changed network ${input.nwid} description to ${input.updateParams.description}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input?.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input?.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
// if central is true, send the request to the central API and return the response
if (input.central) {
const updated = await ztController.network_update({
@@ -494,32 +690,52 @@ export const networkRouter = createTRPCRouter({
z.object({
nwid: z.string(),
central: z.boolean().default(false),
- clearDns: z.boolean().optional(),
- updateParams: z
- .object({
- dns: z
- .object({
- domain: z.string().refine(isValidDomain, {
- message: "Invalid DNS domain provided",
- }),
- servers: z.array(
- z.string().refine(isValidIP, {
- message: "Invalid DNS server provided",
- }),
- ),
- })
- .refine((dns) => dns === undefined || (dns?.domain && dns.servers), {
- message: "Both domain and servers must be provided if dns is defined",
+ organizationId: z.string().optional(),
+ updateParams: z.object({
+ clearDns: z.boolean().optional(),
+ dns: z
+ .object({
+ domain: z.string().refine(isValidDomain, {
+ message: "Invalid DNS domain provided",
}),
- })
- .optional(),
+ servers: z.array(
+ z.string().refine(isValidIP, {
+ message: "Invalid DNS server provided",
+ }),
+ ),
+ })
+ .refine((dns) => dns === undefined || (dns?.domain && dns.servers), {
+ message: "Both domain and servers must be provided if dns is defined",
+ })
+ .optional(),
+ }),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Changed network ${input.nwid} DNS to ${JSON.stringify(
+ input.updateParams,
+ )})}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input?.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input?.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
+
let ztControllerUpdates = {};
// If clearDns is true, set DNS to an empty object
- if (input.clearDns) {
+ if (input.updateParams?.clearDns) {
ztControllerUpdates = { dns: { domain: "", servers: [] } };
} else {
ztControllerUpdates = { ...input.updateParams };
@@ -543,6 +759,7 @@ export const networkRouter = createTRPCRouter({
z.object({
nwid: z.string().nonempty(),
central: z.boolean().optional().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
multicastLimit: z.number().optional(),
enableBroadcast: z.boolean().optional(),
@@ -551,6 +768,25 @@ export const networkRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Changed network ${input.nwid} multicast to ${JSON.stringify(
+ input.updateParams,
+ )}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input?.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input?.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
const updateParams = input.central
? { config: { ...input.updateParams } }
: { ...input.updateParams };
@@ -583,14 +819,32 @@ export const networkRouter = createTRPCRouter({
setFlowRule: protectedProcedure
.input(
z.object({
- nwid: z.string().nonempty(),
+ nwid: z.string(),
central: z.boolean().default(false),
+ organizationId: z.string().optional(),
updateParams: z.object({
- flowRoute: z.string().nonempty(),
+ flowRoute: z.string(),
}),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Updated flow route for network ${input.nwid}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input?.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input?.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
const { flowRoute } = input.updateParams;
const rules = [];
@@ -744,9 +998,27 @@ accept;`;
z.object({
nwid: z.string().nonempty(),
email: z.string().email(),
+ organizationId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Invited user ${input.email} to network ${input.nwid}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input?.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input?.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
const { nwid, email } = input;
const globalOptions = await ctx.prisma.globalOptions.findFirst({
where: {
@@ -791,9 +1063,28 @@ accept;`;
description: z.string().optional(),
showMarkerInTable: z.boolean().optional(),
useAsTableBackground: z.boolean().optional(),
+ organizationId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Added annotation ${input.name} to network ${input.nwid}`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+
+ // Check if the user has permission to update the network
+ if (input?.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input?.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
+
const notation = await ctx.prisma.notation.upsert({
where: {
name_nwid: {
diff --git a/src/server/api/routers/organizationRouter.ts b/src/server/api/routers/organizationRouter.ts
new file mode 100644
index 00000000..7f48453e
--- /dev/null
+++ b/src/server/api/routers/organizationRouter.ts
@@ -0,0 +1,793 @@
+import { uniqueNamesGenerator } from "unique-names-generator";
+import { z } from "zod";
+import {
+ createTRPCRouter,
+ adminRoleProtectedRoute,
+ protectedProcedure,
+} from "~/server/api/trpc";
+import { IPv4gen } from "~/utils/IPv4gen";
+import { customConfig, networkRouter } from "./networkRouter";
+import * as ztController from "~/utils/ztApi";
+import {
+ ORG_INVITE_TOKEN_SECRET,
+ encrypt,
+ generateInstanceSecret,
+} from "~/utils/encryption";
+import { createTransporter, inviteOrganizationTemplate, sendEmail } from "~/utils/mail";
+import ejs from "ejs";
+import { Role } from "@prisma/client";
+import { checkUserOrganizationRole } from "~/utils/role";
+
+export const organizationRouter = createTRPCRouter({
+ createOrg: adminRoleProtectedRoute
+ .input(
+ z.object({
+ orgName: z.string(),
+ orgDescription: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ return await ctx.prisma.$transaction(async (prisma) => {
+ // Step 1: Create the organization and add the user as a member
+ const newOrg = await prisma.organization.create({
+ data: {
+ orgName: input.orgName,
+ description: input.orgDescription,
+ ownerId: ctx.session.user.id, // Set the current user as the owner
+ users: {
+ connect: { id: ctx.session.user.id }, // Connect the user as a member
+ },
+ },
+ });
+
+ // Step 2: Set the user's role in the organization
+ await prisma.userOrganizationRole.create({
+ data: {
+ userId: ctx.session.user.id,
+ organizationId: newOrg.id,
+ role: "ADMIN",
+ },
+ });
+
+ // Optionally, return the updated user with memberOfOrgs included
+ return await prisma.user.findUnique({
+ where: { id: ctx.session.user.id },
+ include: {
+ memberOfOrgs: true,
+ },
+ });
+ });
+ }),
+ deleteOrg: adminRoleProtectedRoute
+ .input(
+ z.object({
+ organizationId: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ // make sure the user is member of the organization
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.ADMIN,
+ });
+ const caller = networkRouter.createCaller(ctx);
+ // make sure the user is the owner of the organization
+ const org = await ctx.prisma.organization.findUnique({
+ where: {
+ id: input.organizationId,
+ },
+ include: {
+ networks: true,
+ },
+ });
+ if (org?.ownerId !== ctx.session.user.id) {
+ throw new Error("You are not the owner of this organization.");
+ }
+ // delete all networks on the controller
+ for (const nw of org.networks) {
+ await caller.deleteNetwork({
+ nwid: nw.nwid,
+ organizationId: input.organizationId,
+ });
+ }
+
+ return await ctx.prisma.$transaction(async (prisma) => {
+ // Delete all activity logs related to the organization
+ await prisma.activityLog.deleteMany({
+ where: {
+ organizationId: input.organizationId,
+ },
+ });
+
+ // Finally, delete the organization itself
+ return await prisma.organization.deleteMany({
+ where: {
+ id: input.organizationId,
+ },
+ });
+ });
+ }),
+ updateMeta: adminRoleProtectedRoute
+ .input(
+ z.object({
+ organizationId: z.string(),
+ orgName: z.string().optional(),
+ orgDescription: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ // make sure the user is member of the organization
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.ADMIN,
+ });
+ // make sure the user is the owner of the organization
+ const org = await ctx.prisma.organization.findUnique({
+ where: {
+ id: input.organizationId,
+ },
+ });
+ if (org?.ownerId !== ctx.session.user.id) {
+ throw new Error("You are not the owner of this organization.");
+ }
+ // update the organization
+ return await ctx.prisma.organization.update({
+ where: {
+ id: input.organizationId,
+ },
+ data: {
+ orgName: input.orgName,
+ description: input.orgDescription,
+ },
+ });
+ }),
+ getOrgIdbyUserid: protectedProcedure.query(async ({ ctx }) => {
+ // get all organizations this user is a member of
+ return await ctx.prisma.organization.findMany({
+ where: {
+ users: {
+ some: {
+ id: ctx.session.user.id,
+ },
+ },
+ },
+ select: {
+ id: true,
+ },
+ });
+ }),
+ getAllOrg: adminRoleProtectedRoute.query(async ({ ctx }) => {
+ // get all organizations related to the user
+ const organizations = await ctx.prisma.organization.findMany({
+ where: {
+ ownerId: ctx.session.user.id,
+ },
+ include: {
+ userRoles: true,
+ users: true,
+ invitations: true,
+ },
+ //order by desc
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+
+ // Add URL to each invitation
+ const organizationsWithInvitationLinks = organizations?.map((org) => {
+ return {
+ ...org,
+ invitations: org.invitations.map((invitation) => {
+ return {
+ ...invitation,
+ tokenUrl: `${process.env.NEXTAUTH_URL}/auth/register?organizationInvite=${invitation.token}`,
+ };
+ }),
+ };
+ });
+
+ return organizationsWithInvitationLinks;
+ }),
+ getOrgUserRoleById: protectedProcedure
+ .input(
+ z.object({
+ organizationId: z.string(),
+ userId: z.string(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ // get all organizations related to the user
+ return await ctx.prisma.userOrganizationRole.findUnique({
+ where: {
+ userId_organizationId: {
+ organizationId: input.organizationId,
+ userId: input.userId,
+ },
+ },
+ });
+ }),
+ getOrgUsers: protectedProcedure
+ .input(
+ z.object({
+ organizationId: z.string(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ // get all organizations related to the user
+ const organization = await ctx.prisma.organization.findUnique({
+ where: {
+ id: input.organizationId,
+ },
+ select: {
+ userRoles: true,
+ users: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ // return user with role flatten
+ const users = organization?.users.map((user) => {
+ const role = organization.userRoles.find((role) => role.userId === user.id);
+ return {
+ ...user,
+ role: role?.role,
+ };
+ });
+
+ return users;
+ }),
+ getOrgById: protectedProcedure
+ .input(
+ z.object({
+ organizationId: z.string(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ // make sure the user is member of the organization
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.READ_ONLY,
+ });
+ // get all organizations related to the user
+ return await ctx.prisma.organization.findUnique({
+ where: {
+ id: input.organizationId,
+ },
+ include: {
+ userRoles: true,
+ users: true,
+ networks: {
+ include: {
+ networkMembers: true,
+ },
+ },
+ },
+ });
+ }),
+ createOrgNetwork: protectedProcedure
+ .input(
+ z.object({
+ networkName: z.string().optional(),
+ orgName: z.string().optional(),
+ organizationId: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.USER,
+ });
+ }
+
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Created a new network: ${input.networkName}`,
+ performedById: ctx.session.user.id,
+ organizationId: input.organizationId || null,
+ },
+ });
+
+ // Generate ipv4 address, cidr, start & end
+ const ipAssignmentPools = IPv4gen(null);
+
+ if (!input?.networkName) {
+ // Generate adjective and noun word
+ input.networkName = uniqueNamesGenerator(customConfig);
+ }
+
+ // Create ZT network
+ const newNw = await ztController.network_create(
+ ctx,
+ input.networkName,
+ ipAssignmentPools,
+ false, // central
+ );
+
+ // Store the created network in the database
+ await ctx.prisma.organization.update({
+ where: { id: input.organizationId },
+ data: {
+ networks: {
+ create: {
+ name: input.networkName,
+ nwid: newNw.nwid,
+ description: input.orgName,
+ },
+ },
+ },
+ include: {
+ networks: true, // Optionally include the updated list of networks in the response
+ },
+ });
+
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Created a new network: ${newNw.nwid}`,
+ performedById: ctx.session.user.id,
+ organizationId: input.organizationId || null, // Use null if organizationId is not provided
+ },
+ });
+ return newNw;
+ }),
+ changeUserRole: protectedProcedure
+ .input(
+ z.object({
+ organizationId: z.string(),
+ userId: z.string(),
+ role: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ if (input.userId === ctx.session.user.id) {
+ throw new Error("You cannot change your own role.");
+ }
+ // Check if the user has permission to update the network
+ if (input.organizationId) {
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.ADMIN,
+ });
+ }
+ // chagne the user's role in the organization
+ return await ctx.prisma.userOrganizationRole.update({
+ where: {
+ userId_organizationId: {
+ organizationId: input.organizationId,
+ userId: input.userId,
+ },
+ },
+ data: {
+ role: input.role as Role,
+ },
+ });
+ }),
+ sendMessage: protectedProcedure
+ .input(
+ z.object({
+ message: z.string().optional(),
+ organizationId: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ // Ensure a message was provided
+ if (!input.message) {
+ throw new Error("Message content cannot be empty.");
+ }
+ // make sure the user is member of the organization
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.READ_ONLY,
+ });
+ // Get the current user's ID
+ const userId = ctx.session.user.id;
+
+ // Create a new message in the database
+ const newMessage = await ctx.prisma.messages.create({
+ data: {
+ content: input.message,
+ userId, // Associate the message with the current user
+ organizationId: input.organizationId, // Associate the message with the specified organization
+ },
+ include: {
+ user: {
+ select: {
+ name: true,
+ email: true,
+ id: true,
+ },
+ },
+ },
+ });
+
+ // Update LastReadMessage for the sender
+ await ctx.prisma.lastReadMessage.upsert({
+ where: {
+ userId_organizationId: {
+ userId: userId,
+ organizationId: input.organizationId,
+ },
+ },
+ update: {
+ lastMessageId: newMessage.id,
+ },
+ create: {
+ userId: userId,
+ organizationId: input.organizationId,
+ lastMessageId: newMessage.id,
+ },
+ });
+
+ // emit to other users in the same organization
+ ctx.wss.emit(input.organizationId, newMessage);
+
+ // Return the newly created message
+ return newMessage;
+ }),
+ getMessages: protectedProcedure
+ .input(
+ z.object({
+ organizationId: z.string(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ // make sure the user is member of the organization
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.READ_ONLY,
+ });
+
+ const userId = ctx.session.user.id;
+
+ // Get the ID of the last message read by the current user
+ const lastRead = await ctx.prisma.lastReadMessage.findUnique({
+ where: {
+ userId_organizationId: {
+ userId: userId,
+ organizationId: input.organizationId,
+ },
+ },
+ });
+
+ // Get all messages associated with the current organization
+ const messages = await ctx.prisma.messages.findMany({
+ where: {
+ organizationId: input.organizationId,
+ },
+ take: 30,
+ orderBy: {
+ createdAt: "desc",
+ },
+ include: {
+ user: {
+ select: {
+ name: true,
+ email: true,
+ id: true,
+ },
+ },
+ },
+ });
+
+ // Optionally, mark messages as read/unread based on the lastReadMessageId
+ const processedMessages = messages.reverse().map((message) => {
+ return {
+ ...message,
+ isRead: lastRead ? message.id <= lastRead.lastMessageId : false,
+ };
+ });
+
+ return processedMessages;
+ }),
+ markMessagesAsRead: protectedProcedure
+ .input(
+ z.object({
+ organizationId: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ // Get the current user's ID
+ const userId = ctx.session.user.id;
+ // Find the latest message in the organization
+ const latestMessage = await ctx.prisma.messages.findFirst({
+ where: {
+ organizationId: input.organizationId,
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+
+ // Check if there's a latest message
+ if (!latestMessage) {
+ return null; // or handle the case where there are no messages
+ }
+ // Get the ID of the last message read by the current user
+ return await ctx.prisma.lastReadMessage.upsert({
+ where: {
+ userId_organizationId: {
+ userId: userId,
+ organizationId: input.organizationId,
+ },
+ },
+ update: {
+ lastMessageId: latestMessage.id,
+ },
+ create: {
+ userId: userId,
+ organizationId: input.organizationId,
+ lastMessageId: latestMessage.id,
+ },
+ });
+ }),
+ getOrgNotifications: protectedProcedure
+ .input(z.object({})) // No input required if fetching for all organizations
+ .query(async ({ ctx }) => {
+ // Get the current user's ID
+ const userId = ctx.session.user.id;
+
+ // Get a list of organizations associated with the user through UserOrganizationRole
+ const userOrganizations = await ctx.prisma.userOrganizationRole.findMany({
+ where: { userId: userId },
+ select: { organizationId: true },
+ });
+
+ // Initialize an object to hold the notification status for each organization
+ const notifications = {};
+
+ // Check unread messages for each organization
+ for (const userOrg of userOrganizations) {
+ const lastRead = await ctx.prisma.lastReadMessage.findUnique({
+ where: {
+ userId_organizationId: {
+ userId: userId,
+ organizationId: userOrg.organizationId,
+ },
+ },
+ });
+
+ const latestMessage = await ctx.prisma.messages.findFirst({
+ where: {
+ organizationId: userOrg.organizationId,
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+
+ const hasUnreadMessages =
+ latestMessage && (!lastRead || latestMessage.id > lastRead.lastMessageId);
+
+ // Add the unread message status to the notifications object
+ notifications[userOrg.organizationId] = { hasUnreadMessages: hasUnreadMessages };
+ }
+
+ // Return the notifications object
+ return notifications;
+ }),
+
+ addUser: adminRoleProtectedRoute
+ .input(
+ z.object({
+ organizationId: z.string(),
+ userId: z.string(),
+ userName: z.string(),
+ organizationRole: z.enum(["READ_ONLY", "USER"]),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ // Log the action
+ await ctx.prisma.activityLog.create({
+ data: {
+ action: `Added user ${input.userName} to organization.`,
+ performedById: ctx.session.user.id,
+ organizationId: input?.organizationId, // Use null if organizationId is not provided
+ },
+ });
+ // Add user to the organization
+ const updatedOrganization = await ctx.prisma.organization.update({
+ where: {
+ id: input.organizationId,
+ },
+ data: {
+ userRoles: {
+ create: {
+ userId: input.userId,
+ role: input.organizationRole,
+ },
+ },
+ users: {
+ connect: {
+ id: input.userId,
+ },
+ },
+ },
+ include: {
+ users: true, // Assuming you want to return the updated list of users
+ },
+ });
+
+ return updatedOrganization;
+ }),
+ leave: protectedProcedure
+ .input(
+ z.object({
+ organizationId: z.string(),
+ userId: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ // make sure the user is not the owner of the organization
+ const org = await ctx.prisma.organization.findUnique({
+ where: {
+ id: input.organizationId,
+ },
+ });
+ if (org?.ownerId === input.userId) {
+ throw new Error("You cannot leave an organization you own.");
+ }
+ // leave organization
+ return await ctx.prisma.organization.update({
+ where: {
+ id: input.organizationId,
+ },
+ data: {
+ users: {
+ disconnect: {
+ id: input.userId,
+ },
+ },
+ userRoles: {
+ deleteMany: {
+ userId: input.userId,
+ organizationId: input.organizationId,
+ },
+ },
+ },
+ });
+ }),
+ getLogs: protectedProcedure
+ .input(
+ z.object({
+ organizationId: z.string(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ // make sure the user is member of the organization
+ await checkUserOrganizationRole({
+ ctx,
+ organizationId: input.organizationId,
+ requiredRole: Role.READ_ONLY,
+ });
+ // Get all messages associated with the current user
+ const logs = await ctx.prisma.activityLog.findMany({
+ where: {
+ organizationId: input.organizationId,
+ },
+ take: 100,
+ orderBy: {
+ createdAt: "desc",
+ },
+ include: {
+ performedBy: {
+ select: {
+ name: true,
+ },
+ },
+ },
+ });
+
+ return logs;
+ }),
+ generateInviteLink: adminRoleProtectedRoute
+ .input(
+ z.object({
+ organizationId: z.string(),
+ email: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ if (!input.email) {
+ throw new Error("Email cannot be empty.");
+ }
+ if (!input.organizationId) {
+ throw new Error("Organization ID cannot be empty.");
+ }
+ const payload = {
+ email: input.email,
+ organizationId: input.organizationId,
+ expires: Date.now() + 3600000, // Current time + 1 hour
+ };
+
+ // Encrypt the payload
+ const secret = generateInstanceSecret(ORG_INVITE_TOKEN_SECRET); // Use SMTP_SECRET or any other relevant context
+ const encryptedToken = encrypt(JSON.stringify(payload), secret);
+
+ // Store the token in the database
+ const invitation = await ctx.prisma.organizationInvitation.create({
+ data: {
+ token: encryptedToken,
+ organizationId: input.organizationId,
+ email: input.email,
+ },
+ });
+
+ const invitationLink = `${process.env.NEXTAUTH_URL}/auth/register?organizationInvite=${invitation.token}`;
+
+ // Return the invitation link
+ return { invitationLink, encryptedToken };
+ }),
+ inviteUserByMail: adminRoleProtectedRoute
+ .input(
+ z.object({
+ organizationId: z.string(),
+ email: z.string().email(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { organizationId, email } = input;
+ const globalOptions = await ctx.prisma.globalOptions.findFirst({
+ where: {
+ id: 1,
+ },
+ });
+
+ const defaultTemplate = inviteOrganizationTemplate();
+ const template = globalOptions?.inviteOrganizationTemplate ?? defaultTemplate;
+
+ const renderedTemplate = await ejs.render(
+ JSON.stringify(template),
+ {
+ toEmail: email,
+ fromAdmin: ctx.session.user.name,
+ fromOrganization: organizationId,
+ },
+ { async: true },
+ );
+ // create transporter
+ const transporter = createTransporter(globalOptions);
+ const parsedTemplate = JSON.parse(renderedTemplate) as Record;
+
+ // define mail options
+ const mailOptions = {
+ from: globalOptions.smtpEmail,
+ to: email,
+ subject: parsedTemplate.subject,
+ html: parsedTemplate.body,
+ };
+
+ // send test mail to user
+ await sendEmail(transporter, mailOptions);
+ }),
+ deleteInvite: adminRoleProtectedRoute
+ .input(
+ z.object({
+ organizationId: z.string(),
+ token: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { organizationId, token } = input;
+ const invite = await ctx.prisma.organizationInvitation.deleteMany({
+ where: {
+ organizationId,
+ token,
+ },
+ });
+ return invite;
+ }),
+});
diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts
index fc0093b0..39b21195 100644
--- a/src/server/api/trpc.ts
+++ b/src/server/api/trpc.ts
@@ -16,14 +16,26 @@
*/
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
import { type Session } from "next-auth";
-
+import { Socket as SocketIOServer } from "socket.io-client";
import { getServerAuthSession } from "~/server/auth";
import { prisma } from "~/server/db";
type CreateContextOptions = {
session: Session | null;
+ wss: SocketIOServer;
};
+// custom type for the socket server
+interface SocketServerCtx {
+ res: {
+ socket: {
+ server?: {
+ io?: SocketIOServer;
+ };
+ };
+ };
+}
+
/**
* This helper generates the "internals" for a tRPC context. If you need to use it, you can export
* it from here.
@@ -37,6 +49,7 @@ type CreateContextOptions = {
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
return {
session: opts.session,
+ wss: opts.wss,
prisma,
};
};
@@ -47,14 +60,17 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => {
*
* @see https://trpc.io/docs/context
*/
-export const createTRPCContext = async (opts: CreateNextContextOptions) => {
+export const createTRPCContext = async (
+ opts: CreateNextContextOptions & SocketServerCtx,
+) => {
const { req, res } = opts;
-
+ const wss = res.socket.server.io;
// Get the session from the server using the getServerSession wrapper function
const session = await getServerAuthSession({ req, res });
return createInnerTRPCContext({
session,
+ wss,
});
};
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 43c556cf..5ae7e28e 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -23,21 +23,25 @@
height: calc(var(--vh, 1vh) * 100 - 48px);
}
-.custom-scrollbar::-webkit-scrollbar {
+.custom-scrollbar::-webkit-scrollbar, .cm-scroller::-webkit-scrollbar {
width: 8px; /* width of the entire scrollbar */
}
-.custom-scrollbar::-webkit-scrollbar-track {
+.custom-scrollbar::-webkit-scrollbar-track, .cm-scroller::-webkit-scrollbar-track {
background: #bbbbbb; /* color of the tracking area */
}
-.custom-scrollbar::-webkit-scrollbar-thumb {
+.custom-scrollbar::-webkit-scrollbar-thumb, .cm-scroller::-webkit-scrollbar-thumb {
background: #2e2e2e; /* color of the scroll thumb */
}
-.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+.custom-scrollbar::-webkit-scrollbar-thumb:hover, .cm-scroller::-webkit-scrollbar-thumb:hover {
background: #555; /* color of the scroll thumb on hover */
}
+
+.glow {
+ box-shadow: 0 0 2px 2px rgb(189, 93, 93);
+}
/* table,
th,
td {
diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts
index ba774abc..16490270 100644
--- a/src/utils/encryption.ts
+++ b/src/utils/encryption.ts
@@ -5,6 +5,7 @@ const ZTNET_SECRET = process.env.NEXTAUTH_SECRET;
export const SMTP_SECRET = "_smtp";
export const API_TOKEN_SECRET = "_ztnet_api_token";
+export const ORG_INVITE_TOKEN_SECRET = "_ztnet_org_invite";
// Generate instance specific auth secret using salt
export const generateInstanceSecret = (contextSuffix: string) => {
diff --git a/src/utils/mail.ts b/src/utils/mail.ts
index b182852c..372c51ec 100644
--- a/src/utils/mail.ts
+++ b/src/utils/mail.ts
@@ -3,6 +3,20 @@ import nodemailer, { type TransportOptions } from "nodemailer";
import { throwError } from "~/server/helpers/errorHandler";
import { SMTP_SECRET, decrypt, generateInstanceSecret } from "./encryption";
+export const inviteOrganizationTemplate = () => {
+ return {
+ subject: "Invitation to join ZTNET Organization <%= fromOrganization %>",
+ body:
+ "Hello <%= toEmail %>,
" +
+ "You have been invited by <%= fromAdmin %> to join our ZTNET organization, '<%= fromOrganization %>'. We are excited to potentially have you on board and look forward to collaborating with you.
" +
+ "To accept this invitation, please follow the link provided.
" +
+ "If you are not familiar with '<%= fromAdmin %>' or '<%= fromOrganization %>', please disregard this message for your security.
" +
+ "Warm regards,
" +
+ "<%= fromOrganization %>
" +
+ "ZTNET Team",
+ };
+};
+
export const inviteUserTemplate = () => {
return {
subject: "ZTNET -- Invitation to join network: <%= nwid %>",
diff --git a/src/utils/randomColor.ts b/src/utils/randomColor.ts
index 16d4eae1..1f60356f 100644
--- a/src/utils/randomColor.ts
+++ b/src/utils/randomColor.ts
@@ -39,3 +39,26 @@ export const getRandomColor = () => {
export const convertRGBtoRGBA = (rgb: string, alpha: number) => {
return rgb.replace(")", `, ${alpha})`).replace("rgb", "rgba");
};
+
+/**
+ * Generates a consistent HSL color based on the input string.
+ * This function calculates a unique hash from the given string and then converts this hash into a hue value.
+ * The function always uses 100% saturation and 30% lightness, resulting in deep, vivid colors.
+ *
+ * @param {string} str - The input string from which the color is generated.
+ * @returns {string} The HSL color string corresponding to the input string.
+ */
+export const stringToColor = (str) => {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ hash = hash & hash; // Convert to 32bit integer
+ }
+
+ let color = "#";
+ for (let i = 0; i < 3; i++) {
+ const value = (hash >> (i * 8)) & 0xff;
+ color += `00${value.toString(16)}`.substr(-2);
+ }
+ return color;
+};
diff --git a/src/utils/role.ts b/src/utils/role.ts
new file mode 100644
index 00000000..fc54feb9
--- /dev/null
+++ b/src/utils/role.ts
@@ -0,0 +1,60 @@
+import { TRPCClientError } from "@trpc/client";
+
+enum Role {
+ READ_ONLY = 0,
+ USER = 1,
+ MODERATOR = 2,
+ ADMIN = 3,
+}
+
+/**
+ * Checks if a user's role within a specific organization meets or exceeds a required role level.
+ *
+ * @param {Object} params - The parameters for the function.
+ * @param {Object} params.ctx - The context object containing the user session and other relevant data.
+ * @param {String} params.organizationId - The ID of the organization for which the role check is being performed.
+ * @param {String} params.requiredRole - The required role level that the user's role is being compared against.
+ *
+ * @returns {Boolean} - Returns `true` if the user's role is equal to or higher than the required role.
+ * Throws an error if the user's role is lower than the required level or if the user does not have a role in the specified organization.
+ *
+ * The function first retrieves the user's role within the given organization from the database.
+ * It directly allows access for users with the 'ADMIN' role. For other roles, it compares the numerical value
+ * of the user's role with the required role's numerical value, based on a predefined 'Role' enum.
+ */
+export const checkUserOrganizationRole = async ({
+ ctx,
+ organizationId,
+ requiredRole,
+}) => {
+ const userId = ctx.session.user.id;
+
+ // get the role of the user in the organization
+ const orgUserRole = await ctx.prisma.userOrganizationRole.findFirst({
+ where: {
+ organizationId: organizationId,
+ userId,
+ },
+ select: {
+ role: true, // Only select the role
+ },
+ });
+
+ // If user role is not found, deny access
+ if (!orgUserRole) {
+ throw new TRPCClientError("You don't have permission to perform this action");
+ }
+ // Directly return true for admin role
+ if (orgUserRole.role === "ADMIN") {
+ return true;
+ }
+
+ const userRoleValue = Role[orgUserRole.role];
+ const requiredRoleValue = Role[requiredRole];
+
+ // Return true if the user's role value meets or exceeds the required role value, otherwise throw an error
+ if (userRoleValue >= requiredRoleValue) {
+ return true;
+ }
+ throw new Error("You don't have permission to perform this action");
+};
diff --git a/src/utils/store.ts b/src/utils/store.ts
index 737421c4..7788b922 100644
--- a/src/utils/store.ts
+++ b/src/utils/store.ts
@@ -1,16 +1,74 @@
import { create } from "zustand";
-
+import { persist } from "zustand/middleware";
+import { Socket, io } from "socket.io-client";
interface StoreI {
open: boolean;
- toggle: () => void;
+ toggle: (orgId?: string) => void;
setOpenState: (state: boolean) => void;
}
-export const useSidebarStore = create((set) => ({
- open: false,
- toggle: () => set((state) => ({ open: !state.open })),
- setOpenState: (state: boolean) => set(() => ({ open: state })),
-}));
+export const useSidebarStore = create(
+ persist(
+ (set) => ({
+ open: false,
+ toggle: () => set((state) => ({ open: !state.open })),
+ setOpenState: (state: boolean) => set(() => ({ open: state })),
+ }),
+ {
+ name: "menu-sidebar",
+ },
+ ),
+);
+
+interface IChat {
+ openChats: string[];
+ toggleChat: (orgId?: string) => void;
+ closeChat: (orgId: string) => void;
+}
+
+export const useAsideChatStore = create(
+ persist(
+ (set) => ({
+ openChats: [],
+ toggleChat: (orgId: string) => {
+ set((state) => {
+ const isOpen = state.openChats.includes(orgId);
+ const newOpenChats = isOpen
+ ? state.openChats.filter((id) => id !== orgId)
+ : [...state.openChats, orgId];
+
+ // If opening the chat, reset hasNewMessages for this organization in useSocketStore
+ if (!isOpen) {
+ useSocketStore.getState().resetHasNewMessages(orgId);
+ }
+
+ return { openChats: newOpenChats };
+ });
+ },
+ closeChat: (orgId: string) => {
+ set((state) => ({
+ openChats: state.openChats.filter((id) => id !== orgId),
+ }));
+ },
+ }),
+ {
+ name: "chat-aside",
+ },
+ ),
+);
+
+export const useLogAsideStore = create(
+ persist(
+ (set) => ({
+ open: false,
+ toggle: () => set((state) => ({ open: !state.open })),
+ setOpenState: (state: boolean) => set(() => ({ open: state })),
+ }),
+ {
+ name: "log-footer",
+ },
+ ),
+);
type IcallModal = {
title: string;
@@ -59,3 +117,100 @@ export const useModalStore = create((set, get) => ({
set({ ...data });
},
}));
+
+interface Message {
+ id: string;
+ organizationId: string; // Add this line
+}
+interface SocketStoreState {
+ messages: { [orgId: string]: Message[] };
+ notifications: { [key: string]: { hasUnreadMessages: boolean } };
+ setBulkNewMessages: (notifications: {
+ [orgId: string]: { hasUnreadMessages: boolean };
+ }) => void;
+ hasNewMessages: { [key: string]: boolean };
+ resetHasNewMessages: (orgId: string) => void;
+ addMessage: (orgId: string, message: Message) => void;
+ setupSocket: (orgId: IOrgId[]) => void;
+ cleanupSocket: () => void;
+ socket?: Socket; // Optional, if you want to keep a reference to the socket in the store
+}
+
+interface IOrgId {
+ id: string;
+}
+
+export const useSocketStore = create((set, get) => ({
+ messages: {},
+ notifications: {},
+ hasNewMessages: {},
+ resetHasNewMessages: (orgId: string) => {
+ set((state) => ({
+ ...state,
+ hasNewMessages: {
+ ...state.hasNewMessages,
+ [orgId]: false,
+ },
+ }));
+ },
+ setBulkNewMessages: (notifications: {
+ [orgId: string]: { hasUnreadMessages: boolean };
+ }) => {
+ const asideState = useAsideChatStore.getState();
+ set((state) => ({
+ ...state,
+ hasNewMessages: Object.keys(notifications).reduce(
+ (acc, orgId) => {
+ acc[orgId] =
+ !asideState.openChats.includes(orgId) &&
+ notifications[orgId].hasUnreadMessages;
+ return acc;
+ },
+ { ...state.hasNewMessages },
+ ),
+ }));
+ },
+ addMessage: (orgId: string, message: Message) => {
+ const asideState = useAsideChatStore.getState();
+
+ set((state) => {
+ const orgMessages = state.messages[orgId] || [];
+ if (!orgMessages.some((msg) => msg.id === message.id)) {
+ return {
+ ...state,
+ messages: {
+ ...state.messages,
+ [orgId]: [...orgMessages, message],
+ },
+ hasNewMessages: {
+ ...state.hasNewMessages,
+ [orgId]: !asideState.openChats.includes(orgId),
+ },
+ };
+ }
+ return state; // No change if message already exists
+ });
+ },
+ setupSocket: async (orgIds: IOrgId[]) => {
+ await fetch("/api/websocket");
+ const socket = io();
+
+ socket.on("connect", () => {
+ if (orgIds) {
+ for (const org of orgIds) {
+ socket.on(org.id, (message) => {
+ get().addMessage(org.id, message);
+ });
+ }
+ }
+ });
+
+ set({ socket });
+ },
+ cleanupSocket: () => {
+ const { socket } = get();
+ if (socket) {
+ socket.disconnect();
+ }
+ },
+}));