Merge branch 'main' into reporting

This commit is contained in:
Darren Clarke 2025-02-13 09:49:55 +01:00
commit 5a1be0de94
19 changed files with 2058 additions and 230 deletions

View file

@ -1 +1,22 @@
# TK
# CDR Link
CDR Link is a simple & streamlined helpdesk that lets you tag, assign and respond to your tickets. It is developed by the [Center for Digital Resilience](https://digiresilience.org) and powered by [Zammad](https://zammad.org).
Key differences between CDR Link and a standard Zammad installation:
- In addition to the full Zammad interface, CDR Link also provides a simplified 'shell' interface that focuses on the most-commonly-used functionality.
- Additional channels to communicate with users, including Signal, Whatsapp & Twilio voice messaging.
- More stringent privacy defaults: ticket data is never sent over email and calls to third-party services are restricted.
## Developing
This is a monorepo that contains CDR Link and several supporting applications and libraries. It also includes Dockerfiles to build all of the other containers required for an installation. By tagging our own versions of these dependencies, we can make sure that different versions of the supporting containers all work together and are updated in sync.
We use [Turborepo](https://turbo.build) to manage development and building of the packages. To get started:
- `npm install` in the root directory
- `turbo build` to build all packages
To run a single package:
- `turbo dev --filter @link-stack/link`

View file

@ -29,6 +29,8 @@
"@link-stack/ui": "*"
},
"devDependencies": {
"@link-stack/eslint-config": "*",
"@link-stack/typescript-config": "*",
"@types/node": "^22",
"@types/pg": "^8.11.11",
"@types/react": "^19",

View file

@ -12,6 +12,7 @@ import {
import {
Apple as AppleIcon,
Google as GoogleIcon,
Microsoft as MicrosoftIcon,
Key as KeyIcon,
} from "@mui/icons-material";
import { signIn, getProviders } from "next-auth/react";
@ -183,6 +184,21 @@ export const Login: FC<LoginProps> = ({ session }) => {
</IconButton>
</Grid>
)}
{provider === "azure-ad" && (
<Grid item sx={{ width: "100%" }}>
<IconButton
sx={buttonStyles}
onClick={() =>
signIn("azure-ad", {
callbackUrl: `${origin}`,
})
}
>
<MicrosoftIcon sx={{ mr: 1 }} />
Sign in with Azure
</IconButton>
</Grid>
)}
{provider === "credentials" && (
<Grid item container spacing={3}>
<Grid item sx={{ width: "100%" }}>

View file

@ -477,14 +477,6 @@ export const Sidebar: FC<SidebarProps> = ({
/>
</List>
</Collapse>
<MenuItem
name="Knowledge Base"
href="/knowledge"
Icon={SchoolIcon}
iconSize={20}
selected={pathname.endsWith("/knowledge")}
open={open}
/>
<MenuItem
name="Documentation"
href="/docs"
@ -567,14 +559,6 @@ export const Sidebar: FC<SidebarProps> = ({
/>
</List>
</Collapse>
<MenuItem
name="Profile"
href="/profile"
Icon={PersonIcon}
iconSize={20}
selected={pathname.endsWith("/profile")}
open={open}
/>
{roles.includes("admin") && (
<>
<MenuItem
@ -641,36 +625,10 @@ export const Sidebar: FC<SidebarProps> = ({
/>
</List>
</Collapse>
<MenuItem
name="Zammad Settings"
href="/admin/zammad"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/admin/zammad")}
open={open}
/>
{roles.includes("label_studio") && (
<MenuItem
name="Label Studio"
href="/admin/label-studio"
Icon={FeaturedPlayListIcon}
iconSize={0}
selected={pathname.endsWith("/admin/label-studio")}
open={open}
/>
)}
</List>
</Collapse>
</>
)}
<MenuItem
name="Zammad Interface"
href="/zammad"
Icon={DvrIcon}
iconSize={20}
open={open}
target="_blank"
/>
<MenuItem
name="Logout"
href="/logout"

View file

@ -20,7 +20,7 @@ export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
const locale = "en";
return (
<SessionProvider>
<SessionProvider basePath="/link/api/auth">
<CssBaseline />
<ZammadLoginProvider>
<CookiesProvider>

View file

@ -11,6 +11,7 @@ import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import Apple from "next-auth/providers/apple";
import { Redis } from "ioredis";
import AzureADProvider from "next-auth/providers/azure-ad";
const headers = { Authorization: `Token ${process.env.ZAMMAD_API_TOKEN}` };
@ -86,6 +87,18 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
clientSecret: process.env.APPLE_CLIENT_SECRET,
}),
);
} else if (
process.env.AZURE_AD_CLIENT_ID &&
process.env.AZURE_AD_CLIENT_SECRET &&
process.env.AZURE_AD_TENANT_ID
) {
providers.push(
AzureADProvider({
clientId: process.env.AZURE_AD_CLIENT_ID,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
tenantId: process.env.AZURE_AD_TENANT_ID,
}),
);
} else {
providers.push(
Credentials({

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { withAuth, NextRequestWithAuth } from "next-auth/middleware";
/*
const rewriteURL = (
request: NextRequestWithAuth,
originBaseURL: string,
@ -27,7 +28,7 @@ const rewriteURL = (
request: { headers: requestHeaders },
});
};
*/
const checkRewrites = async (request: NextRequestWithAuth) => {
const linkBaseURL = process.env.LINK_URL ?? "http://localhost:3000";
const zammadURL = process.env.ZAMMAD_URL ?? "http://zammad-nginx:8080";
@ -86,30 +87,29 @@ const checkRewrites = async (request: NextRequestWithAuth) => {
frame-ancestors 'self';
upgrade-insecure-requests;
`;
const contentSecurityPolicyHeaderValue = cspHeader
.replace(/\s{2,}/g, " ")
.trim();
const contentSecurityPolicyHeaderValue = cspHeader
.replace(/\s{2,}/g, " ")
.trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set(
"Content-Security-Policy",
contentSecurityPolicyHeaderValue,
);
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set(
"Content-Security-Policy",
contentSecurityPolicyHeaderValue,
);
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
response.headers.set(
"Content-Security-Policy",
contentSecurityPolicyHeaderValue,
);
response.headers.set(
"Content-Security-Policy",
contentSecurityPolicyHeaderValue,
);
return response;
}
return response;
};
export default withAuth(checkRewrites, {

View file

@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
basePath: '/link',
poweredByHeader: false,
transpilePackages: [
"@link-stack/leafcutter-ui",
@ -30,20 +31,6 @@ const nextConfig = {
},
];
},
rewrites: async () => {
return {
beforeFiles: [
{
source: "/api/v1/:path*",
destination: `${process.env.ZAMMAD_URL ?? "http://zammad-nginx:8080"}/api/v1/:path*`,
},
{
source: "/ws",
destination: `${process.env.ZAMMAD_URL ?? "http://zammad-nginx:8080"}/ws`,
},
],
};
},
};
export default nextConfig;

View file

@ -2,22 +2,24 @@ x-global-vars: &common-global-variables
TZ: Etc/UTC
x-bridge-vars: &common-bridge-variables
DATABASE_HOST: "bridge-postgresql"
DATABASE_NAME: "bridge"
DATABASE_HOST: "postgresql"
DATABASE_NAME: "cdr"
DATABASE_USER: ${DATABASE_USER}
DATABASE_ROOT_OWNER: "root"
DATABASE_ROOT_PASSWORD: ${BRIDGE_DATABASE_ROOT_PASSWORD}
DATABASE_OWNER: "bridge"
DATABASE_PASSWORD: ${BRIDGE_DATABASE_PASSWORD}
DATABASE_URL: "postgresql://bridge:${BRIDGE_DATABASE_PASSWORD}@bridge-postgresql/bridge"
WORKER_DATABASE_URL: "postgresql://bridge:${BRIDGE_DATABASE_PASSWORD}@bridge-postgresql/bridge"
SHADOW_DATABASE_URL: "postgresql://bridge:${BRIDGE_DATABASE_PASSWORD}@bridge-postgresql/bridge_shadow"
ROOT_DATABASE_URL: "postgresql://bridge:${BRIDGE_DATABASE_PASSWORD}@bridge-postgresql/template1"
APP_ROOT_DATABASE_URL: "postgresql://root:${BRIDGE_DATABASE_ROOT_PASSWORD}@bridge-postgresql/bridge"
DATABASE_AUTH_URL: "postgresql://app_graphile_auth:${BRIDGE_DATABASE_AUTHENTICATOR_PASSWORD}@bridge-postgresql/bridge"
DATABASE_OWNER: ${DATABASE_USER}
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
DATABASE_URL: "postgresql://bridge:${DATABASE_PASSWORD}@postgresql/cdr"
WORKER_DATABASE_URL: "postgresql://bridge:${DATABASE_PASSWORD}@postgresql/cdr"
SHADOW_DATABASE_URL: "postgresql://bridge:${DATABASE_PASSWORD}@postgresql/cdr_shadow"
ROOT_DATABASE_URL: "postgresql://bridge:${DATABASE_PASSWORD}@postgresql/template1"
APP_ROOT_DATABASE_URL: "postgresql://root:${BRIDGE_DATABASE_ROOT_PASSWORD}@postgresql/bridge"
DATABASE_AUTH_URL: "postgresql://app_graphile_auth:${BRIDGE_DATABASE_AUTHENTICATOR_PASSWORD}@postgresql/cdr"
FRONTEND_URL: ${BRIDGE_DOMAIN}
API_URL: "http://bridge-api:3001"
NEXTAUTH_URL: ${BRIDGE_DOMAIN}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
BRIDGE_SIGNAL_URL: ${BRIDGE_SIGNAL_URL}
services:
bridge-frontend:

View file

@ -13,15 +13,23 @@ services:
environment:
ZAMMAD_API_TOKEN: ${ZAMMAD_API_TOKEN}
ZAMMAD_VIRUAL_HOST: ${ZAMMAD_VIRTUAL_HOST}
LINK_URL: http://localhost:3000
LINK_URL: ${LINK_URL}
LEAFCUTTER_URL: https://lc.digiresilience.org
BRIDGE_URL: http://bridge-frontend:3000
BRIDGE_SIGNAL_URL: http://signal-cli-rest-api:8080
BRIDGE_WHATSAPP_URL: http://bridge-whatsapp:3000
ZAMMAD_URL: http://zammad-nginx:8080
REDIS_URL: "redis://zammad-redis:6379"
NEXTAUTH_URL: ${LINK_URL}
NEXTAUTH_URL: ${LINK_URL}/api/auth
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_AUDIENCE: ${NEXTAUTH_AUDIENCE}
NEXTAUTH_SIGNING_KEY_B64: ${NEXTAUTH_SIGNING_KEY_B64}
NEXTAUTH_ENCRYPTION_KEY_B64: ${NEXTAUTH_ENCRYPTION_KEY_B64}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
DATABASE_HOST: ${DATABASE_HOST}
DATABASE_NAME: ${DATABASE_NAME}
DATABASE_PORT: ${DATABASE_PORT}
DATABASE_USER: ${DATABASE_USER}
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
DATABASE_URL: ${DATABASE_URL}

View file

@ -4,7 +4,7 @@ services:
build: ../signal-cli-rest-api
image: registry.gitlab.com/digiresilience/link/link-stack/signal-cli-rest-api:develop
environment:
- MODE=json-rpc
- MODE=normal
volumes:
- signal-cli-rest-api-data:/home/.local/share/signal-cli
ports:

View file

@ -78,7 +78,6 @@ services:
- postgresql
environment:
<<: [*common-global-variables, *common-zammad-variables]
RAILS_RELATIVE_URL_ROOT: /zammad
build:
context: ../zammad
args:

View file

@ -7,6 +7,7 @@ const files = {
all: ["zammad", "postgresql", "bridge", "opensearch", "leafcutter", "link", "signal-cli-rest-api"],
linkDev: ["zammad", "postgresql", "opensearch"],
link: ["zammad", "postgresql", "opensearch", "link"],
linkOnly: ["link"],
leafcutterDev: ["opensearch"],
leafcutter: ["opensearch", "leafcutter"],
opensearch: ["opensearch"],

View file

@ -1 +1 @@
FROM bbernhard/signal-cli-rest-api:0.90
FROM bbernhard/signal-cli-rest-api:0.176-dev

View file

@ -22,7 +22,15 @@ RUN sed -i '/touch db\/schema.rb/a ZAMMAD_SAFE_MODE=1 DATABASE_URL=postgresql:\/
RUN cat contrib/docker/setup.sh
RUN contrib/docker/setup.sh builder
ARG EMBEDDED=false
RUN if [ "$EMBEDDED" = "true" ] ; then sed -i '/proxy_set_header X-Forwarded-User "";/d' ${ZAMMAD_DIR}/contrib/nginx/zammad.conf; fi
RUN if [ "$EMBEDDED" = "true" ] ; then sed -i '/location \/ {/i \
\ \n\
\ location /link {\n\
\ proxy_pass http://link:3000;\n\
\ proxy_set_header Host $host;\n\
\ proxy_set_header X-Real-IP $remote_addr;\n\
\ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\
\ proxy_set_header X-Forwarded-Proto https;\n\
\ }\n' ${ZAMMAD_DIR}/contrib/nginx/zammad.conf; fi
RUN sed -i '/^[[:space:]]*# es config/a\
echo "about to reinstall..."\n\
bundle exec rails runner /opt/zammad/contrib/link/setup.rb\n\

2078
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,7 @@
"docker:link:up": "node docker/scripts/docker.js link up",
"docker:link:down": "node docker/scripts/docker.js link down",
"docker:link:build": "node docker/scripts/docker.js link build",
"docker:linkonly:build": "node docker/scripts/docker.js linkOnly build",
"docker:opensearch:up": "node docker/scripts/docker.js opensearch up",
"docker:opensearch:down": "node docker/scripts/docker.js opensearch down",
"docker:opensearch:build": "node docker/scripts/docker.js opensearch build",

View file

@ -1,7 +1,7 @@
import { ServiceConfig } from "../lib/service";
const getQRCode = async (token: string): Promise<Record<string, string>> => {
const url = `/api/signal/bots/${token}`;
const url = `/link/api/signal/bots/${token}`;
const result = await fetch(url, { cache: "no-store" });
const { qr } = await result.json();

View file

@ -2,7 +2,7 @@ import { ServiceConfig } from "../lib/service";
// import { generateSelectOneAction } from "../lib/actions";
const getQRCode = async (token: string) => {
const url = `/api/whatsapp/bots/${token}`;
const url = `/link/api/whatsapp/bots/${token}`;
const result = await fetch(url, { cache: "no-store" });
const { qr } = await result.json();