├── frontend ├── .env_example ├── src │ ├── app │ │ ├── favicon.ico │ │ ├── login │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── globals.css │ │ ├── page.tsx │ │ ├── dashboard │ │ │ ├── admin │ │ │ │ ├── page.tsx │ │ │ │ ├── groups │ │ │ │ │ └── page.tsx │ │ │ │ ├── webhooks │ │ │ │ │ └── page.tsx │ │ │ │ └── users │ │ │ │ │ └── page.tsx │ │ │ ├── settings │ │ │ │ └── page.tsx │ │ │ ├── groups │ │ │ │ ├── new │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── [groupId] │ │ │ │ │ └── webhooks │ │ │ │ │ ├── new │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── forgot-password │ │ │ └── page.tsx │ │ ├── impressum │ │ │ └── page.tsx │ │ ├── page.module.css │ │ ├── register │ │ │ └── page.tsx │ │ ├── reset-password │ │ │ └── page.tsx │ │ └── privacy │ │ │ └── page.tsx │ ├── contexts │ │ └── AuthContext.tsx │ └── components │ │ └── Navbar.tsx ├── public │ ├── hero_signalcow.jpg │ ├── vercel.svg │ ├── window.svg │ ├── file.svg │ ├── globe.svg │ └── next.svg ├── next-env.d.ts ├── next.config.mjs ├── eslint.config.mjs ├── package.json └── tsconfig.json ├── backend ├── routes │ ├── webhookRoutes.js │ ├── adminApiRoutes.js │ └── groups.js ├── .eslintrc.js ├── config │ └── db.js ├── migrations │ ├── 1748335538478_add-description-to-groups.js │ ├── 1748335538479_add_password_reset_tokens_table.js │ └── 1748330134341_initial-schema-v2.js ├── .env_example ├── package.json ├── middleware │ ├── authMiddleware.js │ └── adminAuthMiddleware.js ├── gulpfile.js ├── server.js └── services │ └── signalService.js ├── backend.service.example ├── frontend.service.example ├── signal-cli.service.example ├── .github └── workflows │ └── npm-gulp.yml ├── nginx.vhost.example.conf └── .gitignore /frontend/.env_example: -------------------------------------------------------------------------------- 1 | PORT=3000 # Frontend 2 | NEXT_PUBLIC_BOT_PHONE_NUMBER="+1234567890" -------------------------------------------------------------------------------- /frontend/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gummipunkt/signalcow/HEAD/frontend/src/app/favicon.ico -------------------------------------------------------------------------------- /frontend/public/hero_signalcow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gummipunkt/signalcow/HEAD/frontend/public/hero_signalcow.jpg -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /backend/routes/webhookRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // Placeholder - webhook-specific routes will go here later 5 | // e.g., router.get('/', ...); // To list webhooks for a group 6 | // router.post('/', ...); // To create a new webhook for a group 7 | // router.delete('/:id', ...); 8 | 9 | module.exports = router; -------------------------------------------------------------------------------- /frontend/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | async rewrites() { 4 | return [ 5 | { 6 | source: '/api/:path*', 7 | destination: 'https://signalbot.ecow.dev/api/:path*', // Forwards requests to the backend on port 3001 8 | }, 9 | ]; 10 | }, 11 | }; 12 | 13 | export default nextConfig; -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: 'eslint:recommended', 8 | parserOptions: { 9 | ecmaVersion: 12, 10 | }, 11 | rules: { 12 | 'no-console': 'warn', 13 | 'indent': ['error', 2], 14 | 'quotes': ['error', 'single'], 15 | 'semi': ['error', 'always'], 16 | }, 17 | }; -------------------------------------------------------------------------------- /frontend/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/config/db.js: -------------------------------------------------------------------------------- 1 | const { Pool } = require('pg'); 2 | require('dotenv').config({ path: '../.env' }); // Ensures .env is loaded from the backend root 3 | 4 | const pool = new Pool({ 5 | user: process.env.DB_USER, 6 | host: process.env.DB_HOST, 7 | database: process.env.DB_DATABASE, 8 | password: process.env.DB_PASSWORD, 9 | port: process.env.DB_PORT, 10 | }); 11 | 12 | module.exports = pool; -------------------------------------------------------------------------------- /frontend/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/login/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function Loading() { 2 | // You can add any UI inside Loading, including a Skeleton. 3 | return ( 4 |
5 |
6 |

Loading login page...

7 | 8 |
9 |
10 | ); 11 | } -------------------------------------------------------------------------------- /backend/migrations/1748335538478_add-description-to-groups.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | exports.shorthands = undefined; 4 | 5 | exports.up = pgm => { 6 | pgm.addColumns('groups', { 7 | description: { type: 'text', allowNull: true } // Oder varchar(255) etc., je nachdem was du brauchst 8 | }); 9 | }; 10 | 11 | exports.down = pgm => { 12 | pgm.dropColumns('groups', ['description']); 13 | }; 14 | -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "npx dotenv-cli next dev", 7 | "build": "next build", 8 | "start": "npx dotenv-cli -- next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "bulma": "^1.0.4", 13 | "next": "15.1.8", 14 | "react": "^19.0.0", 15 | "react-dom": "^19.0.0" 16 | }, 17 | "devDependencies": { 18 | "@eslint/eslintrc": "^3", 19 | "@types/node": "^20", 20 | "@types/react": "^19", 21 | "@types/react-dom": "^19", 22 | "eslint": "^9", 23 | "eslint-config-next": "15.1.8", 24 | "gulp": "^5.0.1", 25 | "typescript": "^5" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /backend/.env_example: -------------------------------------------------------------------------------- 1 | DB_USER=signalcow 2 | DB_HOST=localhost 3 | DB_DATABASE=signalcow 4 | DB_PASSWORD=signalcowExamplePassword 5 | DB_PORT=5432 6 | DATABASE_URL=postgres://signalcow:signalcowExamplePassword@localhost:5432/signalcow 7 | 8 | JWT_SECRET=Your_Own_Secret_JWT_String 9 | SIGNAL_CLI_HOST=localhost 10 | SIGNAL_CLI_PORT=7446 11 | BOT_NUMBER=+1234567890 12 | ADMIN_USERNAME=user_admin 13 | ADMIN_PASSWORD=password 14 | 15 | # Email SMTP Configuration for Password Reset 16 | SMTP_HOST=smtp.example.com 17 | SMTP_PORT=587 18 | SMTP_USER=user@example.com 19 | SMTP_PASS=yourSmtpPassword 20 | SMTP_FROM_EMAIL="SignalCow Password Reset" # Used as the sender for password reset emails 21 | 22 | PORT=3001 # Backend 23 | BASE_URL=http://localhost:3001 # Generates Webhook URL 24 | FRONTEND_BASE_URL=http://localhost:3000 # Base URL for the frontend, used in password reset emails -------------------------------------------------------------------------------- /frontend/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend.service.example: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SignalBot Backend Service 3 | # Start after the network is available and potentially after the database 4 | After=network.target # mysql.service postgresql.service or other DBs 5 | 6 | [Service] 7 | # Replace 'your-user' and 'your-group' with the actual user and group 8 | User=your-user 9 | Group=your-group 10 | 11 | # Replace with the actual path to your backend's root directory on the server 12 | WorkingDirectory=/srv/signalbot/backend 13 | 14 | # The command to start your backend. Adjust if necessary. 15 | # Example for a Node.js app started with 'node server.js': 16 | ExecStart=/usr/bin/node server.js 17 | # Example for a typical Node.js app using npm start: 18 | # ExecStart=/usr/bin/npm start 19 | 20 | Restart=on-failure 21 | Environment=NODE_ENV=production 22 | # Add other environment variables your backend might need 23 | # Environment="DATABASE_URL=your-db-connection-string" 24 | # Environment="PORT=YOUR_BACKEND_PORT" # e.g., 8000 25 | 26 | [Install] 27 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "main": "server.js", 5 | "scripts": { 6 | "start": "node server.js", 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "migrate": "dotenv -- node-pg-migrate", 9 | "migrate:create": "npm run migrate create --", 10 | "migrate:up": "npm run migrate up", 11 | "migrate:down": "npm run migrate down" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "description": "", 17 | "dependencies": { 18 | "bcryptjs": "^3.0.2", 19 | "dotenv": "^16.5.0", 20 | "express": "^5.1.0", 21 | "express-basic-auth": "^1.2.1", 22 | "jsonwebtoken": "^9.0.2", 23 | "nodemailer": "^7.0.3", 24 | "pg": "^8.16.0", 25 | "swagger-jsdoc": "^6.2.8", 26 | "swagger-ui-express": "^5.0.1", 27 | "uuid": "^11.1.0" 28 | }, 29 | "devDependencies": { 30 | "dotenv-cli": "^8.0.0", 31 | "gulp": "^5.0.1", 32 | "gulp-eslint-new": "^2.4.0", 33 | "gulp-nodemon": "^2.5.0", 34 | "gulp-shell": "^0.8.0", 35 | "node-pg-migrate": "^8.0.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/middleware/authMiddleware.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | require('dotenv').config({ path: '../.env' }); // Ensures .env is loaded from the backend root 3 | 4 | const authenticateToken = (req, res, next) => { 5 | let token; 6 | const authHeader = req.headers.authorization; 7 | 8 | if (authHeader && authHeader.startsWith('Bearer ')) { 9 | try { 10 | // Extract token from header (Bearer ) 11 | token = authHeader.split(' ')[1]; 12 | 13 | // Verify token 14 | const decoded = jwt.verify(token, process.env.JWT_SECRET); 15 | 16 | // Add user information to the request object (without password or other sensitive data) 17 | // We stored user ID and email in the token payload 18 | req.user = decoded.user; 19 | 20 | next(); 21 | } catch (error) { 22 | console.error('Token verification error:', error.message); 23 | res.status(401).json({ message: 'Not authorized, token invalid.' }); 24 | } 25 | } 26 | 27 | if (!token) { 28 | res.status(401).json({ message: 'Not authorized, no token provided.' }); 29 | } 30 | }; 31 | 32 | module.exports = { authenticateToken }; -------------------------------------------------------------------------------- /frontend/src/app/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: #ffffff; 3 | --foreground: #171717; 4 | --pastel-lilac: #C8A2C8; /* Lilac */ 5 | --pastel-light-lilac: #E6D9E6; 6 | --pastel-pink: #FFDFD3; /* Light Pink */ 7 | --pastel-blue: #D3E0FF; /* Light Blue */ 8 | --pastel-green: #D3FFD3; /* Light Green */ 9 | --pastel-yellow: #FFFACD; /* Lemon Chiffon (Pastel Yellow) */ 10 | --text-color: #4A4A4A; /* Bulma's default text color for contrast */ 11 | --text-color-light: #7A7A7A; 12 | --border-color: #DBDBDB; /* Bulma's default border color */ 13 | --background-color: #F5F5F5; /* Light grey background */ 14 | } 15 | 16 | @media (prefers-color-scheme: dark) { 17 | :root { 18 | --background: #0a0a0a; 19 | --foreground: #ededed; 20 | } 21 | } 22 | 23 | html, 24 | body { 25 | max-width: 100vw; 26 | overflow-x: hidden; 27 | } 28 | 29 | body { 30 | color: var(--foreground); 31 | background: var(--background); 32 | font-family: Arial, Helvetica, sans-serif; 33 | -webkit-font-smoothing: antialiased; 34 | -moz-osx-font-smoothing: grayscale; 35 | } 36 | 37 | * { 38 | box-sizing: border-box; 39 | padding: 0; 40 | margin: 0; 41 | } 42 | 43 | a { 44 | color: inherit; 45 | text-decoration: none; 46 | } 47 | 48 | @media (prefers-color-scheme: dark) { 49 | html { 50 | color-scheme: dark; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /frontend/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend.service.example: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SignalBot Frontend Service 3 | # Start after the network is available 4 | After=network.target 5 | 6 | [Service] 7 | # Replace 'your-user' and 'your-group' with the actual user and group 8 | # that should run this service. It's highly recommended NOT to use root. 9 | User=your-user 10 | Group=your-group 11 | 12 | # Replace with the actual path to your frontend's root directory on the server 13 | WorkingDirectory=/srv/signalbot/frontend 14 | 15 | # Ensure you have a production build (e.g., 'npm run build' or 'next build') 16 | # The command to start your frontend. Adjust if necessary. 17 | # For Next.js, this is typically 'next start -p YOUR_FRONTEND_PORT' 18 | # Replace YOUR_FRONTEND_PORT with the port your frontend should run on (e.g., 3000) 19 | # Make sure the path to 'node' and 'next' (or 'npm') are correct for your server environment. 20 | # Example for Next.js: 21 | ExecStart=/usr/bin/node node_modules/.bin/next start 22 | # Example for a typical Node.js app using npm start: 23 | # ExecStart=/usr/bin/npm start 24 | 25 | Restart=on-failure 26 | # Set environment variables, NODE_ENV=production is crucial for performance 27 | Environment=NODE_ENV=production 28 | # Add other environment variables your frontend might need 29 | # Environment="API_URL=http://localhost:YOUR_BACKEND_PORT" 30 | 31 | [Install] 32 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /signal-cli.service.example: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=signal-cli Daemon Service 3 | # Start after the network is available 4 | After=network.target 5 | 6 | [Service] 7 | # It is STRONGLY recommended to run signal-cli as its own dedicated, non-privileged user. 8 | # Replace 'signal-cli-user' and 'signal-cli-group' accordingly. 9 | User=signal-cli-user 10 | Group=signal-cli-group 11 | 12 | # WorkingDirectory is usually not critical for signal-cli daemon, 13 | # but can be set to the user's home or a dedicated directory. 14 | # WorkingDirectory=/home/signal-cli-user 15 | 16 | # IMPORTANT: Adjust paths for your Ubuntu server installation! 17 | # - Replace '/PATH_TO_YOUR/signal-cli' with the actual path to the signal-cli executable. 18 | # (e.g., /usr/local/bin/signal-cli or /opt/signal-cli/bin/signal-cli) 19 | # - Replace '/PATH_TO_YOUR_SIGNAL_CONFIG_DIR' with your signal-cli config/data directory. 20 | # (e.g., /home/signal-cli-user/.config/signal-cli, /etc/signal-cli/data, or /opt/signal-cli/data) 21 | # - Replace '+YOUR_SIGNAL_NUMBER' with your actual registered signal number (e.g. +4915678470953) 22 | ExecStart=/PATH_TO_YOUR/signal-cli --config /PATH_TO_YOUR_SIGNAL_CONFIG_DIR -u +YOUR_SIGNAL_NUMBER daemon --tcp 0.0.0.0:7446 23 | 24 | Restart=on-failure 25 | # If signal-cli takes a long time to start, you might increase the timeout 26 | # TimeoutStartSec=300 27 | 28 | [Install] 29 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /backend/middleware/adminAuthMiddleware.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const pool = require('../config/db'); // Database pool 3 | 4 | module.exports = async function(req, res, next) { 5 | // Get the token from the header 6 | const token = req.header('x-auth-token') || (req.headers.authorization && req.headers.authorization.startsWith('Bearer ') ? req.headers.authorization.split(' ')[1] : null); 7 | 8 | // Check if no token is present 9 | if (!token) { 10 | return res.status(401).json({ msg: 'No token, access denied.' }); 11 | } 12 | 13 | // Verify the token 14 | try { 15 | const decoded = jwt.verify(token, process.env.JWT_SECRET); 16 | req.user = decoded.user; // User ID and other info from the token 17 | 18 | // Check if the user is an admin 19 | const userResult = await pool.query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]); 20 | 21 | if (userResult.rows.length === 0) { 22 | return res.status(401).json({ msg: 'Access denied. User not found.' }); 23 | } 24 | 25 | if (userResult.rows[0].is_admin !== true) { 26 | return res.status(403).json({ msg: 'Access denied. Admin rights required.' }); 27 | } 28 | 29 | next(); // Zum nächsten Middleware/Handler 30 | } catch (err) { 31 | console.error('Token error in adminAuthMiddleware:', err.message); 32 | res.status(401).json({ msg: 'Token is not valid' }); 33 | } 34 | }; -------------------------------------------------------------------------------- /.github/workflows/npm-gulp.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Node.js CI with Gulp 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | jobs: 11 | build_and_test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: 16 | - 18.x 17 | - 20.x 18 | - 21.x 19 | - 22.x 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | - name: Set up Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: npm 28 | cache-dependency-path: | 29 | backend/package-lock.json 30 | frontend/package-lock.json 31 | - name: Install Backend Dependencies 32 | working-directory: ./backend 33 | run: npm ci 34 | - name: Install Frontend Dependencies 35 | working-directory: ./frontend 36 | run: npm ci 37 | - name: Run Frontend Build via Gulp 38 | working-directory: ./backend 39 | run: npx gulp build:frontend 40 | - name: Run Linter 41 | working-directory: ./backend 42 | run: npx gulp lint:backend 43 | - name: Upload Frontend Build Artifact 44 | uses: actions/upload-artifact@v4 45 | with: 46 | name: frontend-build-${{ matrix.node-version }} 47 | path: frontend/.next/ 48 | -------------------------------------------------------------------------------- /backend/migrations/1748335538479_add_password_reset_tokens_table.js: -------------------------------------------------------------------------------- 1 | exports.shorthands = undefined; 2 | 3 | exports.up = (pgm) => { 4 | console.log('[MIGRATE UP] Starting migration ADD_PASSWORD_RESET_TABLE...'); 5 | 6 | pgm.createTable('password_reset_tokens', { 7 | id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, 8 | user_id: { 9 | type: 'uuid', 10 | notNull: true, 11 | references: '"users"(id)', // Foreign key to users table 12 | onDelete: 'CASCADE', // If a user is deleted, their reset tokens are also deleted 13 | }, 14 | token_hash: { type: 'varchar(255)', notNull: true, unique: true }, // Store hashed token 15 | expires_at: { 16 | type: 'timestamp with time zone', 17 | notNull: true, 18 | }, 19 | created_at: { 20 | type: 'timestamp with time zone', 21 | notNull: true, 22 | default: pgm.func('current_timestamp'), 23 | } 24 | // No updated_at needed for this table as tokens are typically single-use or expire 25 | }); 26 | 27 | // Index for faster lookups on user_id 28 | pgm.createIndex('password_reset_tokens', 'user_id'); 29 | // Index for faster lookups on token_hash (though unique constraint already creates one) 30 | // pgm.createIndex('password_reset_tokens', 'token_hash'); // Redundant due to unique:true on token_hash 31 | 32 | console.log('[MIGRATE UP] Migration ADD_PASSWORD_RESET_TABLE finished.'); 33 | }; 34 | 35 | exports.down = (pgm) => { 36 | console.log('[MIGRATE DOWN] Starting DOWN migration ADD_PASSWORD_RESET_TABLE...'); 37 | pgm.dropTable('password_reset_tokens'); 38 | console.log('[MIGRATE DOWN] DOWN migration ADD_PASSWORD_RESET_TABLE finished.'); 39 | }; -------------------------------------------------------------------------------- /frontend/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useAuth } from '@/contexts/AuthContext'; 6 | import styles from "./page.module.css"; 7 | 8 | export default function Home() { 9 | const { isAuthenticated, isLoading } = useAuth(); 10 | const router = useRouter(); 11 | 12 | useEffect(() => { 13 | if (!isLoading && isAuthenticated) { 14 | router.push('/dashboard'); 15 | } 16 | }, [isAuthenticated, isLoading, router]); 17 | 18 | if (isLoading) { 19 | return ( 20 |
21 |

Loading...

22 |
23 | ); 24 | } 25 | 26 | if (isAuthenticated) { 27 | return ( 28 |
29 |

Redirecting to dashboard...

30 |
31 | ); 32 | } 33 | 34 | return ( 35 |
36 |
37 |
38 |

39 | Signalcow 40 |

41 |

42 | Your Signalbot for creating webhooks. My name is Torino. 43 |

44 |

45 | Please be careful, this service is an early alpha, join the Github repo to get updates. 46 |

47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /nginx.vhost.example.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_name signalcow.domain.tld; 3 | 4 | listen 443 ssl http2; 5 | listen [::]:443 ssl http2; 6 | 7 | #ssl_certificate /etc/letsencrypt/live/signalcow.domain.tld/fullchain.pem; 8 | #ssl_certificate_key /etc/letsencrypt/live/signalcow.domain.tld/privkey.pem; 9 | ssl_protocols TLSv1.2 TLSv1.3; 10 | ssl_prefer_server_ciphers off; 11 | ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; 12 | ssl_session_cache shared:SSL:10m; 13 | ssl_session_timeout 1d; 14 | ssl_session_tickets off; 15 | 16 | add_header X-Frame-Options "SAMEORIGIN" always; 17 | add_header X-XSS-Protection "1; mode=block" always; 18 | add_header X-Content-Type-Options "nosniff" always; 19 | add_header Referrer-Policy "strict-origin-when-cross-origin" always; 20 | add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; 21 | 22 | 23 | # API Backend (Port 3002) 24 | location ~ ^/(api|webhook|api-docs)/ { 25 | proxy_pass http://127.0.0.1:3002; 26 | proxy_set_header X-Real-IP $remote_addr; 27 | proxy_set_header Host $host; 28 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 29 | proxy_set_header X-Forwarded-Proto $scheme; 30 | 31 | proxy_http_version 1.1; 32 | proxy_set_header Upgrade $http_upgrade; 33 | proxy_set_header Connection "upgrade"; 34 | proxy_redirect off; 35 | } 36 | 37 | location / { 38 | proxy_pass http://127.0.0.1:3005; 39 | 40 | proxy_set_header X-Real-IP $remote_addr; 41 | proxy_set_header Host $host; 42 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 43 | proxy_set_header X-Forwarded-Proto $scheme; 44 | 45 | proxy_http_version 1.1; 46 | proxy_set_header Upgrade $http_upgrade; 47 | proxy_set_header Connection "upgrade"; 48 | 49 | proxy_redirect off; 50 | proxy_buffering off; 51 | } 52 | 53 | 54 | access_log /var/log/nginx/signalcow.domain.tld.access.log; 55 | error_log /var/log/nginx/signalcow.domain.tld.error.log; 56 | } 57 | 58 | server { 59 | listen 80; 60 | listen [::]:80; 61 | server_name signalcow.domain.tld; 62 | return 301 https://$host$request_uri; 63 | } -------------------------------------------------------------------------------- /frontend/src/app/dashboard/admin/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import Link from 'next/link'; 6 | import { useAuth } from '@/contexts/AuthContext'; 7 | 8 | export default function AdminDashboardPage() { 9 | const { isAuthenticated, user, isLoading } = useAuth(); 10 | const router = useRouter(); 11 | 12 | useEffect(() => { 13 | if (!isLoading && (!isAuthenticated || !user?.is_admin)) { 14 | router.push('/login'); // Or redirect to a generic dashboard if not admin but logged in 15 | } 16 | }, [isLoading, isAuthenticated, user, router]); 17 | 18 | if (isLoading || !isAuthenticated || !user?.is_admin) { 19 | return ( 20 |
21 |
22 |

Loading or checking admin permissions...

23 | 15% 24 |
25 |
26 | ); 27 | } 28 | 29 | return ( 30 |
31 |
32 |

Admin Dashboard

33 |

34 | Welcome, Administrator {user.username}! 35 |

36 | 37 |
38 |
39 |
40 |

User Management

41 |

View and manage all users in the system.

42 | 43 | Manage Users 44 | 45 |
46 |
47 | 48 |
49 |
50 |

Group Management

51 |

View and manage all groups in the system.

52 | 53 | Manage Groups 54 | 55 |
56 |
57 | 58 |
59 |
60 |

Webhook Management

61 |

View and manage all webhooks in the system.

62 | 63 | Manage Webhooks 64 | 65 |
66 |
67 |
68 |
69 |
70 | ); 71 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files 2 | .DS_Store 3 | .DS_Store? 4 | ._* 5 | .Spotlight-V100 6 | .Trashes 7 | ehthumbs.db 8 | Thumbs.db 9 | 10 | # IDE / Editor specific 11 | .idea/ 12 | .vscode/ 13 | *.project 14 | *.swp 15 | *~ 16 | 17 | # Dependency directories 18 | node_modules/ 19 | /node_modules 20 | frontend/node_modules/ 21 | backend/node_modules/ 22 | 23 | # Next.js specific (frontend) 24 | frontend/.next/ 25 | frontend/out/ 26 | frontend/.env 27 | frontend/.env.local 28 | frontend/.env.development.local 29 | frontend/.env.test.local 30 | frontend/.env.production.local 31 | frontend/npm-debug.log* 32 | frontend/yarn-debug.log* 33 | frontend/yarn-error.log* 34 | frontend/.pnpm-debug.log* 35 | frontend/*.tsbuildinfocd 36 | # next-env.d.ts is often committed, but if you prefer to ignore: 37 | # frontend/next-env.d.ts 38 | 39 | # Backend specific (assuming Node.js, adjust if different) 40 | # backend/node_modules/ # Already covered by general node_modules/ 41 | backend/.env 42 | backend/.env.local 43 | backend/.env.development.local 44 | backend/.env.test.local 45 | backend/.env.production.local 46 | backend/npm-debug.log* 47 | backend/yarn-debug.log* 48 | backend/yarn-error.log* 49 | backend/.pnpm-debug.log* 50 | # If backend uses a different language/framework, add specific ignores here 51 | # e.g., for Python: 52 | # backend/__pycache__/ 53 | # backend/*.py[cod] 54 | # backend/.venv/ 55 | # backend/venv/ 56 | # backend/env/ 57 | 58 | # General Build / Output Artifacts 59 | build/ 60 | /build 61 | dist/ 62 | /dist 63 | 64 | # Log files 65 | *.log 66 | logs/ 67 | 68 | # Coverage reports 69 | coverage/ 70 | /coverage 71 | 72 | # Temporary files / Caches 73 | .sass-cache/ # if SASS is used directly 74 | frontend/tmp/ 75 | 76 | # Secrets / Sensitive files (add any other sensitive files here) 77 | *.pem 78 | # .env files are generally good to ignore, handled proyecto specific above 79 | 80 | # Bower (if used, legacy) 81 | # frontend/.bower_components/ 82 | # frontend/bower_components/ 83 | 84 | # Specific project files/folders to ignore 85 | .architect # From original .gitignore 86 | # backend/signalbot/ # From original .gitignore, if it's a generated folder 87 | # resources/sass/.sass-cache/ # Covered by .sass-cache/ 88 | # resources/.arch-insternal-preview.css # From original .gitignore 89 | # .arch-internal-preview.css # From original .gitignore 90 | bmad-agent/ # From original .gitignore 91 | migrations/ # From original .gitignore, if auto-generated and not versioned 92 | 93 | # Yarn / PnP 94 | .pnp 95 | .pnp.js 96 | .yarn/cache 97 | .yarn/unplugged 98 | .yarn/build-state.yml 99 | .yarn/install-state.gz 100 | 101 | # PNPM 102 | .pnpm-debug.log* 103 | 104 | # Vercel (if deploying there) 105 | .vercel 106 | -------------------------------------------------------------------------------- /frontend/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | // 'use client'; // No longer needed at this level 2 | 3 | import type { Metadata } from "next"; 4 | import Link from 'next/link'; 5 | import { Inter } from "next/font/google"; 6 | import "./globals.css"; 7 | import 'bulma/css/bulma.min.css'; 8 | import { AuthProvider } from '@/contexts/AuthContext'; // useAuth is no longer directly needed here 9 | import Navbar from '@/components/Navbar'; // Import the externalized Navbar 10 | import Image from 'next/image'; // Ensure Image is imported 11 | 12 | const inter = Inter({ subsets: ["latin"] }); 13 | 14 | export const metadata: Metadata = { // This export should work now 15 | title: "Signalcow Admin", 16 | description: "Admin interface for Signalcow", 17 | }; 18 | 19 | // The Navbar function definition has been removed 20 | 21 | export default function RootLayout({ 22 | children, 23 | }: Readonly<{ 24 | children: React.ReactNode; 25 | }>) { 26 | return ( 27 | 28 | 29 | 36 | 37 | 38 | 39 | {/* Use the imported Navbar component */} 40 |
{children}
41 |
42 |
43 |

44 | Signalcow Logo 45 | SignalCow 46 | by 47 | gummipunkt. The source code is licensed 48 | GPL 3.0. Source code at Github 49 | GPL 3.0. 50 |

51 |
52 | 53 | Legal Notice 54 | 55 | 56 | Privacy Policy 57 | 58 |
59 |
60 |
61 |
62 | 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /frontend/src/app/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import Link from 'next/link'; 5 | 6 | export default function ForgotPasswordPage() { 7 | const [email, setEmail] = useState(''); 8 | const [message, setMessage] = useState(null); 9 | const [error, setError] = useState(null); 10 | const [isLoading, setIsLoading] = useState(false); 11 | 12 | const handleSubmit = async (event: React.FormEvent) => { 13 | event.preventDefault(); 14 | setMessage(null); 15 | setError(null); 16 | setIsLoading(true); 17 | 18 | if (!email) { 19 | setError('Email is required.'); 20 | setIsLoading(false); 21 | return; 22 | } 23 | 24 | try { 25 | const response = await fetch('/api/auth/forgot-password', { 26 | method: 'POST', 27 | headers: { 'Content-Type': 'application/json' }, 28 | body: JSON.stringify({ email }), 29 | }); 30 | 31 | if (!response.ok) { 32 | console.error("Forgot password API error:", await response.text()); 33 | } 34 | 35 | setMessage('If an account with that email exists, a password reset link has been sent.'); 36 | } catch (err: unknown) { 37 | console.error("Forgot password submission error:", err); 38 | if (err instanceof Error) { 39 | setMessage('If an account with that email exists, a password reset link has been sent.'); 40 | } else { 41 | setMessage('If an account with that email exists, a password reset link has been sent.'); 42 | } 43 | } finally { 44 | setIsLoading(false); 45 | } 46 | }; 47 | 48 | return ( 49 |
50 |
51 |
52 |

Forgot Password

53 | {message && ( 54 |
55 | {message} 56 |
57 | )} 58 | {error && ( 59 |
60 | {error} 61 |
62 | )} 63 |
64 |
65 | 66 |
67 | ) => setEmail(e.target.value)} 74 | required 75 | /> 76 |
77 |
78 |
79 | 86 |
87 |
88 |
89 |

90 | Remembered your password? Login here. 91 |

92 |
93 |
94 |
95 | ); 96 | } -------------------------------------------------------------------------------- /backend/gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const nodemon = require('gulp-nodemon'); 3 | const shell = require('gulp-shell'); 4 | const path = require('path'); // Added for path resolution 5 | const eslint = require('gulp-eslint-new'); // For ESLint task 6 | 7 | const backendDir = __dirname; 8 | const frontendDir = path.join(__dirname, '../frontend'); 9 | 10 | // Task to start the backend server with Nodemon 11 | gulp.task('serve:backend', () => { 12 | return nodemon({ 13 | script: 'server.js', // Your main server file 14 | ext: 'js json', // Watch for changes in these file types 15 | ignore: ['node_modules/', 'gulpfile.js', 'migrations/', path.join(frontendDir, '**')], // Ignore these paths and the frontend dir 16 | env: { 'NODE_ENV': 'development' }, 17 | cwd: backendDir 18 | }); 19 | }); 20 | 21 | // Task to run database migrations (up) 22 | // Ensures the command is run in the backend directory 23 | gulp.task('migrate:up', shell.task('npm run migrate:up', { cwd: backendDir })); 24 | 25 | // Task to run database migrations (down) 26 | // Ensures the command is run in the backend directory 27 | gulp.task('migrate:down', shell.task('npm run migrate:down', { cwd: backendDir })); 28 | https://www.spiegel.de/ 29 | // --- Linting Task --- 30 | gulp.task('lint:backend', () => { 31 | // Ensure you have an ESLint configuration file (e.g., .eslintrc.js) in the backend directory 32 | // Adjust the src glob pattern as needed, excluding node_modules, this gulpfile, and migrations 33 | return gulp.src([ 34 | `${backendDir}/**/*.js`, 35 | `!${backendDir}/node_modules/**`, 36 | `!${backendDir}/gulpfile.js`, 37 | `!${backendDir}/migrations/**` 38 | ]) 39 | .pipe(eslint({ overrideConfigFile: path.join(backendDir, '.eslintrc.js') })) 40 | .pipe(eslint.format()) 41 | .pipe(eslint.failAfterError()); 42 | }); 43 | 44 | // --- Frontend Tasks --- 45 | 46 | // Task to start the Next.js development server for the frontend 47 | gulp.task('serve:frontend', shell.task('npm run dev', { 48 | cwd: frontendDir, 49 | // Optionally, you can try to add a prefix to the output to distinguish from backend logs 50 | // This depends on gulp-shell's capabilities and might need a different approach for complex logging. 51 | // verbose: true, // May help see output 52 | // templateData: { prefix: '[Frontend]' } // This is a guess, check gulp-shell docs if needed 53 | })); 54 | 55 | // Task to build the frontend for production 56 | gulp.task('build:frontend', shell.task('npm run build', { cwd: frontendDir })); 57 | 58 | // Task to start the frontend production server 59 | gulp.task('start:frontend', shell.task('npm run start', { cwd: frontendDir })); 60 | 61 | // --- Combined Tasks --- 62 | 63 | // Default task: Starts only the backend server (to keep it simple) 64 | gulp.task('default', gulp.series('serve:backend')); 65 | 66 | // Development task: Starts both backend and frontend development servers in parallel 67 | // It also runs backend linting first. 68 | // If linting fails, a Gulp error will prevent subsequent tasks in the series from running. 69 | gulp.task('dev', gulp.series('lint:backend', gulp.parallel('serve:backend', 'serve:frontend'))); 70 | 71 | console.log("Gulpfile loaded. Available tasks:"); 72 | console.log("- gulp default: Starts the backend server with Nodemon."); 73 | console.log("- gulp serve:backend: Starts the backend server with Nodemon."); 74 | console.log("- gulp lint:backend: Lints backend JavaScript files."); 75 | console.log("- gulp serve:frontend: Starts the Next.js frontend dev server."); 76 | console.log("- gulp dev: Lints backend, then starts backend and frontend dev servers."); 77 | console.log("- gulp migrate:up: Runs database migrations (up)."); 78 | console.log("- gulp migrate:down: Runs database migrations (down)."); 79 | console.log("- gulp build:frontend: Builds the frontend for production."); 80 | console.log("- gulp start:frontend: Starts the frontend production server."); 81 | -------------------------------------------------------------------------------- /frontend/src/app/impressum/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Impressum() { 2 | return ( 3 |
4 |
5 |

Legal Notice

6 | 7 |
8 |

Information according to § 5 TMG

9 |

10 | Patrick Walter
11 | Leipziger Str. 4
12 | 55283 Nierstein
13 | Germany 14 |

15 | 16 |

Contact

17 |

18 | Phone: +49 162 966 9809
19 | Email: pw@gummipunkt.eu 20 |

21 | 22 |

Editorially Responsible

23 |

24 | Patrick Walter
25 | Leipziger Str. 4
26 | 55283 Nierstein, Germany 27 |

28 | 29 |

EU Dispute Resolution

30 |

31 | The European Commission provides a platform for online dispute resolution (ODR): 32 | 33 | https://ec.europa.eu/consumers/odr/ 34 | .
35 | You can find our email address above in the legal notice. 36 |

37 | 38 |

Consumer Dispute Resolution/Universal Arbitration Board

39 |

40 | We are not willing or obligated to participate in dispute resolution proceedings before a 41 | consumer arbitration board. 42 |

43 | 44 |

Liability for Content

45 |

46 | As a service provider, we are responsible for our own content on these pages in accordance with 47 | § 7 para. 1 TMG under general law. According to §§ 8 to 10 TMG, however, we as a service provider 48 | are not under any obligation to monitor transmitted or stored third-party information or to 49 | investigate circumstances that indicate illegal activity. 50 |

51 |

52 | Obligations to remove or block the use of information under general law remain unaffected. 53 | However, liability in this regard is only possible from the point in time at which knowledge of 54 | a specific infringement becomes known. Upon becoming aware of corresponding infringements, we 55 | will remove this content immediately. 56 |

57 | 58 |

Liability for Links

59 |

60 | Our offer contains links to external third-party websites over whose content we have no influence. 61 | Therefore, we cannot assume any liability for this external content. The respective provider or 62 | operator of the pages is always responsible for the content of the linked pages. The linked pages 63 | were checked for possible legal violations at the time of linking. Illegal content was not 64 | recognizable at the time of linking. 65 |

66 |

67 | However, permanent monitoring of the content of the linked pages is not reasonable without concrete 68 | evidence of a legal violation. If we become aware of legal violations, we will remove such links 69 | immediately. 70 |

71 | 72 |

Copyright

73 |

74 | The content and works created by the site operators on these pages are subject to German copyright law. 75 | Duplication, processing, distribution and any kind of commercialization of such material beyond the 76 | scope of the copyright law shall require the prior written consent of its respective author or creator. 77 | Downloads and copies of this site are only permitted for private, non-commercial use. 78 |

79 |

80 | Insofar as the content on this site was not created by the operator, the copyrights of third parties 81 | are respected. In particular, third-party content is identified as such. Should you nevertheless 82 | become aware of a copyright infringement, please inform us accordingly. If we become aware of legal 83 | violations, we will remove such content immediately. 84 |

85 |
86 |
87 |
88 | ); 89 | } -------------------------------------------------------------------------------- /frontend/src/app/page.module.css: -------------------------------------------------------------------------------- 1 | .page { 2 | --gray-rgb: 0, 0, 0; 3 | --gray-alpha-200: rgba(var(--gray-rgb), 0.08); 4 | --gray-alpha-100: rgba(var(--gray-rgb), 0.05); 5 | 6 | --button-primary-hover: #383838; 7 | --button-secondary-hover: #f2f2f2; 8 | 9 | display: grid; 10 | grid-template-rows: 20px 1fr 20px; 11 | align-items: center; 12 | justify-items: center; 13 | min-height: 100svh; 14 | padding: 80px; 15 | gap: 64px; 16 | font-family: var(--font-geist-sans); 17 | } 18 | 19 | @media (prefers-color-scheme: dark) { 20 | .page { 21 | --gray-rgb: 255, 255, 255; 22 | --gray-alpha-200: rgba(var(--gray-rgb), 0.145); 23 | --gray-alpha-100: rgba(var(--gray-rgb), 0.06); 24 | 25 | --button-primary-hover: #ccc; 26 | --button-secondary-hover: #1a1a1a; 27 | } 28 | } 29 | 30 | .main { 31 | display: flex; 32 | flex-direction: column; 33 | gap: 32px; 34 | grid-row-start: 2; 35 | } 36 | 37 | .main ol { 38 | font-family: var(--font-geist-mono); 39 | padding-left: 0; 40 | margin: 0; 41 | font-size: 14px; 42 | line-height: 24px; 43 | letter-spacing: -0.01em; 44 | list-style-position: inside; 45 | } 46 | 47 | .main li:not(:last-of-type) { 48 | margin-bottom: 8px; 49 | } 50 | 51 | .main code { 52 | font-family: inherit; 53 | background: var(--gray-alpha-100); 54 | padding: 2px 4px; 55 | border-radius: 4px; 56 | font-weight: 600; 57 | } 58 | 59 | .ctas { 60 | display: flex; 61 | gap: 16px; 62 | } 63 | 64 | .ctas a { 65 | appearance: none; 66 | border-radius: 128px; 67 | height: 48px; 68 | padding: 0 20px; 69 | border: none; 70 | border: 1px solid transparent; 71 | transition: 72 | background 0.2s, 73 | color 0.2s, 74 | border-color 0.2s; 75 | cursor: pointer; 76 | display: flex; 77 | align-items: center; 78 | justify-content: center; 79 | font-size: 16px; 80 | line-height: 20px; 81 | font-weight: 500; 82 | } 83 | 84 | a.primary { 85 | background: var(--foreground); 86 | color: var(--background); 87 | gap: 8px; 88 | } 89 | 90 | a.secondary { 91 | border-color: var(--gray-alpha-200); 92 | min-width: 180px; 93 | } 94 | 95 | .footer { 96 | grid-row-start: 3; 97 | display: flex; 98 | gap: 24px; 99 | } 100 | 101 | .footer a { 102 | display: flex; 103 | align-items: center; 104 | gap: 8px; 105 | } 106 | 107 | .footer img { 108 | flex-shrink: 0; 109 | } 110 | 111 | /* Enable hover only on non-touch devices */ 112 | @media (hover: hover) and (pointer: fine) { 113 | a.primary:hover { 114 | background: var(--button-primary-hover); 115 | border-color: transparent; 116 | } 117 | 118 | a.secondary:hover { 119 | background: var(--button-secondary-hover); 120 | border-color: transparent; 121 | } 122 | 123 | .footer a:hover { 124 | text-decoration: underline; 125 | text-underline-offset: 4px; 126 | } 127 | } 128 | 129 | @media (max-width: 600px) { 130 | .page { 131 | padding: 32px; 132 | padding-bottom: 80px; 133 | } 134 | 135 | .main { 136 | align-items: center; 137 | } 138 | 139 | .main ol { 140 | text-align: center; 141 | } 142 | 143 | .ctas { 144 | flex-direction: column; 145 | } 146 | 147 | .ctas a { 148 | font-size: 14px; 149 | height: 40px; 150 | padding: 0 16px; 151 | } 152 | 153 | a.secondary { 154 | min-width: auto; 155 | } 156 | 157 | .footer { 158 | flex-wrap: wrap; 159 | align-items: center; 160 | justify-content: center; 161 | } 162 | } 163 | 164 | @media (prefers-color-scheme: dark) { 165 | .logo { 166 | filter: invert(); 167 | } 168 | } 169 | 170 | .heroBackground { 171 | background: linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url('/hero_signalcow.jpg'); /* Angepasster Pfad zum Hero-Image */ 172 | background-size: cover; 173 | background-position: center; 174 | } 175 | 176 | .title { 177 | color: white; /* Reines Weiß für besten Kontrast auf dunklem Overlay */ 178 | font-weight: bold; 179 | margin-bottom: 1.5rem; 180 | } 181 | 182 | .subtitle { 183 | color: var(--pastel-light-lilac); /* Helles Pastell-Lila für den Untertitel */ 184 | margin-bottom: 2rem; 185 | } 186 | 187 | .description { 188 | color: #f5f5f5; /* Ein sehr helles Grau, fast weiß, für gute Lesbarkeit */ 189 | font-size: 1.1rem; 190 | max-width: 700px; /* Begrenzt die Breite für bessere Lesbarkeit */ 191 | margin-left: auto; 192 | margin-right: auto; 193 | margin-bottom: 2.5rem; 194 | } 195 | -------------------------------------------------------------------------------- /frontend/src/app/register/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | import Link from 'next/link'; 5 | import { useRouter } from 'next/navigation'; 6 | import { useAuth } from '@/contexts/AuthContext'; 7 | 8 | export default function RegisterPage() { 9 | const [username, setUsername] = useState(''); 10 | const [email, setEmail] = useState(''); 11 | const [password, setPassword] = useState(''); 12 | const { register, error, setError, isLoading, isAuthenticated } = useAuth(); 13 | const router = useRouter(); 14 | 15 | useEffect(() => { 16 | if (isLoading) return; 17 | 18 | if (isAuthenticated) { 19 | router.push('/dashboard'); 20 | return; 21 | } 22 | if (error) { 23 | setError(null); 24 | } 25 | }, [isAuthenticated, isLoading, router, setError, error]); 26 | 27 | const handleSubmit = async (event: React.FormEvent) => { 28 | event.preventDefault(); 29 | if (!username || !email || !password) { 30 | setError('All fields are required.'); 31 | return; 32 | } 33 | await register(username, email, password); 34 | }; 35 | 36 | if (isLoading || (!error && isAuthenticated)) { 37 | return ( 38 |
39 |
40 | {isLoading &&

Loading authentication status...

} 41 | {isAuthenticated &&

You are already logged in. Redirecting to dashboard...

} 42 | 43 |
44 |
45 | ); 46 | } 47 | 48 | return ( 49 |
50 |
51 |
52 |

Register

53 |
54 | {error && ( 55 |
56 | 57 | {error} 58 |
59 | )} 60 |
61 | 62 |
63 | setUsername(e.target.value)} 70 | required 71 | /> 72 |
73 |
74 |
75 | 76 |
77 | setEmail(e.target.value)} 84 | required 85 | /> 86 |
87 |
88 |
89 | 90 |
91 | setPassword(e.target.value)} 98 | required 99 | /> 100 |
101 |
102 |
103 | 110 |
111 |
112 |
113 |

114 | Already have an account? Login here. 115 |

116 |
117 |
118 |
119 | ); 120 | } -------------------------------------------------------------------------------- /frontend/src/app/dashboard/settings/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import { useAuth } from '@/contexts/AuthContext'; // Assuming useAuth provides the token via a context 5 | 6 | export default function SettingsPage() { 7 | const [currentPassword, setCurrentPassword] = useState(''); 8 | const [newPassword, setNewPassword] = useState(''); 9 | const [confirmNewPassword, setConfirmNewPassword] = useState(''); 10 | const [message, setMessage] = useState(null); 11 | const [error, setError] = useState(null); 12 | const [isLoading, setIsLoading] = useState(false); 13 | const { token } = useAuth(); // Get token for authenticated requests 14 | 15 | const handleSubmit = async (event: React.FormEvent) => { 16 | event.preventDefault(); 17 | setMessage(null); 18 | setError(null); 19 | 20 | if (!currentPassword || !newPassword || !confirmNewPassword) { 21 | setError('All password fields are required.'); 22 | return; 23 | } 24 | if (newPassword !== confirmNewPassword) { 25 | setError('New passwords do not match.'); 26 | return; 27 | } 28 | if (newPassword.length < 6) { 29 | setError('New password must be at least 6 characters long.'); 30 | return; 31 | } 32 | 33 | setIsLoading(true); 34 | try { 35 | const response = await fetch('/api/auth/change-password', { 36 | method: 'POST', 37 | headers: { 38 | 'Content-Type': 'application/json', 39 | 'Authorization': `Bearer ${token}`, 40 | }, 41 | body: JSON.stringify({ currentPassword, newPassword, confirmNewPassword }), 42 | }); 43 | 44 | const data = await response.json(); 45 | 46 | if (!response.ok) { 47 | throw new Error(data.message || 'Failed to change password.'); 48 | } 49 | 50 | setMessage(data.message || 'Password changed successfully.'); 51 | setCurrentPassword(''); 52 | setNewPassword(''); 53 | setConfirmNewPassword(''); 54 | // Optionally, provide feedback or redirect 55 | } catch (err: unknown) { 56 | if (err instanceof Error) { 57 | setError(err.message); 58 | } else { 59 | setError('An unexpected error occurred.'); 60 | } 61 | } finally { 62 | setIsLoading(false); 63 | } 64 | }; 65 | 66 | return ( 67 |
68 |
69 |

Change Password

70 | {message && ( 71 |
72 | {message} 73 |
74 | )} 75 | {error && ( 76 |
77 | {error} 78 |
79 | )} 80 |
81 |
82 | 83 |
84 | ) => setCurrentPassword(e.target.value)} 91 | required 92 | /> 93 |
94 |
95 | 96 |
97 | 98 |
99 | ) => setNewPassword(e.target.value)} 106 | required 107 | /> 108 |
109 |
110 | 111 |
112 | 113 |
114 | ) => setConfirmNewPassword(e.target.value)} 121 | required 122 | /> 123 |
124 |
125 | 126 |
127 | 134 |
135 |
136 |
137 |
138 | ); 139 | } -------------------------------------------------------------------------------- /backend/migrations/1748330134341_initial-schema-v2.js: -------------------------------------------------------------------------------- 1 | // backend/migrations/001_initial_schema.js 2 | exports.shorthands = undefined; 3 | 4 | exports.up = (pgm) => { 5 | console.log('[MIGRATE UP] Starting migration 1748330134341_initial-schema-v2...'); 6 | 7 | console.log('[MIGRATE UP] Attempting to create users table...'); 8 | pgm.createTable('users', { 9 | id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, 10 | username: { type: 'varchar(255)', notNull: true, unique: true }, 11 | email: { type: 'varchar(255)', notNull: true, unique: true }, 12 | password_hash: { type: 'varchar(255)', notNull: true }, 13 | is_admin: { type: 'boolean', default: false, notNull: true }, 14 | created_at: { 15 | type: 'timestamp with time zone', 16 | notNull: true, 17 | default: pgm.func('current_timestamp'), 18 | }, 19 | updated_at: { 20 | type: 'timestamp with time zone', 21 | notNull: true, 22 | default: pgm.func('current_timestamp'), 23 | }, 24 | }); 25 | console.log('[MIGRATE UP] Users table creation attempted.'); 26 | 27 | console.log('[MIGRATE UP] Attempting to create groups table...'); 28 | pgm.createTable('groups', { 29 | id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, 30 | user_id: { 31 | type: 'uuid', 32 | notNull: true, 33 | references: '"users"(id)', // Foreign key to users table 34 | onDelete: 'CASCADE', // If a user is deleted, their groups are also deleted 35 | }, 36 | group_name: { type: 'varchar(255)', notNull: true }, 37 | signal_group_id: { type: 'varchar(255)', unique: true, allowNull: true }, // Signal's internal group ID 38 | bot_phone_number: { type: 'varchar(255)', allowNull: true }, // The bot number associated, might come from user or config 39 | link_token: { type: 'varchar(255)', unique: true, allowNull: true }, 40 | link_token_expires_at: { type: 'timestamp with time zone', allowNull: true }, 41 | bot_linked_at: { type: 'timestamp with time zone', allowNull: true }, 42 | created_at: { 43 | type: 'timestamp with time zone', 44 | notNull: true, 45 | default: pgm.func('current_timestamp'), 46 | }, 47 | updated_at: { 48 | type: 'timestamp with time zone', 49 | notNull: true, 50 | default: pgm.func('current_timestamp'), 51 | }, 52 | }); 53 | // Index for faster lookups on user_id in groups table 54 | pgm.createIndex('groups', 'user_id'); 55 | console.log('[MIGRATE UP] Groups table creation attempted.'); 56 | 57 | console.log('[MIGRATE UP] Attempting to create webhooks table...'); 58 | pgm.createTable('webhooks', { 59 | id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, 60 | group_id: { 61 | type: 'uuid', 62 | notNull: true, 63 | references: '"groups"(id)', // Foreign key to groups table 64 | onDelete: 'CASCADE', // If a group is deleted, its webhooks are also deleted 65 | }, 66 | webhook_token: { type: 'varchar(255)', notNull: true, unique: true }, 67 | description: { type: 'text', allowNull: true }, 68 | is_active: { type: 'boolean', default: true, notNull: true }, 69 | created_at: { 70 | type: 'timestamp with time zone', 71 | notNull: true, 72 | default: pgm.func('current_timestamp'), 73 | }, 74 | }); 75 | // Index for faster lookups on group_id in webhooks table 76 | pgm.createIndex('webhooks', 'group_id'); 77 | // Index for faster lookups on webhook_token 78 | pgm.createIndex('webhooks', 'webhook_token'); 79 | console.log('[MIGRATE UP] Webhooks table creation attempted.'); 80 | 81 | // Optional: Trigger to update 'updated_at' timestamp on row update for all tables 82 | // This requires creating a function first. 83 | pgm.sql(` 84 | CREATE OR REPLACE FUNCTION update_updated_at_column() 85 | RETURNS TRIGGER AS $$ 86 | BEGIN 87 | NEW.updated_at = NOW(); 88 | RETURN NEW; 89 | END; 90 | $$ language 'plpgsql'; 91 | `); 92 | 93 | const tablesWithUpdatedAt = ['users', 'groups', 'webhooks']; 94 | tablesWithUpdatedAt.forEach(tableName => { 95 | pgm.sql(` 96 | CREATE TRIGGER set_updated_at 97 | BEFORE UPDATE ON "${tableName}" 98 | FOR EACH ROW 99 | EXECUTE PROCEDURE update_updated_at_column(); 100 | `); 101 | }); 102 | console.log('[MIGRATE UP] Indexes/triggers creation attempted.'); 103 | 104 | console.log('[MIGRATE UP] Migration 1748330134341_initial-schema-v2 finished.'); 105 | }; 106 | 107 | exports.down = (pgm) => { 108 | console.log('[MIGRATE DOWN] Starting DOWN migration 1748330134341_initial-schema-v2...'); 109 | // Drop tables in reverse order of creation due to foreign key constraints 110 | // First drop triggers if they exist 111 | const tablesWithUpdatedAt = ['users', 'groups', 'webhooks']; 112 | tablesWithUpdatedAt.forEach(tableName => { 113 | pgm.sql(`DROP TRIGGER IF EXISTS set_updated_at ON "${tableName}";`); 114 | }); 115 | // Then drop the function 116 | pgm.sql(`DROP FUNCTION IF EXISTS update_updated_at_column();`); 117 | 118 | pgm.dropTable('webhooks'); 119 | pgm.dropTable('groups'); 120 | pgm.dropTable('users'); 121 | console.log('[MIGRATE DOWN] DOWN migration 1748330134341_initial-schema-v2 finished.'); 122 | }; -------------------------------------------------------------------------------- /frontend/src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; // Important for client-side interactivity like useState and event handlers 2 | 3 | import { useState, useEffect } from 'react'; 4 | import Link from 'next/link'; 5 | import { useRouter, useSearchParams } from 'next/navigation'; // useSearchParams added 6 | import { useAuth } from '@/contexts/AuthContext'; // Import useAuth 7 | 8 | export default function LoginPage() { 9 | const [email, setEmail] = useState(''); 10 | const [password, setPassword] = useState(''); 11 | const { login, error, setError, isLoading, isAuthenticated } = useAuth(); 12 | const router = useRouter(); 13 | const searchParams = useSearchParams(); // For query parameters 14 | const [loginMessage, setLoginMessage] = useState(null); // For messages like "Registration successful" 15 | 16 | useEffect(() => { 17 | if (isLoading) return; 18 | 19 | if (isAuthenticated) { 20 | router.push('/dashboard'); 21 | return; // Important to skip further logic in this effect 22 | } 23 | 24 | // Show message after successful registration 25 | if (searchParams.get('registered') === 'true') { 26 | setLoginMessage('Registration successful! Please log in.'); 27 | // Optional: Remove query parameter from URL to avoid showing the message on every reload 28 | // router.replace('/login', { scroll: false }); // CAUTION: Check Next.js 13 App Router behavior 29 | } 30 | 31 | // Reset error on mount if present and not authenticated 32 | if (error) { 33 | setError(null); 34 | } 35 | // eslint-disable-next-line react-hooks/exhaustive-deps 36 | }, [isAuthenticated, isLoading, router, searchParams]); // setError was removed as it's in the Context and should not be changed directly here, except by the Context itself. 37 | 38 | const handleSubmit = async (event: React.FormEvent) => { 39 | event.preventDefault(); 40 | setLoginMessage(null); // Remove old messages 41 | if (!email || !password) { 42 | setError('Email and password are required.'); // Use setError from Context 43 | return; 44 | } 45 | await login(email, password); // Call the new login function in the Context 46 | // Redirect and error handling is done in the Context 47 | }; 48 | 49 | // Loading indicator when auth status is being checked or already logged in and redirect is pending 50 | if (isLoading || (!error && isAuthenticated)) { // Small adjustment: Show loading indicator also if isAuthenticated but isLoading is still true 51 | return ( 52 |
53 |
54 | {isLoading &&

Loading authentication status...

} 55 | {isAuthenticated &&

You are already logged in. Redirecting to dashboard...

} 56 | 57 |
58 |
59 | ); 60 | } 61 | 62 | return ( 63 |
64 |
65 |
66 |

Login

67 | {loginMessage && ( 68 |
69 | {loginMessage} 70 |
71 | )} 72 | {error && ( 73 |
74 | 75 | {error} 76 |
77 | )} 78 |
79 |
80 | 81 |
82 | setEmail(e.target.value)} 89 | required 90 | /> 91 |
92 |
93 |
94 | 95 |
96 | setPassword(e.target.value)} 103 | required 104 | /> 105 |
106 |
107 |
108 | 115 |
116 |
117 |
118 |

119 | Don't have an account yet? Register here. 120 |

121 |

122 | Forgot password? 123 |

124 |
125 |
126 |
127 | ); 128 | } -------------------------------------------------------------------------------- /frontend/src/contexts/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | 6 | // Extend user data type if necessary (e.g., username) 7 | interface User { 8 | id: number; 9 | email: string; 10 | username: string; // Ensure username is present 11 | is_admin?: boolean; 12 | } 13 | 14 | interface AuthContextType { 15 | isAuthenticated: boolean; 16 | user: User | null; 17 | token: string | null; 18 | login: (email: string, password: string) => Promise; // Takes credentials 19 | register: (username: string, email: string, password: string) => Promise; // For registration 20 | logout: () => void; 21 | isLoading: boolean; // Renamed from authIsLoading for consistency with previous usage 22 | error: string | null; // Error status 23 | setError: (error: string | null) => void; // Function to set errors 24 | } 25 | 26 | const AuthContext = createContext(undefined); 27 | 28 | export const AuthProvider = ({ children }: { children: ReactNode }) => { 29 | const [user, setUser] = useState(null); 30 | const [token, setToken] = useState(null); 31 | const [isLoading, setIsLoading] = useState(true); // For initial loading and API calls 32 | const [error, setError] = useState(null); // Central error status 33 | const router = useRouter(); 34 | 35 | useEffect(() => { 36 | setIsLoading(true); 37 | const storedToken = localStorage.getItem('authToken'); 38 | const storedUser = localStorage.getItem('authUser'); 39 | console.log('[AuthContext] useEffect - storedUser from localStorage:', storedUser); 40 | 41 | if (storedToken && storedUser) { 42 | try { 43 | const parsedUser: User = JSON.parse(storedUser); 44 | console.log('[AuthContext] useEffect - parsedUser:', parsedUser); // DEBUG 45 | setToken(storedToken); 46 | setUser(parsedUser); 47 | } catch (e) { 48 | console.error("Error parsing stored user data from localStorage", e); 49 | console.error("Problematic storedUser string was:", storedUser); // Log problematic string 50 | localStorage.removeItem('authToken'); 51 | localStorage.removeItem('authUser'); 52 | } 53 | } 54 | setIsLoading(false); 55 | }, []); 56 | 57 | // Login function that performs the API call 58 | const login = async (email: string, password: string) => { 59 | setIsLoading(true); 60 | setError(null); 61 | try { 62 | const response = await fetch('/api/auth/login', { 63 | method: 'POST', 64 | headers: { 'Content-Type': 'application/json' }, 65 | body: JSON.stringify({ email, password }), 66 | }); 67 | const data = await response.json(); 68 | console.log('[AuthContext] login - data from API:', data); // DEBUG 69 | 70 | if (!response.ok) { 71 | throw new Error(data.message || 'Login failed'); 72 | } 73 | 74 | // Critical check here: 75 | if (!data.user || typeof data.user !== 'object' || !data.user.username) { 76 | console.error('User data is not an object or username missing in login response:', data.user); 77 | throw new Error('User data incomplete or incorrect after login.'); 78 | } 79 | 80 | console.log('[AuthContext] login - data.user to be stored:', data.user); // DEBUG 81 | localStorage.setItem('authToken', data.token); 82 | localStorage.setItem('authUser', JSON.stringify(data.user)); 83 | setToken(data.token); 84 | setUser(data.user as User); 85 | router.push('/dashboard'); 86 | } catch (err: unknown) { 87 | if (err instanceof Error) { 88 | setError(err.message || 'An error occurred.'); 89 | } else if (typeof err === 'string') { 90 | setError(err); 91 | } else { 92 | setError('An unexpected error occurred during login.'); 93 | } 94 | localStorage.removeItem('authToken'); 95 | localStorage.removeItem('authUser'); 96 | setToken(null); 97 | setUser(null); 98 | } finally { 99 | setIsLoading(false); 100 | } 101 | }; 102 | 103 | // Register function that performs the API call 104 | const register = async (username: string, email: string, password: string) => { 105 | setIsLoading(true); 106 | setError(null); 107 | try { 108 | const response = await fetch('/api/auth/register', { 109 | method: 'POST', 110 | headers: { 'Content-Type': 'application/json' }, 111 | body: JSON.stringify({ username, email, password }), 112 | }); 113 | const data = await response.json(); 114 | if (!response.ok) { 115 | throw new Error(data.message || 'Registration failed'); 116 | } 117 | router.push('/login?registered=true'); 118 | } catch (err: unknown) { 119 | if (err instanceof Error) { 120 | setError(err.message || 'An error occurred.'); 121 | } else if (typeof err === 'string') { 122 | setError(err); 123 | } else { 124 | setError('An unexpected error occurred during registration.'); 125 | } 126 | } finally { 127 | setIsLoading(false); 128 | } 129 | }; 130 | 131 | const logout = () => { 132 | localStorage.removeItem('authToken'); 133 | localStorage.removeItem('authUser'); 134 | setToken(null); 135 | setUser(null); 136 | router.push('/login'); 137 | }; 138 | 139 | return ( 140 | 141 | {children} 142 | 143 | ); 144 | }; 145 | 146 | export const useAuth = () => { 147 | const context = useContext(AuthContext); 148 | if (context === undefined) { 149 | throw new Error('useAuth must be used within an AuthProvider'); 150 | } 151 | return context; 152 | }; -------------------------------------------------------------------------------- /frontend/src/app/dashboard/groups/new/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | import { useAuth } from '@/contexts/AuthContext'; 5 | import { useRouter } from 'next/navigation'; 6 | 7 | interface Group { 8 | id: number; 9 | group_name: string; 10 | description: string | null; 11 | signal_group_id?: string | null; 12 | } 13 | 14 | export default function NewGroupPage() { 15 | const { token, isAuthenticated, isLoading: authIsLoading } = useAuth(); 16 | const router = useRouter(); 17 | const [name, setName] = useState(''); 18 | const [description, setDescription] = useState(''); 19 | const [signalGroupId, setSignalGroupId] = useState(''); 20 | const [error, setError] = useState(null); 21 | const [isSubmitting, setIsSubmitting] = useState(false); 22 | 23 | useEffect(() => { 24 | if (!authIsLoading && !isAuthenticated) { 25 | router.push('/login'); 26 | } 27 | }, [authIsLoading, isAuthenticated, router]); 28 | 29 | const handleSubmit = async (event: React.FormEvent) => { 30 | event.preventDefault(); 31 | setIsSubmitting(true); 32 | setError(null); 33 | 34 | if (!name.trim()) { 35 | setError('Group name is required.'); 36 | setIsSubmitting(false); 37 | return; 38 | } 39 | 40 | try { 41 | const response = await fetch('/api/groups', { 42 | method: 'POST', 43 | headers: { 44 | 'Content-Type': 'application/json', 45 | 'Authorization': `Bearer ${token}`, 46 | }, 47 | body: JSON.stringify({ 48 | group_name: name, 49 | description: description, 50 | signal_group_id: signalGroupId || null 51 | }), 52 | }); 53 | 54 | if (!response.ok) { 55 | const errorData = await response.json(); 56 | throw new Error(errorData.message || 'Error creating group'); 57 | } 58 | 59 | const responseData: Group = await response.json(); 60 | router.push(`/dashboard/groups/${responseData.id}/edit`); 61 | 62 | } catch (err: unknown) { 63 | if (err instanceof Error) { 64 | setError(err.message); 65 | } else if (typeof err === 'string') { 66 | setError(err); 67 | } else { 68 | setError('An unexpected error occurred while creating the group.'); 69 | } 70 | } finally { 71 | setIsSubmitting(false); 72 | } 73 | }; 74 | 75 | if (authIsLoading) { 76 | return ( 77 |
78 |
79 |

Loading...

80 | 81 |
82 |
83 | ); 84 | } 85 | 86 | return ( 87 |
88 |
89 |

Create New Group

90 |
91 |
92 | {error && ( 93 |
94 | 95 | {error} 96 |
97 | )} 98 | 99 |
100 | 101 |
102 | setName(e.target.value)} 109 | required 110 | /> 111 |
112 |
113 | 114 |
115 | 116 |
117 | 124 |
125 |
126 | 127 |
128 | 129 |
130 | setSignalGroupId(e.target.value)} 137 | /> 138 |
139 |

140 | Manual entry of the Signal Group ID is for advanced users. 141 | We recommend leaving this field empty and linking the group automatically and securely via a link token on the next page after saving. 142 | If you still want to enter the ID manually: Add the bot (Tel: {process.env.NEXT_PUBLIC_BOT_PHONE_NUMBER || '[Bot Phone Number]'}) to your Signal group and enter the group ID here (e.g., from Signal Desktop group settings). 143 |

144 |
145 | 146 |
147 |
148 | 155 |
156 |
157 |
158 |
159 |
160 |
161 | ); 162 | } -------------------------------------------------------------------------------- /frontend/src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import Image from 'next/image'; 5 | import { useAuth } from '@/contexts/AuthContext'; 6 | import { useEffect, useState } from 'react'; 7 | import { usePathname } from 'next/navigation'; 8 | 9 | export default function Navbar() { 10 | const { isAuthenticated, user, logout, isLoading } = useAuth(); 11 | const pathname = usePathname(); 12 | const [isMounted, setIsMounted] = useState(false); 13 | 14 | useEffect(() => { 15 | setIsMounted(true); 16 | }, []); 17 | 18 | // DEBUG: Log user object 19 | useEffect(() => { 20 | console.log('[Navbar] Auth isLoading:', isLoading); 21 | console.log('[Navbar] isAuthenticated:', isAuthenticated); 22 | console.log('[Navbar] User object:', user); 23 | }, [user, isAuthenticated, isLoading]); 24 | 25 | const toggleBurger = (event: React.MouseEvent) => { 26 | event.preventDefault(); 27 | const target = event.currentTarget.dataset.target; 28 | if (target) { 29 | const $target = document.getElementById(target); 30 | event.currentTarget.classList.toggle('is-active'); 31 | $target?.classList.toggle('is-active'); 32 | } 33 | }; 34 | 35 | if (isLoading) { 36 | return ( 37 | 51 | ); 52 | } 53 | 54 | return ( 55 | 143 | ); 144 | } -------------------------------------------------------------------------------- /frontend/src/app/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect, Suspense } from 'react'; 4 | import Link from 'next/link'; 5 | import { useSearchParams, useRouter } from 'next/navigation'; 6 | 7 | function ResetPasswordForm() { 8 | const router = useRouter(); 9 | const searchParams = useSearchParams(); 10 | const [token, setToken] = useState(null); 11 | const [email, setEmail] = useState(null); 12 | const [password, setPassword] = useState(''); 13 | const [confirmPassword, setConfirmPassword] = useState(''); 14 | const [message, setMessage] = useState(null); 15 | const [error, setError] = useState(null); 16 | const [isLoading, setIsLoading] = useState(false); 17 | 18 | useEffect(() => { 19 | const tokenFromQuery = searchParams.get('token'); 20 | const emailFromQuery = searchParams.get('email'); 21 | if (tokenFromQuery && emailFromQuery) { 22 | setToken(tokenFromQuery); 23 | setEmail(emailFromQuery); 24 | } else { 25 | setError('Missing token or email in URL. Please use the link from your email.'); 26 | // Optionally redirect to login or forgot-password page 27 | // router.push('/login'); 28 | } 29 | }, [searchParams, router]); 30 | 31 | const handleSubmit = async (event: React.FormEvent) => { 32 | event.preventDefault(); 33 | setMessage(null); 34 | setError(null); 35 | 36 | if (!password || !confirmPassword) { 37 | setError('Please enter and confirm your new password.'); 38 | return; 39 | } 40 | if (password !== confirmPassword) { 41 | setError('Passwords do not match.'); 42 | return; 43 | } 44 | if (!token || !email) { 45 | setError('Token or email is missing. Cannot reset password.'); 46 | return; 47 | } 48 | 49 | setIsLoading(true); 50 | try { 51 | const response = await fetch('/api/auth/reset-password', { 52 | method: 'POST', 53 | headers: { 'Content-Type': 'application/json' }, 54 | body: JSON.stringify({ token, email, password }), 55 | }); 56 | 57 | const data = await response.json(); 58 | 59 | if (!response.ok) { 60 | throw new Error(data.message || 'Failed to reset password.'); 61 | } 62 | 63 | setMessage(data.message || 'Password has been reset successfully. You can now login with your new password.'); 64 | // Optionally redirect to login after a delay 65 | setTimeout(() => router.push('/login'), 5000); 66 | 67 | } catch (err: unknown) { 68 | if (err instanceof Error) { 69 | setError(err.message); 70 | } else { 71 | setError('An unexpected error occurred.'); 72 | } 73 | } finally { 74 | setIsLoading(false); 75 | } 76 | }; 77 | 78 | if (!token || !email) { 79 | // Show error if token/email is not available or still loading 80 | return ( 81 |
82 |
83 |
84 |

Reset Password

85 | {error &&
{error}
} 86 | {!error &&

Loading or invalid link...

} 87 |
88 |

89 | Back to Login 90 |

91 |
92 |
93 |
94 | ); 95 | } 96 | 97 | return ( 98 |
99 |
100 |
101 |

Reset Password

102 | {message && ( 103 |
104 | {message} 105 |
106 | )} 107 | {error && ( 108 |
109 | {error} 110 |
111 | )} 112 |
113 |

Enter your new password for: {email}

114 |
115 | 116 |
117 | ) => setPassword(e.target.value)} 124 | required 125 | /> 126 |
127 |
128 |
129 | 130 |
131 | ) => setConfirmPassword(e.target.value)} 138 | required 139 | /> 140 |
141 |
142 |
143 | 150 |
151 |
152 | {message && ( 153 |

154 | Proceed to Login 155 |

156 | )} 157 |
158 |
159 |
160 | ); 161 | } 162 | 163 | // Wrap with Suspense for useSearchParams 164 | export default function ResetPasswordPage() { 165 | return ( 166 | Loading...}> 167 | 168 | 169 | ); 170 | } -------------------------------------------------------------------------------- /backend/routes/adminApiRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const pool = require('../config/db'); 4 | const { authenticateToken } = require('../middleware/authMiddleware'); 5 | 6 | // Middleware to check if the user is an admin 7 | const isAdmin = (req, res, next) => { 8 | if (!req.user || !req.user.is_admin) { 9 | return res.status(403).json({ message: 'Access denied. Admin privileges required.' }); 10 | } 11 | next(); 12 | }; 13 | 14 | // Apply JWT authentication and admin check to all routes in this file 15 | router.use(authenticateToken, isAdmin); 16 | 17 | // GET /api/admin/users - List all users 18 | router.get('/users', async (req, res) => { 19 | try { 20 | const query = ` 21 | SELECT 22 | u.id, 23 | u.username, 24 | u.email, 25 | u.is_admin, 26 | u.created_at, 27 | COUNT(DISTINCT g.id) as group_count, 28 | COUNT(DISTINCT w.id) as webhook_count_total 29 | FROM users u 30 | LEFT JOIN groups g ON u.id = g.user_id 31 | LEFT JOIN webhooks w ON g.id = w.group_id 32 | GROUP BY u.id 33 | ORDER BY u.created_at DESC 34 | `; 35 | const result = await pool.query(query); 36 | res.json(result.rows); 37 | } catch (err) { 38 | console.error('Error loading users for admin:', err.message); 39 | res.status(500).json({ message: 'Server error loading users.' }); 40 | } 41 | }); 42 | 43 | // DELETE /api/admin/users/:userId - Delete a user 44 | router.delete('/users/:userId', async (req, res) => { 45 | const { userId } = req.params; 46 | const requestingAdminId = req.user.id; 47 | 48 | try { 49 | const userToDeleteResult = await pool.query('SELECT id, email, is_admin FROM users WHERE id = $1', [userId]); 50 | if (userToDeleteResult.rowCount === 0) { 51 | return res.status(404).json({ message: 'User not found.' }); 52 | } 53 | const userToDelete = userToDeleteResult.rows[0]; 54 | 55 | // Prevent admin from deleting themselves 56 | if (parseInt(userId, 10) === requestingAdminId) { 57 | return res.status(403).json({ message: 'Admin cannot delete themselves.' }); 58 | } 59 | 60 | // Prevent deletion of the last admin if this user is an admin 61 | if (userToDelete.is_admin) { 62 | const adminCountResult = await pool.query('SELECT COUNT(*) AS count FROM users WHERE is_admin = TRUE'); 63 | if (parseInt(adminCountResult.rows[0].count, 10) <= 1) { 64 | return res.status(403).json({ message: 'Cannot delete the last admin account.' }); 65 | } 66 | } 67 | 68 | // Database schema should handle cascading deletes for groups and webhooks 69 | // (ON DELETE CASCADE for user_id in groups, and group_id in webhooks) 70 | // If not, explicit deletion would be needed here. 71 | const deleteOp = await pool.query('DELETE FROM users WHERE id = $1 RETURNING id', [userId]); 72 | if (deleteOp.rowCount === 0) { 73 | return res.status(404).json({ message: 'User not found during deletion process.' }); 74 | } 75 | res.json({ message: `User ${userToDelete.email} successfully deleted.` }); 76 | } catch (err) { 77 | console.error('Error deleting user (Admin API):', err.message); 78 | res.status(500).json({ message: 'Server error deleting user.' }); 79 | } 80 | }); 81 | 82 | // GET /api/admin/groups - List all groups 83 | router.get('/groups', async (req, res) => { 84 | try { 85 | const query = ` 86 | SELECT 87 | g.id, 88 | g.group_name, 89 | g.description, 90 | g.signal_group_id, 91 | g.bot_phone_number, 92 | g.created_at, 93 | g.user_id, 94 | u.email as user_email, 95 | u.username as user_username, 96 | COUNT(w.id) as webhook_count 97 | FROM groups g 98 | JOIN users u ON g.user_id = u.id 99 | LEFT JOIN webhooks w ON g.id = w.group_id 100 | GROUP BY g.id, u.email, u.username 101 | ORDER BY g.created_at DESC 102 | `; 103 | const result = await pool.query(query); 104 | res.json(result.rows); 105 | } catch (err) { 106 | console.error('Error loading groups for admin:', err.message); 107 | res.status(500).json({ message: 'Server error loading groups.' }); 108 | } 109 | }); 110 | 111 | // DELETE /api/admin/groups/:groupId - Delete a group 112 | router.delete('/groups/:groupId', async (req, res) => { 113 | const { groupId } = req.params; 114 | try { 115 | // Database schema should handle cascading deletes for webhooks 116 | const deleteOp = await pool.query('DELETE FROM groups WHERE id = $1 RETURNING id', [groupId]); 117 | if (deleteOp.rowCount === 0) { 118 | return res.status(404).json({ message: 'Group not found.' }); 119 | } 120 | res.json({ message: 'Group and associated webhooks successfully deleted.' }); 121 | } catch (err) { 122 | console.error('Error deleting group (Admin API):', err.message); 123 | res.status(500).json({ message: 'Server error deleting group.' }); 124 | } 125 | }); 126 | 127 | // GET /api/admin/webhooks - List all webhooks 128 | router.get('/webhooks', async (req, res) => { 129 | try { 130 | const query = ` 131 | SELECT 132 | w.id, 133 | w.webhook_token, 134 | w.is_active, 135 | w.description as webhook_description, 136 | w.created_at, 137 | w.group_id, 138 | g.group_name, 139 | u.id as user_id, 140 | u.email as user_email, 141 | u.username as user_username 142 | FROM webhooks w 143 | JOIN groups g ON w.group_id = g.id 144 | JOIN users u ON g.user_id = u.id 145 | ORDER BY w.created_at DESC 146 | `; 147 | const result = await pool.query(query); 148 | res.json(result.rows); 149 | } catch (err) { 150 | console.error('Error loading webhooks for admin:', err.message); 151 | res.status(500).json({ message: 'Server error loading webhooks.' }); 152 | } 153 | }); 154 | 155 | // DELETE /api/admin/webhooks/:webhookId - Delete a webhook 156 | router.delete('/webhooks/:webhookId', async (req, res) => { 157 | const { webhookId } = req.params; 158 | try { 159 | const deleteOp = await pool.query('DELETE FROM webhooks WHERE id = $1 RETURNING id', [webhookId]); 160 | if (deleteOp.rowCount === 0) { 161 | return res.status(404).json({ message: 'Webhook not found.' }); 162 | } 163 | res.json({ message: 'Webhook successfully deleted.' }); 164 | } catch (err) { 165 | console.error('Error deleting webhook (Admin API):', err.message); 166 | res.status(500).json({ message: 'Server error deleting webhook.' }); 167 | } 168 | }); 169 | 170 | module.exports = router; -------------------------------------------------------------------------------- /frontend/src/app/dashboard/groups/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | import { useAuth } from '@/contexts/AuthContext'; 5 | import { useRouter } from 'next/navigation'; 6 | import Link from 'next/link'; 7 | 8 | // Type definition for a group 9 | interface Group { 10 | id: number; 11 | group_name: string; 12 | description: string | null; 13 | user_id: number; 14 | created_at: string; 15 | updated_at: string; 16 | signal_group_id?: string | null; 17 | bot_phone_number?: string | null; 18 | } 19 | 20 | export default function GroupsPage() { 21 | const { token, isAuthenticated, isLoading: authIsLoading } = useAuth(); 22 | const router = useRouter(); 23 | const [groups, setGroups] = useState([]); 24 | const [isLoading, setIsLoading] = useState(true); 25 | const [error, setError] = useState(null); 26 | const [deleteError, setDeleteError] = useState(null); 27 | const [isDeleting, setIsDeleting] = useState(null); 28 | 29 | useEffect(() => { 30 | if (!authIsLoading && !isAuthenticated) { 31 | router.push('/login'); 32 | } 33 | }, [authIsLoading, isAuthenticated, router]); 34 | 35 | useEffect(() => { 36 | if (token && isAuthenticated) { 37 | const fetchGroups = async () => { 38 | setIsLoading(true); 39 | setError(null); 40 | setDeleteError(null); 41 | try { 42 | const response = await fetch('/api/groups', { 43 | headers: { 44 | 'Authorization': `Bearer ${token}`, 45 | }, 46 | }); 47 | if (!response.ok) { 48 | const errorData = await response.json(); 49 | throw new Error(errorData.message || 'Error fetching groups'); 50 | } 51 | const data: Group[] = await response.json(); 52 | setGroups(data); 53 | } catch (err: unknown) { 54 | if (err instanceof Error) { 55 | setError(err.message); 56 | } else if (typeof err === 'string') { 57 | setError(err); 58 | } else { 59 | setError('An unexpected error occurred while fetching groups.'); 60 | } 61 | } finally { 62 | setIsLoading(false); 63 | } 64 | }; 65 | fetchGroups(); 66 | } 67 | }, [token, isAuthenticated]); 68 | 69 | const handleDeleteGroup = async (groupId: number) => { 70 | if (!window.confirm('Are you sure you want to delete this group and all associated webhooks?')) { 71 | return; 72 | } 73 | 74 | setIsDeleting(groupId); 75 | setDeleteError(null); 76 | 77 | try { 78 | const response = await fetch(`/api/groups/${groupId}`, { 79 | method: 'DELETE', 80 | headers: { 81 | 'Authorization': `Bearer ${token}`, 82 | }, 83 | }); 84 | 85 | if (!response.ok) { 86 | const errorData = await response.json(); 87 | throw new Error(errorData.message || 'Error deleting group'); 88 | } 89 | 90 | setGroups(prevGroups => prevGroups.filter(group => group.id !== groupId)); 91 | 92 | } catch (err: unknown) { 93 | if (err instanceof Error) { 94 | setDeleteError(err.message); 95 | } else if (typeof err === 'string') { 96 | setDeleteError(err); 97 | } else { 98 | setDeleteError('An unexpected error occurred while deleting the group.'); 99 | } 100 | } finally { 101 | setIsDeleting(null); 102 | } 103 | }; 104 | 105 | if (authIsLoading || isLoading) { 106 | return ( 107 |
108 |
109 |

Loading groups...

110 | 111 |
112 |
113 | ); 114 | } 115 | 116 | if (error) { 117 | return ( 118 |
119 |
120 |
121 |

Error: {error}

122 |
123 |
124 |
125 | ); 126 | } 127 | 128 | return ( 129 |
130 |
131 |
132 |

My Groups

133 | 134 | Create New Group 135 | 136 |
137 | 138 | {deleteError && ( 139 |
140 | 141 | Error deleting: {deleteError} 142 |
143 | )} 144 | 145 | {groups.length === 0 ? ( 146 |
147 |

You haven't created any groups yet.

148 |
149 | ) : ( 150 |
151 | {groups.map((group) => ( 152 |
153 |
154 |

{group.group_name}

155 |

156 | {group.description || 'No description available.'} 157 |

158 |
159 |

160 | 161 | Edit 162 | 163 |

164 |

165 | 166 | Webhooks 167 | 168 |

169 |

170 | 177 |

178 |
179 |
180 |
181 | ))} 182 |
183 | )} 184 |
185 |
186 | ); 187 | } -------------------------------------------------------------------------------- /frontend/src/app/dashboard/admin/groups/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import Link from 'next/link'; 6 | import { useAuth } from '@/contexts/AuthContext'; 7 | 8 | interface AdminGroup { 9 | id: number; 10 | group_name: string; 11 | description: string | null; 12 | signal_group_id: string | null; 13 | bot_phone_number: string | null; 14 | created_at: string; 15 | user_id: number; 16 | user_email: string; 17 | user_username: string; 18 | webhook_count: number; 19 | } 20 | 21 | export default function AdminGroupsPage() { 22 | const { isAuthenticated, user, isLoading, token } = useAuth(); 23 | const router = useRouter(); 24 | const [groups, setGroups] = useState([]); 25 | const [pageError, setPageError] = useState(null); 26 | const [isLoadingData, setIsLoadingData] = useState(true); 27 | 28 | useEffect(() => { 29 | if (!isLoading && (!isAuthenticated || !user?.is_admin)) { 30 | router.push('/login'); 31 | } 32 | }, [isLoading, isAuthenticated, user, router]); 33 | 34 | useEffect(() => { 35 | if (isAuthenticated && user?.is_admin && token) { 36 | const fetchGroups = async () => { 37 | setIsLoadingData(true); 38 | setPageError(null); 39 | try { 40 | const response = await fetch('/api/admin/groups', { 41 | headers: { 42 | 'Authorization': `Bearer ${token}`, 43 | }, 44 | }); 45 | if (!response.ok) { 46 | const errorData = await response.json(); 47 | throw new Error(errorData.message || 'Error fetching groups'); 48 | } 49 | const data: AdminGroup[] = await response.json(); 50 | setGroups(data); 51 | } catch (err: unknown) { 52 | if (err instanceof Error) { 53 | setPageError(err.message); 54 | } else if (typeof err === 'string') { 55 | setPageError(err); 56 | } else { 57 | setPageError('An unexpected error occurred while fetching groups.'); 58 | } 59 | } finally { 60 | setIsLoadingData(false); 61 | } 62 | }; 63 | fetchGroups(); 64 | } 65 | }, [isAuthenticated, user, token]); 66 | 67 | const handleDeleteGroup = async (groupId: number, groupName: string) => { 68 | if (!window.confirm(`Are you sure you want to delete group "${groupName}"? This will also delete all associated webhooks.`)) { 69 | return; 70 | } 71 | setPageError(null); 72 | try { 73 | const response = await fetch(`/api/admin/groups/${groupId}`, { 74 | method: 'DELETE', 75 | headers: { 76 | 'Authorization': `Bearer ${token}`, 77 | }, 78 | }); 79 | if (!response.ok) { 80 | const errorData = await response.json(); 81 | throw new Error(errorData.message || 'Error deleting group'); 82 | } 83 | setGroups(prevGroups => prevGroups.filter(g => g.id !== groupId)); 84 | alert('Group deleted successfully.'); 85 | } catch (err: unknown) { 86 | let errorMessage = 'An unexpected error occurred while deleting the group.'; 87 | if (err instanceof Error) { 88 | errorMessage = err.message; 89 | } else if (typeof err === 'string') { 90 | errorMessage = err; 91 | } 92 | setPageError(errorMessage); 93 | alert(`Error: ${errorMessage}`); 94 | } 95 | }; 96 | 97 | if (isLoading || isLoadingData) { 98 | return ( 99 |
100 |
101 |

Loading group data...

102 | 103 |
104 |
105 | ); 106 | } 107 | 108 | if (!isAuthenticated || !user?.is_admin) { 109 | return ( 110 |
111 |
112 |

Access Denied. You need to be an admin to view this page.

113 | Back to Dashboard 114 |
115 |
116 | ); 117 | } 118 | 119 | return ( 120 |
121 |
122 | 129 |

Group Management (All Users)

130 | 131 | {pageError && ( 132 |
133 | 134 | {pageError} 135 |
136 | )} 137 | 138 | {groups.length === 0 && !isLoadingData ? ( 139 |
140 |

No groups found in the system.

141 |
142 | ) : ( 143 |
144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | {groups.map((g) => ( 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 173 | 174 | ))} 175 | 176 |
IDNameOwner (User)Signal Group IDWebhooksCreated AtActions
{g.id}{g.group_name}{g.user_username} ({g.user_email}){g.signal_group_id || '-'}{g.webhook_count}{new Date(g.created_at).toLocaleString()} 166 | 172 |
177 |
178 | )} 179 |
180 |
181 | ); 182 | } -------------------------------------------------------------------------------- /frontend/src/app/dashboard/admin/webhooks/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import Link from 'next/link'; 6 | import { useAuth } from '@/contexts/AuthContext'; 7 | 8 | interface AdminWebhook { 9 | id: number; 10 | webhook_token: string; 11 | is_active: boolean; 12 | webhook_description: string | null; // Renamed from description to avoid conflict if any 13 | created_at: string; 14 | group_id: number; 15 | group_name: string; 16 | user_id: number; 17 | user_email: string; 18 | user_username: string; 19 | } 20 | 21 | export default function AdminWebhooksPage() { 22 | const { isAuthenticated, user, isLoading, token } = useAuth(); 23 | const router = useRouter(); 24 | const [webhooks, setWebhooks] = useState([]); 25 | const [pageError, setPageError] = useState(null); 26 | const [isLoadingData, setIsLoadingData] = useState(true); 27 | 28 | useEffect(() => { 29 | if (!isLoading && (!isAuthenticated || !user?.is_admin)) { 30 | router.push('/login'); 31 | } 32 | }, [isLoading, isAuthenticated, user, router]); 33 | 34 | useEffect(() => { 35 | if (isAuthenticated && user?.is_admin && token) { 36 | const fetchWebhooks = async () => { 37 | setIsLoadingData(true); 38 | setPageError(null); 39 | try { 40 | const response = await fetch('/api/admin/webhooks', { 41 | headers: { 42 | 'Authorization': `Bearer ${token}`, 43 | }, 44 | }); 45 | if (!response.ok) { 46 | const errorData = await response.json(); 47 | throw new Error(errorData.message || 'Error fetching webhooks'); 48 | } 49 | const data: AdminWebhook[] = await response.json(); 50 | setWebhooks(data); 51 | } catch (err: unknown) { 52 | if (err instanceof Error) { 53 | setPageError(err.message); 54 | } else if (typeof err === 'string') { 55 | setPageError(err); 56 | } else { 57 | setPageError('An unexpected error occurred while fetching webhooks.'); 58 | } 59 | } finally { 60 | setIsLoadingData(false); 61 | } 62 | }; 63 | fetchWebhooks(); 64 | } 65 | }, [isAuthenticated, user, token]); 66 | 67 | const handleDeleteWebhook = async (webhookId: number, webhookDesc: string | null) => { 68 | const displayName = webhookDesc || `ID: ${webhookId}`; 69 | if (!window.confirm(`Are you sure you want to delete webhook "${displayName}"?`)) { 70 | return; 71 | } 72 | setPageError(null); 73 | try { 74 | const response = await fetch(`/api/admin/webhooks/${webhookId}`, { 75 | method: 'DELETE', 76 | headers: { 77 | 'Authorization': `Bearer ${token}`, 78 | }, 79 | }); 80 | if (!response.ok) { 81 | const errorData = await response.json(); 82 | throw new Error(errorData.message || 'Error deleting webhook'); 83 | } 84 | setWebhooks(prevWebhooks => prevWebhooks.filter(wh => wh.id !== webhookId)); 85 | alert('Webhook deleted successfully.'); 86 | } catch (err: unknown) { 87 | let errorMessage = 'An unexpected error occurred while deleting the webhook.'; 88 | if (err instanceof Error) { 89 | errorMessage = err.message; 90 | } else if (typeof err === 'string') { 91 | errorMessage = err; 92 | } 93 | setPageError(errorMessage); 94 | alert(`Error: ${errorMessage}`); 95 | } 96 | }; 97 | 98 | if (isLoading || isLoadingData) { 99 | return ( 100 |
101 |
102 |

Loading webhook data...

103 | 104 |
105 |
106 | ); 107 | } 108 | 109 | if (!isAuthenticated || !user?.is_admin) { 110 | return ( 111 |
112 |
113 |

Access Denied. You need to be an admin to view this page.

114 | Back to Dashboard 115 |
116 |
117 | ); 118 | } 119 | 120 | return ( 121 |
122 |
123 | 130 |

Webhook Management (All Users)

131 | 132 | {pageError && ( 133 |
134 | 135 | {pageError} 136 |
137 | )} 138 | 139 | {webhooks.length === 0 && !isLoadingData ? ( 140 |
141 |

No webhooks found in the system.

142 |
143 | ) : ( 144 |
145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | {webhooks.map((wh) => ( 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 174 | 175 | ))} 176 | 177 |
IDDescriptionToken (Partial)ActiveGroup (Owner)Created AtActions
{wh.id}{wh.webhook_description || '-'}{wh.webhook_token.substring(0,8)}...{wh.is_active ? 'Yes' : 'No'}{wh.group_name} ({wh.user_username}){new Date(wh.created_at).toLocaleString()} 167 | 173 |
178 |
179 | )} 180 |
181 |
182 | ); 183 | } -------------------------------------------------------------------------------- /frontend/src/app/dashboard/admin/users/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import Link from 'next/link'; 6 | import { useAuth } from '@/contexts/AuthContext'; 7 | 8 | interface AdminUser { 9 | id: number; 10 | username: string; 11 | email: string; 12 | is_admin: boolean; 13 | created_at: string; 14 | group_count: number; 15 | webhook_count_total: number; 16 | } 17 | 18 | export default function AdminUsersPage() { 19 | const { isAuthenticated, user, isLoading, token } = useAuth(); 20 | const router = useRouter(); 21 | const [users, setUsers] = useState([]); 22 | const [pageError, setPageError] = useState(null); 23 | const [isLoadingData, setIsLoadingData] = useState(true); 24 | 25 | useEffect(() => { 26 | if (!isLoading && (!isAuthenticated || !user?.is_admin)) { 27 | router.push('/login'); 28 | } 29 | }, [isLoading, isAuthenticated, user, router]); 30 | 31 | useEffect(() => { 32 | if (isAuthenticated && user?.is_admin && token) { 33 | const fetchUsers = async () => { 34 | setIsLoadingData(true); 35 | setPageError(null); 36 | try { 37 | const response = await fetch('/api/admin/users', { 38 | headers: { 39 | 'Authorization': `Bearer ${token}`, 40 | }, 41 | }); 42 | if (!response.ok) { 43 | const errorData = await response.json(); 44 | throw new Error(errorData.message || 'Error fetching users'); 45 | } 46 | const data: AdminUser[] = await response.json(); 47 | setUsers(data); 48 | } catch (err: unknown) { 49 | if (err instanceof Error) { 50 | setPageError(err.message); 51 | } else if (typeof err === 'string') { 52 | setPageError(err); 53 | } else { 54 | setPageError('An unexpected error occurred while fetching users.'); 55 | } 56 | } finally { 57 | setIsLoadingData(false); 58 | } 59 | }; 60 | fetchUsers(); 61 | } 62 | }, [isAuthenticated, user, token]); 63 | 64 | const handleDeleteUser = async (userId: number, userEmail: string) => { 65 | if (userId === user?.id) { 66 | alert("You cannot delete yourself."); 67 | return; 68 | } 69 | if (!window.confirm(`Are you sure you want to delete user ${userEmail}? This action cannot be undone and will also delete all their groups and webhooks.`)) { 70 | return; 71 | } 72 | setPageError(null); 73 | try { 74 | const response = await fetch(`/api/admin/users/${userId}`, { 75 | method: 'DELETE', 76 | headers: { 77 | 'Authorization': `Bearer ${token}`, 78 | }, 79 | }); 80 | if (!response.ok) { 81 | const errorData = await response.json(); 82 | throw new Error(errorData.message || 'Error deleting user'); 83 | } 84 | setUsers(prevUsers => prevUsers.filter(u => u.id !== userId)); 85 | alert('User deleted successfully.'); 86 | } catch (err: unknown) { 87 | let errorMessage = 'An unexpected error occurred while deleting the user.'; 88 | if (err instanceof Error) { 89 | errorMessage = err.message; 90 | } else if (typeof err === 'string') { 91 | errorMessage = err; 92 | } 93 | setPageError(errorMessage); 94 | alert(`Error: ${errorMessage}`); 95 | } 96 | }; 97 | 98 | if (isLoading || isLoadingData) { 99 | return ( 100 |
101 |
102 |

Loading user data...

103 | 104 |
105 |
106 | ); 107 | } 108 | 109 | if (!isAuthenticated || !user?.is_admin) { 110 | // This case should ideally be handled by the initial redirect, but as a fallback: 111 | return ( 112 |
113 |
114 |

Access Denied. You need to be an admin to view this page.

115 | Back to Dashboard 116 |
117 |
118 | ); 119 | } 120 | 121 | return ( 122 |
123 |
124 | 131 |

User Management

132 | 133 | {pageError && ( 134 |
135 | 136 | {pageError} 137 |
138 | )} 139 | 140 | {users.length === 0 && !isLoadingData ? ( 141 |
142 |

No users found.

143 |
144 | ) : ( 145 |
146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | {users.map((u) => ( 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 179 | 180 | ))} 181 | 182 |
IDUsernameEmailIs Admin?Registered AtGroupsWebhooksActions
{u.id}{u.username}{u.email}{u.is_admin ? 'Yes' : 'No'}{new Date(u.created_at).toLocaleString()}{u.group_count}{u.webhook_count_total} 170 | 178 |
183 |
184 | )} 185 |
186 |
187 | ); 188 | } -------------------------------------------------------------------------------- /frontend/src/app/dashboard/groups/[groupId]/webhooks/new/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect, useCallback } from 'react'; 4 | import { useAuth } from '@/contexts/AuthContext'; 5 | import { useRouter, useParams } from 'next/navigation'; 6 | import Link from 'next/link'; 7 | 8 | interface Group { 9 | id: number; 10 | group_name: string; 11 | } 12 | 13 | interface NewWebhookResponse { 14 | id: number; 15 | webhook_token: string; 16 | is_active: boolean; 17 | description: string | null; 18 | created_at: string; 19 | webhook_url: string; // The backend sends this along! 20 | } 21 | 22 | export default function NewWebhookPage() { 23 | const { token, isAuthenticated, isLoading: authIsLoading } = useAuth(); 24 | const router = useRouter(); 25 | const params = useParams(); 26 | const groupId = params.groupId as string; 27 | 28 | const [group, setGroup] = useState(null); 29 | const [description, setDescription] = useState(''); 30 | const [error, setError] = useState(null); 31 | const [isLoadingData, setIsLoadingData] = useState(true); // For loading group data 32 | const [isSubmitting, setIsSubmitting] = useState(false); 33 | const [createdWebhook, setCreatedWebhook] = useState(null); 34 | 35 | const fetchGroupDetails = useCallback(async () => { 36 | if (!token || !isAuthenticated || !groupId) { 37 | setIsLoadingData(false); 38 | return; 39 | } 40 | setIsLoadingData(true); 41 | try { 42 | const response = await fetch(`/api/groups/${groupId}`, { 43 | headers: { 'Authorization': `Bearer ${token}` }, 44 | }); 45 | if (!response.ok) { 46 | const errData = await response.json(); 47 | throw new Error(errData.message || 'Error loading group details.'); 48 | } 49 | const data: Group = await response.json(); 50 | setGroup(data); 51 | } catch (err: unknown) { 52 | if (err instanceof Error) { 53 | setError(err.message); 54 | } else if (typeof err === 'string') { 55 | setError(err); 56 | } else { 57 | setError('An unexpected error occurred while loading group details.'); 58 | } 59 | } finally { 60 | setIsLoadingData(false); 61 | } 62 | }, [token, isAuthenticated, groupId]); 63 | 64 | useEffect(() => { 65 | if (!authIsLoading && !isAuthenticated) { 66 | router.push('/login'); 67 | } 68 | }, [authIsLoading, isAuthenticated, router]); 69 | 70 | useEffect(() => { 71 | fetchGroupDetails(); 72 | }, [fetchGroupDetails]); 73 | 74 | const handleSubmit = async (event: React.FormEvent) => { 75 | event.preventDefault(); 76 | setIsSubmitting(true); 77 | setError(null); 78 | setCreatedWebhook(null); 79 | 80 | try { 81 | const response = await fetch(`/api/groups/${groupId}/webhooks`, { 82 | method: 'POST', 83 | headers: { 84 | 'Content-Type': 'application/json', 85 | 'Authorization': `Bearer ${token}`, 86 | }, 87 | body: JSON.stringify({ description }), 88 | }); 89 | 90 | if (!response.ok) { 91 | const errorData = await response.json(); 92 | throw new Error(errorData.message || 'Error creating webhook'); 93 | } 94 | const newWebhook: NewWebhookResponse = await response.json(); 95 | setCreatedWebhook(newWebhook); 96 | // Optional: Redirect to webhook overview after successful creation? 97 | // router.push(`/dashboard/groups/${groupId}/webhooks`); 98 | } catch (err: unknown) { 99 | if (err instanceof Error) { 100 | setError(err.message); 101 | } else if (typeof err === 'string') { 102 | setError(err); 103 | } else { 104 | setError('An unexpected error occurred while creating the webhook.'); 105 | } 106 | } finally { 107 | setIsSubmitting(false); 108 | } 109 | }; 110 | 111 | if (authIsLoading || isLoadingData) { 112 | return ( 113 |
114 |
115 |

Loading group details...

116 | 117 |
118 |
119 | ); 120 | } 121 | 122 | if (!group && !isLoadingData) { // If group was not loaded (even after attempting to load) 123 | return ( 124 |
125 |
126 |
127 |

{error || 'Group not found or access denied.'}

128 | Back to group overview 129 |
130 |
131 |
132 | ); 133 | } 134 | 135 | return ( 136 |
137 |
138 | 146 | 147 |

Create New Webhook for Group "{group?.group_name}"

148 | 149 | {createdWebhook ? ( 150 |
151 |
152 |

Webhook created successfully!

153 |
154 |

Description: {createdWebhook.description || '-'}

155 |

Webhook URL:

156 |
157 |
158 | 159 |
160 |
161 | 169 |
170 |
171 |

This token is shown only once. Please copy it securely.

172 | 173 |
174 |
175 | 178 |
179 |
180 | 181 | Back to Webhook Overview 182 | 183 |
184 |
185 |
186 | ) : ( 187 |
188 |
189 | {error && ( 190 |
191 | 192 | {error} 193 |
194 | )} 195 | 196 |
197 | 198 |
199 | setDescription(e.target.value)} 206 | /> 207 |
208 |
209 | 210 |
211 |
212 | 219 |
220 |
221 | 222 | Cancel 223 | 224 |
225 |
226 |
227 |
228 | )} 229 |
230 |
231 | ); 232 | } -------------------------------------------------------------------------------- /frontend/src/app/dashboard/groups/[groupId]/webhooks/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState, useCallback } from 'react'; 4 | import { useAuth } from '@/contexts/AuthContext'; 5 | import { useRouter, useParams } from 'next/navigation'; 6 | import Link from 'next/link'; 7 | 8 | interface Webhook { 9 | id: number; 10 | webhook_token: string; 11 | is_active: boolean; 12 | description: string | null; 13 | created_at: string; 14 | webhook_url?: string; // Optional, if sent from the backend on creation 15 | } 16 | 17 | interface Group { 18 | id: number; 19 | group_name: string; 20 | } 21 | 22 | export default function GroupWebhooksPage() { 23 | const { token, isAuthenticated, isLoading: authIsLoading } = useAuth(); 24 | const router = useRouter(); 25 | const params = useParams(); 26 | const groupId = params.groupId as string; 27 | 28 | const [group, setGroup] = useState(null); 29 | const [webhooks, setWebhooks] = useState([]); 30 | const [isLoading, setIsLoading] = useState(true); 31 | const [error, setError] = useState(null); 32 | const [deleteError, setDeleteError] = useState(null); 33 | const [isDeleting, setIsDeleting] = useState(null); 34 | 35 | const fetchGroupDetails = useCallback(async () => { 36 | if (!token || !isAuthenticated || !groupId) return; 37 | try { 38 | const response = await fetch(`/api/groups/${groupId}`, { 39 | headers: { 'Authorization': `Bearer ${token}` }, 40 | }); 41 | if (!response.ok) throw new Error('Error loading group details.'); 42 | const data: Group = await response.json(); 43 | setGroup(data); 44 | } catch (err: unknown) { 45 | if (err instanceof Error) { 46 | setError(err.message); // Set error when loading group 47 | } else if (typeof err === 'string') { 48 | setError(err); 49 | } else { 50 | setError('An unexpected error occurred while loading group details.'); 51 | } 52 | } 53 | }, [token, isAuthenticated, groupId]); 54 | 55 | const fetchWebhooks = useCallback(async () => { 56 | if (!token || !isAuthenticated || !groupId) return; 57 | setIsLoading(true); 58 | setError(null); 59 | setDeleteError(null); 60 | try { 61 | const response = await fetch(`/api/groups/${groupId}/webhooks`, { 62 | headers: { 'Authorization': `Bearer ${token}` }, 63 | }); 64 | if (!response.ok) { 65 | const errorData = await response.json(); 66 | throw new Error(errorData.message || 'Error fetching webhooks'); 67 | } 68 | const data: Webhook[] = await response.json(); 69 | setWebhooks(data); 70 | } catch (err: unknown) { 71 | if (err instanceof Error) { 72 | setError(err.message); 73 | } else if (typeof err === 'string') { 74 | setError(err); 75 | } else { 76 | setError('An unexpected error occurred while fetching webhooks.'); 77 | } 78 | } finally { 79 | setIsLoading(false); 80 | } 81 | }, [token, isAuthenticated, groupId]); 82 | 83 | useEffect(() => { 84 | if (!authIsLoading && !isAuthenticated) { 85 | router.push('/login'); 86 | } 87 | }, [authIsLoading, isAuthenticated, router]); 88 | 89 | useEffect(() => { 90 | fetchGroupDetails(); 91 | fetchWebhooks(); 92 | }, [fetchGroupDetails, fetchWebhooks]); 93 | 94 | const handleDeleteWebhook = async (webhookId: number) => { 95 | if (!window.confirm('Are you sure you want to delete this webhook?')) { 96 | return; 97 | } 98 | setIsDeleting(webhookId); 99 | setDeleteError(null); 100 | try { 101 | const response = await fetch(`/api/groups/${groupId}/webhooks/${webhookId}`, { 102 | method: 'DELETE', 103 | headers: { 'Authorization': `Bearer ${token}` }, 104 | }); 105 | if (!response.ok) { 106 | const errorData = await response.json(); 107 | throw new Error(errorData.message || 'Error deleting webhook'); 108 | } 109 | setWebhooks(prev => prev.filter(wh => wh.id !== webhookId)); 110 | } catch (err: unknown) { 111 | if (err instanceof Error) { 112 | setDeleteError(err.message); 113 | } else if (typeof err === 'string') { 114 | setDeleteError(err); 115 | } else { 116 | setDeleteError('An unexpected error occurred while deleting the webhook.'); 117 | } 118 | } finally { 119 | setIsDeleting(null); 120 | } 121 | }; 122 | 123 | if (authIsLoading || isLoading) { 124 | return ( 125 |
126 |
127 |

Loading webhooks...

128 | 129 |
130 |
131 | ); 132 | } 133 | 134 | if (error && !group) { // Error loading group 135 | return ( 136 |
137 |
138 |
139 |

{error}

140 | 141 | Back to group overview 142 | 143 |
144 |
145 |
146 | ); 147 | } 148 | 149 | if (!group) { // Fallback if group could not be loaded, but no explicit error was caught above 150 | return ( 151 |
152 |
153 |

Group details could not be loaded.

154 | 155 | Back to group overview 156 | 157 |
158 |
159 | ); 160 | } 161 | 162 | 163 | return ( 164 |
165 |
166 | 173 | 174 |
175 |

Webhooks for {group?.group_name || 'Group'}

176 | 177 | Create New Webhook 178 | 179 |
180 | 181 | {error && !webhooks.length && ( // Error specific to webhooks if group was loaded 182 |
183 |

Error loading webhooks: {error}. Please try again later.

184 |
185 | )} 186 | 187 | {deleteError && ( 188 |
189 | 190 | Error deleting: {deleteError} 191 |
192 | )} 193 | 194 | {webhooks.length === 0 && !isLoading && !error ? ( 195 |
196 |

No webhooks have been created for this group yet.

197 |
198 | ) : ( 199 | webhooks.map(webhook => ( 200 |
201 |
202 |
203 |

204 | {webhook.description || 'No description'} 205 |

206 |

207 | Token: {webhook.webhook_token.substring(0, 8)}... 208 | 215 |

216 |

217 | Created at: {new Date(webhook.created_at).toLocaleDateString('en-US')} 218 |

219 |
220 |
221 | 222 | {webhook.is_active ? 'Active' : 'Inactive'} 223 | 224 |
225 |
226 | 233 |
234 |
235 |
236 | )) 237 | )} 238 |
239 |
240 | ); 241 | } -------------------------------------------------------------------------------- /backend/routes/groups.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const pool = require('../config/db'); // Our central DB pool 4 | const { protect } = require('../middleware/authMiddleware'); // Our auth middleware 5 | const { v4: uuidv4 } = require('uuid'); // For generating webhook tokens 6 | 7 | // All endpoints here are protected and require a valid JWT 8 | router.use(protect); 9 | 10 | // GROUP ENDPOINTS 11 | 12 | // POST /api/groups - Create a new group 13 | router.post('/', async (req, res) => { 14 | const { group_name, description, signal_group_id, bot_phone_number } = req.body; 15 | const userId = req.user.id; // Comes from the protect middleware 16 | 17 | if (!group_name) { 18 | return res.status(400).json({ message: 'A group name is required.' }); 19 | } 20 | 21 | try { 22 | // Optional: Check if a group with the same signal_group_id already exists (if signal_group_id was provided) 23 | if (signal_group_id) { 24 | const existingGroup = await pool.query('SELECT id FROM groups WHERE signal_group_id = $1 AND user_id != $2', [signal_group_id, userId]); 25 | if (existingGroup.rows.length > 0) { 26 | return res.status(409).json({ message: 'This Signal group ID is already in use by another user.' }); 27 | } 28 | } 29 | 30 | const newGroup = await pool.query( 31 | 'INSERT INTO groups (user_id, group_name, description, signal_group_id, bot_phone_number) VALUES ($1, $2, $3, $4, $5) RETURNING *', 32 | [userId, group_name, description || null, signal_group_id || null, bot_phone_number || null] 33 | ); 34 | 35 | res.status(201).json(newGroup.rows[0]); 36 | } catch (error) { 37 | console.error('Error creating group:', error); 38 | // Specific error for unique constraint violation on signal_group_id (if it should be globally unique) 39 | if (error.code === '23505' && error.constraint === 'groups_signal_group_id_key') { 40 | return res.status(409).json({ message: 'A group with this Signal group ID already exists globally.' }); 41 | } 42 | res.status(500).json({ message: 'Internal server error creating group.' }); 43 | } 44 | }); 45 | 46 | // GET /api/groups - Get all groups of the logged-in user 47 | router.get('/', async (req, res) => { 48 | const userId = req.user.id; 49 | 50 | try { 51 | const userGroups = await pool.query('SELECT * FROM groups WHERE user_id = $1 ORDER BY created_at DESC', [userId]); 52 | res.json(userGroups.rows); 53 | } catch (error) { 54 | console.error('Error fetching groups:', error); 55 | res.status(500).json({ message: 'Internal server error fetching groups.' }); 56 | } 57 | }); 58 | 59 | // GET /api/groups/:groupId - Get a single group of the logged-in user 60 | router.get('/:groupId', async (req, res) => { 61 | const { groupId } = req.params; 62 | const userId = req.user.id; 63 | 64 | try { 65 | const groupResult = await pool.query('SELECT * FROM groups WHERE id = $1 AND user_id = $2', [groupId, userId]); 66 | if (groupResult.rows.length === 0) { 67 | return res.status(404).json({ message: 'Group not found or does not belong to the user.' }); 68 | } 69 | res.json(groupResult.rows[0]); 70 | } catch (error) { 71 | console.error('Error fetching group:', error); 72 | res.status(500).json({ message: 'Internal server error fetching group.' }); 73 | } 74 | }); 75 | 76 | // PUT /api/groups/:groupId - Update an existing group 77 | router.put('/:groupId', async (req, res) => { 78 | const { groupId } = req.params; 79 | const userId = req.user.id; 80 | const { group_name, description, signal_group_id, bot_phone_number } = req.body; 81 | 82 | // Build the update statement dynamically to only update provided fields 83 | let updateFields = []; 84 | let queryParams = []; 85 | let paramIndex = 1; 86 | 87 | if (group_name !== undefined) { 88 | updateFields.push(`group_name = $${paramIndex++}`); 89 | queryParams.push(group_name); 90 | } 91 | if (description !== undefined) { 92 | updateFields.push(`description = $${paramIndex++}`); 93 | queryParams.push(description === '' ? null : description); 94 | } 95 | if (signal_group_id !== undefined) { 96 | updateFields.push(`signal_group_id = $${paramIndex++}`); 97 | queryParams.push(signal_group_id); 98 | } 99 | if (bot_phone_number !== undefined) { 100 | updateFields.push(`bot_phone_number = $${paramIndex++}`); 101 | queryParams.push(bot_phone_number); 102 | } 103 | 104 | if (updateFields.length === 0) { 105 | return res.status(400).json({ message: 'No fields provided for update.' }); 106 | } 107 | 108 | updateFields.push(`updated_at = current_timestamp`); // Always update updated_at 109 | 110 | queryParams.push(groupId); // for WHERE id = $X 111 | queryParams.push(userId); // for WHERE user_id = $Y 112 | 113 | const updateQuery = `UPDATE groups SET ${updateFields.join(', ')} WHERE id = $${paramIndex++} AND user_id = $${paramIndex++} RETURNING *`; 114 | 115 | try { 116 | // Additional check if the new signal_group_id is already used by ANOTHER user 117 | if (signal_group_id) { 118 | const existingGroup = await pool.query( 119 | 'SELECT id FROM groups WHERE signal_group_id = $1 AND user_id != $2 AND id != $3', 120 | [signal_group_id, userId, groupId] 121 | ); 122 | if (existingGroup.rows.length > 0) { 123 | return res.status(409).json({ message: 'This Signal group ID is already in use by another user.' }); 124 | } 125 | } 126 | 127 | const result = await pool.query(updateQuery, queryParams); 128 | 129 | if (result.rows.length === 0) { 130 | return res.status(404).json({ message: 'Group not found or does not belong to the user.' }); 131 | } 132 | res.json(result.rows[0]); 133 | } catch (error) { 134 | console.error('Error updating group:', error); 135 | if (error.code === '23505' && error.constraint === 'groups_signal_group_id_key') { 136 | return res.status(409).json({ message: 'A group with this (new) Signal group ID already exists globally.' }); 137 | } 138 | res.status(500).json({ message: 'Internal server error updating group.' }); 139 | } 140 | }); 141 | 142 | // DELETE /api/groups/:groupId - Delete a group 143 | router.delete('/:groupId', async (req, res) => { 144 | const { groupId } = req.params; 145 | const userId = req.user.id; 146 | 147 | try { 148 | // First, check if the group belongs to the user 149 | const groupCheck = await pool.query('SELECT id FROM groups WHERE id = $1 AND user_id = $2', [groupId, userId]); 150 | if (groupCheck.rows.length === 0) { 151 | return res.status(404).json({ message: 'Group not found or does not belong to the user.' }); 152 | } 153 | 154 | // If webhooks are linked to the group, they must be deleted first or the link removed, 155 | // depending on how database constraints (ON DELETE CASCADE etc.) are set. 156 | // For this example, we delete dependent webhooks first. 157 | await pool.query('DELETE FROM webhooks WHERE group_id = $1 AND user_id = $2', [groupId, userId]); 158 | 159 | // Then delete the group 160 | await pool.query('DELETE FROM groups WHERE id = $1 AND user_id = $2', [groupId, userId]); 161 | 162 | res.json({ message: 'Group and associated webhooks deleted successfully.' }); 163 | } catch (error) { 164 | console.error('Error deleting group:', error); 165 | res.status(500).json({ message: 'Internal server error deleting group.' }); 166 | } 167 | }); 168 | 169 | // WEBHOOK ENDPOINTS (related to a group) 170 | 171 | // POST /api/groups/:groupId/webhooks - Create a new webhook for a group 172 | router.post('/:groupId/webhooks', async (req, res) => { 173 | const { groupId } = req.params; 174 | const { description } = req.body; 175 | const userId = req.user.id; 176 | 177 | try { 178 | // Check if the group belongs to the user 179 | const groupCheck = await pool.query('SELECT id FROM groups WHERE id = $1 AND user_id = $2', [groupId, userId]); 180 | if (groupCheck.rows.length === 0) { 181 | return res.status(404).json({ message: 'Group not found or does not belong to the user.' }); 182 | } 183 | 184 | const webhookToken = uuidv4(); 185 | 186 | const newWebhook = await pool.query( 187 | 'INSERT INTO webhooks (group_id, user_id, webhook_token, description) VALUES ($1, $2, $3, $4) RETURNING id, webhook_token, is_active, description, created_at', 188 | [groupId, userId, webhookToken, description || null] 189 | ); 190 | 191 | // Assemble the full webhook URL (example) 192 | const fullWebhookUrl = `${req.protocol}://${req.get('host')}/webhook/${webhookToken}`; 193 | 194 | res.status(201).json({ 195 | ...newWebhook.rows[0], 196 | webhook_url: fullWebhookUrl // Inform the client of the constructed URL 197 | }); 198 | 199 | } catch (error) { 200 | console.error('Error creating webhook:', error); 201 | // Specific error for unique constraint violation on webhook_token (extremely unlikely with UUIDv4, but for safety) 202 | if (error.code === '23505' && error.constraint === 'webhooks_webhook_token_key') { 203 | return res.status(500).json({ message: 'Error creating webhook token. Please try again.' }); 204 | } 205 | res.status(500).json({ message: 'Internal server error creating webhook.' }); 206 | } 207 | }); 208 | 209 | // GET /api/groups/:groupId/webhooks - Get all webhooks for a specific group of the user 210 | router.get('/:groupId/webhooks', async (req, res) => { 211 | const { groupId } = req.params; 212 | const userId = req.user.id; 213 | 214 | try { 215 | // Check if the group belongs to the user 216 | const groupCheck = await pool.query('SELECT id FROM groups WHERE id = $1 AND user_id = $2', [groupId, userId]); 217 | if (groupCheck.rows.length === 0) { 218 | return res.status(404).json({ message: 'Group not found or does not belong to the user.' }); 219 | } 220 | 221 | const groupWebhooks = await pool.query( 222 | 'SELECT id, webhook_token, is_active, description, created_at FROM webhooks WHERE group_id = $1 AND user_id = $2 ORDER BY created_at DESC', 223 | [groupId, userId] 224 | ); 225 | res.json(groupWebhooks.rows); 226 | } catch (error) { 227 | console.error('Error fetching webhooks:', error); 228 | res.status(500).json({ message: 'Internal server error fetching webhooks.' }); 229 | } 230 | }); 231 | 232 | // DELETE /api/groups/:groupId/webhooks/:webhookId - Delete a webhook 233 | // Alternatively: DELETE /api/webhooks/:webhookId (if webhookId is globally unique enough and we don't need the groupId) 234 | // We use here the more specific path 235 | router.delete('/:groupId/webhooks/:webhookId', async (req, res) => { 236 | const { groupId, webhookId } = req.params; 237 | const userId = req.user.id; 238 | 239 | try { 240 | // Check if the webhook belongs to the group and to the user 241 | const webhookCheck = await pool.query( 242 | 'SELECT id FROM webhooks WHERE id = $1 AND group_id = $2 AND user_id = $3', 243 | [webhookId, groupId, userId] 244 | ); 245 | 246 | if (webhookCheck.rows.length === 0) { 247 | return res.status(404).json({ message: 'Webhook not found or does not belong to the user/group.' }); 248 | } 249 | 250 | await pool.query('DELETE FROM webhooks WHERE id = $1', [webhookId]); 251 | 252 | res.json({ message: 'Webhook deleted successfully.' }); 253 | } catch (error) { 254 | console.error('Error deleting webhook:', error); 255 | res.status(500).json({ message: 'Internal server error deleting webhook.' }); 256 | } 257 | }); 258 | 259 | module.exports = router; -------------------------------------------------------------------------------- /frontend/src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import Link from 'next/link'; 6 | import { useAuth } from '@/contexts/AuthContext'; 7 | 8 | interface Group { 9 | id: number; 10 | group_name: string; 11 | description: string | null; 12 | signal_group_id: string | null; 13 | bot_phone_number: string | null; 14 | created_at: string; 15 | user_id: number; 16 | // Potentially other fields like webhook_count if the API provides it directly 17 | } 18 | 19 | // Simple interface for Webhook, focusing on what might be displayed or counted 20 | interface Webhook { 21 | id: number; 22 | // other fields like description, is_active etc. 23 | } 24 | 25 | export default function DashboardPage() { 26 | const { isAuthenticated, user, isLoading, token } = useAuth(); 27 | const router = useRouter(); 28 | const [groups, setGroups] = useState([]); 29 | const [totalWebhooks, setTotalWebhooks] = useState(0); 30 | const [dataError, setDataError] = useState(null); 31 | const [isLoadingData, setIsLoadingData] = useState(true); 32 | const [isGuideOpen, setIsGuideOpen] = useState(false); 33 | 34 | useEffect(() => { 35 | console.log('[DashboardPage] Auth isLoading:', isLoading); 36 | console.log('[DashboardPage] isAuthenticated:', isAuthenticated); 37 | console.log('[DashboardPage] User object:', user); 38 | }, [user, isAuthenticated, isLoading]); 39 | 40 | useEffect(() => { 41 | if (!isLoading && !isAuthenticated) { 42 | router.push('/login'); 43 | } 44 | }, [isAuthenticated, isLoading, router]); 45 | 46 | useEffect(() => { 47 | const fetchData = async () => { 48 | if (isAuthenticated && token) { 49 | setIsLoadingData(true); 50 | setDataError(null); 51 | try { 52 | const groupsResponse = await fetch('/api/groups', { 53 | headers: { 'Authorization': `Bearer ${token}` }, 54 | }); 55 | if (!groupsResponse.ok) { 56 | const errorData = await groupsResponse.json(); 57 | throw new Error(errorData.message || 'Error fetching groups'); 58 | } 59 | const groupsData: Group[] = await groupsResponse.json(); 60 | setGroups(groupsData); 61 | 62 | let webhooksCount = 0; 63 | for (const group of groupsData) { 64 | const webhooksResponse = await fetch(`/api/groups/${group.id}/webhooks`, { 65 | headers: { 'Authorization': `Bearer ${token}` }, 66 | }); 67 | if (webhooksResponse.ok) { 68 | const webhooksData: Webhook[] = await webhooksResponse.json(); 69 | webhooksCount += webhooksData.length; 70 | } else { 71 | console.warn(`Could not fetch webhooks for group ${group.id}`); 72 | } 73 | } 74 | setTotalWebhooks(webhooksCount); 75 | 76 | } catch (err: unknown) { 77 | if (err instanceof Error) { 78 | setDataError(err.message); 79 | } else if (typeof err === 'string') { 80 | setDataError(err); 81 | } else { 82 | setDataError('An unexpected error occurred while fetching dashboard data.'); 83 | } 84 | } finally { 85 | setIsLoadingData(false); 86 | } 87 | } 88 | }; 89 | 90 | fetchData(); 91 | }, [isAuthenticated, token]); 92 | 93 | if (isLoading || (!isAuthenticated && isLoadingData)) { 94 | return ( 95 |
96 |

Loading dashboard...

97 |
98 | ); 99 | } 100 | 101 | if (!isAuthenticated) { 102 | return null; 103 | } 104 | 105 | return ( 106 |
107 |
108 |

109 | Welcome to the Signalcow-Dashboard, {user?.username || user?.email}! 110 |

111 | {dataError && ( 112 |
113 | 114 | {dataError} 115 |
116 | )} 117 |
118 | 119 |
120 |
setIsGuideOpen(!isGuideOpen)} 123 | style={{ cursor: 'pointer' }} 124 | aria-expanded={isGuideOpen} 125 | aria-controls="webhook-guide-content" 126 | > 127 |
128 |
129 |

Webhook Creation Guide

130 |
131 |
132 | 133 | 134 | 135 |
136 |
137 |
138 | {isGuideOpen && ( 139 |
140 |
    141 |
  1. 142 | 1. Navigate to the "Groups" section: 143 |

    Click on the "Groups" menu item in the navigation bar.

    144 |
  2. 145 |
  3. 146 | 2. Select a group or create a new one: 147 |

    You will see a list of your existing groups. You can select one for which you want to create a webhook, or create a new group.

    148 |
  4. 149 |
  5. 150 | 3. Access the group's webhook management: 151 |

    Within the detail view of a group, you will find options to manage webhooks (e.g., a tab or a button "Manage Webhooks").

    152 |
  6. 153 |
  7. 154 | 4. Create a new webhook: 155 |

    Click on "Create New Webhook". A form will open.

    156 |
  8. 157 |
  9. 158 | 5. Configure the webhook: 159 |
      160 |
    • Name: Enter a descriptive name for your webhook.
    • 161 |
    • Event Type: Choose the event that should trigger the webhook (e.g., "New message received", "Member joined").
    • 162 |
    • Target URL: Enter the URL of your service that will receive the webhook data. This URL must be publicly accessible.
    • 163 |
    • (Optional) Secret Key: For added security, you can specify a secret key. This will be used to sign the requests so your service can verify their authenticity.
    • 164 |
    165 |
  10. 166 |
  11. 167 | 6. Save: 168 |

    After entering all required information, save the webhook.

    169 |
  12. 170 |
  13. 171 | 7. Test (Recommended): 172 |

    Many systems offer a way to send test notifications to the webhook. Use this feature to ensure your integration is working correctly.

    173 |
  14. 174 |
175 |
176 | )} 177 |
178 | 179 |
180 |
181 |
182 |
183 |

Groups

184 |

Manage your Signal groups. ({isLoadingData ? '...' : groups.length})

185 |
186 | 187 | View Groups 188 | 189 |
190 |
191 |
192 |
193 |
194 |

Webhooks

195 |

Total configured webhooks. ({isLoadingData ? '...' : totalWebhooks})

196 |
197 | 203 | View Webhooks 204 | 205 |
206 |
207 | {user?.is_admin && ( 208 |
209 |
210 |
211 |

Admin Area

212 |

Manage users and system settings.

213 |
214 | 215 | Go to Admin 216 | 217 |
218 |
219 | )} 220 |
221 |
222 |
223 |

Settings

224 |

Change your password and other settings.

225 |
226 | 227 | User Settings 228 | 229 |
230 |
231 |
232 |
233 | ); 234 | } -------------------------------------------------------------------------------- /backend/server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | require('dotenv').config({ path: path.resolve(__dirname, '.env') }); 3 | 4 | const express = require('express'); 5 | const pool = require('./config/db'); // Imports the DB pool 6 | const signalService = require('./services/signalService'); // Imports our Signal Service 7 | const { authenticateToken } = require('./middleware/authMiddleware'); 8 | const groupRoutes = require('./routes/groupRoutes'); // Temporarily commented out 9 | const webhookRoutes = require('./routes/webhookRoutes'); // Temporarily commented out 10 | 11 | // Swagger / OpenAPI 12 | const swaggerJsdoc = require('swagger-jsdoc'); 13 | const swaggerUi = require('swagger-ui-express'); 14 | 15 | const app = express(); 16 | 17 | const port = process.env.PORT || 3001; 18 | 19 | // Middleware for parsing JSON bodies (global for the app) 20 | app.use(express.json()); 21 | // Middleware for parsing plain text bodies (will be used specifically or globally if needed) 22 | app.use(express.text()); // Potentially apply this more selectively if it causes issues elsewhere 23 | 24 | // Swagger/OpenAPI Configuration 25 | const swaggerOptions = { 26 | swaggerDefinition: { 27 | openapi: '3.0.0', // Specification (optional, defaults to swagger: '2.0') 28 | info: { 29 | title: 'Signalcow Backend API', 30 | version: '1.0.0', 31 | description: 'API documentation for the Signalcow backend services', 32 | }, 33 | servers: [ // Optional: Define your server URL 34 | { 35 | url: process.env.BASE_URL || `http://localhost:${port}`, 36 | description: process.env.BASE_URL ? 'Production/Configured Server' : 'Development server (localhost)' 37 | } 38 | ], 39 | // Optional: Add components like securitySchemes for JWT 40 | components: { 41 | securitySchemes: { 42 | bearerAuth: { 43 | type: 'http', 44 | scheme: 'bearer', 45 | bearerFormat: 'JWT', 46 | } 47 | } 48 | }, 49 | security: [{ // Optional: Apply security globally 50 | bearerAuth: [] 51 | }] 52 | }, 53 | // Path to the API docs 54 | // Note: glob pattern might need adjustment based on your project structure 55 | apis: ['./routes/*.js'], // Glob pattern to find API docs in JSDoc format 56 | }; 57 | 58 | const swaggerDocs = swaggerJsdoc(swaggerOptions); 59 | app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocs)); 60 | 61 | // Test database connection (optional) 62 | async function testDbConnection() { 63 | try { 64 | const client = await pool.connect(); 65 | console.log('Successfully connected to PostgreSQL database!'); 66 | const res = await client.query('SELECT NOW()'); 67 | console.log('Current time from DB:', res.rows[0].now); 68 | client.release(); 69 | } catch (err) { 70 | console.error('Error connecting to PostgreSQL database:', err.stack); 71 | } 72 | } 73 | // testDbConnection(); // Commented out for now to keep startup logs clean 74 | 75 | // API Routes 76 | const authRoutes = require('./routes/auth'); // Temporarily commented out 77 | // const adminRoutes = require('./routes/admin'); // Old admin routes (HTML serving) 78 | const adminApiRoutes = require('./routes/adminApiRoutes'); // New JSON API admin routes 79 | 80 | app.use('/api/auth', authRoutes); // Temporarily commented out 81 | // app.use('/admin', adminRoutes); // Mount old admin routes - REMOVED 82 | app.use('/api/admin', adminApiRoutes); // Mount new JSON API admin routes 83 | 84 | app.use('/api/groups', authenticateToken, groupRoutes); // Temporarily commented out 85 | app.use('/api/webhooks', authenticateToken, webhookRoutes); // Temporarily commented out 86 | 87 | // Webhook execution endpoint 88 | app.post('/webhook/:webhookToken', async (req, res) => { 89 | const { webhookToken } = req.params; 90 | 91 | // Temporäres Logging: 92 | console.log(`Webhook ${webhookToken} called.`); 93 | console.log('Request Headers:', JSON.stringify(req.headers, null, 2)); 94 | console.log('Raw Request Body Type:', typeof req.body); 95 | console.log('Raw Request Body:', JSON.stringify(req.body, null, 2)); 96 | 97 | let textToSend = null; 98 | 99 | // Check if body is a string (from express.text()) 100 | if (typeof req.body === 'string' && req.body.trim() !== '') { 101 | textToSend = req.body.trim(); 102 | console.log('[Webhook] Received text/plain body:', textToSend); 103 | } 104 | // Check if body is an object (from express.json()) and has text/message property 105 | else if (typeof req.body === 'object' && req.body !== null) { 106 | if (req.body.text && typeof req.body.text === 'string' && req.body.text.trim() !== '') { 107 | textToSend = req.body.text.trim(); 108 | console.log('[Webhook] Received JSON body with text field:', textToSend); 109 | } else if (req.body.message && typeof req.body.message === 'string' && req.body.message.trim() !== '') { 110 | textToSend = req.body.message.trim(); 111 | console.log('[Webhook] Received JSON body with message field:', textToSend); 112 | } 113 | } 114 | 115 | if (!textToSend) { 116 | console.log(`Webhook ${webhookToken} called without usable text in body (checked for plain text and JSON with text/message field).`); 117 | return res.status(400).json({ message: 'No message text found in request body (expected plain text or JSON with text/message field).' }); 118 | } 119 | 120 | try { 121 | const webhookResult = await pool.query( 122 | 'SELECT w.id, w.is_active, w.group_id, g.signal_group_id, g.bot_phone_number ' + 123 | 'FROM webhooks w JOIN groups g ON w.group_id = g.id ' + 124 | 'WHERE w.webhook_token = $1', 125 | [webhookToken] 126 | ); 127 | 128 | if (webhookResult.rows.length === 0) { 129 | return res.status(404).json({ message: 'Webhook not found.' }); 130 | } 131 | 132 | const webhook = webhookResult.rows[0]; 133 | 134 | if (!webhook.is_active) { 135 | return res.status(403).json({ message: 'Webhook is inactive.' }); 136 | } 137 | 138 | if (!webhook.signal_group_id) { 139 | console.error(`Webhook ${webhookToken} has no signal_group_id in the DB.`); 140 | return res.status(400).json({ message: 'No Signal group ID configured for this webhook.' }); 141 | } 142 | 143 | // Send message via signalService 144 | // The bot_phone_number from the DB is not used directly here, as signalService 145 | // uses the configured BOT_NUMBER. This could be adjusted if different bot numbers 146 | // per group/webhook should be possible and passed to signalService. 147 | await signalService.sendMessage(webhook.signal_group_id, textToSend); 148 | 149 | res.status(200).json({ 150 | message: 'Webhook received successfully and message sent to Signal.', 151 | details: { 152 | webhookToken: webhookToken, 153 | signalGroupId: webhook.signal_group_id, 154 | messageSent: textToSend 155 | } 156 | }); 157 | 158 | } catch (error) { 159 | console.error(`Error during webhook execution (${webhookToken}):`, error.message); 160 | // More detailed error message to the client if it was a signal-cli error 161 | if (error.message && error.message.startsWith('signal-cli error')) { // Assuming 'signal-cli error' is a specific prefix you use 162 | return res.status(502).json({ message: 'Error communicating with the Signal service.', details: error.message }); 163 | } 164 | res.status(500).json({ message: 'Internal server error during webhook execution.' }); 165 | } 166 | }); 167 | 168 | // Route to fetch Signal groups 169 | app.get('/api/signal/groups', authenticateToken, async (req, res) => { 170 | try { 171 | const groups = await signalService.listSignalGroups(); 172 | res.json(groups); 173 | } catch (error) { 174 | console.error('[GET /api/signal/groups] Error fetching Signal groups:', error.message); 175 | res.status(500).json({ message: 'Error fetching Signal groups from signal-cli.', details: error.message }); 176 | } 177 | }); 178 | 179 | // Global Error Handler (example) 180 | app.use((err, req, res, next) => { 181 | console.error("[Global Error Handler]",err.stack); 182 | res.status(500).send('Something broke!'); 183 | }); 184 | 185 | app.get('/', (req, res) => { 186 | res.send('Hello from Signalcow Backend!'); 187 | }); 188 | 189 | app.listen(port, () => { 190 | console.log(`Backend server listening on port ${port}`); 191 | 192 | signalService.startListeningForMessages(async (message) => { 193 | // console.log('[SignalMsgHandler] Raw message from signal-cli:', JSON.stringify(message, null, 2)); 194 | 195 | try { 196 | if (message && message.method === 'receive' && message.params && message.params.envelope) { 197 | const envelope = message.params.envelope; 198 | 199 | // Is it a data message with text and group info? 200 | if (envelope.dataMessage && envelope.dataMessage.message && envelope.dataMessage.groupInfo && envelope.dataMessage.groupInfo.groupId) { 201 | const receivedText = envelope.dataMessage.message.trim(); 202 | const signalGroupId = envelope.dataMessage.groupInfo.groupId; 203 | const sourceNumber = envelope.sourceNumber; // Sender's number of the !link message 204 | 205 | if (receivedText.startsWith('!link ')) { 206 | const linkToken = receivedText.substring(6).trim(); // Extract token 207 | console.log(`[LinkHandler] Received link attempt: Token '${linkToken}' from group ${signalGroupId} by ${sourceNumber}`); 208 | 209 | if (!linkToken) { 210 | console.log('[LinkHandler] Empty token received.'); 211 | // Optional: Message to group that the token is missing. 212 | return; 213 | } 214 | 215 | // Search for token in DB 216 | const tokenSearch = await pool.query( 217 | 'SELECT id, user_id, group_name, link_token_expires_at FROM groups WHERE link_token = $1', 218 | [linkToken] 219 | ); 220 | 221 | if (tokenSearch.rows.length > 0) { 222 | const groupToLink = tokenSearch.rows[0]; 223 | const now = new Date(); 224 | const expiresAt = new Date(groupToLink.link_token_expires_at); 225 | 226 | if (expiresAt > now) { 227 | // Token is valid and not expired 228 | await pool.query( 229 | 'UPDATE groups SET signal_group_id = $1, link_token = NULL, link_token_expires_at = NULL, bot_linked_at = NOW() WHERE link_token = $2', 230 | [signalGroupId, linkToken] 231 | ); 232 | console.log(`[LinkHandler] Group '${groupToLink.group_name}' (ID: ${groupToLink.id}) successfully linked with Signal group ${signalGroupId}.`); 233 | 234 | // Send confirmation message to the Signal group 235 | try { 236 | await signalService.sendMessage(signalGroupId, `The bot group '${groupToLink.group_name}' has been successfully linked with this Signal chat.`); 237 | } catch (sendError) { 238 | console.error('[LinkHandler] Error sending confirmation message:', sendError.message); 239 | } 240 | } else { 241 | // Token expired 242 | console.log(`[LinkHandler] Expired token '${linkToken}' received for group '${groupToLink.group_name}'.`); 243 | try { 244 | await signalService.sendMessage(signalGroupId, `The link token '${linkToken}' has expired. Please generate a new token in the web application.`); 245 | } catch (sendError) { 246 | console.error('[LinkHandler] Error sending message about expired token:', sendError.message); 247 | } 248 | // Optional: Remove the expired token from the DB if not already removed by the UPDATE above 249 | // await pool.query('UPDATE groups SET link_token = NULL, link_token_expires_at = NULL WHERE link_token = $1', [linkToken]); 250 | } 251 | } else { 252 | // Token not found in DB 253 | console.log(`[LinkHandler] Invalid or unknown link token '${linkToken}' received from group ${signalGroupId}.`); 254 | // Optional: Message to group that token is invalid. But be careful about spamming on incorrect entries. 255 | } 256 | } 257 | } 258 | } 259 | } catch (error) { 260 | console.error('[SignalMsgHandler] Error processing an incoming message:', error); 261 | } 262 | }); 263 | }); 264 | -------------------------------------------------------------------------------- /backend/services/signalService.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | require('dotenv').config({ path: '../.env' }); // Ensures .env is loaded from the backend root 3 | 4 | const SIGNAL_CLI_HOST = process.env.SIGNAL_CLI_HOST || 'localhost'; 5 | const SIGNAL_CLI_PORT = parseInt(process.env.SIGNAL_CLI_PORT || '7446', 10); 6 | const BOT_NUMBER = process.env.BOT_NUMBER; // Your Signalcow phone number, e.g., +4915678470953 7 | 8 | let requestIdCounter = 1; 9 | let messageListenerClient = null; 10 | let messageHandlerCallback = null; 11 | let reconnectTimeoutId = null; 12 | const RECONNECT_DELAY = 5000; // 5 seconds until reconnection attempt 13 | 14 | /** 15 | * Sends a JSON-RPC request to the signal-cli socket. 16 | * @param {object} requestObject The JSON-RPC request object. 17 | * @returns {Promise} The parsed JSON-RPC response object. 18 | */ 19 | function sendRpcRequest(requestObject) { 20 | return new Promise((resolve, reject) => { 21 | let responseData = ''; // Explicitly initialized here at the beginning of the Promise callback 22 | const client = new net.Socket(); 23 | 24 | // console.log(`[sendRpcRequest] Initialized responseData: '${responseData}'`); 25 | 26 | client.connect(SIGNAL_CLI_PORT, SIGNAL_CLI_HOST, () => { 27 | const requestString = JSON.stringify(requestObject) + '\n'; 28 | client.write(requestString); 29 | }); 30 | 31 | client.on('data', (data) => { 32 | // console.log(`[sendRpcRequest] client.on('data'): current responseData before append: '${responseData}'`); 33 | try { 34 | responseData += data.toString(); // The error occurred here 35 | } catch (e) { 36 | console.error('[sendRpcRequest] Error during responseData concatenation:', e); 37 | reject(e); // Abort on error here 38 | client.end(); 39 | return; 40 | } 41 | // console.log(`[sendRpcRequest] client.on('data'): responseData after append: '${responseData}'`); 42 | 43 | if (responseData.includes('\n')) { 44 | try { 45 | const singleResponse = responseData.substring(0, responseData.indexOf('\n') + 1).trim(); 46 | if (singleResponse) { 47 | const parsedResponse = JSON.parse(singleResponse); 48 | resolve(parsedResponse); 49 | client.end(); 50 | } 51 | } catch (error) { 52 | if (responseData.includes('\n')) { 53 | reject(new Error('Invalid JSON response from signal-cli: ' + responseData)); 54 | client.end(); 55 | } 56 | } 57 | } 58 | }); 59 | 60 | client.on('close', () => { 61 | // No more reject here, as resolve/reject should happen in the 'data' or 'error' handler 62 | }); 63 | 64 | client.on('error', (err) => { 65 | console.error('Socket error with signal-cli:', err); 66 | reject(err); 67 | client.destroy(); 68 | }); 69 | 70 | client.setTimeout(15000); // Timeout increased to 15 seconds 71 | client.on('timeout', () => { 72 | reject(new Error('Timeout during request to signal-cli')); 73 | client.end(); 74 | }); 75 | }); 76 | } 77 | 78 | /** 79 | * Sends a message via signal-cli. 80 | * @param {string} recipientGroupId The Signal group ID. 81 | * @param {string} message The text to send. 82 | * @returns {Promise} The result of the send operation from signal-cli. 83 | */ 84 | async function sendMessage(recipientGroupId, message) { 85 | if (!BOT_NUMBER) { 86 | throw new Error('BOT_NUMBER is not configured in the .env file.'); 87 | } 88 | 89 | let finalSignalGroupId = null; 90 | 91 | if (recipientGroupId && typeof recipientGroupId === 'string' && recipientGroupId.trim() !== '') { 92 | try { 93 | const hexGroupId = recipientGroupId.replace(/-/g, ''); 94 | 95 | if (/^[0-9a-fA-F]{32}$/.test(hexGroupId)) { // Is it a 32-character hex UUID? 96 | const groupIdBytes = Buffer.from(hexGroupId, 'hex'); 97 | finalSignalGroupId = groupIdBytes.toString('base64'); 98 | console.log(`[signalService] UUID '${recipientGroupId}' was encoded to Base64 (bytes): ${finalSignalGroupId}`); 99 | } else if (/^[a-zA-Z0-9+/=]+$/.test(recipientGroupId)) { // Checks if it ONLY contains Base64 characters 100 | let decodedBuffer; 101 | try { 102 | decodedBuffer = Buffer.from(recipientGroupId, 'base64'); 103 | } catch (e) { 104 | throw new Error(`Error during Base64 decoding of '${recipientGroupId}': ${e.message}`); 105 | } 106 | 107 | if (decodedBuffer && (decodedBuffer.length === 16 || decodedBuffer.length === 32)) { 108 | if (decodedBuffer.toString('base64') === recipientGroupId) { 109 | finalSignalGroupId = recipientGroupId; 110 | console.log(`[signalService] groupId '${recipientGroupId}' is treated as an already correct Base64 string (${decodedBuffer.length} bytes decoded).`); 111 | } else { 112 | throw new Error(`The provided group ID '${recipientGroupId}' appears to be Base64, but round-trip encoding failed. Please check the ID.`); 113 | } 114 | } else { 115 | throw new Error(`The provided group ID '${recipientGroupId}' is Base64, but does not decode to 16 or 32 bytes (length: ${decodedBuffer ? decodedBuffer.length : 'unknown'}).`); 116 | } 117 | } else { 118 | throw new Error(`The provided group ID '${recipientGroupId}' could not be interpreted as a valid Signal group ID (hex UUID or matching Base64 string).`); 119 | } 120 | } catch (e) { 121 | console.error('[signalService] Error processing/encoding groupId:', e.message); 122 | // Rethrow the already generated error or a new general error 123 | throw e instanceof Error && e.message.startsWith('The provided group ID') ? e : new Error(`Error processing group ID: ${e.message}`); 124 | } 125 | } else if (recipientGroupId && (typeof recipientGroupId !== 'string' || recipientGroupId.trim() === '')) { 126 | throw new Error('Group ID must be a non-empty string.'); 127 | } 128 | 129 | if (!finalSignalGroupId) { 130 | console.error('[signalService] Could not derive a valid finalSignalGroupId or recipientGroupId was not provided.'); 131 | throw new Error('No valid Signal group ID available for sending or the ID was empty.'); 132 | } 133 | 134 | const request = { 135 | jsonrpc: '2.0', 136 | id: requestIdCounter++, 137 | method: 'send', 138 | params: { 139 | recipient: [], 140 | groupId: finalSignalGroupId, 141 | message: message, 142 | account: BOT_NUMBER, 143 | }, 144 | }; 145 | 146 | console.log(`Sending JSON-RPC to signal-cli: Method 'send', Account '${BOT_NUMBER}', Group '${finalSignalGroupId}', Message: '${message}'`); 147 | 148 | try { 149 | const response = await sendRpcRequest(request); 150 | console.log('Response from signal-cli (send):', response); 151 | if (response.error) { 152 | throw new Error(`signal-cli error: ${response.error.message} (Code: ${response.error.code})`); 153 | } 154 | return response.result; 155 | } catch (error) { 156 | console.error('Error in signalService.sendMessage:', error.message); 157 | throw error; 158 | } 159 | } 160 | 161 | /** 162 | * Lists all groups the bot is a member of. 163 | * @returns {Promise>} A list of group objects. 164 | */ 165 | async function listSignalGroups() { 166 | if (!BOT_NUMBER) { 167 | throw new Error('BOT_NUMBER is not configured in the .env file.'); 168 | } 169 | 170 | const request = { 171 | jsonrpc: '2.0', 172 | id: requestIdCounter++, 173 | method: 'listGroups', 174 | params: { 175 | account: BOT_NUMBER, 176 | }, 177 | }; 178 | 179 | console.log(`Sending JSON-RPC to signal-cli: Method 'listGroups', Account '${BOT_NUMBER}'`); 180 | 181 | try { 182 | // Important: listGroups can take a while and return a lot of data. 183 | // sendRpcRequest might need adjustment if it only expects single lines. 184 | // For listGroups, we expect a single, complete JSON array response. 185 | const response = await sendRpcRequest(request); // Assumption: sendRpcRequest can handle array responses 186 | console.log('Response from signal-cli (listGroups):', response); 187 | 188 | if (response.error) { 189 | throw new Error(`signal-cli error with listGroups: ${response.error.message} (Code: ${response.error.code})`); 190 | } 191 | 192 | // The response from listGroups is directly an array of group objects. 193 | // Each object should contain fields like 'id' (the internal ID of the group), 'name' (the display name), 194 | // and 'groupId' (the Base64 ID for sending). 195 | // We need to ensure we use the correct ID for sending. 196 | // Typically, the 'groupId' field in the response is what we need. 197 | if (response.result && Array.isArray(response.result.groups)) { 198 | return response.result.groups.map(group => ({ 199 | id: group.groupId, // The Base64 ID for sending 200 | name: group.name || 'Unnamed Group', // Fallback for groups without a name 201 | internal_id: group.id // The internal ID, e.g., group.RandomChars== 202 | })); 203 | } else { 204 | console.warn('[signalService] listGroups returned invalid result or was not an array. Response:', response); 205 | // Try to log the structure of response.result to understand the problem 206 | if (response.result) { 207 | console.log('[signalService] Structure of response.result:', JSON.stringify(response.result, null, 2)); 208 | if (Array.isArray(response.result)) { // Sometimes response.result is directly the array 209 | return response.result.map(group => ({ 210 | id: group.groupId, 211 | name: group.name || 'Unnamed Group', 212 | internal_id: group.id 213 | })); 214 | } 215 | } 216 | return []; // Empty array as fallback 217 | } 218 | } catch (error) { 219 | console.error('Error in signalService.listSignalGroups:', error.message); 220 | throw error; 221 | } 222 | } 223 | 224 | /** 225 | * Builds a persistent connection to signal-cli and listens for incoming messages. 226 | * Calls the messageHandlerCallback for each received message. 227 | */ 228 | function connectAndListen() { 229 | if (messageListenerClient && messageListenerClient.connecting) { 230 | console.log('[signalService-listener] Connection is already being established.'); 231 | return; 232 | } 233 | 234 | if (messageListenerClient && !messageListenerClient.destroyed) { 235 | console.log('[signalService-listener] Existing connection will be terminated first.'); 236 | messageListenerClient.destroy(); 237 | } 238 | 239 | console.log(`[signalService-listener] Trying to establish connection to signal-cli: ${SIGNAL_CLI_HOST}:${SIGNAL_CLI_PORT}`); 240 | messageListenerClient = new net.Socket(); 241 | let accumulatedData = ''; 242 | 243 | messageListenerClient.connect(SIGNAL_CLI_PORT, SIGNAL_CLI_HOST, () => { 244 | console.log('[signalService-listener] Successfully connected to signal-cli for incoming messages.'); 245 | // Here we could instruct signal-cli to send messages if needed and supported. 246 | // e.g., a subscribe command if signal-cli supports it for JSON-RPC. 247 | // For now, we assume the daemon automatically pushes messages. 248 | if (reconnectTimeoutId) { 249 | clearTimeout(reconnectTimeoutId); 250 | reconnectTimeoutId = null; 251 | } 252 | }); 253 | 254 | messageListenerClient.on('data', (data) => { 255 | accumulatedData += data.toString(); 256 | let newlineIndex; 257 | while ((newlineIndex = accumulatedData.indexOf('\n')) >= 0) { 258 | const singleMessage = accumulatedData.substring(0, newlineIndex).trim(); 259 | accumulatedData = accumulatedData.substring(newlineIndex + 1); 260 | 261 | if (singleMessage) { 262 | try { 263 | const parsedMessage = JSON.parse(singleMessage); 264 | if (messageHandlerCallback) { 265 | messageHandlerCallback(parsedMessage); 266 | } 267 | } catch (error) { 268 | console.error('[signalService-listener] Error parsing JSON message from signal-cli:', error, 'Message:', singleMessage); 269 | } 270 | } 271 | } 272 | }); 273 | 274 | messageListenerClient.on('close', (hadError) => { 275 | console.log('[signalService-listener] Connection to signal-cli closed.', hadError ? 'Due to an error.' : 'Normal.'); 276 | messageListenerClient.destroy(); // Free resources 277 | messageListenerClient = null; 278 | if (!reconnectTimeoutId) { // Only reconnect if no timeout is already running 279 | console.log(`[signalService-listener] Next reconnection attempt in ${RECONNECT_DELAY / 1000}s`); 280 | reconnectTimeoutId = setTimeout(() => { 281 | reconnectTimeoutId = null; // Reset timeout-ID before connectAndListen is called 282 | connectAndListen(); 283 | }, RECONNECT_DELAY); 284 | } 285 | }); 286 | 287 | messageListenerClient.on('error', (err) => { 288 | console.error('[signalService-listener] Socket error with signal-cli:', err.message); 289 | // The 'close' handler is also triggered, which starts the reconnect mechanism. 290 | // messageListenerClient.destroy(); // Implied by 'close' or the error itself 291 | }); 292 | 293 | messageListenerClient.setTimeout(0); // No timeout for the persistent connection 294 | messageListenerClient.on('timeout', () => { 295 | // Should not occur with setTimeout(0), but for safety: 296 | console.warn('[signalService-listener] Unexpected timeout during persistent connection.'); 297 | messageListenerClient.destroy(); // Triggers 'close' and thus the reconnect. 298 | }); 299 | } 300 | 301 | /** 302 | * Starts the process of listening for incoming Signal messages. 303 | * @param {function} callback The function to call for each received message. 304 | */ 305 | function startListeningForMessages(callback) { 306 | if (!BOT_NUMBER) { 307 | console.error('[signalService-listener] BOT_NUMBER not configured. Message reception not started.'); 308 | return; 309 | } 310 | if (typeof callback !== 'function') { 311 | console.error('[signalService-listener] Invalid callback passed. Message reception not started.'); 312 | return; 313 | } 314 | messageHandlerCallback = callback; 315 | console.log('[signalService] Starting listener for incoming messages...'); 316 | connectAndListen(); 317 | } 318 | 319 | module.exports = { sendMessage, listSignalGroups, startListeningForMessages }; -------------------------------------------------------------------------------- /frontend/src/app/privacy/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Privacy() { 2 | return ( 3 |
4 |
5 |

Privacy Policy

6 | 7 |
8 |

1. An overview of data protection

9 | 10 |

General information

11 |

12 | The following information will provide you with an easy to navigate overview of what will happen 13 | with your personal data when you visit this website. The term "personal data" comprises all data 14 | that can be used to personally identify you. For detailed information about the subject matter of 15 | data protection, please consult our Data Protection Declaration, which we have included beneath 16 | this copy. 17 |

18 | 19 |

Data recording on this website

20 |

21 | Who is the responsible party for the recording of data on this website (i.e., the "controller")? 22 |

23 |

24 | The data on this website is processed by the operator of the website, whose contact information 25 | is available under section "Information about the responsible party (referred to as the "controller" 26 | in the GDPR)" in this Privacy Policy. 27 |

28 | 29 |

30 | How do we record your data? 31 |

32 |

33 | We collect your data as a result of your sharing of your data with us. This may, for instance be 34 | information you enter into our contact form. 35 |

36 |

37 | Other data shall be recorded by our IT systems automatically upon your visit to this website. 38 | This data comprises primarily technical information (e.g., web browser, operating system or time 39 | the site was accessed). This information is recorded automatically upon your visit to this website. 40 |

41 | 42 |

43 | What are the purposes we use your data for? 44 |

45 |

46 | A portion of the information is generated to guarantee the error free provision of the website. 47 | Other data may be used to analyze your user patterns. 48 |

49 | 50 |

51 | What rights do you have as far as your information is concerned? 52 |

53 |

54 | You have the right to receive information about the source, recipients, and purposes of your 55 | archived personal data at any time without having to pay a fee for such disclosures. You also 56 | have the right to demand that your data are rectified or eradicated. If you have consented to 57 | data processing, you have the option to revoke this consent at any time, which shall affect all 58 | future data processing. Moreover, you have the right to demand that the processing of your data 59 | be restricted under certain circumstances. Furthermore, you have the right to log a complaint 60 | with the competent supervising agency. 61 |

62 |

63 | Please do not hesitate to contact us at any time if you have questions about this or any other 64 | data protection related matters. 65 |

66 | 67 |

2. Hosting

68 |

69 | We host the content of our website at the following provider: 70 |

71 | 72 |

External Hosting

73 |

74 | This website is hosted externally. Personal data collected on this website are stored on the 75 | servers of the host. These may include, but are not limited to, IP addresses, contact requests, 76 | metadata, communications, contract information, contact information, names, web page access, and 77 | other data generated through a web site. 78 |

79 |

80 | The external hosting serves the purpose of fulfilling the contract with our potential and existing 81 | customers (Art. 6 para. 1 lit. b GDPR) and in the interest of secure, fast, and efficient 82 | provision of our online service by a professional provider (Art. 6 para. 1 lit. f GDPR). 83 | If appropriate consent has been obtained, the processing is carried out exclusively on the basis 84 | of Art. 6 para. 1 lit. a GDPR and § 25 (1) TTDSG, insofar the consent includes the storage of 85 | cookies or the access to information in the user's end device (e.g., device fingerprinting) 86 | within the meaning of the TTDSG. This consent can be revoked at any time. 87 |

88 |

89 | Our host will only process your data to the extent necessary to fulfil its performance obligations 90 | and to follow our instructions with respect to such data. 91 |

92 | 93 |

3. General information and mandatory information

94 | 95 |

Data protection

96 |

97 | The operators of this website and its pages take the protection of your personal data very seriously. 98 | Hence, we handle your personal data as confidential information and in compliance with the statutory 99 | data protection regulations and this Data Protection Declaration. 100 |

101 |

102 | Whenever you use this website, a variety of personal information will be collected. Personal data 103 | comprises data that can be used to personally identify you. This Data Protection Declaration 104 | explains which data we collect as well as the purposes we use this data for. It also explains how, 105 | and for which purpose the information is collected. 106 |

107 |

108 | We herewith advise you that the transmission of data via the Internet (i.e., through e-mail 109 | communications) may be prone to security gaps. It is not possible to completely protect data 110 | against third-party access. 111 |

112 | 113 |

Information about the responsible party (referred to as the "controller" in the GDPR)

114 |

115 | The data processing controller on this website is: 116 |

117 |

118 | Patrick Walter
119 | Leipziger Str. 4
120 | 55283 Nierstein
121 | Germany 122 |

123 |

124 | Phone: +49 162 966 9809
125 | Email: pw@gummipunkt.eu 126 |

127 |

128 | The controller is the natural person or legal entity that single-handedly or jointly with others 129 | makes decisions as to the purposes of and resources for the processing of personal data (e.g., 130 | names, e-mail addresses, etc.). 131 |

132 | 133 |

Storage duration

134 |

135 | Unless a more specific storage period has been specified in this privacy policy, your personal 136 | data will remain with us until the purpose for which it was collected no longer applies. If you 137 | assert a justified request for deletion or revoke your consent to data processing, your data will 138 | be deleted, unless we have other legally permissible reasons for storing your personal data 139 | (e.g., tax or commercial law retention periods); in the latter case, the deletion will take place 140 | after these reasons cease to apply. 141 |

142 | 143 |

Revocation of your consent to the processing of data

144 |

145 | A wide range of data processing operations are possible only with your express consent. You can 146 | also revoke at any time any consent you have already given us. This shall be without prejudice 147 | to the lawfulness of any data collection that occurred prior to your revocation. 148 |

149 | 150 |

Right to object to the collection of data in special cases; right to object to direct advertising (Art. 21 GDPR)

151 |

152 | 153 | In the event that data are processed on the basis of Art. 6 para. 1 lit. e or f GDPR, you 154 | have the right to at any time object to the processing of your personal data based on grounds 155 | arising from your unique situation. This also applies to any profiling based on these 156 | provisions. To determine the legal basis for which any processing of data is based on, please 157 | consult this Data Protection Declaration. If you log an objection, we will no longer process 158 | your affected personal data, unless we are in a position to present compelling protection 159 | worthy grounds for the processing which outweigh your interests, rights and freedoms or if 160 | the purpose of the processing is the claiming, exercising or defence of legal claims 161 | (objection pursuant to Art. 21 para. 1 GDPR). 162 | 163 |

164 |

165 | 166 | If your personal data is being processed in order to engage in direct advertising, you have 167 | the right to at any time object to the processing of your affected personal data for the 168 | purposes of such advertising. This also applies to profiling to the extent that it is 169 | affiliated with such direct advertising. If you object, your personal data will subsequently 170 | no longer be used for direct advertising purposes (objection pursuant to Art. 21 para. 2 GDPR). 171 | 172 |

173 | 174 |

Right to log a complaint with the competent supervisory agency

175 |

176 | In the event of violations of the GDPR, data subjects are entitled to log a complaint with a 177 | supervisory agency, in particular in the member state where they usually maintain their domicile, 178 | place of work or at the place where the alleged violation occurred. The right to log a complaint 179 | is in effect regardless of any other administrative or court proceedings available as legal recourses. 180 |

181 | 182 |

Right to data portability

183 |

184 | You have the right to demand that we hand over any data we automatically process on the basis 185 | of your consent or in order to fulfil a contract be handed over to you or a third party in a 186 | commonly used, machine readable format. If you should demand the direct transfer of the data to 187 | another controller, this will be done only if it is technically feasible. 188 |

189 | 190 |

Information about, rectification and eradication of data

191 |

192 | Within the scope of the applicable statutory provisions, you have the right to at any time demand 193 | information about your archived personal data, their source and recipients as well as the purpose 194 | of the processing of your data. You may also have a right to have your data rectified or eradicated. 195 | If you have questions about this subject matter or any other questions about personal data, please 196 | do not hesitate to contact us at any time. 197 |

198 | 199 |

Right to demand processing restrictions

200 |

201 | You have the right to demand the imposition of restrictions as far as the processing of your 202 | personal data is concerned. To do so, you may contact us at any time. The right to demand 203 | restriction of processing applies in the following cases: 204 |

205 |
    206 |
  • 207 | In the event that you should dispute the correctness of your data archived by us, we will 208 | usually need some time to verify this claim. During the time that this investigation is 209 | ongoing, you have the right to demand that we restrict the processing of your personal data. 210 |
  • 211 |
  • 212 | If the processing of your personal data was/is conducted in an unlawful manner, you have the 213 | option to demand the restriction of the processing of your data in lieu of demanding the 214 | eradication of this data. 215 |
  • 216 |
  • 217 | If we do not need your personal data any longer and you need it to exercise, defend or claim 218 | legal rights, you have the right to demand the restriction of the processing of your personal 219 | data instead of its eradication. 220 |
  • 221 |
  • 222 | If you have raised an objection pursuant to Art. 21 para. 1 GDPR, your rights and our rights 223 | will have to be weighed against each other. As long as it has not been determined which 224 | interests prevail, you have the right to demand a restriction of the processing of your 225 | personal data. 226 |
  • 227 |
228 |

229 | If you have restricted the processing of your personal data, these data – with the exception of 230 | their archiving – may be processed only subject to your consent or to claim, exercise or defend 231 | legal rights or to protect the rights of other natural persons or legal entities or for important 232 | public interest reasons cited by the European Union or a member state of the EU. 233 |

234 | 235 |

SSL or TLS encryption

236 |

237 | For security reasons and to protect the transmission of confidential content, such as purchase 238 | orders or inquiries you submit to us as the site operator, this site uses either an SSL or a 239 | TLS encryption program. You can recognize an encrypted connection by checking whether the address 240 | line of the browser switches from "http://" to "https://" and also by the appearance of the lock 241 | icon in the browser line. 242 |

243 |

244 | If the SSL or TLS encryption is activated, third parties cannot read the data you transmit to us. 245 |

246 | 247 |

4. Recording of data on this website

248 | 249 |

Server log files

250 |

251 | The provider of this website and its pages automatically collects and stores information in 252 | so-called server log files, which your browser communicates to us automatically. The information 253 | comprises: 254 |

255 |
    256 |
  • The type and version of browser used
  • 257 |
  • The used operating system
  • 258 |
  • Referrer URL
  • 259 |
  • The hostname of the accessing computer
  • 260 |
  • The time of the server inquiry
  • 261 |
  • The IP address
  • 262 |
263 |

264 | This data is not merged with other data sources. 265 |

266 |

267 | This data is recorded on the basis of Art. 6 para. 1 lit. f GDPR. The operator of the website 268 | has a legitimate interest in the technically error free depiction and the optimization of the 269 | operator's website. In order to achieve this, server log files must be recorded. 270 |

271 |
272 |
273 |
274 | ); 275 | } --------------------------------------------------------------------------------