Merge branch 'develop' into 'main'

Develop

Closes #1

See merge request digiresilience/link/link-stack!7
This commit is contained in:
Darren Clarke 2024-03-13 08:25:54 +00:00
commit 8d42c8fdb2
402 changed files with 20153 additions and 19937 deletions

3
.gitignore vendored
View file

@ -23,3 +23,6 @@ coverage
out/
signald-state/*
!./signald-state/.gitkeep
baileys-state
signald-state
project.org

View file

@ -17,7 +17,7 @@ build-all:
- turbo build
.docker-build:
image: registry.gitlab.com/guardianproject-ops/docker-alpine-git:latest
image: registry.gitlab.com/digiresilience/link/link-stack/buildx:${CI_COMMIT_REF_NAME}
services:
- docker:dind
stage: docker-build
@ -34,7 +34,7 @@ build-all:
- docker push ${DOCKER_NS}:${DOCKER_TAG}
.docker-release:
image: registry.gitlab.com/guardianproject-ops/docker-alpine-git:latest
image: registry.gitlab.com/digiresilience/link/link-stack/buildx:${CI_COMMIT_REF_NAME}
services:
- docker:dind
stage: docker-release
@ -51,6 +51,17 @@ build-all:
- docker tag ${DOCKER_NS}:${DOCKER_TAG} ${DOCKER_NS}:${DOCKER_TAG_NEW}
- docker push ${DOCKER_NS}:${DOCKER_TAG_NEW}
buildx-docker-build:
extends: .docker-build
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/buildx
DOCKERFILE_PATH: ./docker/buildx/Dockerfile
buildx-docker-release:
extends: .docker-release
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/buildx
link-docker-build:
extends: .docker-build
variables:
@ -84,17 +95,6 @@ metamigo-docker-release:
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/metamigo
metamigo-frontend-docker-build:
extends: .docker-build
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/metamigo-frontend
DOCKERFILE_PATH: ./apps/metamigo-frontend/Dockerfile
metamigo-frontend-docker-release:
extends: .docker-release
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/metamigo-frontend
elasticsearch-docker-build:
extends: .docker-build
variables:
@ -200,8 +200,40 @@ zammad-docker-build:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/zammad
DOCKERFILE_PATH: ./docker/zammad/Dockerfile
DOCKER_CONTEXT: ./docker/zammad
before_script:
- apk --update add nodejs npm
script:
- npm install npm@latest -g
- npm install -g turbo
- npm ci
- turbo build --force --filter zammad-addon-*
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- DOCKER_BUILDKIT=1 docker build --build-arg EMBEDDED=true --pull --no-cache -t ${DOCKER_NS}:${DOCKER_TAG} -f ${DOCKERFILE_PATH} ${DOCKER_CONTEXT}
- docker push ${DOCKER_NS}:${DOCKER_TAG}
zammad-docker-release:
extends: .docker-release
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/zammad
zammad-standalone-docker-build:
extends: .docker-build
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/zammad-standalone
DOCKERFILE_PATH: ./docker/zammad/Dockerfile
DOCKER_CONTEXT: ./docker/zammad
before_script:
- apk --update add nodejs npm
script:
- npm install npm@latest -g
- npm install -g turbo
- npm ci
- turbo build --force --filter zammad-addon-*
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- DOCKER_BUILDKIT=1 docker build --pull --no-cache -t ${DOCKER_NS}:${DOCKER_TAG} -f ${DOCKERFILE_PATH} ${DOCKER_CONTEXT}
- docker push ${DOCKER_NS}:${DOCKER_TAG}
zammad-standalone-docker-release:
extends: .docker-release
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/zammad-standalone

View file

@ -14,38 +14,23 @@ Local dev with docker-compose
Or for local dev of a single app
* Create `link-stack/.env` from Bitwarden `.env for root of link-stack`
* Create `link-stack/apps/link/.env.local` from Bitwarden `.env.local for link-stack/apps/link`
* Create `link-stack/apps/metamigo-frontend/.metamigo.local.json` from Bitwarden `.metamigo.local.json for link-stack/apps/metamigo/frontend`
* Build locally for development:
```
npm install
npm run docker:metamigo:dev:up # start supporting containers for metamigo
npm run build # compile the apps
npm run migrate # this migrates the db
npm run dev:metamigo # this runs metamigo frontend, api, and worker
make dev-metamigo # this starts the containers
npm run migrate # this migrates the db
npm run dev:metamigo # this runs metamigo frontend and api
```
# TODO
Notes from abel regarding metamigo. these are in priority order (high priority first)
- [ ] Do not upgrade: postgraphile, graphql and other postgres dependencies until postgrahile supports a newer grapqhl version
* ref: https://github.com/graphile/postgraphile/issues/1583
- [ ] Fix the proxying from metamigo-frontend to metamigo-api, this broke during the next.js `pages/api` -> `app/api/*route.js` change.
* or consider removing the proxy and having the frontend talk directly to the backend, though this may be more work.
- [ ] Upgrade metamigo-frontend react-admin components
* this is the bulk of the real outstanding work, outside of breakages that happend during dep updates between Jun 14 - Aug (of which I'm only aware of the proxying issue, see previous)
* follow react-admin upgrade guide https://marmelab.com/react-admin/Upgrade.html
* in particular: https://marmelab.com/react-admin/Upgrade.html#no-more-prop-injection-in-page-components
* I started this in commit 49650795dff5249c89975d3c0b1cf12836304647
* so you can follow the same pattern in future commits to fix the signal, whatsapp and twilio pages
- [ ] Delete old JWT config stuff
- [ ] Consolidate config
- [ ] Complete react-admin upgrade.. make all the metamigo-frontend stuff work
* https://marmelab.com/react-admin/Upgrade.html#no-more-prop-injection-in-page-components
- [ ] Get metamigo-worker working
* the package.json entry points need to be fixed to be like metamigo-api
* the worker needs a main.ts file like metamigo-api that starts the worker (without the api) `await startWithout(["server"]);`
* while you're at it, I recomnmend moving all source files into a `src` to be consistent with the other metamigo projects
- [ ] Migrate off mui/styles
* https://mui.com/material-ui/migration/v5-style-changes/
* the codemods might help us?
- [ ] Delete old JWT config options stuff in `packages/metamigo-config`
* the JWT is no longer used bdad5f551c536d751be87ecb8464d16c82e32699 and 24d52eef3d26ac5ee1294b949490920765fca96f
* so all of the config related to the JWT can be removed: signingkey (and b64 one), encryption key (and b64 one), audience
- [ ] Consolidate config.. this is basically done. The idea is to not need a config js file, everything can be populated from a root level .env file. This is already done, I have been developing like this for awhile, but I notice in your .env file on bitwarden you're still using the config file.
* the codemods might help us?

View file

@ -1,4 +1,4 @@
import { About } from './_components/About';
import { About } from "leafcutter-common";
export default function Page() {
return <About />;

View file

@ -1,8 +1,10 @@
import { getTemplates } from "app/_lib/opensearch";
import { Create } from "./_components/Create";
import { Create } from "leafcutter-common";
export default async function Page() {
const templates = await getTemplates(100);
return <Create templates={templates} />;
}
export const dynamic = "force-dynamic";

View file

@ -1,4 +1,4 @@
import { FAQ } from "./_components/FAQ";
import { FAQ } from "leafcutter-common";
export default function Page() {
return <FAQ />;

View file

@ -7,16 +7,12 @@ import "@fontsource/roboto/700.css";
import "@fontsource/playfair-display/900.css";
// import getConfig from "next/config";
// import { LicenseInfo } from "@mui/x-data-grid-pro";
import { InternalLayout } from "app/_components/InternalLayout";
import { headers } from 'next/headers'
import { InternalLayout } from "../_components/InternalLayout";
type LayoutProps = {
children: ReactNode;
};
export default function Layout({ children }: LayoutProps) {
const allHeaders = headers();
const embedded = Boolean(allHeaders.get('x-leafcutter-embedded'));
return <InternalLayout embedded={embedded}>{children}</InternalLayout>;
return <InternalLayout embedded={false}>{children}</InternalLayout>;
}

View file

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
import { authOptions } from "app/_lib/auth";
import { getUserVisualizations } from "app/_lib/opensearch";
import { Home } from "app/_components/Home";
import { Home } from "leafcutter-common";
export default async function Page() {
const session = await getServerSession(authOptions);

View file

@ -1,10 +1,10 @@
/* eslint-disable no-underscore-dangle */
// import { Client } from "@opensearch-project/opensearch";
import { Preview } from "./_components/Preview";
import { Preview } from "leafcutter-common";
// import { createVisualization } from "lib/opensearch";
export default function Page() {
return <Preview visualization={undefined} visualizationType={""} data={[]}/>;
return <Preview visualization={undefined} visualizationType={""} data={[]} />;
}
/*

View file

@ -5,7 +5,7 @@ import { useLayoutEffect } from "react";
import { useRouter } from "next/navigation";
import { Grid, CircularProgress } from "@mui/material";
import Iframe from "react-iframe";
import { useAppContext } from "app/_components/AppProvider";
import { useAppContext } from "leafcutter-common/components/AppProvider";
export const Setup: FC = () => {
const {
@ -20,6 +20,7 @@ export const Setup: FC = () => {
<Grid
sx={{ width: "100%", height: 700 }}
direction="row"
container
justifyContent="space-around"
alignItems="center"
alignContent="center"

View file

@ -1,6 +1,5 @@
import { Setup } from './_components/Setup';
import { Setup } from "./_components/Setup";
export default function Page() {
return <Setup />;
}

View file

@ -1,8 +1,10 @@
import { getTrends } from "app/_lib/opensearch";
import { Trends } from "./_components/Trends";
import { Trends } from "leafcutter-common";
export default async function Page() {
const visualizations = await getTrends(25);
return <Trends visualizations={visualizations} />;
}
export const dynamic = "force-dynamic";

View file

@ -1,6 +1,6 @@
/* eslint-disable no-underscore-dangle */
import { Client } from "@opensearch-project/opensearch";
import { VisualizationDetail } from "app/_components/VisualizationDetail";
import { VisualizationDetail } from "leafcutter-common";
const getVisualization = async (visualizationID: string) => {
const node = `https://${process.env.OPENSEARCH_USERNAME}:${process.env.OPENSEARCH_PASSWORD}@${process.env.OPENSEARCH_URL}`;
@ -18,7 +18,7 @@ const getVisualization = async (visualizationID: string) => {
const response = rawResponse.body;
const hits = response.hits.hits.filter(
(hit: any) => hit._id.split(":")[1] === visualizationID[0]
(hit: any) => hit._id.split(":")[1] === visualizationID[0],
);
const hit = hits[0];
const visualization = {

View file

@ -11,7 +11,7 @@ import {
bindTrigger,
bindMenu,
} from "material-ui-popup-state/hooks";
import { useAppContext } from "./AppProvider";
import { useAppContext } from "leafcutter-common/components/AppProvider";
export const AccountButton: FC = () => {
const t = useTranslate();

View file

@ -8,7 +8,7 @@ import {
useState,
PropsWithChildren,
} from "react";
import { colors, typography } from "app/_styles/theme";
import { colors, typography } from "leafcutter-common/styles/theme";
const basePath = process.env.GITLAB_CI
? "/link/link-stack/apps/leafcutter"

View file

@ -4,11 +4,11 @@ import { FC, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Button } from "@mui/material";
import { QuestionMark as QuestionMarkIcon } from "@mui/icons-material";
import { useAppContext } from "./AppProvider";
import { useAppContext } from "leafcutter-common/components/AppProvider";
export const HelpButton: FC = () => {
const router = useRouter();
const pathname = usePathname();
const pathname = usePathname() ?? "";
const [helpActive, setHelpActive] = useState(false);
const {
colors: { leafcutterElectricBlue },

View file

@ -7,8 +7,8 @@ import CookieConsent from "react-cookie-consent";
import { useCookies } from "react-cookie";
import { TopNav } from "./TopNav";
import { Sidebar } from "./Sidebar";
import { GettingStartedDialog } from "./GettingStartedDialog";
import { useAppContext } from "./AppProvider";
import { GettingStartedDialog } from "leafcutter-common";
import { useAppContext } from "leafcutter-common/components/AppProvider";
// import { Footer } from "./Footer";
type LayoutProps = PropsWithChildren<{

View file

@ -8,7 +8,7 @@ import {
bindTrigger,
bindMenu,
} from "material-ui-popup-state/hooks";
import { useAppContext } from "./AppProvider";
import { useAppContext } from "leafcutter-common/components/AppProvider";
// import { Tooltip } from "./Tooltip";
export const LanguageSelect = () => {

View file

@ -6,12 +6,12 @@ import { SessionProvider } from "next-auth/react";
import { CssBaseline } from "@mui/material";
import { CookiesProvider } from "react-cookie";
import { I18n } from "react-polyglot";
import { AdapterDateFns } from "@mui/x-date-pickers-pro/AdapterDateFns";
import { AdapterDateFns } from "@mui/x-date-pickers-pro/AdapterDateFnsV3";
import { LocalizationProvider } from "@mui/x-date-pickers-pro";
import { AppProvider } from "app/_components/AppProvider";
import { AppProvider } from "leafcutter-common/components/AppProvider";
import { NextAppDirEmotionCacheProvider } from "tss-react/next/appDir";
import en from "app/_locales/en.json";
import fr from "app/_locales/fr.json";
import en from "leafcutter-common/locales/en.json";
import fr from "leafcutter-common/locales/fr.json";
import "@fontsource/poppins/400.css";
import "@fontsource/poppins/700.css";
import "@fontsource/roboto/400.css";
@ -21,7 +21,7 @@ import "app/_styles/global.css";
import { LicenseInfo } from "@mui/x-date-pickers-pro";
LicenseInfo.setLicenseKey(
"7c9bf25d9e240f76e77cbf7d2ba58a23Tz02NjU4OCxFPTE3MTU4NjIzMzQ2ODgsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI="
"7c9bf25d9e240f76e77cbf7d2ba58a23Tz02NjU4OCxFPTE3MTU4NjIzMzQ2ODgsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
);
const messages: any = { en, fr };

View file

@ -20,8 +20,8 @@ import {
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslate } from "react-polyglot";
import { useAppContext } from "app/_components/AppProvider";
import { Tooltip } from "app/_components/Tooltip";
import { useAppContext } from "leafcutter-common/components/AppProvider";
import { Tooltip } from "leafcutter-common";
// import { ArrowCircleRight as ArrowCircleRightIcon } from "@mui/icons-material";
const MenuItem = ({
@ -101,8 +101,8 @@ interface SidebarProps {
export const Sidebar: FC<SidebarProps> = ({ open }) => {
const t = useTranslate();
const pathname = usePathname();
const section = pathname.split("/")[1];
const pathname = usePathname() ?? "";
const section = pathname?.split("/")[1];
const {
colors: { white }, // leafcutterElectricBlue, leafcutterLightBlue,
} = useAppContext();

View file

@ -6,10 +6,10 @@ import Image from "next/legacy/image";
import { AppBar, Grid, Box } from "@mui/material";
import { useTranslate } from "react-polyglot";
import LeafcutterLogo from "images/leafcutter-logo.png";
import { AccountButton } from "app/_components/AccountButton";
import { HelpButton } from "app/_components/HelpButton";
import { Tooltip } from "app/_components/Tooltip";
import { useAppContext } from "./AppProvider";
import { AccountButton } from "./AccountButton";
import { HelpButton } from "./HelpButton";
import { Tooltip } from "leafcutter-common";
import { useAppContext } from "leafcutter-common/components/AppProvider";
// import { LanguageSelect } from "./LanguageSelect";
export const TopNav: FC = () => {
@ -43,50 +43,51 @@ export const TopNav: FC = () => {
wrap="nowrap"
spacing={4}
>
<Link href="/" passHref>
<Grid
item
container
direction="row"
justifyContent="flex-start"
spacing={1}
wrap="nowrap"
sx={{ cursor: "pointer" }}
>
<Grid item sx={{ pr: 1 }}>
<Image src={LeafcutterLogo} alt="" width={56} height={52} />
<Grid
item
container
direction="row"
justifyContent="flex-start"
alignItems="center"
alignContent="center"
spacing={1}
wrap="nowrap"
sx={{ cursor: "pointer" }}
>
<Grid item sx={{ pr: 1 }}>
<Image src={LeafcutterLogo} alt="" width={56} height={52} />
</Grid>
<Grid item container direction="column" alignContent="flex-start">
<Grid item sx={{ mt: -1 }}>
<Box
sx={{
...h5,
color: leafcutterElectricBlue,
p: 0,
m: 0,
pt: 1,
textAlign: "left",
}}
>
Leafcutter
</Box>
</Grid>
<Grid item container direction="column" alignContent="flex-start">
<Grid item>
<Box
sx={{
...h5,
color: leafcutterElectricBlue,
p: 0,
m: 0,
pt: 1,
textAlign: "left",
}}
>
Leafcutter
</Box>
</Grid>
<Grid item>
<Box
sx={{
...h6,
m: 0,
p: 0,
color: cdrLinkOrange,
textAlign: "left",
}}
>
A Project of Center for Digital Resilience
</Box>
</Grid>
<Grid item>
<Box
sx={{
...h6,
m: 0,
p: 0,
color: cdrLinkOrange,
textAlign: "left",
}}
>
A Project of Center for Digital Resilience
</Box>
</Grid>
</Grid>
</Link>
</Grid>
<Grid item>
<HelpButton />
</Grid>

View file

@ -1,8 +1,15 @@
import type { NextAuthOptions } from "next-auth";
import Google from "next-auth/providers/google";
import Apple from "next-auth/providers/apple";
import Credentials from "next-auth/providers/credentials";
import { checkAuth } from "./opensearch";
export const authOptions: NextAuthOptions = {
pages: {
signIn: "/login",
error: "/login",
signOut: "/logout",
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
@ -12,6 +19,62 @@ export const authOptions: NextAuthOptions = {
clientId: process.env.APPLE_CLIENT_ID ?? "",
clientSecret: process.env.APPLE_CLIENT_SECRET ?? "",
}),
Credentials({
name: "Link",
credentials: {
authToken: { label: "AuthToken", type: "text", },
},
async authorize(credentials, req) {
const { headers } = req;
console.log({ headers });
const leafcutterUser = headers?.["x-leafcutter-user"];
const authToken = credentials?.authToken;
if (!leafcutterUser || leafcutterUser.trim() === "") {
console.log("no leafcutter user");
return null;
}
console.log({ authToken });
return null;
/*
try {
// add role check
await checkAuth(username, password);
const user = {
id: leafcutterUser,
email: leafcutterUser
};
return user;
} catch (e) {
console.log({ e });
}
return null;
*/
}
})
],
secret: process.env.NEXTAUTH_SECRET,
/*
callbacks: {
signIn: async ({ user, account, profile }) => {
const roles: any = [];
return roles.includes("admin") || roles.includes("agent");
},
session: async ({ session, user, token }) => {
// @ts-ignore
session.user.roles = token.roles;
return session;
},
jwt: async ({ token, user, account, profile, trigger }) => {
if (user) {
token.roles = [];
}
return token;
}
},*/
};

View file

@ -8,9 +8,7 @@ const globalIndex = ".kibana_1";
const dataIndexName = "sample_tagged_tickets";
const userMetadataIndexName = "user_metadata";
// const baseURL = `https://${process.env.OPENSEARCH_USERNAME}:${process.env.OPENSEARCH_PASSWORD}@${process.env.OPENSEARCH_URL}`;
const baseURL = `https://localhost:9200`;
const baseURL = `https://${process.env.OPENSEARCH_USERNAME}:${process.env.OPENSEARCH_PASSWORD}@${process.env.OPENSEARCH_URL}`;
const createClient = () => new Client({
node: baseURL,
@ -23,6 +21,24 @@ const createClient = () => new Client({
},
});
const createUserClient = (username: string, password: string) => new Client({
node: baseURL,
auth: {
username,
password,
},
ssl: {
rejectUnauthorized: false,
},
});
export const checkAuth = async (username: string, password: string) => {
const client = createUserClient(username, password);
const res = await client.ping();
return res.statusCode === 200;
};
const getDocumentID = (doc: any) => doc._id.split(":")[1];
const getEmbedURL = (tenant: string, visualizationID: string) =>

View file

@ -3,3 +3,7 @@ body {
overscroll-behavior-y: none;
text-size-adjust: none;
}
a {
text-decoration: none;
}

View file

@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
export const GET = async (req: NextRequest) => {
const validDomains = "localhost";
console.log({ req });
return NextResponse.json({ response: "ok" });
};
export const POST = async (req: NextRequest) => {
const validDomains = "localhost";
console.log({ req });
return NextResponse.json({ response: "ok" });
};

View file

@ -1,38 +0,0 @@
import { createProxyMiddleware } from "http-proxy-middleware";
import { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
const withAuthInfo =
(handler: any) => async (req: NextApiRequest, res: NextApiResponse) => {
const session: any = await getToken({
req,
secret: process.env.NEXTAUTH_SECRET,
});
if (!session) {
return res.redirect("/login");
}
req.headers["x-proxy-user"] = session.email.toLowerCase();
req.headers["x-proxy-roles"] = "leafcutter_user";
const auth = `${session.email.toLowerCase()}:${process.env.OPENSEARCH_USER_PASSWORD}`;
const buff = Buffer.from(auth);
const base64data = buff.toString("base64");
req.headers.Authorization = `Basic ${base64data}`;
return handler(req, res);
};
const proxy = createProxyMiddleware({
target: process.env.OPENSEARCH_DASHBOARDS_URL,
changeOrigin: true,
xfwd: true,
});
export default withAuthInfo(proxy);
export const config = {
api: {
bodyParser: false,
externalResolver: true,
},
};

View file

@ -3,7 +3,9 @@ import { getTrends } from "app/_lib/opensearch";
export const GET = async () => {
const results = await getTrends(5);
console.log({ results });
NextResponse.json(results);
};
export const dynamic = 'force-dynamic';

View file

@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "app/_lib/auth";
import { getUserVisualizations } from "app/_lib/opensearch";
export const GET = async () => {
const session = await getServerSession(authOptions);
const { user: { email } }: any = session;
const visualizations = await getUserVisualizations(email, 20);
return NextResponse.json(visualizations);
};

View file

@ -8,7 +8,7 @@ import "@fontsource/roboto/700.css";
import "@fontsource/playfair-display/900.css";
// import getConfig from "next/config";
// import { LicenseInfo } from "@mui/x-data-grid-pro";
import { MultiProvider } from "app/_components/MultiProvider";
import { MultiProvider } from "./_components/MultiProvider";
export const metadata: Metadata = {
title: "Leafcutter",

View file

@ -2,5 +2,5 @@ apiVersion: v2
name: leafcutter
description: A Helm chart for Kubernetes
type: application
version: 0.1.54
appVersion: "0.1.54"
version: 0.2.0
appVersion: "0.2.0"

View file

@ -1,57 +1,6 @@
import { NextResponse } from "next/server";
import { withAuth, NextRequestWithAuth } from "next-auth/middleware";
import getConfig from "next/config";
const rewriteURL = (request: NextRequestWithAuth, originBaseURL: string, destinationBaseURL: string, headers: any = {}) => {
if (request.nextUrl.protocol.startsWith('ws')) {
return NextResponse.next();
}
if (request.nextUrl.pathname.includes('/_next/static/development/')) {
return NextResponse.next();
}
const destinationURL = request.url.replace(originBaseURL, destinationBaseURL);
console.log(`Rewriting ${request.url} to ${destinationURL}`);
const requestHeaders = new Headers(request.headers);
for (const [key, value] of Object.entries(headers)) {
// @ts-ignore
requestHeaders.set(key, value);
}
requestHeaders.delete('connection');
// console.log({ finalHeaders: requestHeaders });
return NextResponse.rewrite(new URL(destinationURL), { request: { headers: requestHeaders } });
};
const checkRewrites = async (request: NextRequestWithAuth) => {
console.log({ currentURL: request.nextUrl.href });
const leafcutterBaseURL = process.env.LEAFCUTTER_URL ?? "http://localhost:3000";
const opensearchDashboardsURL = process.env.OPENSEARCH_URL ?? "http://localhost:5602";
if (request.nextUrl.pathname.startsWith('/proxy/opensearch')) {
console.log('proxying to zammad');
const { token } = request.nextauth;
const auth = `${token?.email?.toLowerCase()}:${process.env.OPENSEARCH_USER_PASSWORD}`;
const buff = Buffer.from(auth);
const base64data = buff.toString("base64");
const headers = {
'X-Proxy-User': token?.email?.toLowerCase(),
"X-Proxy-Roles": "leafcutter_user",
"Authorization": `Basic ${base64data}`
};
console.log({ headers });
return rewriteURL(request, `${leafcutterBaseURL}/proxy/opensearch`, opensearchDashboardsURL, headers);
}
};
import { withAuth } from "next-auth/middleware";
export default withAuth(
checkRewrites,
{
pages: {
signIn: `/login`,
@ -60,25 +9,30 @@ export default withAuth(
authorized: ({ token, req }) => {
const {
url,
headers,
} = req;
// check login page
const parsedURL = new URL(url);
if (parsedURL.pathname.startsWith('/login')) {
console.log({ url });
console.log({ pathname: parsedURL.pathname });
console.log({ allowed: parsedURL.pathname.startsWith("/app") });
const allowed = parsedURL.pathname.startsWith('/login') || parsedURL.pathname.startsWith('/api' || parsedURL.pathname.startsWith("/app"));
if (allowed) {
return true;
}
// check session auth
const authorizedDomains = ["redaranj.com", "digiresilience.org"];
const userDomain = token?.email?.toLowerCase().split("@").pop() ?? "unauthorized.net";
if (authorizedDomains.includes(userDomain)) {
if (token?.email) {
return true;
}
return false;
},
}
}
);
export const config = {
matcher: [
'/((?!api|app|bootstrap|3961|ui|translations|internal|login|node_modules|_next/static|_next/image|favicon.ico).*)',
],
};

View file

@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -7,9 +7,19 @@ const ContentSecurityPolicy = `
`;
module.exports = {
publicRuntimeConfig: {
embedded: true
},/*
transpilePackages: ["leafcutter-common"],
experimental: {
missingSuspenseWithCSRBailout: false,
},
rewrites: async () => ({
fallback: [
{
source: "/:path*",
destination: "/api/proxy/:path*",
},
],
}),
/*
basePath: "/proxy/leafcutter",
assetPrefix: "/proxy/leafcutter",
i18n: {
@ -17,25 +27,19 @@ module.exports = {
defaultLocale: "en",
},
*/
/* rewrites: async () => ({
fallback: [
{
source: "/:path*",
destination: "/api/proxy/:path*",
},
],
}) */
/*
async headers() {
return [
{
source: '/:path*',
headers: [
/*
{
key: 'Content-Security-Policy',
value: ContentSecurityPolicy.replace(/\s{2,}/g, ' ').trim()
},
*/
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
@ -52,4 +56,5 @@ module.exports = {
},
]
}
*/
};

View file

@ -6,7 +6,7 @@
"login": "aws sso login --sso-session cdr",
"kubeconfig": "aws eks update-kubeconfig --name cdr-leafcutter-dashboard-cluster --profile cdr-leafcutter-dashboard-production",
"fwd:opensearch": "kubectl port-forward opensearch-cluster-master-0 9200:9200 --namespace leafcutter",
"fwd:dashboards": "kubectl port-forward opensearch-dashboards-1-59854cdb9b-vgmtf 5602:5601 --namespace leafcutter",
"fwd:dashboards": "kubectl port-forward opensearch-dashboards-1-59854cdb9b-mx4qq 5602:5601 --namespace leafcutter",
"build": "next build",
"start": "next start",
"export": "next export",
@ -14,51 +14,53 @@
},
"dependencies": {
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.1",
"@emotion/react": "^11.11.4",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@fontsource/playfair-display": "^5.0.5",
"@fontsource/poppins": "^5.0.5",
"@fontsource/roboto": "^5.0.5",
"@fontsource/playfair-display": "^5.0.21",
"@fontsource/poppins": "^5.0.12",
"@fontsource/roboto": "^5.0.12",
"@mui/icons-material": "^5",
"@mui/lab": "^5.0.0-alpha.136",
"@mui/lab": "^5.0.0-alpha.167",
"@mui/material": "^5",
"@mui/x-data-grid-pro": "^6.10.0",
"@mui/x-date-pickers-pro": "^6.10.0",
"@opensearch-project/opensearch": "^2.3.0",
"date-fns": "^2.30.0",
"@mui/x-data-grid-pro": "^6.19.6",
"@mui/x-date-pickers-pro": "^6.19.6",
"@opensearch-project/opensearch": "^2.5.0",
"cryptr": "^6.3.0",
"date-fns": "^3.3.1",
"http-proxy-middleware": "^2.0.6",
"material-ui-popup-state": "^5.0.9",
"next": "13.4.10",
"next-auth": "^4.22.1",
"next-http-proxy-middleware": "^1.2.5",
"nodemailer": "^6.9.3",
"leafcutter-common": "*",
"material-ui-popup-state": "^5.0.10",
"next": "14.1.2",
"next-auth": "^4.24.6",
"next-http-proxy-middleware": "^1.2.6",
"nodemailer": "^6.9.11",
"react": "18.2.0",
"react-cookie": "^4.1.1",
"react-cookie-consent": "^8.0.1",
"react-cookie": "^7.1.0",
"react-cookie-consent": "^9.0.0",
"react-dom": "18.2.0",
"react-iframe": "^1.8.5",
"react-markdown": "^8.0.7",
"react-markdown": "^9.0.1",
"react-polyglot": "^0.7.2",
"sharp": "^0.32.3",
"swr": "^2.2.0",
"tss-react": "^4.8.8",
"uuid": "^9.0.0"
"sharp": "^0.33.2",
"swr": "^2.2.5",
"tss-react": "^4.9.4",
"uuid": "^9.0.1"
},
"devDependencies": {
"@babel/core": "^7.22.9",
"@types/node": "^20.4.2",
"@types/react": "18.2.15",
"@types/uuid": "^9.0.2",
"@babel/core": "^7.24.0",
"@types/node": "^20.11.24",
"@types/react": "18.2.63",
"@types/uuid": "^9.0.8",
"babel-loader": "^9.1.3",
"eslint": "^8.45.0",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-next": "^13.4.10",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-react": "^7.32.2",
"typescript": "5.1.6"
"eslint-config-next": "^14.1.2",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.34.0",
"typescript": "5.3.3"
}
}

View file

@ -0,0 +1,66 @@
import { createProxyMiddleware } from "http-proxy-middleware";
import { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
/*
if (validDomains.includes(domain)) {
res.headers.set("Access-Control-Allow-Origin", origin);
res.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.headers.set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
}
*/
const withAuthInfo =
(handler: any) => async (req: NextApiRequest, res: NextApiResponse) => {
const session: any = await getToken({
req,
secret: process.env.NEXTAUTH_SECRET,
});
let email = session?.email?.toLowerCase();
const requestSignature = req.query.signature;
const url = new URL(req.headers.referer as string);
const referrerSignature = url.searchParams.get("signature");
console.log({ requestSignature, referrerSignature });
const isAppPath = !!req.url?.startsWith("/app");
const isResourcePath = !!req.url?.match(/\/(api|app|bootstrap|3961|ui|translations|internal|login|node_modules)/);
if (requestSignature && isAppPath) {
console.log("Has Signature");
}
if (referrerSignature && isResourcePath) {
console.log("Has Signature");
}
if (!email) {
return res.status(401).json({ error: "Not authorized" });
}
req.headers["x-proxy-user"] = email;
req.headers["x-proxy-roles"] = "leafcutter_user";
const auth = `${email}:${process.env.OPENSEARCH_USER_PASSWORD}`;
const buff = Buffer.from(auth);
const base64data = buff.toString("base64");
req.headers.Authorization = `Basic ${base64data}`;
return handler(req, res);
};
const proxy = createProxyMiddleware({
target: process.env.OPENSEARCH_DASHBOARDS_URL,
changeOrigin: true,
xfwd: true,
});
export default withAuthInfo(proxy);
export const config = {
api: {
bodyParser: false,
externalResolver: true,
},
};

View file

@ -1,9 +1,24 @@
"use client";
import { FC } from "react";
import { Box, Grid, Container, IconButton } from "@mui/material";
import { Apple as AppleIcon, Google as GoogleIcon } from "@mui/icons-material";
import { FC, useState } from "react";
import {
Box,
Grid,
Container,
IconButton,
Typography,
TextField,
} from "@mui/material";
import {
Apple as AppleIcon,
Google as GoogleIcon,
Key as KeyIcon,
} from "@mui/icons-material";
import { signIn } from "next-auth/react";
import Image from "next/image";
import LinkLogo from "public/link-logo-small.png";
import { colors } from "app/_styles/theme";
import { useSearchParams } from "next/navigation";
type LoginProps = {
session: any;
@ -14,62 +29,198 @@ export const Login: FC<LoginProps> = ({ session }) => {
typeof window !== "undefined" && window.location.origin
? window.location.origin
: "";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const params = useSearchParams();
const error = params.get("error");
const { darkGray, cdrLinkOrange, white } = colors;
const buttonStyles = {
borderRadius: 500,
width: "100%",
fontSize: "16px",
fontWeight: "bold",
backgroundColor: white,
"&:hover": {
color: white,
backgroundColor: cdrLinkOrange,
},
};
const fieldStyles = {
"& label.Mui-focused": {
color: cdrLinkOrange,
},
"& .MuiInput-underline:after": {
borderBottomColor: cdrLinkOrange,
},
"& .MuiFilledInput-underline:after": {
borderBottomColor: cdrLinkOrange,
},
"& .MuiOutlinedInput-root": {
"&.Mui-focused fieldset": {
borderColor: cdrLinkOrange,
},
},
};
return (
<>
<Grid container direction="row-reverse" sx={{ p: 3 }}>
<Grid item />
</Grid>
<Container maxWidth="md" sx={{ mt: 3, mb: 20 }}>
<Box sx={{ backgroundColor: darkGray, height: "100vh" }}>
<Container maxWidth="md" sx={{ p: 10 }}>
<Grid container spacing={2} direction="column" alignItems="center">
<Grid item>
<Box sx={{ maxWidth: 200 }} />
</Grid>
<Grid item sx={{ textAlign: "center" }} />
<Grid item>
{!session ? (
<Grid
container
spacing={3}
direction="column"
alignItems="center"
sx={{ width: 450, mt: 1 }}
<Grid
item
container
direction="row"
justifyContent="center"
alignItems="center"
>
<Grid item>
<Box
sx={{
width: "70px",
height: "70px",
margin: "0 auto",
}}
>
<Grid item sx={{ width: "100%" }}>
<IconButton
sx={buttonStyles}
onClick={() =>
signIn("google", {
callbackUrl: `${origin}/setup`,
})
}
>
<GoogleIcon sx={{ mr: 1 }} />
Google
</IconButton>
<Image
src={LinkLogo}
alt="Link logo"
width={70}
height={70}
style={{
objectFit: "cover",
filter: "grayscale(100) brightness(100)",
}}
/>
</Box>
</Grid>
<Grid item>
<Typography
variant="h2"
sx={{
fontSize: 36,
color: "white",
fontWeight: 700,
mt: 1,
ml: 0.5,
fontFamily: "Poppins",
}}
>
CDR Link
</Typography>
</Grid>
</Grid>
<Grid item sx={{ width: "100%" }}>
{!session ? (
<Container
maxWidth="xs"
sx={{
p: 3,
mt: 3,
}}
>
<Grid
container
spacing={3}
direction="column"
alignItems="center"
>
{error ? (
<Grid item sx={{ width: "100%" }}>
<Box sx={{ backgroundColor: "red", p: 3 }}>
<Typography
variant="body1"
sx={{
fontSize: 18,
color: "white",
textAlign: "center",
}}
>
{`${error} error`}
</Typography>
</Box>
</Grid>
) : null}
<Grid item sx={{ width: "100%" }}>
<IconButton
sx={buttonStyles}
onClick={() =>
signIn("google", {
callbackUrl: `${origin}`,
})
}
>
<GoogleIcon sx={{ mr: 1 }} />
Sign in with Google
</IconButton>
</Grid>
<Grid item sx={{ width: "100%" }}>
<IconButton
aria-label="Sign in with Apple"
sx={buttonStyles}
onClick={() =>
signIn("apple", {
callbackUrl: `${window.location.origin}`,
})
}
>
<AppleIcon sx={{ mr: 1 }} />
Sign in with Apple
</IconButton>
</Grid>
<Grid>
<Typography
variant="body1"
sx={{
fontSize: 18,
color: white,
textAlign: "center",
mt: 3,
}}
>
or
</Typography>
</Grid>
<Grid item sx={{ width: "100%" }}>
<TextField
value={email}
onChange={(e) => setEmail(e.target.value)}
label="Email"
variant="filled"
size="small"
fullWidth
sx={{ ...fieldStyles, backgroundColor: white }}
/>
</Grid>
<Grid item sx={{ ...fieldStyles, width: "100%" }}>
<TextField
value={password}
onChange={(e) => setPassword(e.target.value)}
label="Password"
variant="filled"
size="small"
fullWidth
sx={{ backgroundColor: white }}
type="password"
/>
</Grid>
<Grid item sx={{ width: "100%" }}>
<IconButton
sx={buttonStyles}
onClick={() =>
signIn("credentials", {
email,
password,
callbackUrl: `${origin}/setup`,
})
}
>
<KeyIcon sx={{ mr: 1 }} />
Sign in with Zammad credentials
</IconButton>
</Grid>
</Grid>
{/*
<Grid item sx={{ width: "100%" }}>
<IconButton
sx={buttonStyles}
onClick={() =>
signIn("apple", {
callbackUrl: `${window.location.origin}/setup`,
})
}
>
<AppleIcon sx={{ mr: 1 }} />
</IconButton>
</Grid>*/}
<Grid item sx={{ mt: 2 }} />
</Grid>
</Container>
) : null}
{session ? (
<Box component="h4">
@ -79,6 +230,6 @@ export const Login: FC<LoginProps> = ({ session }) => {
</Grid>
</Grid>
</Container>
</>
</Box>
);
};

View file

@ -0,0 +1,14 @@
"use client";
import dynamic from "next/dynamic";
type ClientOnlyProps = { children: JSX.Element };
const ClientOnly = (props: ClientOnlyProps) => {
const { children } = props;
return children;
};
export default dynamic(() => Promise.resolve(ClientOnly), {
ssr: false,
});

View file

@ -3,4 +3,4 @@
import { FC } from "react";
import { ZammadWrapper } from "./ZammadWrapper";
export const Home: FC = () => <ZammadWrapper path="/#dashboard" hideSidebar />;
export const Home: FC = () => <ZammadWrapper path="#dashboard" hideSidebar />;

View file

@ -0,0 +1,150 @@
import { FC, useState, useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import useSWR from "swr";
import { Grid, Box, TextField, Autocomplete } from "@mui/material";
import { searchQuery } from "@/app/_graphql/searchQuery";
import { colors } from "@/app/_styles/theme";
type SearchResultProps = {
props: any;
option: any;
};
const SearchInput = (params: any) => (
<TextField
{...params}
placeholder="Search"
sx={{
backgroundColor: "white",
borderRadius: 10,
"& .MuiOutlinedInput-root": {
borderRadius: 10,
py: 0,
legend: {
marginLeft: "30px",
},
},
"& .MuiAutocomplete-inputRoot": {
paddingLeft: "20px !important",
borderRadius: 10,
},
"& .MuiInputLabel-outlined": {
paddingLeft: "20px",
},
"& .MuiInputLabel-shrink": {
marginLeft: "20px",
paddingLeft: "10px",
paddingRight: 0,
background: "white",
},
}}
/>
);
const SearchResult: FC<SearchResultProps> = ({ props, option }) => {
console.log({ option });
const { lightGrey, mediumGray, black, white } = colors;
return (
<Box
{...props}
sx={{
px: 2,
py: 1.25,
":hover": {
background: `${lightGrey}`,
},
a: {
color: `${black} !important`,
},
borderBottom: `1px solid ${mediumGray}`,
}}
>
<Grid container direction="column" spacing={0.1}>
<Grid item container direction="row" justifyContent="space-between">
<Grid item>
<Box
sx={{
py: 0,
fontSize: 13,
fontWeight: 500,
}}
>
{option.title}
</Box>
</Grid>
</Grid>
<Grid item>
<Box sx={{ width: "100%" }}>
<Box
sx={{
color: "#999",
fontSize: 13,
wrap: "break-word",
}}
>
{option.note}
</Box>
</Box>
</Grid>
</Grid>
</Box>
);
};
export const SearchBox: FC = () => {
const [open, setOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState(null);
const [searchTerms, setSearchTerms] = useState("");
const pathname = usePathname();
const router = useRouter();
const { data, error }: any = useSWR({
document: searchQuery,
variables: {
search: searchTerms ?? "",
limit: 50,
},
refreshInterval: 10000,
});
useEffect(() => {
setOpen(false);
}, [pathname]);
return (
<Autocomplete
forcePopupIcon={false}
openOnFocus
blurOnSelect
value={selectedValue}
onBlur={() => setOpen(false)}
inputValue={searchTerms}
onChange={(_event, option, reason) => {
if (!option) return;
const url = `/tickets/${option.internalId}`;
setSelectedValue("");
router.push(url);
}}
onInputChange={(_event, value) => {
setSearchTerms(value);
}}
open={open}
onOpen={() => setOpen(true)}
noOptionsText="No results"
options={data?.search ?? []}
getOptionLabel={(option: any) => {
if (option) {
return option.title;
} else {
return "";
}
}}
renderOption={(props, option: any) => (
<SearchResult props={props} key={option.id} option={option} />
)}
sx={{ width: "100%" }}
renderInput={(params: any) => <SearchInput {...params} />}
/>
);
};

View file

@ -1,6 +1,6 @@
"use client";
import { FC, useState } from "react";
import { FC, useEffect, useState } from "react";
import useSWR from "swr";
import {
Box,
@ -17,12 +17,16 @@ import {
import {
FeaturedPlayList as FeaturedPlayListIcon,
Person as PersonIcon,
Analytics as AnalyticsIcon,
Insights as InsightsIcon,
Logout as LogoutIcon,
Cottage as CottageIcon,
Settings as SettingsIcon,
ExpandCircleDown as ExpandCircleDownIcon,
Dvr as DvrIcon,
Assessment as AssessmentIcon,
LibraryBooks as LibraryBooksIcon,
School as SchoolIcon,
Search as SearchIcon,
} from "@mui/icons-material";
import { usePathname } from "next/navigation";
import Link from "next/link";
@ -30,6 +34,7 @@ import Image from "next/image";
import LinkLogo from "public/link-logo-small.png";
import { useSession, signOut } from "next-auth/react";
import { getTicketOverviewCountsQuery } from "app/_graphql/getTicketOverviewCountsQuery";
import { SearchBox } from "./SearchBox";
const openWidth = 270;
const closedWidth = 100;
@ -162,20 +167,31 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
const pathname = usePathname();
const { data: session } = useSession();
const username = session?.user?.name || "User";
// @ts-ignore
const roles = session?.user?.roles || [];
const { data: overviewData, error: overviewError }: any = useSWR(
{
document: getTicketOverviewCountsQuery,
},
{ refreshInterval: 10000 }
{ refreshInterval: 10000 },
);
const findOverviewCountByID = (id: number) =>
overviewData?.ticketOverviews?.edges?.find((overview: any) =>
overview.node.id.endsWith(`/${id}`)
const findOverviewByName = (name: string) =>
overviewData?.ticketOverviews?.edges?.find(
(overview: any) => overview.node.name === name,
)?.node?.id;
const findOverviewCountByID = (id: string) =>
overviewData?.ticketOverviews?.edges?.find(
(overview: any) => overview.node.id === id,
)?.node?.ticketCount ?? 0;
const assignedCount = findOverviewCountByID(1);
const urgentCount = findOverviewCountByID(7);
const pendingCount = findOverviewCountByID(3);
const unassignedCount = findOverviewCountByID(2);
const recentCount = 0;
const assignedID = findOverviewByName("My Assigned Tickets");
const assignedCount = findOverviewCountByID(assignedID);
const openID = findOverviewByName("Open Tickets");
const openCount = findOverviewCountByID(openID);
const urgentID = findOverviewByName("Escalated Tickets");
const urgentCount = findOverviewCountByID(urgentID);
const unassignedID = findOverviewByName("Unassigned & Open Tickets");
const unassignedCount = findOverviewCountByID(unassignedID);
const logout = () => {
signOut({ callbackUrl: "/login" });
@ -221,6 +237,7 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
direction="column"
justifyContent="space-between"
wrap="nowrap"
spacing={0}
sx={{ backgroundColor: "#25272A", height: "100%", p: 2 }}
>
<Grid item container>
@ -307,10 +324,30 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
</Grid>
<Grid item>
<Box
sx={{ height: "0.5px", width: "100%", backgroundColor: "#666" }}
sx={{
height: "0.5px",
width: "100%",
backgroundColor: "#666",
mb: 2,
}}
/>
</Grid>
<Grid item container direction="column" sx={{ mt: "6px" }} flexGrow={1}>
<Grid item>
<SearchBox />
</Grid>
<Grid
item
container
direction="column"
sx={{
mt: "6px",
overflow: "scroll",
scrollbarWidth: "none",
msOverflowStyle: "none",
"&::-webkit-scrollbar": { display: "none" },
}}
flexGrow={1}
>
<List
component="nav"
sx={{
@ -359,7 +396,7 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
/>
<MenuItem
name="Tickets"
href="/overview/assigned"
href="/overview/recent"
Icon={FeaturedPlayListIcon}
selected={
pathname.startsWith("/overview") ||
@ -379,12 +416,21 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
>
<List component="div" disablePadding>
<MenuItem
name="Assigned"
href="/overview/assigned"
name="Recent"
href="/overview/recent"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/overview/assigned")}
badge={assignedCount}
selected={pathname.endsWith("/overview/recent")}
badge={recentCount}
open={open}
/>
<MenuItem
name="Open"
href="/overview/open"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/overview/open")}
badge={openCount}
open={open}
/>
<MenuItem
@ -397,12 +443,12 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
open={open}
/>
<MenuItem
name="Pending"
href="/overview/pending"
name="Assigned"
href="/overview/assigned"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/overview/pending")}
badge={pendingCount}
selected={pathname.endsWith("/overview/assigned")}
badge={assignedCount}
open={open}
/>
<MenuItem
@ -419,17 +465,33 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
<MenuItem
name="Knowledge Base"
href="/knowledge"
Icon={CottageIcon}
Icon={SchoolIcon}
iconSize={20}
selected={pathname.endsWith("/knowledge")}
open={open}
/>
<MenuItem
name="Leafcutter"
href="/leafcutter/about"
Icon={AnalyticsIcon}
name="Documentation"
href="/docs"
Icon={LibraryBooksIcon}
iconSize={20}
selected={pathname.endsWith("/leafcutter")}
selected={pathname.endsWith("/docs")}
open={open}
/>
<MenuItem
name="Reporting"
href="/reporting"
Icon={AssessmentIcon}
iconSize={20}
selected={pathname.endsWith("/reporting")}
open={open}
/>
<MenuItem
name="Leafcutter"
href="/leafcutter"
Icon={InsightsIcon}
iconSize={20}
selected={false}
open={open}
/>
<Collapse
@ -439,7 +501,6 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
onClick={undefined}
>
<List component="div" disablePadding>
{/*
<MenuItem
name="Dashboard"
href="/leafcutter"
@ -447,7 +508,6 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
selected={pathname.endsWith("/leafcutter")}
open={open}
/>
<MenuItem
name="Search and Create"
href="/leafcutter/create"
@ -455,7 +515,6 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
selected={pathname.endsWith("/leafcutter/create")}
open={open}
/>
<MenuItem
name="Trends"
href="/leafcutter/trends"
@ -463,7 +522,6 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
selected={pathname.endsWith("/leafcutter/trends")}
open={open}
/>
*/}
<MenuItem
name="FAQ"
href="/leafcutter/faq"
@ -475,7 +533,7 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
<MenuItem
name="About"
href="/leafcutter/about"
Icon={AnalyticsIcon}
Icon={InsightsIcon}
iconSize={0}
selected={pathname.endsWith("/leafcutter/about")}
open={open}
@ -490,49 +548,57 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
selected={pathname.endsWith("/profile")}
open={open}
/>
<MenuItem
name="Admin"
href="/admin/zammad"
Icon={SettingsIcon}
iconSize={20}
open={open}
/>
<Collapse
in={pathname.startsWith("/admin/")}
timeout="auto"
unmountOnExit
onClick={undefined}
>
<List component="div" disablePadding>
{roles.includes("admin") && (
<>
<MenuItem
name="Zammad Settings"
name="Admin"
href="/admin/zammad"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/admin/zammad")}
Icon={SettingsIcon}
iconSize={20}
open={open}
/>
<MenuItem
name="Metamigo"
href="/admin/metamigo"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/admin/metamigo")}
open={open}
/>
<MenuItem
name="Label Studio"
href="/admin/label-studio"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/admin/label-studio")}
open={open}
/>
</List>
</Collapse>
<Collapse
in={pathname.startsWith("/admin/")}
timeout="auto"
unmountOnExit
onClick={undefined}
>
<List component="div" disablePadding>
<MenuItem
name="Zammad Settings"
href="/admin/zammad"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/admin/zammad")}
open={open}
/>
{false && roles.includes("metamigo") && (
<MenuItem
name="Metamigo"
href="/admin/metamigo"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/admin/metamigo")}
open={open}
/>
)}
{roles.includes("label_studio") && (
<MenuItem
name="Label Studio"
href="/admin/label-studio"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/admin/label-studio")}
open={open}
/>
)}
</List>
</Collapse>
</>
)}
<MenuItem
name="Zammad Interface"
href="/proxy/zammad"
href="/zammad"
Icon={DvrIcon}
iconSize={20}
open={open}

View file

@ -27,7 +27,7 @@ export const StyledDataGrid: FC<StyledDataGridProps> = ({
columns,
rows,
onRowClick,
height = "calc(100vh - 20px)",
height = "100%",
selectedRows,
setSelectedRows,
}) => {
@ -43,6 +43,13 @@ export const StyledDataGrid: FC<StyledDataGridProps> = ({
border: 0,
width: "100%",
height,
".MuiDataGrid-row": {
cursor: "pointer",
"&:hover": {
backgroundColor: "#1982fc33 !important",
fontWeight: "bold",
},
},
".MuiDataGrid-row:nth-of-type(1n)": {
backgroundColor: "#f3f3f3",
},
@ -66,12 +73,14 @@ export const StyledDataGrid: FC<StyledDataGridProps> = ({
rows={rows}
columns={columns}
density="compact"
hideFooter
pagination
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
}}
pageSizeOptions={[5, 10, 25]}
paginationMode="client"
sx={{ height }}
rowBuffer={30}
checkboxSelection={!!setSelectedRows}
onRowSelectionModelChange={setSelectedRows}
rowSelectionModel={selectedRows}
rowHeight={46}
scrollbarSize={0}
disableVirtualization

View file

@ -1,9 +1,10 @@
"use client";
import { FC, useState } from "react";
import getConfig from "next/config";
import { FC, useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Iframe from "react-iframe";
import { useSession } from "next-auth/react";
import { Box, Grid, CircularProgress } from "@mui/material";
type ZammadWrapperProps = {
path: string;
@ -15,68 +16,146 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
hideSidebar = true,
}) => {
const router = useRouter();
const { data: session } = useSession({ required: true });
const timeoutRef = useRef(null);
const [hashCheckComplete, setHashCheckComplete] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [display, setDisplay] = useState("none");
const url = `/proxy/zammad${path}`;
console.log({ url });
const url = `/zammad${path}`;
const id = url.replace(/[^a-zA-Z0-9]/g, "");
return (
// @ts-ignore
<Iframe
id="zammad"
url={url}
width="100%"
height="100%"
frameBorder={0}
styles={{ display }}
onLoad={() => {
const linkElement = document.querySelector("iframe");
if (
linkElement.contentDocument &&
linkElement.contentDocument?.querySelector &&
linkElement.contentDocument.querySelector("#navigation") &&
linkElement.contentDocument.querySelector("body") &&
linkElement.contentDocument.querySelector(".sidebar")
) {
// @ts-ignore
linkElement.contentDocument.querySelector("#navigation").style =
"display: none";
// @ts-ignore
linkElement.contentDocument.querySelector("body").style =
"font-family: Arial";
useEffect(() => {
const hash = window?.location?.hash;
if (hash && hash.startsWith("#ticket/zoom/")) {
const ticketID = hash.split("/").pop();
router.push(`/tickets/${ticketID}`);
}
setHashCheckComplete(true);
});
if (hideSidebar) {
// @ts-ignore
linkElement.contentDocument.querySelector(".sidebar").style =
"display: none";
}
useEffect(() => {
if (!hashCheckComplete) return;
// @ts-ignore
if (linkElement.contentDocument.querySelector(".overview-header")) {
// @ts-ignore
(
linkElement.contentDocument.querySelector(
".overview-header"
) as any
).style = "display: none";
}
const checkAuthenticated = async () => {
const res = await fetch("/zammad/auth/sso", {
method: "GET",
redirect: "manual",
});
console.log({ res });
if (res.type === "opaqueredirect") {
setAuthenticated(true);
} else {
setAuthenticated(false);
}
};
setDisplay("inherit");
checkAuthenticated();
}, [path, hashCheckComplete]);
if (linkElement.contentWindow) {
linkElement.contentWindow.addEventListener("hashchange", () => {
const hash = linkElement.contentWindow?.location?.hash ?? "";
if (hash.startsWith("#ticket/zoom/")) {
setDisplay("none");
const ticketID = hash.split("/").pop();
router.push(`/tickets/${ticketID}`);
setTimeout(() => {
setDisplay("inherit");
}, 1000);
}
});
}
useEffect(() => {
if (session === null) {
timeoutRef.current = setTimeout(() => {
if (session === null) {
router.push("/login");
}
}}
/>
);
}, 3000);
}
if (session !== null) {
clearTimeout(timeoutRef.current);
}
return () => clearTimeout(timeoutRef.current);
}, [session]);
if (!session || !authenticated) {
console.log("Not authenticated");
return (
<Box sx={{ width: "100%" }}>
<Grid
container
direction="column"
sx={{ height: 500 }}
justifyContent="center"
alignContent="center"
alignItems="center"
>
<Grid item>
<CircularProgress size={80} color="success" />
</Grid>
</Grid>
</Box>
);
}
if (session && authenticated) {
console.log("Session and authenticated");
return (
<Iframe
id={id}
url={url}
width="100%"
height="100%"
frameBorder={0}
styles={{ display }}
onLoad={() => {
const linkElement = document.querySelector(
`#${id}`,
) as HTMLIFrameElement;
console.log({ path });
console.log({ id });
console.log({ linkElement });
if (
linkElement.contentDocument &&
linkElement.contentDocument?.querySelector &&
linkElement.contentDocument.querySelector("#navigation") &&
linkElement.contentDocument.querySelector("body")
) {
// @ts-ignore
linkElement.contentDocument.querySelector("#navigation").style =
"display: none";
// @ts-ignore
linkElement.contentDocument.querySelector("body").style =
"font-family: Arial";
if (
hideSidebar &&
linkElement.contentDocument.querySelector(".sidebar")
) {
// @ts-ignore
linkElement.contentDocument.querySelector(".sidebar").style =
"display: none";
}
// @ts-ignore
if (linkElement.contentDocument.querySelector(".overview-header")) {
// @ts-ignore
(
linkElement.contentDocument.querySelector(
".overview-header",
) as any
).style = "display: none";
}
setDisplay("inherit");
if (linkElement.contentWindow) {
linkElement.contentWindow.addEventListener("hashchange", () => {
const hash = linkElement.contentWindow?.location?.hash ?? "";
if (hash.startsWith("#ticket/zoom/")) {
setDisplay("none");
const ticketID = hash.split("/").pop();
router.push(`/tickets/${ticketID}`);
setTimeout(() => {
setDisplay("inherit");
}, 1000);
}
});
}
}
}}
/>
);
}
};

View file

@ -13,8 +13,8 @@ export const LabelStudioWrapper: FC = () => (
>
<Grid item sx={{ height: "100vh", width: "100%" }}>
<Iframe
id="link"
url={"https://label-studio:3000"}
id="label-studio"
url={"/label-studio"}
width="100%"
height="100%"
frameBorder={0}

View file

@ -1,36 +0,0 @@
"use client";
import { FC } from "react";
import getConfig from "next/config";
import { Grid } from "@mui/material";
import Iframe from "react-iframe";
type MetamigoWrapperProps = {
path: string;
};
export const MetamigoWrapper: FC<MetamigoWrapperProps> = ({ path }) => {
const {
publicRuntimeConfig: { linkURL },
} = getConfig();
const fullMetamigoURL = `${linkURL}/metamigo/${path}`;
return (
<Grid
container
spacing={0}
sx={{ height: "100%", width: "100%" }}
direction="column"
>
<Grid item sx={{ height: "100vh", width: "100%" }}>
<Iframe
id="link"
url={fullMetamigoURL}
width="100%"
height="100%"
frameBorder={0}
/>
</Grid>
</Grid>
);
};

View file

@ -1,16 +0,0 @@
import { Metadata } from "next";
import { MetamigoWrapper } from "./_components/MetamigoWrapper";
export const metadata: Metadata = {
title: "Metamigo",
};
type PageProps = {
params: {
path: string;
};
};
export default function Page({ params: { path } }: PageProps) {
return <MetamigoWrapper path={path} />;
}

View file

@ -0,0 +1,6 @@
// import { Admin } from "./_components/Admin";
import { Box } from "@mui/material";
export default function Page() {
return <Box />;
}

View file

@ -1,5 +1,5 @@
import { Metadata } from "next";
import { ZammadWrapper } from "../../../(main)/_components/ZammadWrapper";
import { ZammadWrapper } from "app/(main)/_components/ZammadWrapper";
export const metadata: Metadata = {
title: "Zammad",

View file

@ -1,22 +0,0 @@
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
// import Apple from "next-auth/providers/apple";
const handler = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
/*
Apple({
clientId: process.env.APPLE_CLIENT_ID,
clientSecret: process.env.APPLE_CLIENT_SECRET
}),
*/
],
secret: process.env.NEXTAUTH_SECRET,
});
export { handler as GET, handler as POST };

View file

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

View file

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

View file

@ -1,36 +0,0 @@
"use client";
import { FC } from "react";
import getConfig from "next/config";
import { Grid } from "@mui/material";
import Iframe from "react-iframe";
type LeafcutterWrapperProps = {
path: string;
};
export const LeafcutterWrapper: FC<LeafcutterWrapperProps> = ({ path }) => {
const {
publicRuntimeConfig: { linkURL },
} = getConfig();
const fullLeafcutterURL = `${linkURL}/proxy/leafcutter/${path}`;
return (
<Grid
container
spacing={0}
sx={{ height: "100%", width: "100%" }}
direction="column"
>
<Grid item sx={{ height: "100vh", width: "100%" }}>
<Iframe
id="leafcutter"
url={fullLeafcutterURL}
width="100%"
height="100%"
frameBorder={0}
/>
</Grid>
</Grid>
);
};

View file

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

View file

@ -0,0 +1,30 @@
"use client";
import { FC, /* useEffect,*/ useState } from "react";
import { Home as HomeInternal } from "leafcutter-common";
// import { fetchLeafcutter } from "@/app/_lib/utils";
import ClientOnly from "@/app/(main)/_components/ClientOnly";
export const Home: FC = () => {
const [visualizations, setVisualizations] = useState([]);
/*
useEffect(() => {
const getVisualizations = async () => {
const visualizations = await fetchLeafcutter(
"/api/visualizations/list",
{},
);
if (visualizations) {
setVisualizations(visualizations);
}
};
getVisualizations();
}, []);
*/
return (
<ClientOnly>
<HomeInternal visualizations={visualizations} />
</ClientOnly>
);
};

View file

@ -0,0 +1,6 @@
import { FC, PropsWithChildren } from "react";
import { Box } from "@mui/material";
export const LeafcutterWrapper: FC<PropsWithChildren> = ({ children }) => {
return <Box sx={{ p: 3 }}>{children}</Box>;
};

View file

@ -0,0 +1,10 @@
import { Box } from "@mui/material";
import { About } from "leafcutter-common";
export default function Page() {
return (
<Box sx={{ p: 3 }}>
<About />
</Box>
);
}

View file

@ -0,0 +1,13 @@
// import { getTemplates } from "app/_lib/opensearch";
import { Create } from "leafcutter-common";
import { Box } from "@mui/material";
export default async function Page() {
const templates = []; // await getTemplates(100);
return (
<Box sx={{ p: 3 }}>
<Create templates={templates} />
</Box>
);
}

View file

@ -0,0 +1,10 @@
import { Box } from "@mui/material";
import { FAQ } from "leafcutter-common";
export default function Page() {
return (
<Box sx={{ p: 3 }}>
<FAQ />
</Box>
);
}

View file

@ -0,0 +1,10 @@
import { Box } from "@mui/material";
import { FAQ } from "leafcutter-common";
export default function Page() {
return (
<Box sx={{ p: 3 }}>
<FAQ />
</Box>
);
}

View file

@ -1,5 +1,10 @@
import { redirect } from "next/navigation";
import { Home } from "./_components/Home";
import { LeafcutterWrapper } from "./_components/LeafcutterWrapper";
export default function Page() {
redirect("/leafcutter/home");
export default async function Page() {
return (
<LeafcutterWrapper>
<Home />
</LeafcutterWrapper>
);
}

View file

@ -0,0 +1,12 @@
import { Box } from "@mui/material";
import { Trends } from "leafcutter-common";
export default function Page() {
return (
<Box sx={{ p: 3 }}>
<Trends visualizations={[]} />
</Box>
);
}
export const dynamic = "force-dynamic";

View file

@ -0,0 +1,9 @@
"use client";
import { signOut } from "next-auth/react";
export default function Page() {
signOut({ callbackUrl: "/login" });
return <div />;
}

View file

@ -0,0 +1,154 @@
"use client";
import { FC, useState } from "react";
import {
Grid,
Button,
Dialog,
DialogActions,
DialogContent,
TextField,
Autocomplete,
} from "@mui/material";
import useSWR, { useSWRConfig } from "swr";
import { createTicketMutation } from "app/_graphql/createTicketMutation";
interface TicketCreateDialogProps {
open: boolean;
closeDialog: () => void;
}
export const TicketCreateDialog: FC<TicketCreateDialogProps> = ({
open,
closeDialog,
}) => {
const [kind, setKind] = useState("note");
const [customerID, setCustomerID] = useState("");
const [groupID, setGroupID] = useState("");
const [ownerID, setOwnerID] = useState("");
const [priorityID, setPriorityID] = useState("");
const [stateID, setStateID] = useState("");
const [tags, setTags] = useState([]);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const backgroundColor = "#1982FC";
const color = "white";
const { fetcher } = useSWRConfig();
const ticket = {
customerId: customerID,
groupId: groupID,
ownerId: ownerID,
priorityId: priorityID,
stateId: stateID,
tags,
title,
article: {
body,
type: kind,
internal: kind === "note",
},
};
const { data: users, error: usersError }: any = useSWR({
url: "/api/v1/users",
method: "GET",
});
console.log({ users, usersError });
const customers =
users?.filter((user: any) => user.role_ids.includes(3)) ?? [];
const formattedCustomers = customers.map((customer: any) => ({
label: customer.login,
id: `${customer.id}`,
}));
const createTicket = async () => {
await fetcher({
document: createTicketMutation,
variables: {
input: {
ticket,
},
},
});
closeDialog();
setBody("");
};
return (
<Dialog open={open} maxWidth="md" fullWidth>
<DialogContent>
<Grid container direction="column" spacing={2}>
<Grid item>
<TextField
label={"Title"}
fullWidth
value={title}
onChange={(e: any) => setTitle(e.target.value)}
/>
</Grid>
<Grid item>
<Autocomplete
disablePortal
options={formattedCustomers}
value={customerID}
sx={{ width: 300 }}
onChange={(e: any) => setCustomerID(e.target.value.id)}
renderInput={(params) => (
<TextField {...params} label="Customer" />
)}
/>
</Grid>
<Grid item>
<TextField
label={"Details"}
multiline
rows={10}
fullWidth
value={body}
onChange={(e: any) => setBody(e.target.value)}
/>
</Grid>
</Grid>
</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={createTicket}
>
Create Ticket
</Button>
</Grid>
</Grid>
</DialogActions>
</Dialog>
);
};

View file

@ -1,12 +1,13 @@
"use client";
import { FC } from "react";
import { FC, useState } from "react";
import { Grid, Box } from "@mui/material";
import { GridColDef } from "@mui/x-data-grid-pro";
import { StyledDataGrid } from "../../../_components/StyledDataGrid";
import { Button } from "../../../../_components/Button";
import { typography } from "../../../../_styles/theme";
import { StyledDataGrid } from "app/(main)/_components/StyledDataGrid";
import { Button } from "app/_components/Button";
import { typography } from "app/_styles/theme";
import { useRouter } from "next/navigation";
import { TicketCreateDialog } from "./TicketCreateDialog";
interface TicketListProps {
title: string;
@ -14,6 +15,7 @@ interface TicketListProps {
}
export const TicketList: FC<TicketListProps> = ({ title, tickets }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const router = useRouter();
let gridColumns: GridColDef[] = [
{
@ -24,12 +26,24 @@ export const TicketList: FC<TicketListProps> = ({ title, tickets }) => {
{
field: "title",
headerName: "Title",
flex: 1,
flex: 5,
},
{
field: "customer",
headerName: "Sender",
valueGetter: (params) => params.row?.customer?.fullname,
flex: 2,
},
{
field: "createdAt",
headerName: "Created At",
valueGetter: (params) => new Date(params.row?.createdAt).toLocaleString(),
flex: 1,
},
{
field: "updatedAt",
headerName: "Updated At",
valueGetter: (params) => new Date(params.row?.updatedAt).toLocaleString(),
flex: 1,
},
{
@ -39,47 +53,58 @@ export const TicketList: FC<TicketListProps> = ({ title, tickets }) => {
flex: 1,
},
];
console.log({ tickets });
const rowClick = ({ row }) => {
router.push(`/tickets/${row.internalId}`);
};
return (
<Box sx={{ height: "100vh", backgroundColor: "#ddd", p: 3 }}>
<Grid container direction="column">
<Grid
item
container
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Grid item>
<Box
sx={{
backgroundColor: "#ddd",
px: "8px",
pb: "16px",
...typography.h4,
fontSize: 24,
}}
>
{title}
</Box>
<>
<Box sx={{ height: "100vh", backgroundColor: "#ddd", p: 3 }}>
<Grid container direction="column">
<Grid
item
container
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Grid item>
<Box
sx={{
backgroundColor: "#ddd",
px: "8px",
pb: "16px",
...typography.h4,
fontSize: 24,
}}
>
{title}
</Box>
</Grid>
<Grid item>
<Button
href={""}
onClick={() => setDialogOpen(true)}
text="Create"
color="#1982FC"
/>
</Grid>
</Grid>
<Grid item>
<Button href="/tickets/create" text="Create" color="#1982FC" />
<StyledDataGrid
name={title}
columns={gridColumns}
rows={tickets}
onRowClick={rowClick}
/>
</Grid>
</Grid>
<Grid item>
<StyledDataGrid
name={title}
columns={gridColumns}
rows={tickets}
onRowClick={rowClick}
/>
</Grid>
</Grid>
</Box>
</Box>
<TicketCreateDialog
open={dialogOpen}
closeDialog={() => setDialogOpen(false)}
/>
</>
);
};

View file

@ -1,32 +1,113 @@
"use client";
import { FC } from "react";
import { FC, useEffect, useState } from "react";
import useSWR from "swr";
import { TicketList } from "./TicketList";
import { getTicketsByOverviewQuery } from "../../../../_graphql/getTicketsByOverviewQuery";
import { getTicketOverviewCountsQuery } from "app/_graphql/getTicketOverviewCountsQuery";
import { getTicketsByOverviewQuery } from "app/_graphql/getTicketsByOverviewQuery";
type ZammadOverviewProps = {
name: string;
id: string;
};
export const ZammadOverview: FC<ZammadOverviewProps> = ({ name, id }) => {
const { data: ticketData, error: ticketError }: any = useSWR(
export const ZammadOverview: FC<ZammadOverviewProps> = ({ name }) => {
const [overviewID, setOverviewID] = useState(null);
const [tickets, setTickets] = useState([]);
const [error, setError] = useState(null);
const { data: overviewData, error: overviewError }: any = useSWR(
{
document: getTicketsByOverviewQuery,
variables: { overviewId: `gid://zammad/Overview/${id}` },
document: getTicketOverviewCountsQuery,
},
{ refreshInterval: 10000 },
);
const shouldRender = !ticketError && ticketData;
const tickets =
ticketData?.ticketsByOverview?.edges.map((edge: any) => edge.node) || [];
return (
<>
{shouldRender && <TicketList title={name} tickets={tickets} />}
{ticketError && <div>{ticketError.toString()}</div>}
</>
const { data: ticketData, error: ticketError }: any = useSWR(
{
document: getTicketsByOverviewQuery,
variables: { overviewId: overviewID, pageSize: 250 },
},
{ refreshInterval: 10000 },
);
const overviewLookup = {
Assigned: "My Assigned Tickets",
Open: "Open Tickets",
Urgent: "Escalated Tickets",
Unassigned: "Unassigned & Open Tickets",
};
const findOverviewByName = (name: string) => {
const fullName = overviewLookup[name];
return overviewData?.ticketOverviews?.edges?.find(
(overview: any) => overview.node.name === fullName,
)?.node?.id;
};
useEffect(() => {
if (overviewData) {
setOverviewID(findOverviewByName(name));
}
}, [overviewData, name]);
console.log({
name,
overviewID,
overviewData,
overviewError,
ticketData,
ticketError,
});
const restFetcher = (url: string) => fetch(url).then((r) => r.json());
const { data: recent } = useSWR("/api/v1/recent_view", restFetcher);
const sortTickets = (tickets: any) => {
return tickets.sort((a: any, b: any) => {
if (a.internalId < b.internalId) {
return 1;
}
if (a.internalId > b.internalId) {
return -1;
}
return 0;
});
};
useEffect(() => {
if (name != "Recent") {
const edges = ticketData?.ticketsByOverview?.edges;
if (edges) {
const nodes = edges.map((edge: any) => edge.node);
console.log({ nodes });
setError(null);
setTickets(sortTickets(nodes));
}
if (ticketError) {
setError(ticketError);
}
}
}, [ticketData, ticketError]);
useEffect(() => {
const fetchRecentTickets = async () => {
if (name === "Recent" && recent) {
let allTickets = [];
for (const rec of recent) {
const res = await fetch(`/api/v1/tickets/${rec.o_id}`);
const tkt = await res.json();
allTickets.push({
...tkt,
internalId: tkt.id,
createdAt: tkt.created_at,
updatedAt: tkt.updated_at,
});
}
setTickets(sortTickets(allTickets));
console.log({ allTickets });
}
};
fetchRecentTickets();
}, [name]);
const shouldRender = tickets && !error;
console.log({ shouldRender, tickets, error });
return shouldRender && <TicketList title={name} tickets={tickets} />;
};

View file

@ -1,6 +1,6 @@
"use client";
import { DisplayError } from "../../../_components/DisplayError";
import { DisplayError } from "app/_components/DisplayError";
type PageProps = {
error: Error;

View file

@ -21,13 +21,6 @@ export async function generateMetadata({
};
}
const overviews = {
assigned: 1,
unassigned: 2,
pending: 3,
urgent: 7,
};
type PageProps = {
params: {
overview: string;
@ -37,5 +30,5 @@ type PageProps = {
export default function Page({ params: { overview } }: PageProps) {
const section = getSection(overview);
return <ZammadOverview name={section} id={overviews[overview]} />;
return <ZammadOverview name={section} />;
}

View file

@ -0,0 +1,11 @@
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

@ -2,7 +2,8 @@
import { FC, useLayoutEffect } from "react";
import { useRouter } from "next/navigation";
import { ZammadWrapper } from "../../../(main)/_components/ZammadWrapper";
import { CircularProgress, Box, Grid } from "@mui/material";
import { ZammadWrapper } from "app/(main)/_components/ZammadWrapper";
export const Setup: FC = () => {
const router = useRouter();
@ -10,5 +11,21 @@ export const Setup: FC = () => {
setTimeout(() => router.push("/"), 4000);
}, [router]);
return <ZammadWrapper path="/auth/sso" hideSidebar={false} />;
return (
<Box sx={{ width: "100%" }}>
<Grid
container
direction="column"
sx={{ height: 500 }}
justifyContent="center"
alignContent="center"
alignItems="center"
>
<Grid item>
<CircularProgress size={80} color="success" />
</Grid>
</Grid>
<ZammadWrapper path="/auth/sso" hideSidebar={false} />
</Box>
);
};

View file

@ -10,13 +10,14 @@ import {
TextField,
} from "@mui/material";
import { useSWRConfig } from "swr";
import { updateTicketMutation } from "../../../../../_graphql/updateTicketMutation";
import { updateTicketMutation } from "app/_graphql/updateTicketMutation";
interface ArticleCreateDialogProps {
ticketID: string;
open: boolean;
closeDialog: () => void;
kind: "reply" | "note";
kind: string;
recipient?: string;
}
export const ArticleCreateDialog: FC<ArticleCreateDialogProps> = ({
@ -24,22 +25,29 @@ export const ArticleCreateDialog: FC<ArticleCreateDialogProps> = ({
open,
closeDialog,
kind,
recipient,
}) => {
const [body, setBody] = useState("");
const backgroundColor = kind === "reply" ? "#1982FC" : "#FFB620";
const color = kind === "reply" ? "white" : "black";
const backgroundColor = kind === "note" ? "#FFB620" : "#1982FC";
const color = kind === "note" ? "black" : "white";
const { fetcher } = useSWRConfig();
const article = {
body,
type: kind,
internal: kind === "note",
};
if (kind === "email") {
article["to"] = recipient;
}
const createArticle = async () => {
await fetcher({
document: updateTicketMutation,
variables: {
ticketId: `gid://zammad/Ticket/${ticketID}`,
input: {
article: {
body,
type: kind === "note" ? "note" : "phone",
internal: kind === "note",
},
article,
},
},
});
@ -51,7 +59,7 @@ export const ArticleCreateDialog: FC<ArticleCreateDialogProps> = ({
<Dialog open={open} maxWidth="sm" fullWidth>
<DialogContent>
<TextField
label={kind === "reply" ? "Write reply" : "Write internal note"}
label={kind === "note" ? "Write internal note" : "Write reply"}
multiline
rows={10}
fullWidth
@ -92,7 +100,7 @@ export const ArticleCreateDialog: FC<ArticleCreateDialogProps> = ({
}}
onClick={createArticle}
>
{kind === "reply" ? "Send Reply" : "Save Note"}
{kind === "note" ? "Save Note" : "Send Reply"}
</Button>
</Grid>
</Grid>

View file

@ -1,9 +1,9 @@
"use client";
import { FC, useState } from "react";
import { FC, useEffect, useState } from "react";
import useSWR from "swr";
import { getTicketQuery } from "../../../../../_graphql/getTicketQuery";
import { getTicketArticlesQuery } from "../../../../../_graphql/getTicketArticlesQuery";
import { getTicketQuery } from "app/_graphql/getTicketQuery";
import { getTicketArticlesQuery } from "app/_graphql/getTicketArticlesQuery";
import {
Grid,
Box,
@ -28,12 +28,14 @@ interface TicketDetailProps {
}
export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [articleKind, setArticleKind] = useState("note");
const { data: ticketData, error: ticketError }: any = useSWR(
{
document: getTicketQuery,
variables: { ticketId: `gid://zammad/Ticket/${id}` },
},
{ refreshInterval: 100000 },
{ refreshInterval: 10000 },
);
const { data: ticketArticlesData, error: ticketArticlesError }: any = useSWR(
{
@ -43,12 +45,21 @@ export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
{ refreshInterval: 2000 },
);
const { data: recentViewData, error: recentViewError }: any = useSWR({
url: "/api/v1/recent_view",
method: "POST",
body: JSON.stringify({ object: "Ticket", o_id: id }),
});
const closeDialog = () => setDialogOpen(false);
console.log({ recentViewData, recentViewError });
const ticket = ticketData?.ticket;
const ticketArticles = ticketArticlesData?.ticketArticles;
const [dialogOpen, setDialogOpen] = useState(false);
const [articleKind, setArticleKind] = useState<"reply" | "note">("reply");
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 =
ticketData && !ticketError && ticketArticlesData && !ticketArticlesError;
@ -89,11 +100,11 @@ export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
article.internal
? "internal-note"
: article?.sender?.name === "Agent"
? "outgoing-message"
: "incoming-message"
? "outgoing-message"
: "incoming-message"
}
model={{
message: article.body.replace(/<div>*<br>*<div>/g, ""),
message: article.bodyWithUrls,
sentTime: article.updated_at,
sender: article.from,
direction:
@ -139,7 +150,7 @@ export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
mt: 2,
}}
onClick={() => {
setArticleKind("reply");
setArticleKind(firstArticleKind);
setDialogOpen(true);
}}
>
@ -179,6 +190,7 @@ export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
open={dialogOpen}
closeDialog={closeDialog}
kind={articleKind}
recipient={recipient}
/>
</Box>
)

View file

@ -12,9 +12,11 @@ import {
} from "@mui/material";
import { MuiChipsInput } from "mui-chips-input";
import useSWR, { useSWRConfig } from "swr";
import { getTicketQuery } from "../../../../../_graphql/getTicketQuery";
import { updateTicketMutation } from "../../../../../_graphql/updateTicketMutation";
import { getTicketQuery } from "app/_graphql/getTicketQuery";
import { updateTicketMutation } from "app/_graphql/updateTicketMutation";
import { updateTagsMutation } from "app/_graphql/updateTagsMutation";
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
interface TicketEditProps {
id: string;
@ -25,6 +27,8 @@ export const TicketEdit: FC<TicketEditProps> = ({ id }) => {
const [selectedOwner, setSelectedOwner] = useState("");
const [selectedPriority, setSelectedPriority] = useState("");
const [selectedState, setSelectedState] = useState("");
const [pendingDate, setPendingDate] = useState(new Date());
const [pendingVisible, setPendingVisible] = useState(false);
const [selectedTags, setSelectedTags] = useState([]);
const handleDelete = () => {
console.info("You clicked the delete icon.");
@ -35,15 +39,20 @@ export const TicketEdit: FC<TicketEditProps> = ({ id }) => {
const { data: users } = useSWR("/api/v1/users", restFetcher);
const { data: states } = useSWR("/api/v1/ticket_states", restFetcher);
const { data: priorities } = useSWR("/api/v1/ticket_priorities", restFetcher);
const { data: tags } = useSWR("/api/v1/tags", restFetcher);
const { data: recent } = useSWR("/api/v1/recent_view", restFetcher);
// const { data: tags } = useSWR("/api/v1/tags", restFetcher);
const filteredStates = states?.filter(
(state: any) => !["new", "merged", "removed"].includes(state.name),
);
const agents = users?.filter((user: any) => user.role_ids.includes(2)) ?? [];
const { fetcher } = useSWRConfig();
const { data: ticketData, error: ticketError }: any = useSWR(
{
document: getTicketQuery,
variables: { ticketId: `gid://zammad/Ticket/${id}` },
},
{ refreshInterval: 100000 },
{ refreshInterval: 10000 },
);
useEffect(() => {
const ticket = ticketData?.ticket;
@ -59,14 +68,15 @@ export const TicketEdit: FC<TicketEditProps> = ({ id }) => {
setSelectedTags(ticket.tags);
}
}, [ticketData, ticketError]);
const updateTicket = async () => {
const input = {
ownerId: `gid://zammad/User/${selectedOwner}`,
priorityId: `gid://zammad/Ticket::Priority/${selectedPriority}`,
stateId: `gid://zammad/Ticket::State/${selectedState}`,
groupId: `gid://zammad/Group/${selectedGroup}`,
// tags: selectedTags,
};
useEffect(() => {
const stateName = filteredStates?.find(
(state: any) => state.id === selectedState,
)?.name;
setPendingVisible(stateName?.includes("pending") ?? false);
}, [selectedState]);
const updateTicket = async (input: any) => {
console.log({ input });
const res = await fetcher({
document: updateTicketMutation,
variables: {
@ -76,6 +86,17 @@ export const TicketEdit: FC<TicketEditProps> = ({ id }) => {
});
console.log({ res });
};
const updateTags = async (tags: string[]) => {
console.log({ tags });
const res = await fetcher({
document: updateTagsMutation,
variables: {
objectId: `gid://zammad/Ticket/${id}`,
tags,
},
});
console.log({ res });
};
const shouldRender = ticketData && !ticketError;
return (
@ -88,8 +109,11 @@ export const TicketEdit: FC<TicketEditProps> = ({ id }) => {
defaultValue={selectedGroup}
value={selectedGroup}
onChange={(e: any) => {
setSelectedGroup(e.target.value);
updateTicket();
const newGroup = e.target.value;
setSelectedGroup(newGroup);
updateTicket({
groupId: `gid://zammad/Group/${newGroup}`,
});
}}
size="small"
sx={{
@ -109,8 +133,9 @@ export const TicketEdit: FC<TicketEditProps> = ({ id }) => {
<Select
value={selectedOwner}
onChange={(e: any) => {
setSelectedOwner(e.target.value);
updateTicket();
const newOwner = e.target.value;
setSelectedOwner(newOwner);
updateTicket({ ownerId: `gid://zammad/User/${newOwner}` });
}}
size="small"
sx={{
@ -118,20 +143,24 @@ export const TicketEdit: FC<TicketEditProps> = ({ id }) => {
backgroundColor: "white",
}}
>
{users?.map((user: any) => (
<MenuItem key={user.id} value={user.id}>
{agents?.map((user: any) => (
<MenuItem key={user.id} value={`${user.id}`}>
{user.firstname} {user.lastname}
</MenuItem>
))}
</Select>
</Grid>
<Grid item>
<Grid item xs={12}>
<Box sx={{ m: 1, mt: 0 }}>State</Box>
<Select
value={selectedState}
onChange={(e: any) => {
setSelectedState(e.target.value);
updateTicket();
const newState = e.target.value;
setSelectedState(newState);
updateTicket({
stateId: `gid://zammad/Ticket::State/${newState}`,
pendingTime: pendingDate.toISOString(),
});
}}
size="small"
sx={{
@ -139,20 +168,45 @@ export const TicketEdit: FC<TicketEditProps> = ({ id }) => {
backgroundColor: "white",
}}
>
{states?.map((state: any) => (
{filteredStates?.map((state: any) => (
<MenuItem key={state.id} value={state.id}>
{state.name}
</MenuItem>
))}
</Select>
</Grid>
<Grid
item
xs={12}
sx={{ display: pendingVisible ? "inherit" : "none" }}
>
<DatePicker
label="Pending Date"
value={pendingDate}
onChange={(newValue: any) => {
console.log(newValue);
setPendingDate(newValue);
updateTicket({
pendingTime: newValue.toISOString(),
});
}}
slotProps={{ textField: { size: "small" } }}
sx={{
width: "100%",
backgroundColor: "white",
}}
/>
</Grid>
<Grid item>
<Box sx={{ m: 1, mt: 0 }}>Priority</Box>
<Select
value={selectedPriority}
onChange={(e: any) => {
setSelectedPriority(e.target.value);
updateTicket();
const newPriority = e.target.value;
setSelectedPriority(newPriority);
updateTicket({
priorityId: `gid://zammad/Ticket::Priority/${newPriority}`,
});
}}
size="small"
sx={{
@ -175,7 +229,7 @@ export const TicketEdit: FC<TicketEditProps> = ({ id }) => {
value={selectedTags}
onChange={(tags: any) => {
setSelectedTags(tags);
updateTicket();
updateTags(tags);
}}
/>
</Grid>

View file

@ -1,6 +1,6 @@
"use client";
import { DisplayError } from "../../../_components/DisplayError";
import { DisplayError } from "app/_components/DisplayError";
type PageProps = {
error: Error;

View file

@ -8,13 +8,15 @@ interface ButtonProps {
text: string;
color: string;
href: string;
onClick: any;
}
export const Button: FC<ButtonProps> = ({ text, color, href }) => (
export const Button: FC<ButtonProps> = ({ text, color, href, onClick }) => (
<Link href={href} passHref>
<MUIButton
variant="contained"
disableElevation
onClick={onClick}
sx={{
fontFamily: "Poppins, sans-serif",
fontWeight: 700,

View file

@ -1,18 +1,21 @@
"use client";
import { FC, PropsWithChildren, useState } from "react";
import { usePathname } from "next/navigation";
import { CssBaseline } from "@mui/material";
import { CookiesProvider } from "react-cookie";
import { SessionProvider } from "next-auth/react";
import { NextAppDirEmotionCacheProvider } from "tss-react/next/appDir";
import { SWRConfig } from "swr";
import { GraphQLClient } from "graphql-request";
import { AdapterDateFns } from "@mui/x-date-pickers-pro/AdapterDateFns";
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-date-pickers-pro";
import { locales } from "leafcutter-common";
LicenseInfo.setLicenseKey(
"7c9bf25d9e240f76e77cbf7d2ba58a23Tz02NjU4OCxFPTE3MTU4NjIzMzQ2ODgsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI="
"7c9bf25d9e240f76e77cbf7d2ba58a23Tz02NjU4OCxFPTE3MTU4NjIzMzQ2ODgsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
);
export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
@ -21,25 +24,83 @@ export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
typeof window !== "undefined" && window.location.origin
? window.location.origin
: null;
const client = new GraphQLClient(`${origin}/proxy/zammad/graphql`, {
headers: {
const client = new GraphQLClient(`${origin}/zammad/graphql`);
const messages: any = { en: locales.en, fr: locales.fr };
const locale = "en";
const fetchAndCheckAuth = async ({
document,
variables,
url,
method,
body,
}: any) => {
const requestHeaders = {
"Content-Type": "application/json",
Accept: "application/json",
},
});
const graphQLFetcher = async ({ document, variables }: any) => {
const requestHeaders = {
"X-CSRF-Token": csrfToken,
};
const { data, headers } = await client.rawRequest(
document,
variables,
requestHeaders
);
let responseData = null;
let responseHeaders = new Headers();
let responseStatus = null;
const token = headers.get("CSRF-Token");
if (document) {
const { data, headers, status } = await client.rawRequest(
document,
variables,
requestHeaders,
);
responseData = data;
responseHeaders = headers;
responseStatus = status;
} else {
const res = await fetch(url, {
method,
headers: requestHeaders,
body,
});
responseData = await res.json();
responseHeaders = res.headers;
responseStatus = res.status;
}
if (responseStatus !== 200) {
const res = await fetch("/zammad/auth/sso", {
method: "GET",
redirect: "manual",
});
console.log({ checkAuth: res });
return null;
}
const token = responseHeaders.get("CSRF-Token");
setCsrfToken(token);
return responseData;
};
const multiFetcher = async ({
document,
variables,
url,
method,
body,
}: any) => {
let checks = 0;
let data = null;
while (!data && checks < 2) {
data = await fetchAndCheckAuth({
document,
variables,
url,
method,
body,
});
checks++;
}
return data;
};
@ -47,11 +108,13 @@ export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
<>
<CssBaseline />
<NextAppDirEmotionCacheProvider options={{ key: "css" }}>
<SWRConfig value={{ fetcher: graphQLFetcher }}>
<SWRConfig value={{ fetcher: multiFetcher }}>
<SessionProvider>
<CookiesProvider>
<LocalizationProvider dateAdapter={AdapterDateFns}>
{children}
<I18n locale={locale} messages={messages[locale]}>
{children}
</I18n>
</LocalizationProvider>
</CookiesProvider>
</SessionProvider>

View file

@ -0,0 +1,13 @@
import { gql } from "graphql-request";
export const createTicketMutation = gql`
mutation CreateTicket($ticketId: ID!, $input: TicketCreateInput!) {
ticketCreate(input: $input) {
ticket {
id
priority {
id
}
}
}
}`;

View file

@ -6,7 +6,7 @@ query getTicketArticles($ticketId: ID!) {
edges {
node {
id
body
bodyWithUrls
internal
type {
name
@ -14,6 +14,11 @@ query getTicketArticles($ticketId: ID!) {
sender {
name
}
from {
parsed {
emailAddress
}
}
}
}
}

View file

@ -0,0 +1,20 @@
import { gql } from 'graphql-request';
export const searchQuery = gql`
query search($search: String!, $limit: Int = 10, $onlyIn: EnumSearchableModels = Ticket) {
search(search: $search, limit: $limit, onlyIn: $onlyIn) {
... on Ticket {
id
number
internalId
title
state {
id
name
}
stateColorCode
note
}
}
}
`;

View file

@ -0,0 +1,12 @@
import { gql } from "graphql-request";
export const updateTagsMutation = gql`
mutation UpdateTags($objectId: ID!, $tags: [String!]!) {
tagAssignmentUpdate(objectId: $objectId, tags: $tags) {
success
errors {
message
field
}
}
}`;

View file

@ -5,6 +5,9 @@ mutation UpdateTicket($ticketId: ID!, $input: TicketUpdateInput!) {
ticketUpdate(ticketId: $ticketId, input: $input) {
ticket {
id
priority {
id
}
}
}
}`;

View file

@ -0,0 +1,41 @@
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

@ -0,0 +1,128 @@
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import Apple from "next-auth/providers/apple";
const headers = { Authorization: `Token ${process.env.ZAMMAD_API_TOKEN}` };
const fetchRoles = async () => {
const url = `${process.env.ZAMMAD_URL}/api/v1/roles`;
const res = await fetch(url, { headers });
const roles = await res.json();
console.log({ roles });
const formattedRoles = roles.reduce((acc: any, role: any) => {
acc[role.id] = role.name;
return acc;
}, {});
return formattedRoles;
};
const fetchUser = async (email: string) => {
console.log({ email });
const url = `${process.env.ZAMMAD_URL}/api/v1/users/search?query=login:${email}&limit=1`;
console.log({ url });
const res = await fetch(url, { headers });
console.log({ res });
const users = await res.json();
console.log({ users });
const user = users?.[0];
return user;
};
const getUserRoles = async (email: string) => {
try {
const user = await fetchUser(email);
console.log({ user });
const allRoles = await fetchRoles();
console.log({ allRoles });
const roles = user.role_ids.map((roleID: number) => {
const role = allRoles[roleID];
return role ? role.toLowerCase().replace(" ", "_") : null;
});
return roles.filter((role: string) => role !== null);
} catch (e) {
console.log({ e });
return [];
}
};
const login = async (email: string, password: string) => {
const url = `${process.env.ZAMMAD_URL}/api/v1/users/me`;
console.log({ url });
const authorization =
"Basic " + Buffer.from(email + ":" + password).toString("base64");
const res = await fetch(url, {
headers: {
authorization,
},
});
const user = await res.json();
console.log({ user });
if (user && !user.error && user.id) {
return user;
} else {
return null;
}
};
const handler = NextAuth({
pages: {
signIn: "/login",
error: "/login",
signOut: "/logout",
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
Apple({
clientId: process.env.APPLE_CLIENT_ID,
clientSecret: process.env.APPLE_CLIENT_SECRET,
}),
Credentials({
name: "Zammad",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
const user = await login(credentials.email, credentials.password);
if (user) {
return user;
} else {
return null;
}
},
}),
],
secret: process.env.NEXTAUTH_SECRET,
callbacks: {
signIn: async ({ user, account, profile }) => {
const roles = (await getUserRoles(user.email)) ?? [];
return (
roles.includes("admin") ||
roles.includes("agent") ||
process.env.SETUP_MODE === "true"
);
},
session: async ({ session, user, token }) => {
// @ts-ignore
session.user.roles = token.roles ?? [];
// @ts-ignore
session.user.leafcutter = token.leafcutter; // remove
return session;
},
jwt: async ({ token, user, account, profile, trigger }) => {
if (user) {
token.roles = (await getUserRoles(user.email)) ?? [];
}
return token;
},
},
});
export { handler as GET, handler as POST };

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
const handler = (req: NextRequest) => {
NextResponse.redirect('/proxy/zammad/api/v1' + req.url.substring('/api/v1'.length));
NextResponse.redirect('/zammad/api/v1' + req.url.substring('/api/v1'.length));
};
export { handler as GET, handler as POST };

View file

@ -2,69 +2,48 @@ import { NextResponse } from 'next/server';
import { withAuth, NextRequestWithAuth } from "next-auth/middleware";
const rewriteURL = (request: NextRequestWithAuth, originBaseURL: string, destinationBaseURL: string, headers: any = {}) => {
if (request.nextUrl.protocol.startsWith('ws')) {
return NextResponse.next();
}
if (request.nextUrl.pathname.includes('/_next/static/development/')) {
return NextResponse.next();
if (request.nextUrl.pathname.startsWith('/api/v1/reports/sets')) {
console.log(request.nextUrl.searchParams.get("sheet"));
NextResponse.next();
}
const destinationURL = request.url.replace(originBaseURL, destinationBaseURL);
console.log(`Rewriting ${request.url} to ${destinationURL}`);
const requestHeaders = new Headers(request.headers);
for (const [key, value] of Object.entries(headers)) {
// @ts-ignore
requestHeaders.set(key, value);
requestHeaders.set(key, value as string);
}
requestHeaders.delete('connection');
// console.log({ finalHeaders: requestHeaders });
return NextResponse.rewrite(new URL(destinationURL), { request: { headers: requestHeaders } });
};
const checkRewrites = async (request: NextRequestWithAuth) => {
console.log({ currentURL: request.nextUrl.href });
const linkBaseURL = process.env.LINK_URL ?? "http://localhost:3000";
const zammadURL = process.env.ZAMMAD_URL ?? "http://zammad-nginx:8080";
const leafcutterURL = process.env.LEAFCUTTER_URL ?? "https://lc.digiresilience.org";
const metamigoURL = process.env.METAMIGO_URL ?? "http://metamigo-frontend:3000";
const metamigoURL = process.env.METAMIGO_URL ?? "http://metamigo-api:3000";
const labelStudioURL = process.env.LABEL_STUDIO_URL ?? "http://label-studio:8080";
const { token } = request.nextauth;
const headers = { 'X-Forwarded-User': token?.email?.toLowerCase() };
console.log({ linkBaseURL, zammadURL, leafcutterURL, metamigoURL });
if (request.nextUrl.pathname.startsWith('/proxy/leafcutter')) {
const headers = { 'X-Leafcutter-Embedded': "true" };
return rewriteURL(request, linkBaseURL, leafcutterURL, headers);
} else if (request.nextUrl.pathname.startsWith('/proxy/metamigo')) {
return rewriteURL(request, linkBaseURL, metamigoURL);
} else if (request.nextUrl.pathname.startsWith('/proxy/zammad')) {
console.log('proxying to zammad');
const { token } = request.nextauth;
// console.log({ nextauth: request.nextauth });
const headers = {
'X-Forwarded-User': token.email.toLowerCase(),
host: 'link-stack-dev.digiresilience.org'
};
// console.log({ headers });
return rewriteURL(request, `${linkBaseURL}/proxy/zammad`, zammadURL, headers);
} else if (request.nextUrl.pathname.startsWith('/assets') || request.nextUrl.pathname.startsWith('/api/v1')) {
console.log('asset');
return rewriteURL(request, linkBaseURL, zammadURL);
} else if (request.nextUrl.pathname.startsWith('/proxy/assets')) {
console.log('proxy asset');
return rewriteURL(request, `${linkBaseURL}/proxy`, zammadURL);
} else if (request.nextUrl.pathname.startsWith('/proxy/api')) {
console.log('proxy api');
if (request.nextUrl.pathname.startsWith('/metamigo')) {
return rewriteURL(request, `${linkBaseURL}/metamigo`, metamigoURL);
} else if (request.nextUrl.pathname.startsWith('/label-studio')) {
return rewriteURL(request, `${linkBaseURL}/label-studio`, labelStudioURL);
} else if (request.nextUrl.pathname.startsWith('/zammad')) {
return rewriteURL(request, `${linkBaseURL}/zammad`, zammadURL, headers);
} else if (request.nextUrl.pathname.startsWith('/auth/sso') || request.nextUrl.pathname.startsWith('/assets')) {
return rewriteURL(request, linkBaseURL, zammadURL, headers);
} else if (request.nextUrl.pathname.startsWith('/proxy/api') || request.nextUrl.pathname.startsWith('/proxy/assets')) {
return rewriteURL(request, `${linkBaseURL}/proxy`, zammadURL);
} else if (request.nextUrl.pathname.startsWith('/api/v1') || request.nextUrl.pathname.startsWith('/auth/sso') || request.nextUrl.pathname.startsWith('/mobile')) {
return rewriteURL(request, linkBaseURL, zammadURL, headers);
}
return NextResponse.next();
};
export default withAuth(
@ -77,20 +56,19 @@ export default withAuth(
authorized: ({ token, req }) => {
const {
url,
headers,
} = req;
// check login page
const noAuthPaths = ["/login", "/api/v1"];
const parsedURL = new URL(url);
if (parsedURL.pathname.startsWith('/login')) {
const path = parsedURL.pathname;
if (noAuthPaths.some((p: string) => path.startsWith(p))) {
console.log({ p: parsedURL.pathname, auth: "no" });
return true;
}
// check session auth
const authorizedDomains = ["redaranj.com", "digiresilience.org"];
const userDomain = token?.email?.toLowerCase().split("@").pop() ?? "unauthorized.net";
if (authorizedDomains.includes(userDomain)) {
const roles: any = token?.roles ?? [];
if (roles.includes("admin") || roles.includes("agent") || process.env.SETUP_MODE === "true") {
return true;
}
@ -99,3 +77,10 @@ export default withAuth(
}
}
);
export const config = {
matcher: [
'/((?!ws|wss|_next/static|_next/image|favicon.ico).*)',
],
};

View file

@ -1,6 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
missingSuspenseWithCSRBailout: false,
},
modularizeImports: {
"@mui/material": {
transform: "@mui/material/{{member}}",
@ -9,10 +12,13 @@ const nextConfig = {
transform: "@mui/icons-material/{{member}}",
},
},
transpilePackages: ["leafcutter-common"],
publicRuntimeConfig: {
linkURL: process.env.LINK_URL ?? "http://localhost:3000",
leafcutterURL: process.env.LEAFCUTTER_URL ?? "http://localhost:3001",
metamigoURL: process.env.METAMIGO_URL ?? "http://localhost:3002",
leafcutterURL:
process.env.LEAFCUTTER_URL ?? "https://lc.digiresilience.org",
metamigoURL: process.env.METAMIGO_URL ?? "http://localhost:8002",
labelStudioURL: process.env.LABEL_STUDIO_URL ?? "http://localhost:8006",
muiLicenseKey: process.env.MUI_LICENSE_KEY ?? "",
},
async rewrites() {
@ -20,7 +26,7 @@ const nextConfig = {
fallback: [
{
source: "/:path*",
destination: `/proxy/zammad/:path*`,
destination: `/proxy/leafcutter/:path*`,
},
],
};

View file

@ -9,49 +9,61 @@
"lint": "next lint"
},
"dependencies": {
"@chatscope/chat-ui-kit-react": "^1.10.1",
"@chatscope/chat-ui-kit-react": "^2.0.3",
"@chatscope/chat-ui-kit-styles": "^1.4.0",
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.1",
"@emotion/react": "^11.11.4",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@fontsource/playfair-display": "^5.0.5",
"@fontsource/poppins": "^5.0.5",
"@fontsource/roboto": "^5.0.5",
"@fontsource/playfair-display": "^5.0.21",
"@fontsource/poppins": "^5.0.12",
"@fontsource/roboto": "^5.0.12",
"@mui/icons-material": "^5",
"@mui/lab": "^5.0.0-alpha.136",
"@mui/lab": "^5.0.0-alpha.167",
"@mui/material": "^5",
"@mui/x-data-grid-pro": "^6.10.0",
"@mui/x-date-pickers-pro": "^6.10.0",
"date-fns": "^2.30.0",
"@mui/x-data-grid-pro": "^6.19.6",
"@mui/x-date-pickers-pro": "^6.19.6",
"cryptr": "^6.3.0",
"date-fns": "^3.3.1",
"graphql-request": "^6.1.0",
"material-ui-popup-state": "^5.0.9",
"mui-chips-input": "^2.0.2",
"next": "13.4.10",
"next-auth": "^4.22.1",
"leafcutter-common": "*",
"material-ui-popup-state": "^5.0.10",
"mui-chips-input": "^2.1.4",
"next": "14.1.2",
"next-auth": "^4.24.6",
"ra-data-graphql": "^4.16.12",
"ra-i18n-polyglot": "^4.16.12",
"ra-input-rich-text": "^4.16.12",
"ra-language-english": "^4.16.12",
"ra-postgraphile": "^6.1.2",
"react": "18.2.0",
"react-cookie": "^4.1.1",
"react-admin": "^4.16.12",
"react-cookie": "^7.1.0",
"react-digit-input": "^2.1.0",
"react-dom": "18.2.0",
"react-iframe": "^1.8.5",
"react-polyglot": "^0.7.2",
"sharp": "^0.32.3",
"swr": "^2.2.0",
"tss-react": "^4.8.8"
"react-qr-code": "^2.0.12",
"react-timer-hook": "^3.0.7",
"sharp": "^0.33.2",
"swr": "^2.2.5",
"tss-react": "^4.9.4",
"twilio-client": "^1.15.1"
},
"devDependencies": {
"@babel/core": "^7.22.9",
"@types/node": "^20.4.2",
"@types/react": "18.2.15",
"@types/uuid": "^9.0.2",
"@babel/core": "^7.24.0",
"@types/node": "^20.11.24",
"@types/react": "18.2.63",
"@types/uuid": "^9.0.8",
"babel-loader": "^9.1.3",
"eslint": "^8.45.0",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-next": "^13.4.10",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-react": "^7.32.2",
"typescript": "5.1.6"
"eslint-config-next": "^14.1.2",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.34.0",
"typescript": "5.3.3"
}
}

View file

@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
@ -19,10 +15,7 @@
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": [
"./*",
"../../node_modules/*"
]
"@/*": ["./*", "../../node_modules/*"]
},
"baseUrl": ".",
"plugins": [
@ -35,9 +28,8 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
"../leafcutter/app/(login)/login/link/_components/AutoLogin.tsx"
],
"exclude": [
"node_modules"
]
"exclude": ["node_modules"]
}

View file

@ -17,49 +17,49 @@
"@graphile-contrib/pg-simplify-inflector": "^6.1.0",
"@hapi/basic": "^7.0.2",
"@hapi/boom": "^10.0.1",
"@hapi/vision": "^7.0.2",
"@hapi/vision": "^7.0.3",
"@hapi/wreck": "^18.0.1",
"@hapipal/schmervice": "^3.0.0",
"@hapipal/toys": "^4.0.0",
"blipp": "^4.0.2",
"camelcase-keys": "^8.0.2",
"camelcase-keys": "^9.1.3",
"expiry-map": "^2.0.0",
"fluent-ffmpeg": "^2.1.2",
"graphile-migrate": "^1.4.1",
"graphile-worker": "^0.13.0",
"hapi-auth-bearer-token": "^8.0.0",
"hapi-auth-jwt2": "^10.4.0",
"hapi-swagger": "^17.1.0",
"joi": "^17.9.2",
"jsonwebtoken": "^9.0.1",
"jwks-rsa": "^3.0.1",
"hapi-auth-jwt2": "^10.5.1",
"hapi-swagger": "^17.2.1",
"joi": "^17.12.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"long": "^5.2.3",
"p-memoize": "^7.1.1",
"pg": "^8.11.1",
"pg": "^8.11.3",
"pg-monitor": "^2.0.0",
"pg-promise": "^11.5.0",
"pg-promise": "^11.5.4",
"postgraphile": "4.12.3",
"postgraphile-plugin-connection-filter": "^2.3.0",
"remeda": "^1.24.0",
"twilio": "^4.14.0",
"typeorm": "^0.3.17",
"@whiskeysockets/baileys": "^6.3.1"
"remeda": "^1.46.2",
"twilio": "^4.23.0",
"typeorm": "^0.3.20",
"@whiskeysockets/baileys": "^6.6.0"
},
"devDependencies": {
"@types/long": "^4.0.2",
"@types/node": "*",
"babel-preset-link": "*",
"camelcase-keys": "^8.0.2",
"camelcase-keys": "^9.1.3",
"eslint-config-link": "*",
"jest-config-link": "*",
"nodemon": "^3.0.1",
"nodemon": "^3.1.0",
"pg-monitor": "^2.0.0",
"pino-pretty": "^10.0.1",
"ts-node": "^10.9.1",
"pino-pretty": "^10.3.1",
"ts-node": "^10.9.2",
"tsc-watch": "^6.0.4",
"tsconfig-link": "*",
"typedoc": "^0.24.8",
"typescript": "^5.1.6"
"typedoc": "^0.25.11",
"typescript": "^5.3.3"
},
"nodemonConfig": {
"ignore": [

View file

@ -9,14 +9,14 @@ const AppPlugin = {
name: "App",
async register(
server: Hapi.Server,
options: { config: IAppConfig }
options: { config: IAppConfig },
): Promise<void> {
// declare our **run-time** plugin dependencies
// these are runtime only deps, not registration time
// ref: https://hapipal.com/best-practices/handling-plugin-dependencies
server.dependency(["config", "hapi-pino"]);
server.validator(Joi);
server.validator(Joi as any);
await Plugins.register(server, options.config);
await Services.register(server);
await Routes.register(server);

View file

@ -3,7 +3,7 @@ import { IAppConfig } from "@digiresilience/metamigo-config";
import { postgraphile, HttpRequestHandler } from "postgraphile";
import { getPostGraphileOptions } from "@digiresilience/metamigo-db";
export interface HapiPostgraphileOptions {}
export interface HapiPostgraphileOptions { }
const PostgraphilePlugin: Hapi.Plugin<HapiPostgraphileOptions> = {
name: "postgraphilePlugin",
@ -29,7 +29,7 @@ const PostgraphilePlugin: Hapi.Plugin<HapiPostgraphileOptions> = {
};
}
},
}
} as any
);
server.route({

View file

@ -34,9 +34,9 @@ export const register = async (
},
]);
await registerNextAuth(server, config);
// await registerNextAuth(server, config);
await registerSwagger(server);
await registerCloudflareAccessJwt(server, config);
await registerAuthBearer(server, config);
//await registerCloudflareAccessJwt(server, config);
// await registerAuthBearer(server, config);
await registerPostgraphile(server, config);
};

View file

@ -91,7 +91,7 @@ export const TwilioRoutes = Helpers.noAuth([
},
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { voiceLineId } = request.params;
const { To } = request.payload as { To: string };
const { To } = request.payload as { To: string; };
const voiceLine = await request.db().voiceLines.findBy({ number: To });
if (!voiceLine) return Boom.notFound();
if (voiceLine.id !== voiceLineId) return Boom.badRequest();
@ -193,7 +193,7 @@ export const TwilioRoutes = Helpers.noAuth([
},
},
async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { providerId } = request.params as { providerId: string };
const { providerId } = request.params as { providerId: string; };
const provider: SavedVoiceProvider = await request
.db()
.voiceProviders.findById({ id: providerId });

View file

@ -15,7 +15,7 @@
"@digiresilience/metamigo-db": "*",
"@digiresilience/metamigo-api": "*",
"@digiresilience/metamigo-worker": "*",
"commander": "^11.0.0",
"commander": "^12.0.0",
"graphile-migrate": "^1.4.1",
"graphile-worker": "^0.13.0",
"node-jose": "^2.2.0",
@ -23,14 +23,14 @@
"graphql": "15.8.0"
},
"devDependencies": {
"@types/jest": "^29.5.3",
"pino-pretty": "^10.0.1",
"nodemon": "^3.0.1",
"@types/jest": "^29.5.12",
"pino-pretty": "^10.3.1",
"nodemon": "^3.1.0",
"tsconfig-link": "*",
"eslint-config-link": "*",
"jest-config-link": "*",
"babel-preset-link": "*",
"typescript": "^5.1.6"
"typescript": "^5.3.3"
},
"scripts": {
"migrate": "NODE_ENV=development node --unhandled-rejections=strict build/main/index.js db -- migrate",
@ -40,4 +40,4 @@
"lint": "eslint src --ext .ts && prettier \"src/**/*.ts\" --list-different",
"test": "echo no tests"
}
}
}

View file

@ -2,6 +2,7 @@ import {
generateConfig,
printConfigOptions,
} from "@digiresilience/metamigo-common";
import { IAppConfig, IAppConvict } from "@digiresilience/metamigo-config";
import { loadConfigRaw } from "@digiresilience/metamigo-config";
export const genConf = async (): Promise<void> => {

View file

@ -1,13 +1,24 @@
#!/usr/bin/env node
import { Command } from "commander";
import { startWithout } from "@digiresilience/montar";
import { migrateWrapper } from "@digiresilience/metamigo-db";
import { loadConfig } from "@digiresilience/metamigo-config";
import { genConf, listConfig } from "./config.js";
import { createTokenForTesting, generateJwks } from "./jwks.js";
import { exportGraphqlSchema } from "./metamigo-postgraphile.js";
import "@digiresilience/metamigo-api";
import "@digiresilience/metamigo-worker";
const program = new Command();
export async function runServer(): Promise<void> {
await startWithout(["worker"]);
}
export async function runWorker(): Promise<void> {
await startWithout(["server"]);
}
program
.command("config-generate")
@ -19,6 +30,16 @@ program
.description("Prints the entire convict config ")
.action(listConfig);
program
.command("api")
.description("Run the application api server")
.action(runServer);
program
.command("worker")
.description("Run the worker to process jobs")
.action(runWorker);
program
.command("db <commands...>")
.description("Run graphile-migrate commands with your app's config loaded.")
@ -27,6 +48,16 @@ program
return migrateWrapper(args, config);
});
program
.command("gen-jwks")
.description("Generate the JWKS")
.action(generateJwks);
program
.command("gen-testing-jwt")
.description("Generate a JWT for the test suite")
.action(createTokenForTesting);
program
.command("export-graphql-schema")
.description("Export the graphql schema")

View file

@ -0,0 +1,67 @@
import jose from "node-jose";
import * as jwt from "jsonwebtoken";
const generateKeystore = async () => {
const keystore = jose.JWK.createKeyStore();
await keystore.generate("oct", 256, {
alg: "A256GCM",
use: "enc",
});
await keystore.generate("oct", 256, {
alg: "HS512",
use: "sig",
});
return keystore;
};
const safeString = (input) =>
Buffer.from(JSON.stringify(input)).toString("base64");
const stringify = (v) => JSON.stringify(v, undefined, 2);
const _generateJwks = async () => {
const keystore = await generateKeystore();
const encryption = keystore.all({ use: "enc" })[0].toJSON(true);
const signing = keystore.all({ use: "sig" })[0].toJSON(true);
return {
nextAuth: {
signingKeyB64: safeString(signing),
encryptionKeyB64: safeString(encryption),
},
};
};
export const generateJwks = async (): Promise<void> => {
console.log(stringify(await _generateJwks()));
};
export const createTokenForTesting = async (): Promise<void> => {
const keys = await _generateJwks();
const signingKey = Buffer.from(
JSON.parse(
Buffer.from(keys.nextAuth.signingKeyB64, "base64").toString("utf-8")
).k,
"base64"
);
const token = jwt.sign(
{
iss: "Test Env",
iat: 1606893960,
aud: "metamigo",
sub: "abel@guardianproject.info",
name: "Abel Luck",
email: "abel@guardianproject.info",
userRole: "admin",
},
signingKey,
{ expiresIn: "100y", algorithm: "HS512" }
);
console.log("CONFIG");
console.log(stringify(keys));
console.log();
console.log("TOKEN");
console.log(token);
console.log();
};

View file

@ -1,13 +0,0 @@
.git
.idea
**/node_modules
!/node_modules
**/build
**/dist
**/tmp
**/.env*
**/coverage
**/.next
**/amigo.*.json
**/cypress/videos
**/cypress/screenshots

View file

@ -1,7 +0,0 @@
node_modules
**/dist
/data/schema.graphql
/data/schema.sql
/graphql/index.*
/client/.next
.next

View file

@ -1,11 +0,0 @@
require("eslint-config-link/patch/modern-module-resolution");
module.exports = {
extends: [
"eslint-config-link/profile/node",
"eslint-config-link/profile/typescript",
"eslint-config-link/profile/jest",
"next",
],
parserOptions: { tsconfigRootDir: __dirname },
};

View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

Some files were not shown because too many files have changed in this diff Show more