diff --git a/src/components/adminPage/organization/organizationInviteModal.tsx b/src/components/adminPage/organization/organizationInviteModal.tsx index 2b6b6f8d..dca7e225 100644 --- a/src/components/adminPage/organization/organizationInviteModal.tsx +++ b/src/components/adminPage/organization/organizationInviteModal.tsx @@ -24,7 +24,7 @@ const OrganizationInviteModal = ({ organizationId }: Iprops) => { const { refetch: refecthOrgUsers } = api.org.getOrgUsers.useQuery({ organizationId, }); - const { data: allUsers } = api.admin.getUsers.useQuery({ isAdmin: false }); + const { data: allUsers } = api.org.getPlatformUsers.useQuery({ organizationId }); const { mutate: addUser } = api.org.addUser.useMutation(); @@ -74,8 +74,9 @@ const OrganizationInviteModal = ({ organizationId }: Iprops) => { onChange={(e) => dropDownHandler(e)} className="select select-sm select-bordered select-ghost max-w-xs" > - + + diff --git a/src/components/elements/dropdownlist.tsx b/src/components/elements/dropdownlist.tsx index 8b2d9630..5d6181b8 100644 --- a/src/components/elements/dropdownlist.tsx +++ b/src/components/elements/dropdownlist.tsx @@ -80,7 +80,7 @@ const ScrollableDropdown = ({ width: containerRef.current ? containerRef.current.offsetWidth : "auto", }} > - {filteredItems.map((item) => ( + {filteredItems?.map((item) => (
  • { const [inputMsg, setInputMsg] = useState({ chatMessage: "" }); const query = useRouter().query; const orgId = query.orgid as string; + const networkId = query.id as string; const messageEndRef = useRef(null); const { mutate: markMessagesAsRead } = api.org.markMessagesAsRead.useMutation(); @@ -145,57 +146,62 @@ const ChatAside = () => { }, ); }; + + // add showChatBtn = true if networkId is not null + const showChatBtn = networkId !== undefined; + return ( <> {/* Chat Toggle Button */} - + + ) : null} {/* Chat Aside Panel */}
  • - {me?.memberOfOrgs.map((org) => ( -
  • - { + const truncatedOrgName = + org.orgName.length > 15 + ? `${org.orgName.slice(0, 15)}...` + : org.orgName; + + return ( +
  • + - - {/* https://heroicons.com/ */} - - - - - {org.orgName} -
    - {hasNewMessages[org.id] ? ( - // + > + + {/* https://heroicons.com/ */} - ) : null} -
    - -
  • - ))} + + {truncatedOrgName} +
    + {hasNewMessages[org.id] ? ( + // + + + + ) : null} +
    + + + ); + })} ) : null} {session?.user?.role === "ADMIN" ? ( diff --git a/src/components/networkByIdPage/table/memberHeaderColumns.tsx b/src/components/networkByIdPage/table/memberHeaderColumns.tsx index 3d4328cf..1e34fa5f 100644 --- a/src/components/networkByIdPage/table/memberHeaderColumns.tsx +++ b/src/components/networkByIdPage/table/memberHeaderColumns.tsx @@ -249,6 +249,7 @@ export const MemberHeaderColumns = ({ nwid, central = false, organizationId }: I sortUndefined: -1, sortingFn: sortingPhysicalIpAddress, cell: ({ getValue, row: { original } }) => { + const isOffline = original?.conStatus === ConnectionStatus.Offline; if (central) { const centralPhysicalAddress: string = original?.physicalAddress; if (!centralPhysicalAddress || typeof centralPhysicalAddress !== "string") @@ -268,7 +269,17 @@ export const MemberHeaderColumns = ({ nwid, central = false, organizationId }: I ); - return physicalAddress.split("/")[0]; + return ( +
    + {isOffline ? ( + + {physicalAddress.split("/")[0]} + + ) : ( + {physicalAddress.split("/")[0]} + )} +
    + ); }, }, ), diff --git a/src/components/organization/editOrgModal.tsx b/src/components/organization/editOrgModal.tsx index 35b2f6b4..4835c4b1 100644 --- a/src/components/organization/editOrgModal.tsx +++ b/src/components/organization/editOrgModal.tsx @@ -4,6 +4,7 @@ import toast from "react-hot-toast"; import { useModalStore } from "~/utils/store"; import { useTranslations } from "next-intl"; import Input from "../elements/input"; +import { ErrorData } from "~/types/errorHandling"; interface Iprops { organizationId: string; @@ -15,10 +16,29 @@ const EditOrganizationModal = ({ organizationId }: Iprops) => { const [input, setInput] = useState({ orgDescription: "", orgName: "" }); const { closeModal } = useModalStore((state) => state); const { refetch: refecthAllOrg } = api.org.getAllOrg.useQuery(); - const { data: orgData } = api.org.getOrgById.useQuery({ + const { data: orgData, refetch: refecthOrgById } = api.org.getOrgById.useQuery({ organizationId, }); - const { mutate: updateOrg } = api.org.updateMeta.useMutation(); + const { mutate: updateOrg } = api.org.updateMeta.useMutation({ + onSuccess: () => { + toast.success("Organization updated successfully"); + refecthAllOrg(); + refecthOrgById(); + closeModal(); + }, + 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"); + } + }, + }); useEffect(() => { if (orgData) { setInput({ diff --git a/src/components/organization/orgNavBar.tsx b/src/components/organization/orgNavBar.tsx new file mode 100644 index 00000000..c7128536 --- /dev/null +++ b/src/components/organization/orgNavBar.tsx @@ -0,0 +1,239 @@ +import { useTranslations } from "next-intl"; +import { useAsideChatStore, useModalStore, useSocketStore } from "~/utils/store"; +import OrganizationWebhook from "./webhookModal"; +import OrganizationInviteModal from "../adminPage/organization/organizationInviteModal"; +import EditOrganizationModal from "./editOrgModal"; +import { useSession } from "next-auth/react"; +import { api } from "~/utils/api"; + +// Utility function to open modals +const useOpenModal = (orgData) => { + const { callModal } = useModalStore(); + const t = useTranslations(); + + const openModal = (type) => { + const modalTypes = { + editMeta: { + title: ( +

    + Edit Meta + {orgData.orgName} +

    + ), + content: , + }, + webhooks: { + rootStyle: "h-4/6", + title: ( +

    + + {t.rich( + "admin.organization.listOrganization.webhookModal.createWebhookTitle", + { + span: (children) => {children}, + organization: orgData.orgName, + }, + )} + +

    + ), + content: , + }, + inviteUser: { + rootStyle: "h-3/6", + title: ( +

    + {t("admin.organization.listOrganization.invitationModal.title")} +

    + ), + content: , + }, + }; + + callModal(modalTypes[type]); + }; + + return openModal; +}; + +const AdminHamburgerMenu = ({ orgData }) => { + const openModal = useOpenModal(orgData); + return ( +
      +
    • openModal("editMeta")}> + META +
    • +
    • openModal("webhooks")}> + WEBHOOKS +
    • +
    • openModal("inviteUser")}> + INVITE USER +
    • +
    + ); +}; + +const AdminNavMenu = ({ orgData }) => { + const { callModal } = useModalStore(); + const openModal = useOpenModal(orgData); + const b = useTranslations("commonButtons"); + const t = useTranslations(); + + return ( +
    +
    +
    +
    {b("addWebhooks")}
    +
    +
      +
    • openModal("webhooks")}> + Create new webhook +
    • + {orgData?.webhooks.map((webhook) => { + return ( +
    • + void callModal({ + title: ( +

      + + {t( + "admin.organization.listOrganization.webhookModal.editWebhookTitle", + )} + + {webhook.name} +

      + ), + content: ( + + ), + }) + } + key={webhook.id} + > + {webhook.name} +
    • + ); + })} +
    +
    +
    +
    openModal("inviteUser")} + tabIndex={0} + role="button" + className="btn btn-ghost" + > +
    {b("inviteUser")}
    +
    +
    +
    +
    openModal("editMeta")} + tabIndex={0} + role="button" + className="btn btn-ghost" + > +
    {b("meta")}
    +
    +
    +
    + ); +}; + +export const OrgNavBar = ({ title, orgData }) => { + const { toggleChat } = useAsideChatStore(); + const { hasNewMessages } = useSocketStore(); + const { callModal } = useModalStore((state) => state); + const { data: session } = useSession(); + + const { data: meOrgRole } = api.org.getOrgUserRoleById.useQuery({ + organizationId: orgData.id, + userId: session.user.id, + }); + + return ( +
    +
    +
    +
    + + + +
    +
    +
    { + callModal({ + title: ( +

    + Edit Meta + {orgData.orgName} +

    + ), + content: , + }); + }} + tabIndex={0} + role="button" + className="btn btn-ghost" + > +
    META
    +
    +
    + +
    + {title} +
    +
    + {meOrgRole.role === "ADMIN" ? : null} +
    +
    + +
    +
    + ); +}; diff --git a/src/components/organization/orgUserRole.tsx b/src/components/organization/orgUserRole.tsx index 3113dbdc..aaa9d28c 100644 --- a/src/components/organization/orgUserRole.tsx +++ b/src/components/organization/orgUserRole.tsx @@ -63,6 +63,7 @@ const OrgUserRole = ({ user, organizationId }: Iuser) => { onChange={(e) => dropDownHandler(e, user?.id)} className="select select-sm select-bordered select-ghost max-w-xs" > + diff --git a/src/components/organization/webhookModal.tsx b/src/components/organization/webhookModal.tsx index b15ab3aa..e23cfdce 100644 --- a/src/components/organization/webhookModal.tsx +++ b/src/components/organization/webhookModal.tsx @@ -25,11 +25,30 @@ const OrganizationWebhook = ({ organizationId, hook }: Iprops) => { hookType: [], }); + // TODO make only one request instead of Orgbyid and AllOrgs const { closeModal } = useModalStore((state) => state); const { refetch: refecthAllOrg } = api.org.getAllOrg.useQuery(); + const { refetch: refecthOrg } = api.org.getOrgById.useQuery({ + organizationId, + }); - const { mutate: addWebhook } = api.org.addOrgWebhooks.useMutation(); - const { mutate: deleteWebhook } = api.org.deleteOrgWebhooks.useMutation(); + const { mutate: addWebhook } = api.org.addOrgWebhooks.useMutation({ + onSuccess: () => { + toast.success(`Webhook ${hook ? "updated" : "added"} successfully`); + closeModal(); + refecthOrg(); + refecthAllOrg(); + }, + }); + + const { mutate: deleteWebhook } = api.org.deleteOrgWebhooks.useMutation({ + onSuccess: () => { + toast.success("Webhook deleted successfully"); + closeModal(); + refecthOrg(); + refecthAllOrg(); + }, + }); useEffect(() => { if (!hook) return; @@ -71,6 +90,7 @@ const OrganizationWebhook = ({ organizationId, hook }: Iprops) => { onSuccess: () => { toast.success(`Webhook ${hook ? "updated" : "added"} successfully`); closeModal(); + refecthOrg(); refecthAllOrg(); }, onError: (error) => { @@ -78,8 +98,6 @@ const OrganizationWebhook = ({ organizationId, hook }: Iprops) => { }, }, ); - - refecthAllOrg(); } catch (_err) { toast.error("Error adding webhook"); } @@ -95,6 +113,7 @@ const OrganizationWebhook = ({ organizationId, hook }: Iprops) => { { onSuccess: () => { closeModal(); + refecthOrg(); refecthAllOrg(); }, onError: (error) => { @@ -102,8 +121,6 @@ const OrganizationWebhook = ({ organizationId, hook }: Iprops) => { }, }, ); - - refecthAllOrg(); } catch (_err) { toast.error("Error deleting webhook"); } diff --git a/src/pages/organization/[orgid].tsx b/src/pages/organization/[orgid].tsx index adf02994..14bd72fb 100644 --- a/src/pages/organization/[orgid].tsx +++ b/src/pages/organization/[orgid].tsx @@ -15,6 +15,7 @@ import useOrganizationWebsocket from "~/hooks/useOrganizationWebsocket"; import MetaTags from "~/components/shared/metaTags"; import NetworkLoadingSkeleton from "~/components/shared/networkLoadingSkeleton"; import { globalSiteTitle } from "~/utils/global"; +import { OrgNavBar } from "~/components/organization/orgNavBar"; const title = `${globalSiteTitle} - Organization`; @@ -116,18 +117,23 @@ const OrganizationById = ({ user, orgIds }) => { ); } + + const truncatedOrgName = + orgData.orgName.length > 20 ? `${orgData.orgName.slice(0, 20)}...` : orgData.orgName; + return ( -
    +
    -
    -
    -
    -

    {orgData?.orgName}

    -

    {orgData?.description}

    -

    {t("organizationDashboard.header")}

    +
    + + {orgData?.description ? ( +
    + {orgData?.description}
    -
    - + ) : null}
    {/* Organization Users */}
    @@ -172,7 +178,9 @@ const OrganizationById = ({ user, orgIds }) => {

    {user.name}

    -

    {user?.role}

    +

    + {user.id === orgData.ownerId ? "OWNER" : user?.role} +

    @@ -190,7 +198,7 @@ const OrganizationById = ({ user, orgIds }) => {
    • {t("informationSection.name")} - {orgData?.orgName} + {truncatedOrgName}
    • {t("informationSection.created")} diff --git a/src/pages/organization/[orgid]/[id].tsx b/src/pages/organization/[orgid]/[id].tsx index 530705c8..aafa4710 100644 --- a/src/pages/organization/[orgid]/[id].tsx +++ b/src/pages/organization/[orgid]/[id].tsx @@ -142,7 +142,7 @@ const OrganizationNetworkById = ({ orgIds }: IProps) => { } title={t("copyToClipboard.title")} > -
      +
      {network?.nwid}
      diff --git a/src/server/api/routers/organizationRouter.ts b/src/server/api/routers/organizationRouter.ts index 74564ff9..7376d689 100644 --- a/src/server/api/routers/organizationRouter.ts +++ b/src/server/api/routers/organizationRouter.ts @@ -115,11 +115,11 @@ export const organizationRouter = createTRPCRouter({ }); }); }), - updateMeta: adminRoleProtectedRoute + updateMeta: protectedProcedure .input( z.object({ organizationId: z.string(), - orgName: z.string().optional(), + orgName: z.string().min(3).max(40), orgDescription: z.string().optional(), }), ) @@ -130,15 +130,15 @@ export const organizationRouter = createTRPCRouter({ organizationId: input.organizationId, minimumRequiredRole: Role.ADMIN, }); - // make sure the user is the owner of the organization - const org = await ctx.prisma.organization.findUnique({ - where: { - id: input.organizationId, + + // Log the action + await ctx.prisma.activityLog.create({ + data: { + action: `Updated organization meta: ${input.orgName}`, + performedById: ctx.session.user.id, + organizationId: input.organizationId || null, }, }); - 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: { @@ -216,6 +216,30 @@ export const organizationRouter = createTRPCRouter({ }, }); }), + getPlatformUsers: 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, + minimumRequiredRole: Role.ADMIN, + }); + + // get all users + return await ctx.prisma.user.findMany({ + select: { + id: true, + name: true, + email: true, + }, + }); + }), + getOrgUsers: protectedProcedure .input( z.object({ @@ -247,14 +271,16 @@ export const organizationRouter = createTRPCRouter({ }, }); - // 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 user with role flatten and sort by id + const users = organization?.users + .map((user) => { + const role = organization.userRoles.find((role) => role.userId === user.id); + return { + ...user, + role: role?.role, + }; + }) + .sort((a, b) => a.id.localeCompare(b.id)); // Sort users by id in ascending order return users; }), @@ -279,6 +305,7 @@ export const organizationRouter = createTRPCRouter({ include: { userRoles: true, users: true, + webhooks: true, networks: { include: { networkMembers: true, @@ -382,6 +409,7 @@ export const organizationRouter = createTRPCRouter({ 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({ @@ -390,6 +418,25 @@ export const organizationRouter = createTRPCRouter({ minimumRequiredRole: Role.ADMIN, }); } + + // get the user name + const user = await ctx.prisma.user.findUnique({ + where: { + id: input.userId, + }, + select: { + name: true, + }, + }); + + // Log the action + await ctx.prisma.activityLog.create({ + data: { + action: `Changed user ${user.name} role to ${input.role} in organization.`, + performedById: ctx.session.user.id, + organizationId: input.organizationId || null, + }, + }); // chagne the user's role in the organization return await ctx.prisma.userOrganizationRole.update({ where: { @@ -609,16 +656,23 @@ export const organizationRouter = createTRPCRouter({ return notifications; }), - addUser: adminRoleProtectedRoute + addUser: protectedProcedure .input( z.object({ organizationId: z.string(), userId: z.string(), userName: z.string(), - organizationRole: z.enum(["READ_ONLY", "USER"]), + organizationRole: z.enum(["READ_ONLY", "USER", "ADMIN"]), }), ) .mutation(async ({ ctx, input }) => { + // make sure the user is member of the organization + await checkUserOrganizationRole({ + ctx, + organizationId: input.organizationId, + minimumRequiredRole: Role.ADMIN, + }); + // Log the action await ctx.prisma.activityLog.create({ data: { @@ -676,7 +730,7 @@ export const organizationRouter = createTRPCRouter({ // make sure the user is not the owner of the organization if (org?.ownerId === input.userId) { - throw new Error("You cannot leave an organization you own."); + throw new Error("You cannot kick the organization owner."); } // Find the email of the user @@ -920,6 +974,7 @@ export const organizationRouter = createTRPCRouter({ organizationId: input.organizationId, minimumRequiredRole: Role.ADMIN, }); + // Validate the URL to be HTTPS if (!input.webhookUrl.startsWith("https://")) { // throw error