WhatsApp/Signal/Formstack/admin updates
This commit is contained in:
parent
bcecf61a46
commit
d0cc5a21de
451 changed files with 16139 additions and 39623 deletions
|
|
@ -2,22 +2,28 @@ FROM node:22-bookworm-slim AS base
|
|||
|
||||
FROM base AS builder
|
||||
ARG APP_DIR=/opt/link
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN mkdir -p ${APP_DIR}/
|
||||
RUN npm i -g turbo
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
RUN pnpm add -g turbo
|
||||
WORKDIR ${APP_DIR}
|
||||
COPY . .
|
||||
RUN turbo prune --scope=@link-stack/link --scope=@link-stack/bridge-migrations --docker
|
||||
|
||||
FROM base AS installer
|
||||
ARG APP_DIR=/opt/link
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
WORKDIR ${APP_DIR}
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
COPY --from=builder ${APP_DIR}/.gitignore .gitignore
|
||||
COPY --from=builder ${APP_DIR}/out/json/ .
|
||||
COPY --from=builder ${APP_DIR}/out/package-lock.json ./package-lock.json
|
||||
RUN npm ci
|
||||
COPY --from=builder ${APP_DIR}/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --from=builder ${APP_DIR}/out/full/ .
|
||||
RUN npm i -g turbo
|
||||
RUN pnpm add -g turbo
|
||||
ENV ZAMMAD_URL http://zammad-nginx:8080
|
||||
RUN turbo run build --filter=@link-stack/link --filter=@link-stack/bridge-migrations
|
||||
|
||||
|
|
@ -30,6 +36,9 @@ LABEL maintainer="Darren Clarke <darren@redaranj.com>"
|
|||
LABEL org.label-schema.build-date=$BUILD_DATE
|
||||
LABEL org.label-schema.version=$VERSION
|
||||
ENV APP_DIR ${APP_DIR}
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
dumb-init
|
||||
|
|
@ -38,10 +47,7 @@ RUN chown -R node ${APP_DIR}/
|
|||
|
||||
USER node
|
||||
WORKDIR ${APP_DIR}
|
||||
COPY --from=installer ${APP_DIR}/node_modules/ ./node_modules/
|
||||
COPY --from=installer ${APP_DIR}/apps/link/ ./apps/link/
|
||||
COPY --from=installer ${APP_DIR}/apps/bridge-migrations/ ./apps/bridge-migrations/
|
||||
COPY --from=installer ${APP_DIR}/package.json ./package.json
|
||||
COPY --from=installer ${APP_DIR} ./
|
||||
USER root
|
||||
WORKDIR ${APP_DIR}/apps/link/
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
|
|
|
|||
|
|
@ -1,32 +1,106 @@
|
|||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
# CDR Link
|
||||
|
||||
## Getting Started
|
||||
The main CDR (Center for Digital Resilience) Link application - a streamlined helpdesk interface built on top of Zammad with integrated communication channels and data visualization.
|
||||
|
||||
First, run the development server:
|
||||
## Overview
|
||||
|
||||
CDR Link provides a unified dashboard for managing support tickets, communication channels, and data analytics. It integrates multiple services including Zammad (ticketing), Bridge (multi-channel messaging), and OpenSearch.
|
||||
|
||||
## Features
|
||||
|
||||
- **Simplified Helpdesk Interface**: Streamlined UI for Zammad ticket management
|
||||
- **Multi-Channel Communication**: Integration with Signal, WhatsApp, Facebook, and Voice channels
|
||||
- **User Management**: Role-based access control with Google OAuth
|
||||
- **Search**: Integrated OpenSearch for advanced queries
|
||||
- **Label Studio Integration**: For data annotation workflows
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 20
|
||||
- npm >= 10
|
||||
- Running instances of Zammad, PostgreSQL, and Redis
|
||||
- Configured authentication providers
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Start production server
|
||||
npm run start
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
### Environment Variables
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
Key environment variables required:
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
|
||||
- `ZAMMAD_URL` - Zammad instance URL
|
||||
- `ZAMMAD_API_TOKEN` - Zammad API authentication token
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `REDIS_URL` - Redis connection URL
|
||||
- `NEXTAUTH_URL` - Application URL for authentication
|
||||
- `NEXTAUTH_SECRET` - Secret for NextAuth.js
|
||||
- `GOOGLE_CLIENT_ID` - Google OAuth client ID
|
||||
- `GOOGLE_CLIENT_SECRET` - Google OAuth client secret
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
### Available Scripts
|
||||
|
||||
## Learn More
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run start` - Start production server
|
||||
- `npm run lint` - Run ESLint
|
||||
- `npm run export` - Export static site
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
## Architecture
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
### Pages Structure
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
- `/` - Main dashboard
|
||||
- `/overview/[overview]` - Ticket overview pages
|
||||
- `/tickets/[id]` - Individual ticket view/edit
|
||||
- `/admin/bridge` - Bridge configuration management
|
||||
- `/opensearch` - Search dashboard
|
||||
- `/zammad` - Direct Zammad access
|
||||
- `/profile` - User profile management
|
||||
|
||||
## Deploy on Vercel
|
||||
### API Routes
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
- `/api/auth` - NextAuth.js authentication
|
||||
- `/api/v2/users` - User management API
|
||||
- `/api/[service]/bots` - Bot management for communication channels
|
||||
- `/api/[service]/webhooks` - Webhook endpoints
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
### Key Components
|
||||
|
||||
- `ZammadWrapper` - Embeds Zammad UI with authentication
|
||||
- `SearchBox` - Global search functionality
|
||||
- `TicketList` / `TicketDetail` - Ticket management components
|
||||
- `Sidebar` - Navigation and service switching
|
||||
|
||||
## Docker Support
|
||||
|
||||
Build and run with Docker:
|
||||
|
||||
```bash
|
||||
# Build image
|
||||
docker build -t link-stack/link .
|
||||
|
||||
# Run with docker-compose
|
||||
docker-compose -f docker/compose/link.yml up
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
- **Zammad**: GraphQL queries for ticket data
|
||||
- **Bridge Services**: REST APIs for channel management
|
||||
- **OpenSearch**: Direct dashboard embedding
|
||||
- **Redis**: Session and cache storage
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
13
apps/link/app/(main)/_components/DefaultDashboard.tsx
Normal file
13
apps/link/app/(main)/_components/DefaultDashboard.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
10
apps/link/app/(main)/admin/opensearch/page.tsx
Normal file
10
apps/link/app/(main)/admin/opensearch/page.tsx
Normal 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#/" />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { About } from "@link-stack/leafcutter-ui";
|
||||
|
||||
export default function Page() {
|
||||
return <About />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { FAQ } from "@link-stack/leafcutter-ui";
|
||||
|
||||
export default function Page() {
|
||||
return <FAQ />;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { Trends } from "@link-stack/leafcutter-ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function Page() {
|
||||
return <Trends visualizations={[]} />;
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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" />;
|
||||
}
|
||||
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 ?? "" };
|
||||
}
|
||||
};
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
44
apps/link/app/_components/OpenSearchWrapper.tsx
Normal file
44
apps/link/app/_components/OpenSearchWrapper.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
4
apps/link/app/api/[service]/bots/[token]/relink/route.ts
Normal file
4
apps/link/app/api/[service]/bots/[token]/relink/route.ts
Normal 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";
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
98
apps/link/app/api/formstack/route.ts
Normal file
98
apps/link/app/api/formstack/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
apps/link/app/api/logout/route.ts
Normal file
43
apps/link/app/api/logout/route.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -2,6 +2,6 @@
|
|||
|
||||
set -e
|
||||
echo "running migrations"
|
||||
(cd ../bridge-migrations/ && npm run migrate:up:all)
|
||||
(cd ../bridge-migrations/ && pnpm run migrate:up:all)
|
||||
echo "starting link"
|
||||
exec dumb-init npm run start
|
||||
exec dumb-init pnpm run start
|
||||
|
|
|
|||
|
|
@ -1,21 +1,30 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { withAuth, NextRequestWithAuth } from "next-auth/middleware";
|
||||
import { createLogger } from "@link-stack/logger";
|
||||
|
||||
const logger = createLogger('link-middleware');
|
||||
|
||||
/*
|
||||
const rewriteURL = (
|
||||
request: NextRequestWithAuth,
|
||||
originBaseURL: string,
|
||||
destinationBaseURL: string,
|
||||
headers: any = {},
|
||||
) => {
|
||||
logger.debug({
|
||||
originBaseURL,
|
||||
destinationBaseURL,
|
||||
headerKeys: Object.keys(headers)
|
||||
}, "Rewriting URL");
|
||||
let path = request.url.replace(originBaseURL, "");
|
||||
if (path.startsWith("/")) {
|
||||
path = path.slice(1);
|
||||
}
|
||||
const destinationURL = `${destinationBaseURL}/${path}`;
|
||||
console.log(`Rewriting ${request.url} to ${destinationURL}`);
|
||||
logger.debug({ from: request.url, to: destinationURL }, "URL rewrite");
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
|
||||
requestHeaders.delete("x-forwarded-user");
|
||||
requestHeaders.delete("x-forwarded-roles");
|
||||
requestHeaders.delete("connection");
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
|
|
@ -26,17 +35,61 @@ const rewriteURL = (
|
|||
request: { headers: requestHeaders },
|
||||
});
|
||||
};
|
||||
*/
|
||||
|
||||
const checkRewrites = async (request: NextRequestWithAuth) => {
|
||||
// const linkBaseURL = process.env.LINK_URL ?? "http://localhost:3000";
|
||||
const linkBaseURL = process.env.LINK_URL ?? "http://localhost:3000";
|
||||
logger.debug({ linkBaseURL }, "Link base URL");
|
||||
const opensearchBaseURL =
|
||||
process.env.OPENSEARCH_DASHBOARDS_URL ??
|
||||
"http://opensearch-dashboards:5601";
|
||||
|
||||
const { token } = request.nextauth;
|
||||
const email = token?.email?.toLowerCase() ?? "unknown";
|
||||
// let headers = { "x-forwarded-user": email };
|
||||
const roles = (token?.roles as string[]) ?? [];
|
||||
let headers = {
|
||||
"x-forwarded-user": email,
|
||||
"x-forwarded-roles": roles.join(","),
|
||||
};
|
||||
|
||||
if (request.nextUrl.pathname.startsWith("/dashboards")) {
|
||||
// Extract the path after /dashboards and append to OpenSearch URL
|
||||
let path = request.nextUrl.pathname.slice("/dashboards".length);
|
||||
if (path.startsWith("/")) {
|
||||
path = path.slice(1);
|
||||
}
|
||||
const search = request.nextUrl.search;
|
||||
const destinationURL = `${opensearchBaseURL}/${path}${search}`;
|
||||
|
||||
logger.debug({
|
||||
pathname: request.nextUrl.pathname,
|
||||
path,
|
||||
search,
|
||||
destinationURL
|
||||
}, "OpenSearch proxy");
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.delete("x-forwarded-user");
|
||||
requestHeaders.delete("x-forwarded-roles");
|
||||
requestHeaders.delete("connection");
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
requestHeaders.set(key, value as string);
|
||||
}
|
||||
|
||||
return NextResponse.rewrite(new URL(destinationURL), {
|
||||
request: { headers: requestHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||
|
||||
// Allow digiresilience.org for embedding documentation
|
||||
const frameSrcDirective = `frame-src 'self' https://digiresilience.org;`;
|
||||
|
||||
const cspHeader = `
|
||||
default-src 'self';
|
||||
${frameSrcDirective}
|
||||
connect-src 'self';
|
||||
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ""};
|
||||
style-src 'self' 'unsafe-inline';
|
||||
|
|
@ -45,7 +98,7 @@ const checkRewrites = async (request: NextRequestWithAuth) => {
|
|||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
frame-ancestors 'self';
|
||||
upgrade-insecure-requests;
|
||||
`;
|
||||
const contentSecurityPolicyHeaderValue = cspHeader
|
||||
|
|
@ -70,8 +123,18 @@ const checkRewrites = async (request: NextRequestWithAuth) => {
|
|||
contentSecurityPolicyHeaderValue,
|
||||
);
|
||||
|
||||
// Additional security headers
|
||||
response.headers.set("X-Frame-Options", "SAMEORIGIN");
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set("X-XSS-Protection", "1; mode=block");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=()"
|
||||
);
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
export default withAuth(checkRewrites, {
|
||||
callbacks: {
|
||||
|
|
@ -101,5 +164,7 @@ export default withAuth(checkRewrites, {
|
|||
});
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!ws|wss|api|_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
matcher: [
|
||||
"/((?!ws|wss|api/signal|api/whatsapp|api/formstack|_next/static|_next/image|favicon.ico).*)",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
3
apps/link/next-env.d.ts
vendored
3
apps/link/next-env.d.ts
vendored
|
|
@ -1,5 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
|
|
@ -1,36 +1,41 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
basePath: '/link',
|
||||
poweredByHeader: false,
|
||||
transpilePackages: [
|
||||
"@link-stack/leafcutter-ui",
|
||||
"@link-stack/opensearch-common",
|
||||
"@link-stack/ui",
|
||||
"@link-stack/bridge-common",
|
||||
"@link-stack/bridge-ui",
|
||||
"mui-chips-input",
|
||||
],
|
||||
headers: async () => {
|
||||
return [
|
||||
{
|
||||
source: "/((?!zammad).*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "DENY",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
/** @type {(phase: string) => import('next').NextConfig} */
|
||||
export default function () {
|
||||
const base = {
|
||||
basePath: '/link',
|
||||
poweredByHeader: false,
|
||||
transpilePackages: [
|
||||
'@link-stack/ui',
|
||||
'@link-stack/bridge-common',
|
||||
'@link-stack/bridge-ui',
|
||||
'mui-chips-input',
|
||||
],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/((?!zammad).*)',
|
||||
headers: [
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
|
||||
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
/** dev-only extras */
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return {
|
||||
...base,
|
||||
experimental: {
|
||||
...(base.experimental ?? {}),
|
||||
serverActions: {
|
||||
allowedOrigins: ['localhost:8001'],
|
||||
allowedForwardedHosts: ['localhost'],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,48 @@
|
|||
{
|
||||
"name": "@link-stack/link",
|
||||
"version": "2.2.0",
|
||||
"version": "3.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"export": "next export",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chatscope/chat-ui-kit-react": "^2.0.3",
|
||||
"@chatscope/chat-ui-kit-react": "^2.1.1",
|
||||
"@chatscope/chat-ui-kit-styles": "^1.4.0",
|
||||
"@emotion/cache": "^11.13.1",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@link-stack/bridge-common": "*",
|
||||
"@link-stack/bridge-ui": "*",
|
||||
"@link-stack/leafcutter-ui": "*",
|
||||
"@link-stack/opensearch-common": "*",
|
||||
"@link-stack/ui": "*",
|
||||
"@mui/icons-material": "^5",
|
||||
"@mui/material": "^5",
|
||||
"@mui/material-nextjs": "^5",
|
||||
"@mui/x-data-grid-pro": "^7.18.0",
|
||||
"@mui/x-date-pickers": "^7.18.0",
|
||||
"@mui/x-date-pickers-pro": "^7.18.0",
|
||||
"@mui/x-license": "^7.18.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@link-stack/bridge-common": "workspace:*",
|
||||
"@link-stack/bridge-ui": "workspace:*",
|
||||
"@link-stack/logger": "workspace:*",
|
||||
"@link-stack/ui": "workspace:*",
|
||||
"@mui/icons-material": "^6",
|
||||
"@mui/material": "^6",
|
||||
"@mui/material-nextjs": "^6",
|
||||
"@mui/x-data-grid-pro": "^7",
|
||||
"@mui/x-date-pickers": "^7",
|
||||
"@mui/x-date-pickers-pro": "^7",
|
||||
"@mui/x-license": "^7",
|
||||
"date-fns": "^4.1.0",
|
||||
"graphql-request": "^7.1.0",
|
||||
"mui-chips-input": "^2.1.5",
|
||||
"next": "^14.2.25",
|
||||
"next-auth": "^4.24.8",
|
||||
"react": "18.3.1",
|
||||
"react-cookie": "^7.2.0",
|
||||
"react-dom": "18.3.1",
|
||||
"graphql-request": "^7.2.0",
|
||||
"ioredis": "^5.8.1",
|
||||
"mui-chips-input": "^6.0.0",
|
||||
"next": "15.5.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"react": "19.2.0",
|
||||
"react-cookie": "^8.0.1",
|
||||
"react-dom": "19.2.0",
|
||||
"react-iframe": "^1.8.5",
|
||||
"react-polyglot": "^0.7.2",
|
||||
"sharp": "^0.33.5"
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@link-stack/eslint-config": "*",
|
||||
"@types/node": "^22.7.3",
|
||||
"@types/react": "^18",
|
||||
"@types/uuid": "^10.0.0"
|
||||
"@link-stack/eslint-config": "workspace:*",
|
||||
"@types/node": "^24.7.0",
|
||||
"@types/react": "19.2.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
apps/link/public/.DS_Store
vendored
BIN
apps/link/public/.DS_Store
vendored
Binary file not shown.
2
apps/link/public/robots.txt
Normal file
2
apps/link/public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
||||
Loading…
Add table
Add a link
Reference in a new issue