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/ out/
signald-state/* signald-state/*
!./signald-state/.gitkeep !./signald-state/.gitkeep
baileys-state
signald-state
project.org

View file

@ -17,7 +17,7 @@ build-all:
- turbo build - turbo build
.docker-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: services:
- docker:dind - docker:dind
stage: docker-build stage: docker-build
@ -34,7 +34,7 @@ build-all:
- docker push ${DOCKER_NS}:${DOCKER_TAG} - docker push ${DOCKER_NS}:${DOCKER_TAG}
.docker-release: .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: services:
- docker:dind - docker:dind
stage: docker-release stage: docker-release
@ -51,6 +51,17 @@ build-all:
- docker tag ${DOCKER_NS}:${DOCKER_TAG} ${DOCKER_NS}:${DOCKER_TAG_NEW} - docker tag ${DOCKER_NS}:${DOCKER_TAG} ${DOCKER_NS}:${DOCKER_TAG_NEW}
- docker push ${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: link-docker-build:
extends: .docker-build extends: .docker-build
variables: variables:
@ -84,17 +95,6 @@ metamigo-docker-release:
variables: variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/metamigo 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: elasticsearch-docker-build:
extends: .docker-build extends: .docker-build
variables: variables:
@ -200,8 +200,40 @@ zammad-docker-build:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/zammad DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/zammad
DOCKERFILE_PATH: ./docker/zammad/Dockerfile DOCKERFILE_PATH: ./docker/zammad/Dockerfile
DOCKER_CONTEXT: ./docker/zammad 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: zammad-docker-release:
extends: .docker-release extends: .docker-release
variables: variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/zammad 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 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: * Build locally for development:
``` ```
npm install npm install
npm run docker:metamigo:dev:up # start supporting containers for metamigo make dev-metamigo # this starts the containers
npm run build # compile the apps npm run migrate # this migrates the db
npm run migrate # this migrates the db npm run dev:metamigo # this runs metamigo frontend and api
npm run dev:metamigo # this runs metamigo frontend, api, and worker
``` ```
# TODO # TODO
Notes from abel regarding metamigo. these are in priority order (high priority first) - [ ] Delete old JWT config stuff
- [ ] Consolidate config
- [ ] Do not upgrade: postgraphile, graphql and other postgres dependencies until postgrahile supports a newer grapqhl version - [ ] Complete react-admin upgrade.. make all the metamigo-frontend stuff work
* ref: https://github.com/graphile/postgraphile/issues/1583 * https://marmelab.com/react-admin/Upgrade.html#no-more-prop-injection-in-page-components
- [ ] 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
- [ ] Get metamigo-worker working - [ ] 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 - [ ] Migrate off mui/styles
* https://mui.com/material-ui/migration/v5-style-changes/ * https://mui.com/material-ui/migration/v5-style-changes/
* the codemods might help us? * 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.

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,10 @@
/* eslint-disable no-underscore-dangle */ /* eslint-disable no-underscore-dangle */
// import { Client } from "@opensearch-project/opensearch"; // import { Client } from "@opensearch-project/opensearch";
import { Preview } from "./_components/Preview"; import { Preview } from "leafcutter-common";
// import { createVisualization } from "lib/opensearch"; // import { createVisualization } from "lib/opensearch";
export default function Page() { 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 { useRouter } from "next/navigation";
import { Grid, CircularProgress } from "@mui/material"; import { Grid, CircularProgress } from "@mui/material";
import Iframe from "react-iframe"; import Iframe from "react-iframe";
import { useAppContext } from "app/_components/AppProvider"; import { useAppContext } from "leafcutter-common/components/AppProvider";
export const Setup: FC = () => { export const Setup: FC = () => {
const { const {
@ -20,6 +20,7 @@ export const Setup: FC = () => {
<Grid <Grid
sx={{ width: "100%", height: 700 }} sx={{ width: "100%", height: 700 }}
direction="row" direction="row"
container
justifyContent="space-around" justifyContent="space-around"
alignItems="center" alignItems="center"
alignContent="center" alignContent="center"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,15 @@
import type { NextAuthOptions } from "next-auth"; import type { NextAuthOptions } from "next-auth";
import Google from "next-auth/providers/google"; import Google from "next-auth/providers/google";
import Apple from "next-auth/providers/apple"; import Apple from "next-auth/providers/apple";
import Credentials from "next-auth/providers/credentials";
import { checkAuth } from "./opensearch";
export const authOptions: NextAuthOptions = { export const authOptions: NextAuthOptions = {
pages: {
signIn: "/login",
error: "/login",
signOut: "/logout",
},
providers: [ providers: [
Google({ Google({
clientId: process.env.GOOGLE_CLIENT_ID ?? "", clientId: process.env.GOOGLE_CLIENT_ID ?? "",
@ -12,6 +19,62 @@ export const authOptions: NextAuthOptions = {
clientId: process.env.APPLE_CLIENT_ID ?? "", clientId: process.env.APPLE_CLIENT_ID ?? "",
clientSecret: process.env.APPLE_CLIENT_SECRET ?? "", 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, 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 dataIndexName = "sample_tagged_tickets";
const userMetadataIndexName = "user_metadata"; const userMetadataIndexName = "user_metadata";
// const baseURL = `https://${process.env.OPENSEARCH_USERNAME}:${process.env.OPENSEARCH_PASSWORD}@${process.env.OPENSEARCH_URL}`; const baseURL = `https://${process.env.OPENSEARCH_USERNAME}:${process.env.OPENSEARCH_PASSWORD}@${process.env.OPENSEARCH_URL}`;
const baseURL = `https://localhost:9200`;
const createClient = () => new Client({ const createClient = () => new Client({
node: baseURL, 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 getDocumentID = (doc: any) => doc._id.split(":")[1];
const getEmbedURL = (tenant: string, visualizationID: string) => const getEmbedURL = (tenant: string, visualizationID: string) =>

View file

@ -3,3 +3,7 @@ body {
overscroll-behavior-y: none; overscroll-behavior-y: none;
text-size-adjust: 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 () => { export const GET = async () => {
const results = await getTrends(5); const results = await getTrends(5);
console.log({ results });
NextResponse.json(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 "@fontsource/playfair-display/900.css";
// import getConfig from "next/config"; // import getConfig from "next/config";
// import { LicenseInfo } from "@mui/x-data-grid-pro"; // import { LicenseInfo } from "@mui/x-data-grid-pro";
import { MultiProvider } from "app/_components/MultiProvider"; import { MultiProvider } from "./_components/MultiProvider";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Leafcutter", title: "Leafcutter",

View file

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

View file

@ -1,57 +1,6 @@
import { NextResponse } from "next/server"; import { withAuth } from "next-auth/middleware";
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);
}
};
export default withAuth( export default withAuth(
checkRewrites,
{ {
pages: { pages: {
signIn: `/login`, signIn: `/login`,
@ -60,25 +9,30 @@ export default withAuth(
authorized: ({ token, req }) => { authorized: ({ token, req }) => {
const { const {
url, url,
headers,
} = req; } = req;
// check login page
const parsedURL = new URL(url); 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; return true;
} }
// check session auth if (token?.email) {
const authorizedDomains = ["redaranj.com", "digiresilience.org"];
const userDomain = token?.email?.toLowerCase().split("@").pop() ?? "unauthorized.net";
if (authorizedDomains.includes(userDomain)) {
return true; return true;
} }
return false; 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" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -7,9 +7,19 @@ const ContentSecurityPolicy = `
`; `;
module.exports = { module.exports = {
publicRuntimeConfig: { transpilePackages: ["leafcutter-common"],
embedded: true experimental: {
},/* missingSuspenseWithCSRBailout: false,
},
rewrites: async () => ({
fallback: [
{
source: "/:path*",
destination: "/api/proxy/:path*",
},
],
}),
/*
basePath: "/proxy/leafcutter", basePath: "/proxy/leafcutter",
assetPrefix: "/proxy/leafcutter", assetPrefix: "/proxy/leafcutter",
i18n: { i18n: {
@ -17,25 +27,19 @@ module.exports = {
defaultLocale: "en", defaultLocale: "en",
}, },
*/ */
/* rewrites: async () => ({
fallback: [ /*
{
source: "/:path*",
destination: "/api/proxy/:path*",
},
],
}) */
async headers() { async headers() {
return [ return [
{ {
source: '/:path*', source: '/:path*',
headers: [ headers: [
/*
{ {
key: 'Content-Security-Policy', key: 'Content-Security-Policy',
value: ContentSecurityPolicy.replace(/\s{2,}/g, ' ').trim() value: ContentSecurityPolicy.replace(/\s{2,}/g, ' ').trim()
}, },
*/
{ {
key: 'Strict-Transport-Security', key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload' 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", "login": "aws sso login --sso-session cdr",
"kubeconfig": "aws eks update-kubeconfig --name cdr-leafcutter-dashboard-cluster --profile cdr-leafcutter-dashboard-production", "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: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", "build": "next build",
"start": "next start", "start": "next start",
"export": "next export", "export": "next export",
@ -14,51 +14,53 @@
}, },
"dependencies": { "dependencies": {
"@emotion/cache": "^11.11.0", "@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.4",
"@emotion/server": "^11.11.0", "@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@fontsource/playfair-display": "^5.0.5", "@fontsource/playfair-display": "^5.0.21",
"@fontsource/poppins": "^5.0.5", "@fontsource/poppins": "^5.0.12",
"@fontsource/roboto": "^5.0.5", "@fontsource/roboto": "^5.0.12",
"@mui/icons-material": "^5", "@mui/icons-material": "^5",
"@mui/lab": "^5.0.0-alpha.136", "@mui/lab": "^5.0.0-alpha.167",
"@mui/material": "^5", "@mui/material": "^5",
"@mui/x-data-grid-pro": "^6.10.0", "@mui/x-data-grid-pro": "^6.19.6",
"@mui/x-date-pickers-pro": "^6.10.0", "@mui/x-date-pickers-pro": "^6.19.6",
"@opensearch-project/opensearch": "^2.3.0", "@opensearch-project/opensearch": "^2.5.0",
"date-fns": "^2.30.0", "cryptr": "^6.3.0",
"date-fns": "^3.3.1",
"http-proxy-middleware": "^2.0.6", "http-proxy-middleware": "^2.0.6",
"material-ui-popup-state": "^5.0.9", "leafcutter-common": "*",
"next": "13.4.10", "material-ui-popup-state": "^5.0.10",
"next-auth": "^4.22.1", "next": "14.1.2",
"next-http-proxy-middleware": "^1.2.5", "next-auth": "^4.24.6",
"nodemailer": "^6.9.3", "next-http-proxy-middleware": "^1.2.6",
"nodemailer": "^6.9.11",
"react": "18.2.0", "react": "18.2.0",
"react-cookie": "^4.1.1", "react-cookie": "^7.1.0",
"react-cookie-consent": "^8.0.1", "react-cookie-consent": "^9.0.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-iframe": "^1.8.5", "react-iframe": "^1.8.5",
"react-markdown": "^8.0.7", "react-markdown": "^9.0.1",
"react-polyglot": "^0.7.2", "react-polyglot": "^0.7.2",
"sharp": "^0.32.3", "sharp": "^0.33.2",
"swr": "^2.2.0", "swr": "^2.2.5",
"tss-react": "^4.8.8", "tss-react": "^4.9.4",
"uuid": "^9.0.0" "uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.22.9", "@babel/core": "^7.24.0",
"@types/node": "^20.4.2", "@types/node": "^20.11.24",
"@types/react": "18.2.15", "@types/react": "18.2.63",
"@types/uuid": "^9.0.2", "@types/uuid": "^9.0.8",
"babel-loader": "^9.1.3", "babel-loader": "^9.1.3",
"eslint": "^8.45.0", "eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-config-next": "^13.4.10", "eslint-config-next": "^14.1.2",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.34.0",
"typescript": "5.1.6" "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"; "use client";
import { FC } from "react"; import { FC, useState } from "react";
import { Box, Grid, Container, IconButton } from "@mui/material"; import {
import { Apple as AppleIcon, Google as GoogleIcon } from "@mui/icons-material"; 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 { 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 = { type LoginProps = {
session: any; session: any;
@ -14,62 +29,198 @@ export const Login: FC<LoginProps> = ({ session }) => {
typeof window !== "undefined" && window.location.origin typeof window !== "undefined" && window.location.origin
? 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 = { const buttonStyles = {
borderRadius: 500, borderRadius: 500,
width: "100%", width: "100%",
fontSize: "16px", fontSize: "16px",
fontWeight: "bold", 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 ( return (
<> <Box sx={{ backgroundColor: darkGray, height: "100vh" }}>
<Grid container direction="row-reverse" sx={{ p: 3 }}> <Container maxWidth="md" sx={{ p: 10 }}>
<Grid item />
</Grid>
<Container maxWidth="md" sx={{ mt: 3, mb: 20 }}>
<Grid container spacing={2} direction="column" alignItems="center"> <Grid container spacing={2} direction="column" alignItems="center">
<Grid item> <Grid
<Box sx={{ maxWidth: 200 }} /> item
</Grid> container
<Grid item sx={{ textAlign: "center" }} /> direction="row"
justifyContent="center"
<Grid item> alignItems="center"
{!session ? ( >
<Grid <Grid item>
container <Box
spacing={3} sx={{
direction="column" width: "70px",
alignItems="center" height: "70px",
sx={{ width: 450, mt: 1 }} margin: "0 auto",
}}
> >
<Grid item sx={{ width: "100%" }}> <Image
<IconButton src={LinkLogo}
sx={buttonStyles} alt="Link logo"
onClick={() => width={70}
signIn("google", { height={70}
callbackUrl: `${origin}/setup`, style={{
}) objectFit: "cover",
} filter: "grayscale(100) brightness(100)",
> }}
<GoogleIcon sx={{ mr: 1 }} /> />
Google </Box>
</IconButton> </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>
{/* </Container>
<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>
) : null} ) : null}
{session ? ( {session ? (
<Box component="h4"> <Box component="h4">
@ -79,6 +230,6 @@ export const Login: FC<LoginProps> = ({ session }) => {
</Grid> </Grid>
</Grid> </Grid>
</Container> </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 { FC } from "react";
import { ZammadWrapper } from "./ZammadWrapper"; 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"; "use client";
import { FC, useState } from "react"; import { FC, useEffect, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { import {
Box, Box,
@ -17,12 +17,16 @@ import {
import { import {
FeaturedPlayList as FeaturedPlayListIcon, FeaturedPlayList as FeaturedPlayListIcon,
Person as PersonIcon, Person as PersonIcon,
Analytics as AnalyticsIcon, Insights as InsightsIcon,
Logout as LogoutIcon, Logout as LogoutIcon,
Cottage as CottageIcon, Cottage as CottageIcon,
Settings as SettingsIcon, Settings as SettingsIcon,
ExpandCircleDown as ExpandCircleDownIcon, ExpandCircleDown as ExpandCircleDownIcon,
Dvr as DvrIcon, Dvr as DvrIcon,
Assessment as AssessmentIcon,
LibraryBooks as LibraryBooksIcon,
School as SchoolIcon,
Search as SearchIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
@ -30,6 +34,7 @@ import Image from "next/image";
import LinkLogo from "public/link-logo-small.png"; import LinkLogo from "public/link-logo-small.png";
import { useSession, signOut } from "next-auth/react"; import { useSession, signOut } from "next-auth/react";
import { getTicketOverviewCountsQuery } from "app/_graphql/getTicketOverviewCountsQuery"; import { getTicketOverviewCountsQuery } from "app/_graphql/getTicketOverviewCountsQuery";
import { SearchBox } from "./SearchBox";
const openWidth = 270; const openWidth = 270;
const closedWidth = 100; const closedWidth = 100;
@ -162,20 +167,31 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
const pathname = usePathname(); const pathname = usePathname();
const { data: session } = useSession(); const { data: session } = useSession();
const username = session?.user?.name || "User"; const username = session?.user?.name || "User";
// @ts-ignore
const roles = session?.user?.roles || [];
const { data: overviewData, error: overviewError }: any = useSWR( const { data: overviewData, error: overviewError }: any = useSWR(
{ {
document: getTicketOverviewCountsQuery, document: getTicketOverviewCountsQuery,
}, },
{ refreshInterval: 10000 } { refreshInterval: 10000 },
); );
const findOverviewCountByID = (id: number) => const findOverviewByName = (name: string) =>
overviewData?.ticketOverviews?.edges?.find((overview: any) => overviewData?.ticketOverviews?.edges?.find(
overview.node.id.endsWith(`/${id}`) (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; )?.node?.ticketCount ?? 0;
const assignedCount = findOverviewCountByID(1); const recentCount = 0;
const urgentCount = findOverviewCountByID(7); const assignedID = findOverviewByName("My Assigned Tickets");
const pendingCount = findOverviewCountByID(3); const assignedCount = findOverviewCountByID(assignedID);
const unassignedCount = findOverviewCountByID(2); 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 = () => { const logout = () => {
signOut({ callbackUrl: "/login" }); signOut({ callbackUrl: "/login" });
@ -221,6 +237,7 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
direction="column" direction="column"
justifyContent="space-between" justifyContent="space-between"
wrap="nowrap" wrap="nowrap"
spacing={0}
sx={{ backgroundColor: "#25272A", height: "100%", p: 2 }} sx={{ backgroundColor: "#25272A", height: "100%", p: 2 }}
> >
<Grid item container> <Grid item container>
@ -307,10 +324,30 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
</Grid> </Grid>
<Grid item> <Grid item>
<Box <Box
sx={{ height: "0.5px", width: "100%", backgroundColor: "#666" }} sx={{
height: "0.5px",
width: "100%",
backgroundColor: "#666",
mb: 2,
}}
/> />
</Grid> </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 <List
component="nav" component="nav"
sx={{ sx={{
@ -359,7 +396,7 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
/> />
<MenuItem <MenuItem
name="Tickets" name="Tickets"
href="/overview/assigned" href="/overview/recent"
Icon={FeaturedPlayListIcon} Icon={FeaturedPlayListIcon}
selected={ selected={
pathname.startsWith("/overview") || pathname.startsWith("/overview") ||
@ -379,12 +416,21 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
> >
<List component="div" disablePadding> <List component="div" disablePadding>
<MenuItem <MenuItem
name="Assigned" name="Recent"
href="/overview/assigned" href="/overview/recent"
Icon={FeaturedPlayListIcon} Icon={FeaturedPlayListIcon}
iconSize={0} iconSize={0}
selected={pathname.endsWith("/overview/assigned")} selected={pathname.endsWith("/overview/recent")}
badge={assignedCount} badge={recentCount}
open={open}
/>
<MenuItem
name="Open"
href="/overview/open"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/overview/open")}
badge={openCount}
open={open} open={open}
/> />
<MenuItem <MenuItem
@ -397,12 +443,12 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
open={open} open={open}
/> />
<MenuItem <MenuItem
name="Pending" name="Assigned"
href="/overview/pending" href="/overview/assigned"
Icon={FeaturedPlayListIcon} Icon={FeaturedPlayListIcon}
iconSize={0} iconSize={0}
selected={pathname.endsWith("/overview/pending")} selected={pathname.endsWith("/overview/assigned")}
badge={pendingCount} badge={assignedCount}
open={open} open={open}
/> />
<MenuItem <MenuItem
@ -419,17 +465,33 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
<MenuItem <MenuItem
name="Knowledge Base" name="Knowledge Base"
href="/knowledge" href="/knowledge"
Icon={CottageIcon} Icon={SchoolIcon}
iconSize={20} iconSize={20}
selected={pathname.endsWith("/knowledge")} selected={pathname.endsWith("/knowledge")}
open={open} open={open}
/> />
<MenuItem <MenuItem
name="Leafcutter" name="Documentation"
href="/leafcutter/about" href="/docs"
Icon={AnalyticsIcon} Icon={LibraryBooksIcon}
iconSize={20} 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} open={open}
/> />
<Collapse <Collapse
@ -439,7 +501,6 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
onClick={undefined} onClick={undefined}
> >
<List component="div" disablePadding> <List component="div" disablePadding>
{/*
<MenuItem <MenuItem
name="Dashboard" name="Dashboard"
href="/leafcutter" href="/leafcutter"
@ -447,7 +508,6 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
selected={pathname.endsWith("/leafcutter")} selected={pathname.endsWith("/leafcutter")}
open={open} open={open}
/> />
<MenuItem <MenuItem
name="Search and Create" name="Search and Create"
href="/leafcutter/create" href="/leafcutter/create"
@ -455,7 +515,6 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
selected={pathname.endsWith("/leafcutter/create")} selected={pathname.endsWith("/leafcutter/create")}
open={open} open={open}
/> />
<MenuItem <MenuItem
name="Trends" name="Trends"
href="/leafcutter/trends" href="/leafcutter/trends"
@ -463,7 +522,6 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
selected={pathname.endsWith("/leafcutter/trends")} selected={pathname.endsWith("/leafcutter/trends")}
open={open} open={open}
/> />
*/}
<MenuItem <MenuItem
name="FAQ" name="FAQ"
href="/leafcutter/faq" href="/leafcutter/faq"
@ -475,7 +533,7 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
<MenuItem <MenuItem
name="About" name="About"
href="/leafcutter/about" href="/leafcutter/about"
Icon={AnalyticsIcon} Icon={InsightsIcon}
iconSize={0} iconSize={0}
selected={pathname.endsWith("/leafcutter/about")} selected={pathname.endsWith("/leafcutter/about")}
open={open} open={open}
@ -490,49 +548,57 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
selected={pathname.endsWith("/profile")} selected={pathname.endsWith("/profile")}
open={open} open={open}
/> />
<MenuItem {roles.includes("admin") && (
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>
<MenuItem <MenuItem
name="Zammad Settings" name="Admin"
href="/admin/zammad" href="/admin/zammad"
Icon={FeaturedPlayListIcon} Icon={SettingsIcon}
iconSize={0} iconSize={20}
selected={pathname.endsWith("/admin/zammad")}
open={open} open={open}
/> />
<MenuItem <Collapse
name="Metamigo" in={pathname.startsWith("/admin/")}
href="/admin/metamigo" timeout="auto"
Icon={FeaturedPlayListIcon} unmountOnExit
iconSize={0} onClick={undefined}
selected={pathname.endsWith("/admin/metamigo")} >
open={open} <List component="div" disablePadding>
/> <MenuItem
<MenuItem name="Zammad Settings"
name="Label Studio" href="/admin/zammad"
href="/admin/label-studio" Icon={FeaturedPlayListIcon}
Icon={FeaturedPlayListIcon} iconSize={0}
iconSize={0} selected={pathname.endsWith("/admin/zammad")}
selected={pathname.endsWith("/admin/label-studio")} open={open}
open={open} />
/> {false && roles.includes("metamigo") && (
</List> <MenuItem
</Collapse> 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 <MenuItem
name="Zammad Interface" name="Zammad Interface"
href="/proxy/zammad" href="/zammad"
Icon={DvrIcon} Icon={DvrIcon}
iconSize={20} iconSize={20}
open={open} open={open}

View file

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

View file

@ -1,9 +1,10 @@
"use client"; "use client";
import { FC, useState } from "react"; import { FC, useState, useEffect, useRef } from "react";
import getConfig from "next/config";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Iframe from "react-iframe"; import Iframe from "react-iframe";
import { useSession } from "next-auth/react";
import { Box, Grid, CircularProgress } from "@mui/material";
type ZammadWrapperProps = { type ZammadWrapperProps = {
path: string; path: string;
@ -15,68 +16,146 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
hideSidebar = true, hideSidebar = true,
}) => { }) => {
const router = useRouter(); 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 [display, setDisplay] = useState("none");
const url = `/proxy/zammad${path}`; const url = `/zammad${path}`;
console.log({ url }); const id = url.replace(/[^a-zA-Z0-9]/g, "");
return ( useEffect(() => {
// @ts-ignore const hash = window?.location?.hash;
<Iframe if (hash && hash.startsWith("#ticket/zoom/")) {
id="zammad" const ticketID = hash.split("/").pop();
url={url} router.push(`/tickets/${ticketID}`);
width="100%" }
height="100%" setHashCheckComplete(true);
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";
if (hideSidebar) { useEffect(() => {
// @ts-ignore if (!hashCheckComplete) return;
linkElement.contentDocument.querySelector(".sidebar").style =
"display: none";
}
// @ts-ignore const checkAuthenticated = async () => {
if (linkElement.contentDocument.querySelector(".overview-header")) { const res = await fetch("/zammad/auth/sso", {
// @ts-ignore method: "GET",
( redirect: "manual",
linkElement.contentDocument.querySelector( });
".overview-header" console.log({ res });
) as any if (res.type === "opaqueredirect") {
).style = "display: none"; setAuthenticated(true);
} } else {
setAuthenticated(false);
}
};
setDisplay("inherit"); checkAuthenticated();
}, [path, hashCheckComplete]);
if (linkElement.contentWindow) { useEffect(() => {
linkElement.contentWindow.addEventListener("hashchange", () => { if (session === null) {
const hash = linkElement.contentWindow?.location?.hash ?? ""; timeoutRef.current = setTimeout(() => {
if (hash.startsWith("#ticket/zoom/")) { if (session === null) {
setDisplay("none"); router.push("/login");
const ticketID = hash.split("/").pop();
router.push(`/tickets/${ticketID}`);
setTimeout(() => {
setDisplay("inherit");
}, 1000);
}
});
}
} }
}} }, 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%" }}> <Grid item sx={{ height: "100vh", width: "100%" }}>
<Iframe <Iframe
id="link" id="label-studio"
url={"https://label-studio:3000"} url={"/label-studio"}
width="100%" width="100%"
height="100%" height="100%"
frameBorder={0} 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 { Metadata } from "next";
import { ZammadWrapper } from "../../../(main)/_components/ZammadWrapper"; import { ZammadWrapper } from "app/(main)/_components/ZammadWrapper";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Zammad", 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() { export default async function Page() {
redirect("/leafcutter/home"); 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"; "use client";
import { FC } from "react"; import { FC, useState } from "react";
import { Grid, Box } from "@mui/material"; import { Grid, Box } from "@mui/material";
import { GridColDef } from "@mui/x-data-grid-pro"; import { GridColDef } from "@mui/x-data-grid-pro";
import { StyledDataGrid } from "../../../_components/StyledDataGrid"; import { StyledDataGrid } from "app/(main)/_components/StyledDataGrid";
import { Button } from "../../../../_components/Button"; import { Button } from "app/_components/Button";
import { typography } from "../../../../_styles/theme"; import { typography } from "app/_styles/theme";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { TicketCreateDialog } from "./TicketCreateDialog";
interface TicketListProps { interface TicketListProps {
title: string; title: string;
@ -14,6 +15,7 @@ interface TicketListProps {
} }
export const TicketList: FC<TicketListProps> = ({ title, tickets }) => { export const TicketList: FC<TicketListProps> = ({ title, tickets }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const router = useRouter(); const router = useRouter();
let gridColumns: GridColDef[] = [ let gridColumns: GridColDef[] = [
{ {
@ -24,12 +26,24 @@ export const TicketList: FC<TicketListProps> = ({ title, tickets }) => {
{ {
field: "title", field: "title",
headerName: "Title", headerName: "Title",
flex: 1, flex: 5,
}, },
{ {
field: "customer", field: "customer",
headerName: "Sender", headerName: "Sender",
valueGetter: (params) => params.row?.customer?.fullname, 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, flex: 1,
}, },
{ {
@ -39,47 +53,58 @@ export const TicketList: FC<TicketListProps> = ({ title, tickets }) => {
flex: 1, flex: 1,
}, },
]; ];
console.log({ tickets });
const rowClick = ({ row }) => { const rowClick = ({ row }) => {
router.push(`/tickets/${row.internalId}`); router.push(`/tickets/${row.internalId}`);
}; };
return ( return (
<Box sx={{ height: "100vh", backgroundColor: "#ddd", p: 3 }}> <>
<Grid container direction="column"> <Box sx={{ height: "100vh", backgroundColor: "#ddd", p: 3 }}>
<Grid <Grid container direction="column">
item <Grid
container item
direction="row" container
justifyContent="space-between" direction="row"
alignItems="center" justifyContent="space-between"
> alignItems="center"
<Grid item> >
<Box <Grid item>
sx={{ <Box
backgroundColor: "#ddd", sx={{
px: "8px", backgroundColor: "#ddd",
pb: "16px", px: "8px",
...typography.h4, pb: "16px",
fontSize: 24, ...typography.h4,
}} fontSize: 24,
> }}
{title} >
</Box> {title}
</Box>
</Grid>
<Grid item>
<Button
href={""}
onClick={() => setDialogOpen(true)}
text="Create"
color="#1982FC"
/>
</Grid>
</Grid> </Grid>
<Grid item> <Grid item>
<Button href="/tickets/create" text="Create" color="#1982FC" /> <StyledDataGrid
name={title}
columns={gridColumns}
rows={tickets}
onRowClick={rowClick}
/>
</Grid> </Grid>
</Grid> </Grid>
<Grid item> </Box>
<StyledDataGrid <TicketCreateDialog
name={title} open={dialogOpen}
columns={gridColumns} closeDialog={() => setDialogOpen(false)}
rows={tickets} />
onRowClick={rowClick} </>
/>
</Grid>
</Grid>
</Box>
); );
}; };

View file

@ -1,32 +1,113 @@
"use client"; "use client";
import { FC } from "react"; import { FC, useEffect, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { TicketList } from "./TicketList"; import { TicketList } from "./TicketList";
import { getTicketsByOverviewQuery } from "../../../../_graphql/getTicketsByOverviewQuery"; import { getTicketOverviewCountsQuery } from "app/_graphql/getTicketOverviewCountsQuery";
import { getTicketsByOverviewQuery } from "app/_graphql/getTicketsByOverviewQuery";
type ZammadOverviewProps = { type ZammadOverviewProps = {
name: string; name: string;
id: string;
}; };
export const ZammadOverview: FC<ZammadOverviewProps> = ({ name, id }) => { export const ZammadOverview: FC<ZammadOverviewProps> = ({ name }) => {
const { data: ticketData, error: ticketError }: any = useSWR( const [overviewID, setOverviewID] = useState(null);
const [tickets, setTickets] = useState([]);
const [error, setError] = useState(null);
const { data: overviewData, error: overviewError }: any = useSWR(
{ {
document: getTicketsByOverviewQuery, document: getTicketOverviewCountsQuery,
variables: { overviewId: `gid://zammad/Overview/${id}` },
}, },
{ refreshInterval: 10000 }, { refreshInterval: 10000 },
); );
const { data: ticketData, error: ticketError }: any = useSWR(
const shouldRender = !ticketError && ticketData; {
const tickets = document: getTicketsByOverviewQuery,
ticketData?.ticketsByOverview?.edges.map((edge: any) => edge.node) || []; variables: { overviewId: overviewID, pageSize: 250 },
},
return ( { refreshInterval: 10000 },
<>
{shouldRender && <TicketList title={name} tickets={tickets} />}
{ticketError && <div>{ticketError.toString()}</div>}
</>
); );
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"; "use client";
import { DisplayError } from "../../../_components/DisplayError"; import { DisplayError } from "app/_components/DisplayError";
type PageProps = { type PageProps = {
error: Error; error: Error;

View file

@ -21,13 +21,6 @@ export async function generateMetadata({
}; };
} }
const overviews = {
assigned: 1,
unassigned: 2,
pending: 3,
urgent: 7,
};
type PageProps = { type PageProps = {
params: { params: {
overview: string; overview: string;
@ -37,5 +30,5 @@ type PageProps = {
export default function Page({ params: { overview } }: PageProps) { export default function Page({ params: { overview } }: PageProps) {
const section = getSection(overview); 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 { FC, useLayoutEffect } from "react";
import { useRouter } from "next/navigation"; 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 = () => { export const Setup: FC = () => {
const router = useRouter(); const router = useRouter();
@ -10,5 +11,21 @@ export const Setup: FC = () => {
setTimeout(() => router.push("/"), 4000); setTimeout(() => router.push("/"), 4000);
}, [router]); }, [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, TextField,
} from "@mui/material"; } from "@mui/material";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import { updateTicketMutation } from "../../../../../_graphql/updateTicketMutation"; import { updateTicketMutation } from "app/_graphql/updateTicketMutation";
interface ArticleCreateDialogProps { interface ArticleCreateDialogProps {
ticketID: string; ticketID: string;
open: boolean; open: boolean;
closeDialog: () => void; closeDialog: () => void;
kind: "reply" | "note"; kind: string;
recipient?: string;
} }
export const ArticleCreateDialog: FC<ArticleCreateDialogProps> = ({ export const ArticleCreateDialog: FC<ArticleCreateDialogProps> = ({
@ -24,22 +25,29 @@ export const ArticleCreateDialog: FC<ArticleCreateDialogProps> = ({
open, open,
closeDialog, closeDialog,
kind, kind,
recipient,
}) => { }) => {
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const backgroundColor = kind === "reply" ? "#1982FC" : "#FFB620"; const backgroundColor = kind === "note" ? "#FFB620" : "#1982FC";
const color = kind === "reply" ? "white" : "black"; const color = kind === "note" ? "black" : "white";
const { fetcher } = useSWRConfig(); const { fetcher } = useSWRConfig();
const article = {
body,
type: kind,
internal: kind === "note",
};
if (kind === "email") {
article["to"] = recipient;
}
const createArticle = async () => { const createArticle = async () => {
await fetcher({ await fetcher({
document: updateTicketMutation, document: updateTicketMutation,
variables: { variables: {
ticketId: `gid://zammad/Ticket/${ticketID}`, ticketId: `gid://zammad/Ticket/${ticketID}`,
input: { input: {
article: { article,
body,
type: kind === "note" ? "note" : "phone",
internal: kind === "note",
},
}, },
}, },
}); });
@ -51,7 +59,7 @@ export const ArticleCreateDialog: FC<ArticleCreateDialogProps> = ({
<Dialog open={open} maxWidth="sm" fullWidth> <Dialog open={open} maxWidth="sm" fullWidth>
<DialogContent> <DialogContent>
<TextField <TextField
label={kind === "reply" ? "Write reply" : "Write internal note"} label={kind === "note" ? "Write internal note" : "Write reply"}
multiline multiline
rows={10} rows={10}
fullWidth fullWidth
@ -92,7 +100,7 @@ export const ArticleCreateDialog: FC<ArticleCreateDialogProps> = ({
}} }}
onClick={createArticle} onClick={createArticle}
> >
{kind === "reply" ? "Send Reply" : "Save Note"} {kind === "note" ? "Save Note" : "Send Reply"}
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>

View file

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

View file

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

View file

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

View file

@ -8,13 +8,15 @@ interface ButtonProps {
text: string; text: string;
color: string; color: string;
href: 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> <Link href={href} passHref>
<MUIButton <MUIButton
variant="contained" variant="contained"
disableElevation disableElevation
onClick={onClick}
sx={{ sx={{
fontFamily: "Poppins, sans-serif", fontFamily: "Poppins, sans-serif",
fontWeight: 700, fontWeight: 700,

View file

@ -1,18 +1,21 @@
"use client"; "use client";
import { FC, PropsWithChildren, useState } from "react"; import { FC, PropsWithChildren, useState } from "react";
import { usePathname } from "next/navigation";
import { CssBaseline } from "@mui/material"; import { CssBaseline } from "@mui/material";
import { CookiesProvider } from "react-cookie"; import { CookiesProvider } from "react-cookie";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import { NextAppDirEmotionCacheProvider } from "tss-react/next/appDir"; import { NextAppDirEmotionCacheProvider } from "tss-react/next/appDir";
import { SWRConfig } from "swr"; import { SWRConfig } from "swr";
import { GraphQLClient } from "graphql-request"; 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 { LocalizationProvider } from "@mui/x-date-pickers-pro";
import { LicenseInfo } from "@mui/x-date-pickers-pro"; import { LicenseInfo } from "@mui/x-date-pickers-pro";
import { locales } from "leafcutter-common";
LicenseInfo.setLicenseKey( LicenseInfo.setLicenseKey(
"7c9bf25d9e240f76e77cbf7d2ba58a23Tz02NjU4OCxFPTE3MTU4NjIzMzQ2ODgsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=" "7c9bf25d9e240f76e77cbf7d2ba58a23Tz02NjU4OCxFPTE3MTU4NjIzMzQ2ODgsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
); );
export const MultiProvider: FC<PropsWithChildren> = ({ children }) => { export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
@ -21,25 +24,83 @@ export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
typeof window !== "undefined" && window.location.origin typeof window !== "undefined" && window.location.origin
? window.location.origin ? window.location.origin
: null; : null;
const client = new GraphQLClient(`${origin}/proxy/zammad/graphql`, { const client = new GraphQLClient(`${origin}/zammad/graphql`);
headers: { 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", "Content-Type": "application/json",
Accept: "application/json", Accept: "application/json",
},
});
const graphQLFetcher = async ({ document, variables }: any) => {
const requestHeaders = {
"X-CSRF-Token": csrfToken, "X-CSRF-Token": csrfToken,
}; };
const { data, headers } = await client.rawRequest( let responseData = null;
document, let responseHeaders = new Headers();
variables, let responseStatus = null;
requestHeaders
);
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); 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; return data;
}; };
@ -47,11 +108,13 @@ export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
<> <>
<CssBaseline /> <CssBaseline />
<NextAppDirEmotionCacheProvider options={{ key: "css" }}> <NextAppDirEmotionCacheProvider options={{ key: "css" }}>
<SWRConfig value={{ fetcher: graphQLFetcher }}> <SWRConfig value={{ fetcher: multiFetcher }}>
<SessionProvider> <SessionProvider>
<CookiesProvider> <CookiesProvider>
<LocalizationProvider dateAdapter={AdapterDateFns}> <LocalizationProvider dateAdapter={AdapterDateFns}>
{children} <I18n locale={locale} messages={messages[locale]}>
{children}
</I18n>
</LocalizationProvider> </LocalizationProvider>
</CookiesProvider> </CookiesProvider>
</SessionProvider> </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 { edges {
node { node {
id id
body bodyWithUrls
internal internal
type { type {
name name
@ -14,6 +14,11 @@ query getTicketArticles($ticketId: ID!) {
sender { sender {
name 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) { ticketUpdate(ticketId: $ticketId, input: $input) {
ticket { ticket {
id 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'; import { NextRequest, NextResponse } from 'next/server';
const handler = (req: NextRequest) => { 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 }; 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"; import { withAuth, NextRequestWithAuth } from "next-auth/middleware";
const rewriteURL = (request: NextRequestWithAuth, originBaseURL: string, destinationBaseURL: string, headers: any = {}) => { const rewriteURL = (request: NextRequestWithAuth, originBaseURL: string, destinationBaseURL: string, headers: any = {}) => {
if (request.nextUrl.protocol.startsWith('ws')) { if (request.nextUrl.pathname.startsWith('/api/v1/reports/sets')) {
return NextResponse.next(); console.log(request.nextUrl.searchParams.get("sheet"));
} NextResponse.next();
if (request.nextUrl.pathname.includes('/_next/static/development/')) {
return NextResponse.next();
} }
const destinationURL = request.url.replace(originBaseURL, destinationBaseURL); const destinationURL = request.url.replace(originBaseURL, destinationBaseURL);
console.log(`Rewriting ${request.url} to ${destinationURL}`); console.log(`Rewriting ${request.url} to ${destinationURL}`);
const requestHeaders = new Headers(request.headers); const requestHeaders = new Headers(request.headers);
for (const [key, value] of Object.entries(headers)) { for (const [key, value] of Object.entries(headers)) {
// @ts-ignore requestHeaders.set(key, value as string);
requestHeaders.set(key, value);
} }
requestHeaders.delete('connection'); requestHeaders.delete('connection');
// console.log({ finalHeaders: requestHeaders });
return NextResponse.rewrite(new URL(destinationURL), { request: { headers: requestHeaders } }); return NextResponse.rewrite(new URL(destinationURL), { request: { headers: requestHeaders } });
}; };
const checkRewrites = async (request: NextRequestWithAuth) => { const checkRewrites = async (request: NextRequestWithAuth) => {
console.log({ currentURL: request.nextUrl.href });
const linkBaseURL = process.env.LINK_URL ?? "http://localhost:3000"; const linkBaseURL = process.env.LINK_URL ?? "http://localhost:3000";
const zammadURL = process.env.ZAMMAD_URL ?? "http://zammad-nginx:8080"; 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-api:3000";
const metamigoURL = process.env.METAMIGO_URL ?? "http://metamigo-frontend: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('/metamigo')) {
return rewriteURL(request, `${linkBaseURL}/metamigo`, metamigoURL);
if (request.nextUrl.pathname.startsWith('/proxy/leafcutter')) { } else if (request.nextUrl.pathname.startsWith('/label-studio')) {
const headers = { 'X-Leafcutter-Embedded': "true" }; return rewriteURL(request, `${linkBaseURL}/label-studio`, labelStudioURL);
return rewriteURL(request, linkBaseURL, leafcutterURL, headers); } else if (request.nextUrl.pathname.startsWith('/zammad')) {
} else if (request.nextUrl.pathname.startsWith('/proxy/metamigo')) { return rewriteURL(request, `${linkBaseURL}/zammad`, zammadURL, headers);
return rewriteURL(request, linkBaseURL, metamigoURL); } else if (request.nextUrl.pathname.startsWith('/auth/sso') || request.nextUrl.pathname.startsWith('/assets')) {
} else if (request.nextUrl.pathname.startsWith('/proxy/zammad')) { return rewriteURL(request, linkBaseURL, zammadURL, headers);
console.log('proxying to zammad'); } else if (request.nextUrl.pathname.startsWith('/proxy/api') || request.nextUrl.pathname.startsWith('/proxy/assets')) {
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');
return rewriteURL(request, `${linkBaseURL}/proxy`, zammadURL); 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( export default withAuth(
@ -77,20 +56,19 @@ export default withAuth(
authorized: ({ token, req }) => { authorized: ({ token, req }) => {
const { const {
url, url,
headers,
} = req; } = req;
// check login page const noAuthPaths = ["/login", "/api/v1"];
const parsedURL = new URL(url); 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; return true;
} }
// check session auth const roles: any = token?.roles ?? [];
const authorizedDomains = ["redaranj.com", "digiresilience.org"]; if (roles.includes("admin") || roles.includes("agent") || process.env.SETUP_MODE === "true") {
const userDomain = token?.email?.toLowerCase().split("@").pop() ?? "unauthorized.net";
if (authorizedDomains.includes(userDomain)) {
return 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} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
experimental: {
missingSuspenseWithCSRBailout: false,
},
modularizeImports: { modularizeImports: {
"@mui/material": { "@mui/material": {
transform: "@mui/material/{{member}}", transform: "@mui/material/{{member}}",
@ -9,10 +12,13 @@ const nextConfig = {
transform: "@mui/icons-material/{{member}}", transform: "@mui/icons-material/{{member}}",
}, },
}, },
transpilePackages: ["leafcutter-common"],
publicRuntimeConfig: { publicRuntimeConfig: {
linkURL: process.env.LINK_URL ?? "http://localhost:3000", linkURL: process.env.LINK_URL ?? "http://localhost:3000",
leafcutterURL: process.env.LEAFCUTTER_URL ?? "http://localhost:3001", leafcutterURL:
metamigoURL: process.env.METAMIGO_URL ?? "http://localhost:3002", 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 ?? "", muiLicenseKey: process.env.MUI_LICENSE_KEY ?? "",
}, },
async rewrites() { async rewrites() {
@ -20,7 +26,7 @@ const nextConfig = {
fallback: [ fallback: [
{ {
source: "/:path*", source: "/:path*",
destination: `/proxy/zammad/:path*`, destination: `/proxy/leafcutter/:path*`,
}, },
], ],
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,9 +34,9 @@ export const register = async (
}, },
]); ]);
await registerNextAuth(server, config); // await registerNextAuth(server, config);
await registerSwagger(server); await registerSwagger(server);
await registerCloudflareAccessJwt(server, config); //await registerCloudflareAccessJwt(server, config);
await registerAuthBearer(server, config); // await registerAuthBearer(server, config);
await registerPostgraphile(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) { async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { voiceLineId } = request.params; 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 }); const voiceLine = await request.db().voiceLines.findBy({ number: To });
if (!voiceLine) return Boom.notFound(); if (!voiceLine) return Boom.notFound();
if (voiceLine.id !== voiceLineId) return Boom.badRequest(); if (voiceLine.id !== voiceLineId) return Boom.badRequest();
@ -193,7 +193,7 @@ export const TwilioRoutes = Helpers.noAuth([
}, },
}, },
async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) { 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 const provider: SavedVoiceProvider = await request
.db() .db()
.voiceProviders.findById({ id: providerId }); .voiceProviders.findById({ id: providerId });

View file

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

View file

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

View file

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