WhatsApp/Signal/Formstack/admin updates

This commit is contained in:
Darren Clarke 2025-11-21 14:55:28 +01:00
parent bcecf61a46
commit d0cc5a21de
451 changed files with 16139 additions and 39623 deletions

View file

@ -23,13 +23,15 @@ import { useSearchParams } from "next/navigation";
type LoginProps = {
session: any;
baseURL: string;
};
export const Login: FC<LoginProps> = ({ session }) => {
const origin =
typeof window !== "undefined" && window.location.origin
? window.location.origin
: "";
export const Login: FC<LoginProps> = ({ session, baseURL }) => {
let origin = null;
if (typeof window !== "undefined") {
origin = window.location.origin;
}
const callbackUrl = `${origin}/link`;
const [provider, setProvider] = useState(undefined);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@ -158,7 +160,7 @@ export const Login: FC<LoginProps> = ({ session }) => {
sx={buttonStyles}
onClick={() =>
signIn("google", {
callbackUrl: `${origin}`,
callbackUrl,
})
}
>
@ -174,7 +176,7 @@ export const Login: FC<LoginProps> = ({ session }) => {
sx={buttonStyles}
onClick={() =>
signIn("apple", {
callbackUrl: `${window.location.origin}`,
callbackUrl,
})
}
>
@ -189,7 +191,7 @@ export const Login: FC<LoginProps> = ({ session }) => {
sx={buttonStyles}
onClick={() =>
signIn("azure-ad", {
callbackUrl: `${origin}`,
callbackUrl,
})
}
>
@ -226,13 +228,13 @@ export const Login: FC<LoginProps> = ({ session }) => {
<Grid item sx={{ width: "100%" }}>
<IconButton
sx={buttonStyles}
onClick={() =>
onClick={() => {
signIn("credentials", {
email,
password,
callbackUrl: `${origin}/setup`,
})
}
callbackUrl,
});
}}
>
<KeyIcon sx={{ mr: 1 }} />
Sign in with Zammad credentials

View file

@ -9,10 +9,11 @@ export const metadata: Metadata = {
export default async function Page() {
const session = await getSession();
const baseURL = process.env.LINK_URL;
return (
<Suspense fallback={<div>Loading...</div>}>
<Login session={session} />
<Login session={session} baseURL={baseURL} />
</Suspense>
);
}

View file

@ -1,8 +1,9 @@
"use client";
import { ReactNode } from "react";
import dynamic from "next/dynamic";
type ClientOnlyProps = { children: JSX.Element };
type ClientOnlyProps = { children: ReactNode };
const ClientOnly = (props: ClientOnlyProps) => {
const { children } = props;

View file

@ -0,0 +1,13 @@
"use client";
import { OpenSearchWrapper } from "@/app/_components/OpenSearchWrapper";
export function DefaultDashboard() {
// Extract just the URL path from the full dashboard URL
// The env var format is like: app/dashboards?security_tenant=global#/view/...
const defaultUrl =
process.env.NEXT_PUBLIC_OPENSEARCH_DEFAULT_DASHBOARD_URL ||
"app/dashboards#/";
return <OpenSearchWrapper url={defaultUrl} />;
}

View file

@ -1,11 +0,0 @@
"use client";
import { FC } from "react";
import { OpenSearchWrapper } from "@link-stack/leafcutter-ui";
export const Home: FC = () => (
<OpenSearchWrapper
url="/app/visualize#/edit/237b8f00-e6a0-11ee-94b3-d7b7409294e7?embed=true"
marginTop="0"
/>
);

View file

@ -7,13 +7,11 @@ import { SetupModeWarning } from "./SetupModeWarning";
interface InternalLayoutProps extends PropsWithChildren {
setupModeActive: boolean;
leafcutterEnabled: boolean;
}
export const InternalLayout: FC<InternalLayoutProps> = ({
children,
setupModeActive,
leafcutterEnabled,
}) => {
const [open, setOpen] = useState(true);
@ -24,7 +22,6 @@ export const InternalLayout: FC<InternalLayoutProps> = ({
<Sidebar
open={open}
setOpen={setOpen}
leafcutterEnabled={leafcutterEnabled}
/>
<Grid
item

View file

@ -31,8 +31,6 @@ import Link from "next/link";
import Image from "next/image";
import LinkLogo from "@app/../public/link-logo-small.png";
import { useSession, signOut } from "next-auth/react";
import { getOverviewTicketCountsAction } from "@/app/_actions/overviews";
import { SearchBox } from "./SearchBox";
import { fonts } from "@link-stack/ui";
const openWidth = 270;
@ -178,39 +176,19 @@ const MenuItem = ({
interface SidebarProps {
open: boolean;
setOpen: (open: boolean) => void;
leafcutterEnabled?: boolean;
}
export const Sidebar: FC<SidebarProps> = ({
open,
setOpen,
leafcutterEnabled = false,
}) => {
const pathname = usePathname();
const { data: session } = useSession();
const [overviewCounts, setOverviewCounts] = useState<any>(null);
const { poppins } = fonts;
const username = session?.user?.name || "";
// @ts-ignore
const roles = session?.user?.roles || [];
useEffect(() => {
const fetchCounts = async () => {
const counts = await getOverviewTicketCountsAction();
setOverviewCounts(counts);
};
fetchCounts();
const interval = setInterval(fetchCounts, 30000);
return () => clearInterval(interval);
}, []);
const logout = () => {
signOut({ callbackUrl: "/login" });
};
return (
<Drawer
sx={{ width: open ? openWidth : closedWidth, flexShrink: 0 }}
@ -331,9 +309,9 @@ export const Sidebar: FC<SidebarProps> = ({
{open
? username
: username
.split(" ")
.map((name) => name.substring(0, 1))
.join("")}
.split(" ")
.map((name) => name.substring(0, 1))
.join("")}
</Typography>
</Grid>
<Grid item>
@ -346,7 +324,6 @@ export const Sidebar: FC<SidebarProps> = ({
}}
/>
</Grid>
<Grid item>{open && <SearchBox />}</Grid>
<Grid
item
container
@ -378,9 +355,6 @@ export const Sidebar: FC<SidebarProps> = ({
fontSize: 16,
},
},
".badge": {
p: { fontSize: 12, color: "black !important" },
},
},
".Mui-selected": {
background: "#444",
@ -391,92 +365,18 @@ export const Sidebar: FC<SidebarProps> = ({
fontSize: 16,
},
},
".badge": {
p: { fontSize: 12, color: "black !important" },
},
},
},
}}
>
{leafcutterEnabled && (
<MenuItem
name="Home"
href="/"
Icon={CottageIcon}
iconSize={20}
selected={pathname.endsWith("/")}
open={open}
/>
)}
<MenuItem
name="Tickets"
href="/overview/recent"
Icon={FeaturedPlayListIcon}
selected={
pathname.startsWith("/overview") ||
pathname.startsWith("/tickets")
}
name="Dashboard"
href="/"
Icon={InsightsIcon}
iconSize={20}
selected={pathname === "/"}
open={open}
/>
<Collapse
in={
open &&
(pathname.startsWith("/overview") ||
pathname.startsWith("/tickets"))
}
timeout="auto"
unmountOnExit
onClick={undefined}
>
<List component="div" disablePadding>
<MenuItem
name="Recent"
href="/overview/recent"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/overview/recent")}
badge={overviewCounts?.recent}
open={open}
/>
<MenuItem
name="Open"
href="/overview/open"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/overview/open")}
badge={overviewCounts?.open}
open={open}
/>
<MenuItem
name="Urgent"
href="/overview/urgent"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/overview/urgent")}
badge={overviewCounts?.urgent}
open={open}
/>
<MenuItem
name="Assigned"
href="/overview/assigned"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/overview/assigned")}
badge={overviewCounts?.assigned}
open={open}
/>
<MenuItem
name="Unassigned"
href="/overview/unassigned"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/overview/unassigned")}
badge={overviewCounts?.unassigned}
open={open}
/>
</List>
</Collapse>
<MenuItem
name="Documentation"
href="/docs"
@ -485,62 +385,6 @@ export const Sidebar: FC<SidebarProps> = ({
selected={pathname.endsWith("/docs")}
open={open}
/>
{leafcutterEnabled && (
<MenuItem
name="Leafcutter"
href="/leafcutter"
Icon={InsightsIcon}
iconSize={20}
selected={false}
open={open}
/>
)}
<Collapse
in={open && pathname.startsWith("/leafcutter")}
timeout="auto"
unmountOnExit
onClick={undefined}
>
<List component="div" disablePadding>
<MenuItem
name="Dashboard"
href="/leafcutter"
iconSize={0}
selected={pathname.endsWith("/leafcutter")}
open={open}
/>
<MenuItem
name="Search and Create"
href="/leafcutter/create"
iconSize={0}
selected={pathname.endsWith("/leafcutter/create")}
open={open}
/>
<MenuItem
name="Trends"
href="/leafcutter/trends"
iconSize={0}
selected={pathname.endsWith("/leafcutter/trends")}
open={open}
/>
<MenuItem
name="FAQ"
href="/leafcutter/faq"
iconSize={0}
selected={pathname.endsWith("/leafcutter/faq")}
open={open}
/>
<MenuItem
name="About"
href="/leafcutter/about"
Icon={InsightsIcon}
iconSize={0}
selected={pathname.endsWith("/leafcutter/about")}
open={open}
/>
</List>
</Collapse>
{roles.includes("admin") && (
<>
<MenuItem
@ -549,6 +393,7 @@ export const Sidebar: FC<SidebarProps> = ({
Icon={SettingsIcon}
iconSize={20}
open={open}
selected={pathname.startsWith("/admin")}
/>
<Collapse
in={open && pathname.startsWith("/admin/")}
@ -558,55 +403,47 @@ export const Sidebar: FC<SidebarProps> = ({
>
<List component="div" disablePadding>
<MenuItem
name="CDR Bridge"
href="/admin/bridge"
selected={pathname.endsWith("/admin/bridge")}
name="WhatsApp"
href="/admin/bridge/whatsapp"
iconSize={0}
selected={pathname.endsWith("/admin/bridge/whatsapp")}
open={open}
/>
<MenuItem
name="Signal"
href="/admin/bridge/signal"
iconSize={0}
selected={pathname.endsWith("/admin/bridge/signal")}
open={open}
/>
<MenuItem
name="Facebook"
href="/admin/bridge/facebook"
iconSize={0}
selected={pathname.endsWith("/admin/bridge/facebook")}
open={open}
/>
<MenuItem
name="Voice"
href="/admin/bridge/voice"
iconSize={0}
selected={pathname.endsWith("/admin/bridge/voice")}
open={open}
/>
<MenuItem
name="Webhooks"
href="/admin/bridge/webhooks"
iconSize={0}
selected={pathname.endsWith("/admin/bridge/webhooks")}
open={open}
/>
<MenuItem
name="OpenSearch"
href="/admin/opensearch"
iconSize={0}
selected={pathname.endsWith("/admin/opensearch")}
open={open}
/>
<Collapse
in={open && pathname.startsWith("/admin/bridge")}
timeout="auto"
unmountOnExit
onClick={undefined}
>
<List component="div" disablePadding>
<MenuItem
name="WhatsApp"
href="/admin/bridge/whatsapp"
depth={1}
selected={pathname.endsWith("/admin/bridge/whatsapp")}
open={open}
/>
<MenuItem
name="Signal"
href="/admin/bridge/signal"
depth={1}
selected={pathname.endsWith("/admin/bridge/signal")}
open={open}
/>
<MenuItem
name="Facebook"
href="/admin/bridge/facebook"
depth={1}
selected={pathname.endsWith("/admin/bridge/facebook")}
open={open}
/>
<MenuItem
name="Voice"
href="/admin/bridge/voice"
depth={1}
selected={pathname.endsWith("/admin/bridge/voice")}
open={open}
/>
<MenuItem
name="Webhooks"
href="/admin/bridge/webhooks"
depth={1}
selected={pathname.endsWith("/admin/bridge/webhooks")}
open={open}
/>
</List>
</Collapse>
</List>
</Collapse>
</>
@ -617,7 +454,6 @@ export const Sidebar: FC<SidebarProps> = ({
Icon={LogoutIcon}
iconSize={20}
open={open}
onClick={logout}
/>
</List>
</Grid>

View file

@ -41,7 +41,6 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
method: "GET",
redirect: "manual",
});
console.log({ res });
if (res.type === "opaqueredirect") {
setAuthenticated(true);
} else {
@ -69,7 +68,6 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
}, [session]);
if (!session || !authenticated) {
console.log("Not authenticated");
return (
<Box sx={{ width: "100%" }}>
<Grid
@ -89,7 +87,6 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
}
if (session && authenticated) {
console.log("Session and authenticated");
return (
<Iframe
id={id}
@ -102,10 +99,6 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
const linkElement = document.querySelector(
`#${id}`,
) as HTMLIFrameElement;
console.log({ path });
console.log({ id });
console.log({ linkElement });
if (
linkElement.contentDocument &&
linkElement.contentDocument?.querySelector &&

View file

@ -1,10 +1,11 @@
import { Create } from "@link-stack/bridge-ui";
type PageProps = {
params: { segment: string[] };
params: Promise<{ segment: string[] }>;
};
export default function Page({ params: { segment } }: PageProps) {
export default async function Page({ params }: PageProps) {
const { segment } = await params;
const service = segment[0];
return <Create service={service} />;

View file

@ -1,11 +1,12 @@
import { db } from "@link-stack/bridge-common";
import { serviceConfig, Detail } from "@link-stack/bridge-ui";
type Props = {
params: { segment: string[] };
type PageProps = {
params: Promise<{ segment: string[] }>;
};
export default async function Page({ params: { segment } }: Props) {
export default async function Page({ params }: PageProps) {
const { segment } = await params;
const service = segment[0];
const id = segment?.[1];

View file

@ -2,10 +2,11 @@ import { db } from "@link-stack/bridge-common";
import { serviceConfig, Edit } from "@link-stack/bridge-ui";
type PageProps = {
params: { segment: string[] };
params: Promise<{ segment: string[] }>;
};
export default async function Page({ params: { segment } }: PageProps) {
export default async function Page({ params }: PageProps) {
const { segment } = await params;
const service = segment[0];
const id = segment?.[1];

View file

@ -2,12 +2,13 @@ import { db } from "@link-stack/bridge-common";
import { serviceConfig, List } from "@link-stack/bridge-ui";
type PageProps = {
params: {
params: Promise<{
segment: string[];
};
}>;
};
export default async function Page({ params: { segment } }: PageProps) {
export default async function Page({ params }: PageProps) {
const { segment } = await params;
const service = segment[0];
if (!service) return null;

View file

@ -1,24 +0,0 @@
"use client";
import { FC } from "react";
import { Grid } from "@mui/material";
import Iframe from "react-iframe";
export const LabelStudioWrapper: FC = () => (
<Grid
container
spacing={0}
sx={{ height: "100%", width: "100%" }}
direction="column"
>
<Grid item sx={{ height: "100vh", width: "100%" }}>
<Iframe
id="label-studio"
url={"/label-studio"}
width="100%"
height="100%"
frameBorder={0}
/>
</Grid>
</Grid>
);

View file

@ -1,10 +0,0 @@
import { Metadata } from "next";
import { LabelStudioWrapper } from "./_components/LabelStudioWrapper";
export const metadata: Metadata = {
title: "Label Studio",
};
export default function Page() {
return <LabelStudioWrapper />;
}

View file

@ -0,0 +1,10 @@
import { Metadata } from "next";
import { OpenSearchWrapper } from "@/app/_components/OpenSearchWrapper";
export const metadata: Metadata = {
title: "CDR Link - OpenSearch",
};
export default function Page() {
return <OpenSearchWrapper url="app/home#/" />;
}

View file

@ -1,10 +0,0 @@
import { Metadata } from "next";
import { ZammadWrapper } from "app/(main)/_components/ZammadWrapper";
export const metadata: Metadata = {
title: "Zammad",
};
export default function Page() {
return <ZammadWrapper path="/#manage" hideSidebar={false} />;
}

View file

@ -4,6 +4,8 @@ import { FC } from "react";
import { Grid } from "@mui/material";
import Iframe from "react-iframe";
const docsUrl = "https://digiresilience.org/docs/link/about/";
export const DocsWrapper: FC = () => (
<Grid
container
@ -17,7 +19,7 @@ export const DocsWrapper: FC = () => (
>
<Iframe
id="docs"
url={"https://digiresilience.org/docs/link/about/"}
url={docsUrl}
width="100%"
height="100%"
frameBorder={0}

View file

@ -1,10 +0,0 @@
import { Metadata } from "next";
import { ZammadWrapper } from "../../(main)/_components/ZammadWrapper";
export const metadata: Metadata = {
title: "Knowledge Base",
};
export default function Page() {
return <ZammadWrapper path="/#knowledge_base/1/locale/en-us" />;
}

View file

@ -12,12 +12,10 @@ type LayoutProps = {
export default function Layout({ children }: LayoutProps) {
const setupModeActive = process.env.SETUP_MODE === "true";
const leafcutterEnabled = process.env.LEAFCUTTER_ENABLED === "true";
return (
<InternalLayout
setupModeActive={setupModeActive}
leafcutterEnabled={leafcutterEnabled}
>
{children}
</InternalLayout>

View file

@ -1,5 +0,0 @@
import { About } from "@link-stack/leafcutter-ui";
export default function Page() {
return <About />;
}

View file

@ -1,10 +0,0 @@
import { getTemplates } from "@link-stack/opensearch-common";
import { Create } from "@link-stack/leafcutter-ui";
export const dynamic = "force-dynamic";
export default async function Page() {
const templates = await getTemplates(100);
return <Create templates={templates} />;
}

View file

@ -1,5 +0,0 @@
import { FAQ } from "@link-stack/leafcutter-ui";
export default function Page() {
return <FAQ />;
}

View file

@ -1,10 +0,0 @@
import { ReactNode } from "react";
import { LeafcutterWrapper } from "@link-stack/leafcutter-ui";
type LayoutProps = {
children: ReactNode;
};
export default function Layout({ children }: LayoutProps) {
return <LeafcutterWrapper>{children}</LeafcutterWrapper>;
}

View file

@ -1,11 +0,0 @@
import { Home, LeafcutterWrapper } from "@link-stack/leafcutter-ui";
export const dynamic = "force-dynamic";
export default async function Page() {
return (
<LeafcutterWrapper>
<Home visualizations={[]} showWelcome={false} />
</LeafcutterWrapper>
);
}

View file

@ -1,7 +0,0 @@
import { Trends } from "@link-stack/leafcutter-ui";
export const dynamic = "force-dynamic";
export default function Page() {
return <Trends visualizations={[]} />;
}

View file

@ -1,9 +1,20 @@
"use client";
import { useEffect } from "react";
import { signOut } from "next-auth/react";
export default function Page() {
signOut({ callbackUrl: "/login" });
useEffect(() => {
const multistepSignOut = async () => {
const response = await fetch("/api/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
});
signOut({ callbackUrl: "/link/login" });
};
multistepSignOut();
}, []);
return <div />;
}

View file

@ -1,140 +0,0 @@
"use client";
import { FC, useState, useEffect } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import { Grid } from "@mui/material";
import {
Dialog,
Button,
TextField,
Autocomplete,
Select,
} from "@link-stack/ui";
import { createTicketAction } from "app/_actions/tickets";
import { getCustomersAction } from "app/_actions/users";
import { getGroupsAction } from "app/_actions/groups";
interface TicketCreateDialogProps {
open: boolean;
closeDialog: () => void;
}
export const TicketCreateDialog: FC<TicketCreateDialogProps> = ({
open,
closeDialog,
}) => {
const [customers, setCustomers] = useState([]);
const [groups, setGroups] = useState([]);
const initialState = {
messages: [],
errors: [],
values: {
customerId: "",
groupId: "",
ownerId: "",
priorityId: "",
stateId: "",
tags: [],
title: "",
article: {
body: "",
type: "note",
internal: true,
},
},
};
const [formState, formAction] = useFormState(
createTicketAction,
initialState,
);
const [liveFormState, setLiveFormState] = useState(formState);
const updateFormState = (field: string, value: any) => {
const newState = { ...liveFormState };
newState.values[field] = value;
setLiveFormState(newState);
};
useEffect(() => {
const fetchUsers = async () => {
const result = await getCustomersAction();
setCustomers(result);
};
fetchUsers();
}, []);
useEffect(() => {
const fetchGroups = async () => {
const result = await getGroupsAction();
setGroups(result);
};
fetchGroups();
}, []);
const router = useRouter();
useEffect(() => {
if (formState.success) {
closeDialog();
}
}, [formState.success, router]);
return (
<Dialog
title="Create Ticket"
open={open}
onClose={closeDialog}
formAction={formAction}
buttons={
<Grid container justifyContent="space-between">
<Grid item>
<Button
text="Cancel"
kind="secondary"
onClick={() => {
closeDialog();
}}
/>
</Grid>
<Grid item>
<Button text="Save" type="submit" kind="primary" />
</Grid>
</Grid>
}
>
<Grid container direction="column" spacing={3}>
<Grid item>
<Select
name="groupId"
label="Group"
getOptions={() => groups as any}
formState={liveFormState}
updateFormState={updateFormState}
/>
</Grid>
<Grid item>
<Select
name="customerId"
label="Customer"
getOptions={() => customers as any}
formState={liveFormState}
updateFormState={updateFormState}
/>
</Grid>
<Grid item>
<TextField name="title" label="Title" formState={formState} />
</Grid>
<Grid item>
<TextField
name="details"
label="Details"
lines={10}
formState={formState}
/>
</Grid>
</Grid>
</Dialog>
);
};

View file

@ -1,89 +0,0 @@
"use client";
import { FC, useState } from "react";
import { Grid, Box } from "@mui/material";
import { GridColDef } from "@mui/x-data-grid-pro";
import { StyledDataGrid } from "app/(main)/_components/StyledDataGrid";
import { Button, List, typography } from "@link-stack/ui";
import { useRouter } from "next/navigation";
import { TicketCreateDialog } from "./TicketCreateDialog";
interface TicketListProps {
title: string;
tickets: any;
}
export const TicketList: FC<TicketListProps> = ({ title, tickets }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const router = useRouter();
let gridColumns: GridColDef[] = [
{
field: "number",
headerName: "Number",
flex: 1,
},
{
field: "title",
headerName: "Title",
flex: 3,
},
{
field: "customer",
headerName: "Sender",
valueGetter: (value: any) => value?.fullname,
flex: 1,
},
{
field: "createdAt",
headerName: "Created At",
valueGetter: (value: any) => new Date(value).toLocaleString(),
flex: 1,
},
{
field: "updatedAt",
headerName: "Updated At",
valueGetter: (value: any) => new Date(value).toLocaleString(),
flex: 1,
},
{
field: "group",
headerName: "Group",
valueGetter: (value: any) => value?.name,
flex: 1,
},
];
const onRowClick = (id: any) => {
router.push(`/tickets/${id}`);
};
return (
<>
<List
title={title}
rows={tickets}
columns={gridColumns}
onRowClick={onRowClick}
getRowID={(row: any) => {
console.log({ row });
return row.internalId;
}}
buttons={
<Grid container direction="row-reverse" alignItems="center">
<Grid item>
<Button
onClick={() => setDialogOpen(true)}
text="Create"
color="primary"
/>
</Grid>
</Grid>
}
/>
<TicketCreateDialog
open={dialogOpen}
closeDialog={() => setDialogOpen(false)}
/>
</>
);
};

View file

@ -1,29 +0,0 @@
"use client";
import { FC, useEffect, useState } from "react";
import { getOverviewTicketsAction } from "app/_actions/overviews";
import { TicketList } from "./TicketList";
type ZammadOverviewProps = {
name: string;
};
export const ZammadOverview: FC<ZammadOverviewProps> = ({ name }) => {
const [tickets, setTickets] = useState([]);
useEffect(() => {
const fetchTickets = async () => {
const { tickets } = await getOverviewTicketsAction(name);
setTickets(tickets);
};
fetchTickets();
const interval = setInterval(fetchTickets, 10000);
return () => clearInterval(interval);
}, [name]);
return <TicketList title={name} tickets={tickets} />;
};

View file

@ -1,11 +0,0 @@
"use client";
import { DisplayError } from "app/_components/DisplayError";
type PageProps = {
error: Error;
};
export default function Page({ error }: PageProps) {
return <DisplayError error={error} />;
}

View file

@ -1,34 +0,0 @@
import { Metadata } from "next";
import { ZammadOverview } from "./_components/ZammadOverview";
const getSection = (overview: string) => {
return overview.charAt(0).toUpperCase() + overview.slice(1);
};
type MetadataProps = {
params: {
overview: string;
};
};
export async function generateMetadata({
params: { overview },
}: MetadataProps): Promise<Metadata> {
const section = getSection(overview);
return {
title: `CDR Link - ${section} Tickets`,
};
}
type PageProps = {
params: {
overview: string;
};
};
export default function Page({ params: { overview } }: PageProps) {
const section = getSection(overview);
return <ZammadOverview name={section} />;
}

View file

@ -1,31 +1,10 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "app/_lib/authentication";
import { Home } from "@link-stack/leafcutter-ui";
import { getUserVisualizations } from "@link-stack/opensearch-common";
import { LeafcutterWrapper } from "@link-stack/leafcutter-ui";
import { DefaultDashboard } from "./_components/DefaultDashboard";
export const metadata: Metadata = {
title: "CDR Link - Home",
title: "CDR Link - Dashboard",
};
export default async function Page() {
const leafcutterEnabled = process.env.LEAFCUTTER_ENABLED === "true";
if (!leafcutterEnabled) {
redirect("/overview/recent");
}
const session = await getServerSession();
const {
user: { email },
}: any = session;
const visualizations = await getUserVisualizations(email ?? "none", 20);
return (
<LeafcutterWrapper>
<Home visualizations={visualizations} showWelcome={false} />
</LeafcutterWrapper>
);
return <DefaultDashboard />;
}

View file

@ -1,10 +0,0 @@
import { Metadata } from "next";
import { ZammadWrapper } from "../../(main)/_components/ZammadWrapper";
export const metadata: Metadata = {
title: "Profile",
};
export default function Page() {
return <ZammadWrapper path="/#profile" hideSidebar={false} />;
}

View file

@ -1,11 +0,0 @@
import { Metadata } from "next";
import { ZammadWrapper } from "../../(main)/_components/ZammadWrapper";
export const metadata: Metadata = {
title: "Reporting",
};
export default function Page() {
return <ZammadWrapper path="#report" />;
}

View file

@ -8,6 +8,13 @@ import { ZammadWrapper } from "app/(main)/_components/ZammadWrapper";
export const Setup: FC = () => {
const router = useRouter();
useLayoutEffect(() => {
const fingerprint = localStorage.getItem("fingerprint");
if (!fingerprint || fingerprint === "") {
const newFingerprint = `${Math.floor(
Math.random() * 100000000,
)}`.padStart(8, "0");
localStorage.setItem("fingerprint", newFingerprint);
}
setTimeout(() => router.push("/"), 4000);
}, [router]);

View file

@ -1,100 +0,0 @@
"use client";
import { FC, useState } from "react";
import {
Grid,
Button,
Dialog,
DialogActions,
DialogContent,
TextField,
} from "@mui/material";
import { createTicketArticleAction } from "app/_actions/tickets";
interface ArticleCreateDialogProps {
ticketID: string;
open: boolean;
closeDialog: () => void;
kind: string;
recipient?: string;
}
export const ArticleCreateDialog: FC<ArticleCreateDialogProps> = ({
ticketID,
open,
closeDialog,
kind,
recipient,
}) => {
const [body, setBody] = useState("");
const backgroundColor = kind === "note" ? "#FFB620" : "#1982FC";
const color = kind === "note" ? "black" : "white";
const article = {
body,
type: kind,
internal: kind === "note",
};
if (kind === "email") {
article["to"] = recipient;
}
const createArticle = async () => {
await createTicketArticleAction(ticketID, article);
closeDialog();
setBody("");
};
return (
<Dialog open={open} maxWidth="sm" fullWidth>
<DialogContent>
<TextField
label={kind === "note" ? "Write internal note" : "Write reply"}
multiline
rows={10}
fullWidth
value={body}
onChange={(e: any) => setBody(e.target.value)}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pt: 0, pb: 3 }}>
<Grid container justifyContent="space-between">
<Grid item>
<Button
sx={{
backgroundColor: "white",
color: "#666",
fontFamily: "Poppins, sans-serif",
fontWeight: 700,
borderRadius: 2,
textTransform: "none",
}}
onClick={() => {
setBody("");
closeDialog();
}}
>
Cancel
</Button>
</Grid>
<Grid item>
<Button
sx={{
backgroundColor,
color,
fontFamily: "Poppins, sans-serif",
fontWeight: 700,
borderRadius: 2,
textTransform: "none",
px: 3,
}}
onClick={createArticle}
>
{kind === "note" ? "Save Note" : "Send Reply"}
</Button>
</Grid>
</Grid>
</DialogActions>
</Dialog>
);
};

View file

@ -1,177 +0,0 @@
"use client";
import { FC, useState, useEffect } from "react";
import { getTicketAction, getTicketArticlesAction } from "app/_actions/tickets";
import { Grid, Box, Typography } from "@mui/material";
import { Button, fonts, colors } from "@link-stack/ui";
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import {
MainContainer,
ChatContainer,
MessageList,
Message,
ConversationHeader,
} from "@chatscope/chat-ui-kit-react";
import { ArticleCreateDialog } from "./ArticleCreateDialog";
interface TicketDetailProps {
id: string;
}
export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
const [ticket, setTicket] = useState<any>(null);
const [ticketArticles, setTicketArticles] = useState<any>(null);
const { poppins, roboto } = fonts;
const { veryLightGray, lightGray } = colors;
const [dialogOpen, setDialogOpen] = useState(false);
const [articleKind, setArticleKind] = useState("note");
useEffect(() => {
const fetchTicket = async () => {
const result = await getTicketAction(id);
setTicket(result);
};
fetchTicket();
const interval = setInterval(fetchTicket, 20000);
return () => clearInterval(interval);
}, [id]);
useEffect(() => {
const fetchTicketArticles = async () => {
const result = await getTicketArticlesAction(id);
setTicketArticles(result);
};
fetchTicketArticles();
const interval = setInterval(fetchTicketArticles, 5000);
return () => clearInterval(interval);
}, [id]);
const closeDialog = () => setDialogOpen(false);
const firstArticle = ticketArticles?.edges[0]?.node;
const firstArticleKind = firstArticle?.type?.name ?? "phone";
const firstEmailSender = firstArticle?.from?.parsed?.[0]?.emailAddress ?? "";
const recipient = firstEmailSender;
const shouldRender = !!ticket && !!ticketArticles;
return (
<Box sx={{ height: "100%", width: "100%", background: veryLightGray }}>
{shouldRender && (
<>
<MainContainer>
<ChatContainer>
<ConversationHeader>
<ConversationHeader.Content>
<Box
sx={{
width: "100%",
textAlign: "center",
fontWeight: "bold",
}}
>
<Typography
variant="h5"
sx={{
fontFamily: poppins.style.fontFamily,
fontWeight: 700,
}}
>
{ticket.title}
</Typography>
<Typography
variant="h6"
sx={{
fontFamily: roboto.style.fontFamily,
fontWeight: 400,
}}
>{`Ticket #${ticket.number} (created ${new Date(
ticket.createdAt,
).toLocaleDateString()})`}</Typography>
</Box>
</ConversationHeader.Content>
</ConversationHeader>
<MessageList style={{ marginBottom: 80 }}>
{ticketArticles.edges.map(({ node: article }: any) => (
<Message
key={article.id}
className={
article.internal
? "internal-note"
: article?.sender?.name === "Agent"
? "outgoing-message"
: "incoming-message"
}
model={{
message: article.bodyWithUrls,
type:
article.contentType === "text/html" ? "html" : "text",
sentTime: article.updated_at,
sender: article.from,
direction:
article.sender === "Agent" ? "outgoing" : "incoming",
position: "single",
}}
/>
))}
</MessageList>
</ChatContainer>
<Box
sx={{
height: 80,
background: veryLightGray,
borderTop: `1px solid ${lightGray}`,
position: "absolute",
bottom: 0,
width: "100%",
zIndex: 1000,
}}
>
<Grid
container
spacing={6}
justifyContent="center"
alignItems="center"
alignContent="center"
sx={{ height: "100%", pt: 6 }}
>
<Grid item>
<Button
text="Write note to agent"
color="#FFB620"
onClick={() => {
setArticleKind("note");
setDialogOpen(true);
}}
/>
</Grid>
<Grid item>
<Button
text="Reply to ticket"
kind="primary"
onClick={() => {
setArticleKind(firstArticleKind);
setDialogOpen(true);
}}
/>
</Grid>
</Grid>
</Box>
</MainContainer>
<ArticleCreateDialog
ticketID={ticket.internalId}
open={dialogOpen}
closeDialog={closeDialog}
kind={articleKind}
recipient={recipient}
/>
</>
)}
</Box>
);
};

View file

@ -1,11 +0,0 @@
import { TicketDetail } from "./_components/TicketDetail";
type PageProps = {
params: {
id: string;
};
};
export default function Page({ params: { id } }: PageProps) {
return <TicketDetail id={id} />;
}

View file

@ -1,205 +0,0 @@
"use client";
import { FC, useEffect, useState } from "react";
import { Grid, Box } from "@mui/material";
import { Select, Button } from "@link-stack/ui";
import { MuiChipsInput } from "mui-chips-input";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import {
updateTicketAction,
getTicketAction,
getTicketStatesAction,
getTicketPrioritiesAction,
} from "app/_actions/tickets";
import { getAgentsAction } from "app/_actions/users";
import { getGroupsAction } from "app/_actions/groups";
interface TicketEditProps {
id: string;
}
export const TicketEdit: FC<TicketEditProps> = ({ id }) => {
const [ticket, setTicket] = useState<any>();
const [hasChanges, setHasChanges] = useState(false);
const [formState, setFormState] = useState({
values: {
group: null,
owner: null,
priority: null,
pendingTime: null,
state: null,
tags: [],
},
});
const [ticketStates, setTicketStates] = useState<any>();
const [ticketPriorities, setTicketPriorities] = useState<any>();
const [groups, setGroups] = useState<any>();
const [agents, setAgents] = useState<any>();
const [pendingVisible, setPendingVisible] = useState(false);
const filteredStates =
ticketStates?.filter(
(state: any) => !["new", "merged", "removed"].includes(state.label),
) ?? [];
useEffect(() => {
const fetchAgents = async () => {
const result = await getAgentsAction();
setAgents(result);
};
const fetchGroups = async () => {
const result = await getGroupsAction();
setGroups(result);
};
const fetchTicketStates = async () => {
const result = await getTicketStatesAction();
setTicketStates(result);
};
const fetchTicketPriorities = async () => {
const result = await getTicketPrioritiesAction();
setTicketPriorities(result);
};
fetchTicketStates();
fetchTicketPriorities();
fetchAgents();
fetchGroups();
}, []);
useEffect(() => {
const fetchTicket = async () => {
const result = await getTicketAction(id);
setTicket(result);
setFormState({
values: {
...formState.values,
group: result?.group?.id,
owner: result?.owner?.id,
priority: result?.priority?.id,
state: result?.state?.id,
tags: result?.tags,
},
});
};
fetchTicket();
}, []);
const updateFormState = (name: string, value: any) => {
setFormState({
values: {
...formState.values,
[name]: value,
},
});
const stateName = filteredStates?.find(
(state: any) => state.id === formState.values.state,
)?.name;
setPendingVisible(stateName?.includes("pending") ?? false);
setHasChanges(true);
};
const updateTicket = async () => {
await updateTicketAction(id, formState.values);
setHasChanges(false);
};
const shouldRender = !!ticket;
return (
<Box sx={{ height: "100vh", background: "#ddd", p: 2 }}>
{shouldRender && (
<Grid container direction="column" spacing={3}>
<Grid item>
<Box sx={{ m: 1 }}>Group</Box>
<Select
name="group"
label="Group"
formState={formState}
updateFormState={updateFormState}
getOptions={() => groups}
/>
</Grid>
<Grid item>
<Box sx={{ m: 1, mt: 0 }}>Owner</Box>
<Select
name="owner"
label="Owner"
formState={formState}
updateFormState={updateFormState}
getOptions={() => agents}
/>
</Grid>
<Grid item xs={12}>
<Box sx={{ m: 1, mt: 0 }}>State</Box>
<Select
name="state"
label="State"
formState={formState}
updateFormState={updateFormState}
getOptions={() => filteredStates}
/>
</Grid>
<Grid
item
xs={12}
sx={{ display: pendingVisible ? "inherit" : "none" }}
>
<DatePicker
label="Pending Date"
value={new Date(formState.values.pendingTime)}
onChange={(newValue: any) => {
updateFormState("pendingDate", newValue.toISOString());
}}
slotProps={{ textField: { size: "small" } }}
sx={{
width: "100%",
backgroundColor: "white",
}}
/>
</Grid>
<Grid item>
<Box sx={{ m: 1, mt: 0 }}>Priority</Box>
<Select
name="priority"
label="Priority"
formState={formState}
updateFormState={updateFormState}
getOptions={() => ticketPriorities}
/>
</Grid>
<Grid item>
<Box sx={{ mb: 1 }}>Tags</Box>
<MuiChipsInput
sx={{ backgroundColor: "white", width: "100%" }}
value={formState.values.tags}
hideClearAll
onChange={(tags: any) => {
updateFormState("tags", tags);
}}
onDeleteChip={(tag: any) => {
const tags = formState.values.tags.filter(
(t: any) => t !== tag,
);
updateFormState("tags", tags);
}}
/>
</Grid>
<Grid item container direction="row-reverse">
<Grid item>
<Button
text="Save"
kind="primary"
onClick={updateTicket}
disabled={!hasChanges}
/>
</Grid>
</Grid>
</Grid>
)}
</Box>
);
};

View file

@ -1,11 +0,0 @@
import { TicketEdit } from "./_components/TicketEdit";
type PageProps = {
params: {
id: string;
};
};
export default function Page({ params: { id } }: PageProps) {
return <TicketEdit id={id} />;
}

View file

@ -1,11 +0,0 @@
"use client";
import { DisplayError } from "app/_components/DisplayError";
type PageProps = {
error: Error;
};
export default function Page({ error }: PageProps) {
return <DisplayError error={error} />;
}

View file

@ -1,24 +0,0 @@
"use client";
import { Grid } from "@mui/material";
type LayoutProps = {
detail: any;
edit: any;
params: {
id: string;
};
};
export default function Layout({ detail, edit, params: { id } }: LayoutProps) {
return (
<Grid container spacing={0} sx={{ height: "100vh" }} direction="row">
<Grid item sx={{ height: "100vh" }} xs={9}>
{detail}
</Grid>
<Grid item xs={3} sx={{ height: "100vh" }}>
{edit}
</Grid>
</Grid>
);
}

View file

@ -1,15 +0,0 @@
"use client";
import Link from "next/link";
export default function Page() {
return (
<div>
<h2>Not Found</h2>
<p>Could not find requested resource</p>
<p>
View <Link href="/blog">all posts</Link>
</p>
</div>
);
}

View file

@ -1,6 +1,9 @@
"use server";
import { executeREST } from "app/_lib/zammad";
import { createLogger } from "@link-stack/logger";
const logger = createLogger('link-groups');
export const getGroupsAction = async () => {
try {
@ -15,7 +18,7 @@ export const getGroupsAction = async () => {
return formattedGroups;
} catch (e) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return [];
}
};

View file

@ -1,97 +0,0 @@
"use server";
import { executeGraphQL, executeREST } from "app/_lib/zammad";
import { getTicketOverviewCountsQuery } from "app/_graphql/getTicketOverviewCountsQuery";
import { getTicketsByOverviewQuery } from "app/_graphql/getTicketsByOverviewQuery";
const overviewLookup = {
Assigned: "My Assigned Tickets",
Open: "Open Tickets",
Urgent: "Escalated Tickets",
Unassigned: "Unassigned & Open Tickets",
Recent: "Recent Tickets",
Pending: "Pending Reached Tickets",
MyPending: "My Pending Reached Tickets",
MySubscribed: "My Subscribed Tickets",
};
export const getOverviewTicketCountsAction = async () => {
try {
const recent = await executeREST({ path: "/api/v1/recent_view" });
const countResult = await executeGraphQL({
query: getTicketOverviewCountsQuery,
});
const overviews = countResult?.ticketOverviews?.edges ?? [];
const counts = overviews.reduce((acc: any, overview: any) => {
const name = overview.node.name;
const key = Object.keys(overviewLookup)
.find((k) => overviewLookup[k] === name)
?.toLowerCase();
if (key) {
acc[key] = overview.node.ticketCount ?? 0;
}
return acc;
}, {});
counts.recent = recent.length;
return counts;
} catch (e) {
console.error(e.message);
return {};
}
};
export const getOverviewTicketsAction = async (name: string) => {
let tickets = [];
try {
if (name === "Recent") {
const recent = await executeREST({ path: "/api/v1/recent_view" });
for (const rec of recent) {
const tkt = await executeREST({
path: `/api/v1/tickets/${rec.o_id}`,
});
tickets.push({
...tkt,
internalId: tkt.id,
createdAt: tkt.created_at,
updatedAt: tkt.updated_at,
});
}
} else {
const fullName = overviewLookup[name];
const countResult = await executeGraphQL({
query: getTicketOverviewCountsQuery,
});
const overviewID = countResult?.ticketOverviews?.edges?.find(
(overview: any) => overview.node.name === fullName,
)?.node?.id;
const ticketsResult = await executeGraphQL({
query: getTicketsByOverviewQuery,
variables: { overviewId: overviewID, pageSize: 250 },
});
const edges = ticketsResult?.ticketsByOverview?.edges;
if (edges) {
tickets = edges.map((edge: any) => edge.node);
}
}
const sortedTickets = tickets.sort((a: any, b: any) => {
if (a.internalId < b.internalId) {
return 1;
}
if (a.internalId > b.internalId) {
return -1;
}
return 0;
});
return { tickets: sortedTickets };
} catch (e) {
console.error(e.message);
return { tickets, message: e.message ?? "" };
}
};

View file

@ -1,6 +1,9 @@
"use server";
import { executeGraphQL } from "app/_lib/zammad";
import { searchQuery } from "@/app/_graphql/searchQuery";
import { createLogger } from "@link-stack/logger";
const logger = createLogger('link-search');
export const searchAllAction = async (query: string, limit: number) => {
try {
@ -11,7 +14,7 @@ export const searchAllAction = async (query: string, limit: number) => {
return result?.search;
} catch (e) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return [];
}
};

View file

@ -1,12 +1,14 @@
"use server";
import { revalidatePath } from "next/cache";
import { getTicketQuery } from "app/_graphql/getTicketQuery";
import { getTicketArticlesQuery } from "app/_graphql/getTicketArticlesQuery";
import { createTicketMutation } from "app/_graphql/createTicketMutation";
import { updateTicketMutation } from "app/_graphql/updateTicketMutation";
import { updateTagsMutation } from "app/_graphql/updateTagsMutation";
import { executeGraphQL, executeREST } from "app/_lib/zammad";
import { createLogger } from "@link-stack/logger";
const logger = createLogger('link-tickets');
export const createTicketAction = async (
currentState: any,
@ -36,7 +38,7 @@ export const createTicketAction = async (
success: true,
};
} catch (e: any) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return {
success: false,
values: {},
@ -63,7 +65,7 @@ export const createTicketArticleAction = async (
success: true,
};
} catch (e: any) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return {
success: false,
message: e?.message ?? "Unknown error",
@ -75,7 +77,6 @@ export const updateTicketAction = async (
ticketID: string,
ticketInfo: Record<string, any>,
) => {
console.log({ ticketID, ticketInfo });
try {
const input = {};
if (ticketInfo.state) {
@ -117,7 +118,7 @@ export const updateTicketAction = async (
success: true,
};
} catch (e: any) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return {
success: false,
message: e?.message ?? "Unknown error",
@ -134,7 +135,7 @@ export const getTicketAction = async (id: string) => {
return ticketData?.ticket;
} catch (e) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return {};
}
};
@ -148,7 +149,7 @@ export const getTicketArticlesAction = async (id: string) => {
return ticketData?.ticketArticles;
} catch (e) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return {};
}
};
@ -158,16 +159,15 @@ export const getTicketStatesAction = async () => {
const states = await executeREST({
path: "/api/v1/ticket_states",
});
const formattedStates =
states?.map((state: any) => ({
value: `gid://zammad/Ticket::State/${state.id}`,
label: state.name,
disabled: ["new", "merged", "removed"].includes(state.name),
})) ?? [];
return formattedStates;
} catch (e) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return [];
}
};
@ -180,7 +180,7 @@ export const getTagsAction = async () => {
return tags;
} catch (e) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return [];
}
};
@ -199,7 +199,7 @@ export const getTicketPrioritiesAction = async () => {
return formattedPriorities;
} catch (e) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return [];
}
};

View file

@ -1,14 +1,20 @@
"use server";
import { executeREST } from "app/_lib/zammad";
import { createLogger } from "@link-stack/logger";
export const getAgentsAction = async () => {
const logger = createLogger('link-users');
export const getAgentsAction = async (groupID: number) => {
try {
const users = await executeREST({
path: "/api/v1/users",
const group = await executeREST({
path: `/api/v1/groups/${groupID}`,
});
const { user_ids: groupUserIDs } = group;
const path = `/api/v1/users/search?query=role_ids:2&limit=1000`;
const users = await executeREST({ path });
const agents =
users?.filter((user: any) => user.role_ids.includes(2)) ?? [];
users?.filter((user: any) => groupUserIDs.includes(user.id)) ?? [];
const formattedAgents = agents
.map((agent: any) => ({
label: `${agent.firstname} ${agent.lastname}`,
@ -18,7 +24,7 @@ export const getAgentsAction = async () => {
return formattedAgents;
} catch (e) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return [];
}
};
@ -39,7 +45,7 @@ export const getCustomersAction = async () => {
return formattedCustomers;
} catch (e) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return [];
}
};
@ -49,7 +55,6 @@ export const getUsersAction = async () => {
const users = await executeREST({
path: "/api/v1/users",
});
console.log({ users });
const formattedUsers = users
.map((customer: any) => ({
label: customer.login,
@ -59,7 +64,7 @@ export const getUsersAction = async () => {
return formattedUsers;
} catch (e) {
console.error(e.message);
logger.error({ error: e }, "Error occurred");
return [];
}
};

View file

@ -4,30 +4,23 @@ import { FC, PropsWithChildren } from "react";
import { CssBaseline } from "@mui/material";
import { CookiesProvider } from "react-cookie";
import { SessionProvider } from "next-auth/react";
import { I18n } from "react-polyglot";
import { AdapterDateFns } from "@mui/x-date-pickers-pro/AdapterDateFnsV3";
import { LocalizationProvider } from "@mui/x-date-pickers-pro";
import { LicenseInfo } from "@mui/x-license";
import { locales, LeafcutterProvider } from "@link-stack/leafcutter-ui";
import { ZammadLoginProvider } from "./ZammadLoginProvider";
LicenseInfo.setLicenseKey(
"c787ac6613c5f2aa0494c4285fe3e9f2Tz04OTY1NyxFPTE3NDYzNDE0ODkwMDAsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
"2a7dd73ee59e3e028b96b0d2adee1ad8Tz0xMTMwOTUsRT0xNzc5MDYyMzk5MDAwLFM9cHJvLExNPXN1YnNjcmlwdGlvbixQVj1pbml0aWFsLEtWPTI=",
);
export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
const messages: any = { en: locales.en, fr: locales.fr };
const locale = "en";
return (
<SessionProvider basePath="/link/api/auth">
<CssBaseline />
<ZammadLoginProvider>
<CookiesProvider>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<I18n locale={locale} messages={messages[locale]}>
<LeafcutterProvider>{children}</LeafcutterProvider>
</I18n>
{children}
</LocalizationProvider>
</CookiesProvider>
</ZammadLoginProvider>

View file

@ -0,0 +1,44 @@
"use client";
import { FC } from "react";
import Iframe from "react-iframe";
import { Box } from "@mui/material";
interface OpenSearchWrapperProps {
url: string;
margin?: number;
}
export const OpenSearchWrapper: FC<OpenSearchWrapperProps> = ({
url,
margin = 50,
}) => (
<Box sx={{ position: "relative", marginTop: "-100px" }}>
<Box
sx={{
width: "100%",
height: "100px",
marginTop: "-20px",
backgroundColor: "white",
zIndex: 100,
position: "relative",
}}
/>
<Box
sx={{
marginTop: `-${margin}px`,
zIndex: 1,
position: "relative",
height: `calc(100vh + ${margin}px)`,
}}
>
<Iframe
id="opensearch"
url={`/link/dashboards/${url}`}
width="100%"
height="100%"
frameBorder={0}
/>
</Box>
</Box>
);

View file

@ -14,12 +14,12 @@ export const ZammadLoginProvider: FC<PropsWithChildren> = ({ children }) => {
const response = await fetch("/api/v1/users/me", {
method: "GET",
headers: {
"X-Browser-Fingerprint": `${session.expires}`,
"X-Browser-Fingerprint": localStorage.getItem("fingerprint") || "",
},
});
if (response.status !== 200) {
window.location.href = "/zammad/auth/sso";
window.location.href = "/link/login";
} else {
const token = response.headers.get("CSRF-Token");
update({ zammadCsrfToken: token });

View file

@ -1,60 +0,0 @@
import { gql } from 'graphql-request';
export const getTicketsByOverviewQuery = gql`
query ticketsByOverview($overviewId: ID!, $orderBy: String, $orderDirection: EnumOrderDirection, $cursor: String, $showPriority: Boolean = false, $showUpdatedBy: Boolean = false, $pageSize: Int = 10) {
ticketsByOverview(
overviewId: $overviewId
orderBy: $orderBy
orderDirection: $orderDirection
after: $cursor
first: $pageSize
) {
totalCount
edges {
node {
id
internalId
number
title
createdAt
updatedAt
updatedBy @include(if: $showUpdatedBy) {
id
fullname
}
customer {
id
firstname
lastname
fullname
}
organization {
id
name
}
state {
id
name
stateType {
name
}
}
group {
id
name
}
priority @include(if: $showPriority) {
id
name
uiColor
defaultCreate
}
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
}
}`;

View file

@ -11,6 +11,9 @@ import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import Apple from "next-auth/providers/apple";
import AzureADProvider from "next-auth/providers/azure-ad";
import { createLogger } from "@link-stack/logger";
const logger = createLogger('link-authentication');
const headers = { Authorization: `Token ${process.env.ZAMMAD_API_TOKEN}` };
@ -26,7 +29,7 @@ const fetchRoles = async () => {
};
const fetchUser = async (email: string) => {
const url = `${process.env.ZAMMAD_URL}/api/v1/users/search?query=login:${email}&limit=1`;
const url = `${process.env.ZAMMAD_URL}/api/v1/users/search?query=${encodeURIComponent(`login:${email}`)}&limit=1`;
const res = await fetch(url, { headers });
const users = await res.json();
const user = users?.[0];
@ -37,6 +40,9 @@ const fetchUser = async (email: string) => {
const getUserRoles = async (email: string) => {
try {
const user = await fetchUser(email);
if (!user) {
return [];
}
const allRoles = await fetchRoles();
const roles = user.role_ids.map((roleID: number) => {
const role = allRoles[roleID];
@ -44,7 +50,7 @@ const getUserRoles = async (email: string) => {
});
return roles.filter((role: string) => role !== null);
} catch (e) {
console.log({ e });
logger.error({ error: e, email }, 'Failed to get user roles');
return [];
}
};
@ -122,6 +128,9 @@ export const authOptions: NextAuthOptions = {
signOut: "/logout",
},
providers,
session: {
maxAge: 3 * 24 * 60 * 60,
},
secret: process.env.NEXTAUTH_SECRET,
callbacks: {
signIn: async ({ user }) => {

View file

@ -1,41 +0,0 @@
export const fetchLeafcutter = async (url: string, options: any) => {
/*
const headers = {
'X-Opensearch-Username': process.env.OPENSEARCH_USER!,
'X-Opensearch-Password': process.env.OPENSEARCH_PASSWORD!,
'X-Leafcutter-User': token.email.toLowerCase()
};
*/
const fetchData = async (url: string, options: any) => {
try {
const res = await fetch(url, options);
const json = await res.json();
return json;
} catch (error) {
console.log({ error });
return null;
}
};
const data = await fetchData(url, options);
console.log({ data });
if (!data) {
const csrfURL = `${process.env.NEXT_PUBLIC_LEAFCUTTER_URL}/api/auth/csrf`;
const csrfData = await fetchData(csrfURL, {});
console.log({ csrfData });
const authURL = `${process.env.NEXT_PUBLIC_LEAFCUTTER_URL}/api/auth/callback/credentials`;
const authData = await fetchData(authURL, { method: "POST" });
console.log({ authData });
if (!authData) {
return null;
} else {
return await fetchData(url, options);
}
} else {
return data;
}
};

View file

@ -2,12 +2,11 @@ import { getServerSession } from "app/_lib/authentication";
import { cookies } from "next/headers";
const getHeaders = async () => {
const allCookies = cookies().getAll();
const allCookies = (await cookies()).getAll();
const session = await getServerSession();
const headers = {
const finalHeaders = {
"Content-Type": "application/json",
Accept: "application/json",
"X-Browser-Fingerprint": `${session.expires}`,
// @ts-ignore
"X-CSRF-Token": session.user.zammadCsrfToken,
Cookie: allCookies
@ -15,7 +14,7 @@ const getHeaders = async () => {
.join("; "),
};
return headers;
return finalHeaders;
};
interface ExecuteGraphQLOptions {

View file

@ -1 +1,4 @@
// Force this route to be dynamic (not statically generated at build time)
export const dynamic = 'force-dynamic';
export { receiveMessage as POST } from "@link-stack/bridge-ui";

View file

@ -0,0 +1,4 @@
// Force this route to be dynamic (not statically generated at build time)
export const dynamic = 'force-dynamic';
export { relinkBot as POST } from "@link-stack/bridge-ui";

View file

@ -1 +1,4 @@
// Force this route to be dynamic (not statically generated at build time)
export const dynamic = 'force-dynamic';
export { getBot as GET } from "@link-stack/bridge-ui";

View file

@ -1 +1,4 @@
// Force this route to be dynamic (not statically generated at build time)
export const dynamic = 'force-dynamic';
export { sendMessage as POST } from "@link-stack/bridge-ui";

View file

@ -1,3 +1,6 @@
import { handleWebhook } from "@link-stack/bridge-ui";
// Force this route to be dynamic (not statically generated at build time)
export const dynamic = 'force-dynamic';
export { handleWebhook as GET, handleWebhook as POST };

View file

@ -1,6 +1,9 @@
import NextAuth from "next-auth";
import { authOptions } from "@/app/_lib/authentication";
// Force this route to be dynamic (not statically generated at build time)
export const dynamic = 'force-dynamic';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View file

@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from "next/server";
import { createLogger } from "@link-stack/logger";
import { getWorkerUtils } from "@link-stack/bridge-common";
import { timingSafeEqual } from "crypto";
// Force this route to be dynamic (not statically generated at build time)
export const dynamic = 'force-dynamic';
const logger = createLogger('formstack-webhook');
export async function POST(req: NextRequest): Promise<NextResponse> {
try {
const clientIp = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || 'unknown';
// Get the shared secret from environment variable
const expectedSecret = process.env.FORMSTACK_SHARED_SECRET;
if (!expectedSecret) {
logger.error('FORMSTACK_SHARED_SECRET environment variable is not configured');
return NextResponse.json(
{ error: "Server configuration error" },
{ status: 500 }
);
}
// Get the shared secret from the request body
const body = await req.json();
const receivedSecret = body.HandshakeKey;
// Validate that secret is provided
if (!receivedSecret || typeof receivedSecret !== 'string') {
logger.warn({ clientIp }, 'Missing or invalid HandshakeKey');
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Use timing-safe comparison to prevent timing attacks
const expectedBuffer = Buffer.from(expectedSecret);
const receivedBuffer = Buffer.from(receivedSecret);
let secretsMatch = false;
if (expectedBuffer.length === receivedBuffer.length) {
try {
secretsMatch = timingSafeEqual(expectedBuffer, receivedBuffer);
} catch (e) {
secretsMatch = false;
}
}
if (!secretsMatch) {
logger.warn({
secretMatch: false,
timestamp: new Date().toISOString(),
userAgent: req.headers.get('user-agent'),
clientIp
}, 'Invalid shared secret received');
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Log webhook receipt with non-PII metadata only
logger.info({
formId: body.FormID,
uniqueId: body.UniqueID,
timestamp: new Date().toISOString(),
fieldCount: Object.keys(body).length
}, 'Received Formstack webhook');
// Enqueue a bridge-worker task to process this form submission
const worker = await getWorkerUtils();
await worker.addJob('formstack/create-ticket-from-form', {
formData: body,
receivedAt: new Date().toISOString(),
});
logger.info('Formstack webhook task enqueued successfully');
return NextResponse.json({
status: "success",
message: "Webhook received and queued for processing"
});
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : undefined,
}, 'Error processing Formstack webhook');
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const allCookies = request.cookies.getAll();
const zammadURL = process.env.ZAMMAD_URL ?? "http://zammad-nginx:8080";
const signOutURL = `${zammadURL}/api/v1/signout`;
const headers = {
"Content-Type": "application/json",
Accept: "application/json",
Cookie: allCookies
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join("; "),
};
// Add timeout to prevent hanging requests
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout
try {
await fetch(signOutURL, {
headers,
signal: controller.signal
});
} catch (error) {
// Log but don't fail logout if Zammad signout fails
console.error('Zammad signout failed:', error);
} finally {
clearTimeout(timeout);
}
const cookiePrefixesToRemove = ["_zammad"];
const response = NextResponse.json({ message: "ok" });
for (const cookie of allCookies) {
if (
cookiePrefixesToRemove.some((prefix) => cookie.name.startsWith(prefix))
) {
response.cookies.set(cookie.name, "", { path: "/", maxAge: 0 });
}
}
return response;
}