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": "*" "@link-stack/ui": "*"
}, },
"devDependencies": { "devDependencies": {
"@link-stack/eslint-config": "*",
"@link-stack/typescript-config": "*",
"@types/node": "^22", "@types/node": "^22",
"@types/pg": "^8.11.11", "@types/pg": "^8.11.11",
"@types/react": "^19", "@types/react": "^19",

View file

@ -12,6 +12,7 @@ import {
import { import {
Apple as AppleIcon, Apple as AppleIcon,
Google as GoogleIcon, Google as GoogleIcon,
Microsoft as MicrosoftIcon,
Key as KeyIcon, Key as KeyIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { signIn, getProviders } from "next-auth/react"; import { signIn, getProviders } from "next-auth/react";
@ -183,6 +184,21 @@ export const Login: FC<LoginProps> = ({ session }) => {
</IconButton> </IconButton>
</Grid> </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" && ( {provider === "credentials" && (
<Grid item container spacing={3}> <Grid item container spacing={3}>
<Grid item sx={{ width: "100%" }}> <Grid item sx={{ width: "100%" }}>

View file

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

View file

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

View file

@ -11,6 +11,7 @@ import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials"; import Credentials from "next-auth/providers/credentials";
import Apple from "next-auth/providers/apple"; import Apple from "next-auth/providers/apple";
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import AzureADProvider from "next-auth/providers/azure-ad";
const headers = { Authorization: `Token ${process.env.ZAMMAD_API_TOKEN}` }; 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, 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 { } else {
providers.push( providers.push(
Credentials({ Credentials({

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { withAuth, NextRequestWithAuth } from "next-auth/middleware"; import { withAuth, NextRequestWithAuth } from "next-auth/middleware";
/*
const rewriteURL = ( const rewriteURL = (
request: NextRequestWithAuth, request: NextRequestWithAuth,
originBaseURL: string, originBaseURL: string,
@ -27,7 +28,7 @@ const rewriteURL = (
request: { headers: requestHeaders }, request: { headers: requestHeaders },
}); });
}; };
*/
const checkRewrites = async (request: NextRequestWithAuth) => { const checkRewrites = async (request: NextRequestWithAuth) => {
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";
@ -109,7 +110,6 @@ const checkRewrites = async (request: NextRequestWithAuth) => {
); );
return response; return response;
}
}; };
export default withAuth(checkRewrites, { export default withAuth(checkRewrites, {

View file

@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
basePath: '/link',
poweredByHeader: false, poweredByHeader: false,
transpilePackages: [ transpilePackages: [
"@link-stack/leafcutter-ui", "@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; export default nextConfig;

View file

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

View file

@ -13,15 +13,23 @@ services:
environment: environment:
ZAMMAD_API_TOKEN: ${ZAMMAD_API_TOKEN} ZAMMAD_API_TOKEN: ${ZAMMAD_API_TOKEN}
ZAMMAD_VIRUAL_HOST: ${ZAMMAD_VIRTUAL_HOST} ZAMMAD_VIRUAL_HOST: ${ZAMMAD_VIRTUAL_HOST}
LINK_URL: http://localhost:3000 LINK_URL: ${LINK_URL}
LEAFCUTTER_URL: https://lc.digiresilience.org LEAFCUTTER_URL: https://lc.digiresilience.org
BRIDGE_URL: http://bridge-frontend:3000 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 ZAMMAD_URL: http://zammad-nginx:8080
REDIS_URL: "redis://zammad-redis:6379" REDIS_URL: "redis://zammad-redis:6379"
NEXTAUTH_URL: ${LINK_URL} NEXTAUTH_URL: ${LINK_URL}/api/auth
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_AUDIENCE: ${NEXTAUTH_AUDIENCE} NEXTAUTH_AUDIENCE: ${NEXTAUTH_AUDIENCE}
NEXTAUTH_SIGNING_KEY_B64: ${NEXTAUTH_SIGNING_KEY_B64} NEXTAUTH_SIGNING_KEY_B64: ${NEXTAUTH_SIGNING_KEY_B64}
NEXTAUTH_ENCRYPTION_KEY_B64: ${NEXTAUTH_ENCRYPTION_KEY_B64} NEXTAUTH_ENCRYPTION_KEY_B64: ${NEXTAUTH_ENCRYPTION_KEY_B64}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} 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 build: ../signal-cli-rest-api
image: registry.gitlab.com/digiresilience/link/link-stack/signal-cli-rest-api:develop image: registry.gitlab.com/digiresilience/link/link-stack/signal-cli-rest-api:develop
environment: environment:
- MODE=json-rpc - MODE=normal
volumes: volumes:
- signal-cli-rest-api-data:/home/.local/share/signal-cli - signal-cli-rest-api-data:/home/.local/share/signal-cli
ports: ports:

View file

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

View file

@ -7,6 +7,7 @@ const files = {
all: ["zammad", "postgresql", "bridge", "opensearch", "leafcutter", "link", "signal-cli-rest-api"], all: ["zammad", "postgresql", "bridge", "opensearch", "leafcutter", "link", "signal-cli-rest-api"],
linkDev: ["zammad", "postgresql", "opensearch"], linkDev: ["zammad", "postgresql", "opensearch"],
link: ["zammad", "postgresql", "opensearch", "link"], link: ["zammad", "postgresql", "opensearch", "link"],
linkOnly: ["link"],
leafcutterDev: ["opensearch"], leafcutterDev: ["opensearch"],
leafcutter: ["opensearch", "leafcutter"], leafcutter: ["opensearch", "leafcutter"],
opensearch: ["opensearch"], 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 cat contrib/docker/setup.sh
RUN contrib/docker/setup.sh builder RUN contrib/docker/setup.sh builder
ARG EMBEDDED=false 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\ RUN sed -i '/^[[:space:]]*# es config/a\
echo "about to reinstall..."\n\ echo "about to reinstall..."\n\
bundle exec rails runner /opt/zammad/contrib/link/setup.rb\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:up": "node docker/scripts/docker.js link up",
"docker:link:down": "node docker/scripts/docker.js link down", "docker:link:down": "node docker/scripts/docker.js link down",
"docker:link:build": "node docker/scripts/docker.js link build", "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:up": "node docker/scripts/docker.js opensearch up",
"docker:opensearch:down": "node docker/scripts/docker.js opensearch down", "docker:opensearch:down": "node docker/scripts/docker.js opensearch down",
"docker:opensearch:build": "node docker/scripts/docker.js opensearch build", "docker:opensearch:build": "node docker/scripts/docker.js opensearch build",

View file

@ -1,7 +1,7 @@
import { ServiceConfig } from "../lib/service"; import { ServiceConfig } from "../lib/service";
const getQRCode = async (token: string): Promise<Record<string, string>> => { 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 result = await fetch(url, { cache: "no-store" });
const { qr } = await result.json(); const { qr } = await result.json();

View file

@ -2,7 +2,7 @@ import { ServiceConfig } from "../lib/service";
// import { generateSelectOneAction } from "../lib/actions"; // import { generateSelectOneAction } from "../lib/actions";
const getQRCode = async (token: string) => { 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 result = await fetch(url, { cache: "no-store" });
const { qr } = await result.json(); const { qr } = await result.json();