first commit
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Server
|
||||
PORT=3000
|
||||
SESSION_SECRET=change_this_to_a_long_random_string
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
|
||||
# Admin seeding (created on first boot if not present)
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# Google OAuth 2.0
|
||||
# 1) In Google Cloud Console create OAuth Client ID (Web application)
|
||||
# 2) Add Authorized redirect URI: http://localhost:3000/auth/google/callback
|
||||
# 3) Paste the credentials below
|
||||
GOOGLE_CLIENT_ID=your_google_oauth_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_oauth_client_secret
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback
|
||||
|
||||
# Notes:
|
||||
# - The Google login button appears only when GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set.
|
||||
# - If you change PORT, also update GOOGLE_CALLBACK_URL and the redirect URI in Google Cloud.
|
||||
|
||||
# SMTP (for email verification)
|
||||
# If not set, verification links will be printed to the server console instead of sending emails.
|
||||
# Typical values for a provider:
|
||||
# SMTP_HOST=smtp.yourprovider.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=your_username
|
||||
# SMTP_PASS=your_password
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_FROM="Support <no-reply@yourdomain.com>"
|
||||
|
||||
# Discord webhook (alternative delivery for verification links)
|
||||
# If SMTP is not configured but DISCORD_WEBHOOK_URL is set, verification links
|
||||
# will be sent to that Discord channel via webhook instead of console logging.
|
||||
# Create a webhook in your Discord channel and paste the URL here.
|
||||
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxxxx/xxxxxxxxxxxxxxxx
|
||||
@@ -0,0 +1,5 @@
|
||||
.idea
|
||||
.env
|
||||
data
|
||||
node_modules
|
||||
package-lock.json
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 WinniePatGG
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,67 @@
|
||||
# WinniePatGG's Ticket Support
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Environment Variables (.env)
|
||||
### Server Variables
|
||||
| Key | Value |
|
||||
|:---------------|:----------------------------------------------:|
|
||||
| PORT | Default: 3000 ; Port the service is running on |
|
||||
| SESSION_SECRET | Long Random String that is used for sessions |
|
||||
|
||||
### Admin Seeding (Initial Admin Account)
|
||||
|
||||
| Key | Value |
|
||||
|:---------------|:---------------------------------------------------------------:|
|
||||
| ADMIN_EMAIL | Default: admin@example.com; Email for the initial admin account |
|
||||
| ADMIN_PASSWORD | Default: admin123; Password for the initial admin account |
|
||||
|
||||
### Google OAuth 2.0
|
||||
|
||||
| Key | Value |
|
||||
|:---------------------|:--------------------------------------:|
|
||||
| GOOGLE_CLIENT_ID | Email for the initial admin account |
|
||||
| GOOGLE_CLIENT_SECRET | Password for the initial admin account |
|
||||
| GOOGLE_CALLBACK_URL | Password for the initial admin account |
|
||||
|
||||
### SMTP (Email)
|
||||
|
||||
| Key | Value |
|
||||
|:------------|:--------------------------------------------------------------:|
|
||||
| SMTP_HOST | Your SMTP Host |
|
||||
| SMTP_PORT | Your SMTP Port |
|
||||
| SMTP_USER | Your SMTP User |
|
||||
| SMTP_PASS | Your SMTP User password |
|
||||
| SMTP_SECURE | true or false |
|
||||
| SMTP_FROM | Default: "Support <no-reply@yourdomain.com>"; The from message |
|
||||
|
||||
### Discord webhook
|
||||
|
||||
| Key | Value |
|
||||
|:--------------------|:------------------------------:|
|
||||
| DISCORD_WEBHOOK_URL | The url of you discord webhook |
|
||||
|
||||
# Information
|
||||
The Service creates a support.sqlite3 file that stores all data the service needs.
|
||||
|
||||
# Getting Started
|
||||
### Docker
|
||||
- Create a folder `support-service`
|
||||
- Download the `docker-compose.yml` from the repo
|
||||
- Create a new directory `app`
|
||||
- Put all files into the `app` directory
|
||||
- Run `docker compose up -d` in the directory with the `docker-compose.yml`
|
||||
|
||||
### Standalone
|
||||
- `git clone https://github.com/WinniePatGG/TicketSupport.git`
|
||||
- `cd TicketSupport`
|
||||
- `npm install`
|
||||
- `npm run start`
|
||||
|
||||
# Important Notes
|
||||
|
||||
- The Google login button appears only when GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set.
|
||||
- If you change PORT, also update GOOGLE_CALLBACK_URL and the redirect URI in Google Cloud.
|
||||
- I couldn't test the SMTP stuff, but it should work (I hope). If not, let me know.
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
hykaikanet-website:
|
||||
image: node:20.19.6-bullseye
|
||||
container_name: SupportPanel
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./app:/app
|
||||
command:
|
||||
sh -c "
|
||||
npm install &&
|
||||
npm run dev
|
||||
"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: 3
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: supportpanel
|
||||
@@ -0,0 +1,517 @@
|
||||
require('dotenv').config({ quiet: true });
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const methodOverride = require('method-override');
|
||||
const morgan = require('morgan');
|
||||
const passport = require('passport');
|
||||
const LocalStrategy = require('passport-local').Strategy;
|
||||
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const { db, initDb } = require('./server/db');
|
||||
const expressLayouts = require('express-ejs-layouts');
|
||||
const { sendVerificationEmail } = require('./server/mailer');
|
||||
|
||||
const app = express();
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || 'dev_secret_change_me';
|
||||
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
|
||||
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
|
||||
const GOOGLE_CALLBACK_URL = process.env.GOOGLE_CALLBACK_URL || `http://localhost:${PORT}/auth/google/callback`;
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.use('/public', express.static(path.join(__dirname, 'public')));
|
||||
app.use(expressLayouts);
|
||||
app.set('layout', 'layout');
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use(methodOverride('_method'));
|
||||
app.use(morgan('dev'));
|
||||
|
||||
app.use(
|
||||
session({
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
passport.serializeUser((user, done) => {
|
||||
done(null, { id: user.id, email: user.email, role: user.role, name: user.name });
|
||||
});
|
||||
passport.deserializeUser((obj, done) => done(null, obj));
|
||||
|
||||
passport.use(
|
||||
new LocalStrategy(
|
||||
{ usernameField: 'email', passwordField: 'password' },
|
||||
(email, password, done) => {
|
||||
const normEmail = email.toLowerCase();
|
||||
db.get('SELECT * FROM users WHERE email = ?', [normEmail], async (err, user) => {
|
||||
if (err) return done(err);
|
||||
if (!user || !user.password_hash) return done(null, false, { message: 'Invalid credentials' });
|
||||
if (user.banned) return done(null, false, { message: 'Account is banned' });
|
||||
const ok = await bcrypt.compare(password, user.password_hash);
|
||||
if (!ok) return done(null, false, { message: 'Invalid credentials' });
|
||||
if (!user.email_verified) return done(null, false, { reason: 'unverified', email: normEmail });
|
||||
return done(null, user);
|
||||
});
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET) {
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: GOOGLE_CALLBACK_URL,
|
||||
},
|
||||
(accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
const googleId = profile.id;
|
||||
const rawEmail = (profile.emails && profile.emails[0] && profile.emails[0].value) || null;
|
||||
const email = rawEmail ? rawEmail.toLowerCase() : null;
|
||||
const name = profile.displayName || (profile.name ? `${profile.name.givenName || ''} ${profile.name.familyName || ''}`.trim() : (email || 'Google User'));
|
||||
db.get('SELECT * FROM users WHERE google_id = ? OR (email IS NOT NULL AND email = ?)', [googleId, email], (err, user) => {
|
||||
if (err) return done(err);
|
||||
if (user) {
|
||||
if (user.banned) return done(null, false, { message: 'Account is banned' });
|
||||
if (!user.google_id) {
|
||||
db.run('UPDATE users SET google_id = ?, email_verified = 1 WHERE id = ?', [googleId, user.id], (uErr) => done(uErr, { ...user, google_id: googleId, email_verified: 1 }));
|
||||
} else {
|
||||
if (!user.email_verified) {
|
||||
db.run('UPDATE users SET email_verified = 1 WHERE id = ?', [user.id], (uErr) => done(uErr, { ...user, email_verified: 1 }));
|
||||
} else {
|
||||
return done(null, user);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
db.run(
|
||||
'INSERT INTO users (email, name, google_id, role, email_verified, created_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
|
||||
[email, name, googleId, 'user', 1],
|
||||
function (insErr) {
|
||||
if (insErr) return done(insErr);
|
||||
db.get('SELECT * FROM users WHERE id = ?', [this.lastID], (selErr, newUser) => done(selErr, newUser));
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
return done(e);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ensureAuth(req, res, next) {
|
||||
if (req.isAuthenticated()) {
|
||||
db.get('SELECT banned FROM users WHERE id = ?', [req.user.id], (e, row) => {
|
||||
if (e || !row) return res.redirect('/logout');
|
||||
if (row.banned) {
|
||||
req.logout(() => {
|
||||
req.session.message = 'Your account has been banned.';
|
||||
return res.redirect('/login');
|
||||
});
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.redirect('/login');
|
||||
}
|
||||
function ensureAdmin(req, res, next) {
|
||||
if (!req.isAuthenticated()) return res.status(403).send('Forbidden');
|
||||
db.get('SELECT role, banned FROM users WHERE id = ?', [req.user.id], (e, row) => {
|
||||
if (e || !row) return res.status(403).send('Forbidden');
|
||||
if (row.banned) {
|
||||
req.logout(() => {
|
||||
req.session.message = 'Your account has been banned.';
|
||||
return res.redirect('/login');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (row.role === 'admin') return next();
|
||||
return res.status(403).send('Forbidden');
|
||||
});
|
||||
}
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.locals.currentUser = req.user || null;
|
||||
res.locals.googleEnabled = Boolean(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET);
|
||||
res.locals.message = req.session.message || null;
|
||||
delete req.session.message;
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
if (req.isAuthenticated()) return res.redirect('/dashboard');
|
||||
res.redirect('/login');
|
||||
});
|
||||
|
||||
app.get('/login', (req, res) => res.render('login', { query: req.query }));
|
||||
app.post('/login', (req, res, next) => {
|
||||
passport.authenticate('local', (err, user, info) => {
|
||||
if (err) return next(err);
|
||||
if (!user) {
|
||||
if (info && info.reason === 'unverified' && info.email) {
|
||||
return res.redirect(`/login?unverified=1&email=${encodeURIComponent(info.email)}`);
|
||||
}
|
||||
return res.redirect('/login?error=1');
|
||||
}
|
||||
req.logIn(user, (e) => {
|
||||
if (e) return next(e);
|
||||
return res.redirect('/dashboard');
|
||||
});
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
app.get('/register', (req, res) => res.render('register'));
|
||||
app.post('/register', async (req, res) => {
|
||||
const { name, email, password } = req.body;
|
||||
if (!email || !password) {
|
||||
req.session.message = 'Email and password are required';
|
||||
return res.redirect('/register');
|
||||
}
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const normEmail = email.toLowerCase();
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
db.run(
|
||||
'INSERT INTO users (name, email, password_hash, role, email_verified, verify_token, verify_token_expires, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
|
||||
[name || '', normEmail, hash, 'user', 0, token, expires],
|
||||
async function (err) {
|
||||
if (err) {
|
||||
req.session.message = 'Registration failed. Email may be already in use.';
|
||||
return res.redirect('/register');
|
||||
}
|
||||
try { await sendVerificationEmail(normEmail, token, name || ''); } catch (e) { console.error('Failed to send verification email:', e.message); }
|
||||
req.session.message = 'Registration successful. Please check your email to verify your account.';
|
||||
res.redirect('/login');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
app.post('/logout', (req, res, next) => {
|
||||
req.logout(err => {
|
||||
if (err) return next(err);
|
||||
res.redirect('/login');
|
||||
});
|
||||
});
|
||||
|
||||
if (GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET) {
|
||||
app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
|
||||
app.get('/auth/google/callback', passport.authenticate('google', { failureRedirect: '/login?oauth_error=1' }), (req, res) => {
|
||||
res.redirect('/dashboard');
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/dashboard', ensureAuth, (req, res) => {
|
||||
db.all(
|
||||
'SELECT * FROM tickets WHERE user_id = ? ORDER BY updated_at DESC, created_at DESC',
|
||||
[req.user.id],
|
||||
(err, rows) => {
|
||||
if (err) rows = [];
|
||||
res.render('dashboard', { tickets: rows });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/tickets/new', ensureAuth, (req, res) => res.render('ticket_new'));
|
||||
app.post('/tickets', ensureAuth, (req, res) => {
|
||||
const { subject, description } = req.body;
|
||||
if (!subject || !description) {
|
||||
req.session.message = 'Subject and description are required.';
|
||||
return res.redirect('/tickets/new');
|
||||
}
|
||||
db.run(
|
||||
'INSERT INTO tickets (user_id, subject, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)',
|
||||
[req.user.id, subject, description, 'open'],
|
||||
function (err) {
|
||||
if (err) {
|
||||
req.session.message = 'Failed to create ticket.';
|
||||
return res.redirect('/tickets/new');
|
||||
}
|
||||
res.redirect(`/tickets/${this.lastID}`);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/tickets/:id', ensureAuth, (req, res) => {
|
||||
const id = req.params.id;
|
||||
db.get('SELECT t.*, u.name as user_name, u.email as user_email FROM tickets t JOIN users u ON u.id = t.user_id WHERE t.id = ?', [id], (err, ticket) => {
|
||||
if (err || !ticket) return res.status(404).send('Not found');
|
||||
if (req.user.role !== 'admin' && ticket.user_id !== req.user.id) return res.status(403).send('Forbidden');
|
||||
db.all('SELECT r.*, u.name, u.email FROM ticket_responses r JOIN users u ON u.id = r.user_id WHERE r.ticket_id = ? ORDER BY r.created_at ASC', [id], (rErr, responses) => {
|
||||
if (rErr) responses = [];
|
||||
res.render('ticket_view', { ticket, responses });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/tickets/:id/respond', ensureAuth, (req, res) => {
|
||||
const id = req.params.id;
|
||||
const { message } = req.body;
|
||||
if (!message) return res.redirect(`/tickets/${id}`);
|
||||
db.get('SELECT * FROM tickets WHERE id = ?', [id], (err, ticket) => {
|
||||
if (err || !ticket) return res.status(404).send('Not found');
|
||||
if (req.user.role !== 'admin' && ticket.user_id !== req.user.id) return res.status(403).send('Forbidden');
|
||||
db.run(
|
||||
'INSERT INTO ticket_responses (ticket_id, user_id, message, is_admin_response, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)',
|
||||
[id, req.user.id, message, req.user.role === 'admin' ? 1 : 0],
|
||||
function (rErr) {
|
||||
if (rErr) return res.status(500).send('Failed to add response');
|
||||
db.run('UPDATE tickets SET updated_at = CURRENT_TIMESTAMP, status = ? WHERE id = ?', [req.user.role === 'admin' ? 'answered' : 'open', id], () => {
|
||||
res.redirect(`/tickets/${id}`);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/admin', ensureAdmin, (req, res) => {
|
||||
const status = req.query.status;
|
||||
const params = [];
|
||||
let sql = 'SELECT t.*, u.email as user_email, u.name as user_name FROM tickets t JOIN users u ON u.id = t.user_id';
|
||||
if (status) {
|
||||
sql += ' WHERE t.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
sql += ' ORDER BY updated_at DESC, created_at DESC';
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) rows = [];
|
||||
res.render('admin', { tickets: rows, status: status || '' });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/tickets/:id/status', ensureAdmin, (req, res) => {
|
||||
const { status } = req.body;
|
||||
const id = req.params.id;
|
||||
db.run('UPDATE tickets SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [status, id], (err) => {
|
||||
if (err) req.session.message = 'Failed to update status';
|
||||
res.redirect('/admin');
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/tickets/:id/delete', ensureAdmin, (req, res) => {
|
||||
const id = req.params.id;
|
||||
db.run('DELETE FROM tickets WHERE id = ?', [id], (err) => {
|
||||
req.session.message = err ? 'Failed to delete ticket.' : 'Ticket deleted.';
|
||||
res.redirect('/admin');
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/admin/users', ensureAdmin, (req, res) => {
|
||||
db.all(
|
||||
`SELECT
|
||||
id, name, email, role, created_at, banned, email_verified,
|
||||
CASE WHEN google_id IS NOT NULL AND TRIM(google_id) <> '' THEN 1 ELSE 0 END AS has_google,
|
||||
CASE WHEN password_hash IS NOT NULL AND TRIM(password_hash) <> '' THEN 1 ELSE 0 END AS has_password
|
||||
FROM users
|
||||
ORDER BY created_at DESC, id DESC`,
|
||||
[],
|
||||
(err, users) => {
|
||||
if (err) users = [];
|
||||
res.render('admin_users', { users });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/make-admin', ensureAdmin, (req, res) => {
|
||||
const id = req.params.id;
|
||||
db.run('UPDATE users SET role = ? WHERE id = ?', ['admin', id], (err) => {
|
||||
req.session.message = err ? 'Failed to grant admin rights.' : 'Admin rights granted.';
|
||||
res.redirect('/admin/users');
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/unadmin', ensureAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
req.session.message = 'Invalid user id.';
|
||||
return res.redirect('/admin/users');
|
||||
}
|
||||
if (req.user && req.user.id === id) {
|
||||
req.session.message = 'You cannot remove your own admin role.';
|
||||
return res.redirect('/admin/users');
|
||||
}
|
||||
db.get('SELECT COUNT(*) AS cnt FROM users WHERE role = ?', ['admin'], (err, row) => {
|
||||
if (err) {
|
||||
req.session.message = 'Database error.';
|
||||
return res.redirect('/admin/users');
|
||||
}
|
||||
const adminCount = row ? row.cnt : 0;
|
||||
db.get('SELECT id, email, role FROM users WHERE id = ?', [id], (e2, user) => {
|
||||
if (e2 || !user) {
|
||||
req.session.message = 'User not found.';
|
||||
return res.redirect('/admin/users');
|
||||
}
|
||||
if (user.role !== 'admin') {
|
||||
req.session.message = 'User is not an admin.';
|
||||
return res.redirect('/admin/users');
|
||||
}
|
||||
if (adminCount <= 1) {
|
||||
req.session.message = 'Cannot remove the last admin.';
|
||||
return res.redirect('/admin/users');
|
||||
}
|
||||
db.run('UPDATE users SET role = ? WHERE id = ?', ['user', id], (uErr) => {
|
||||
req.session.message = uErr ? 'Failed to remove admin rights.' : `Admin rights removed from ${user.email || 'user'}.`;
|
||||
return res.redirect('/admin/users');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/users/grant-admin', ensureAdmin, (req, res) => {
|
||||
let { email, name } = req.body;
|
||||
email = (email || '').trim().toLowerCase();
|
||||
name = (name || '').trim();
|
||||
if (!email) {
|
||||
req.session.message = 'Email is required.';
|
||||
return res.redirect('/admin/users');
|
||||
}
|
||||
db.get('SELECT * FROM users WHERE email = ?', [email], (err, user) => {
|
||||
if (err) {
|
||||
req.session.message = 'Database error.';
|
||||
return res.redirect('/admin/users');
|
||||
}
|
||||
if (user) {
|
||||
db.run('UPDATE users SET role = ? WHERE id = ?', ['admin', user.id], (uErr) => {
|
||||
req.session.message = uErr ? 'Failed to grant admin rights.' : `Admin rights granted to ${email}.`;
|
||||
return res.redirect('/admin/users');
|
||||
});
|
||||
} else {
|
||||
db.run(
|
||||
'INSERT INTO users (name, email, role, created_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)',
|
||||
[name, email, 'admin'],
|
||||
(iErr) => {
|
||||
req.session.message = iErr ? 'Failed to create admin user.' : `Admin user created for ${email}.`;
|
||||
return res.redirect('/admin/users');
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/ban', ensureAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) { req.session.message = 'Invalid user id.'; return res.redirect('/admin/users'); }
|
||||
if (req.user && req.user.id === id) { req.session.message = 'You cannot ban yourself.'; return res.redirect('/admin/users'); }
|
||||
db.get('SELECT id, email, role FROM users WHERE id = ?', [id], (e, user) => {
|
||||
if (e || !user) { req.session.message = 'User not found.'; return res.redirect('/admin/users'); }
|
||||
if (user.role === 'admin') {
|
||||
db.get('SELECT COUNT(*) AS cnt FROM users WHERE role = ?', ['admin'], (err, row) => {
|
||||
const adminCount = row ? row.cnt : 0;
|
||||
if (adminCount <= 1) { req.session.message = 'Cannot ban the last admin.'; return res.redirect('/admin/users'); }
|
||||
db.run('UPDATE users SET banned = 1 WHERE id = ?', [id], (uErr) => {
|
||||
req.session.message = uErr ? 'Failed to ban user.' : `User ${user.email || ''} banned.`;
|
||||
return res.redirect('/admin/users');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
db.run('UPDATE users SET banned = 1 WHERE id = ?', [id], (uErr) => {
|
||||
req.session.message = uErr ? 'Failed to ban user.' : `User ${user.email || ''} banned.`;
|
||||
return res.redirect('/admin/users');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/unban', ensureAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) { req.session.message = 'Invalid user id.'; return res.redirect('/admin/users'); }
|
||||
db.run('UPDATE users SET banned = 0 WHERE id = ?', [id], (uErr) => {
|
||||
req.session.message = uErr ? 'Failed to unban user.' : 'User unbanned.';
|
||||
return res.redirect('/admin/users');
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/delete', ensureAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) { req.session.message = 'Invalid user id.'; return res.redirect('/admin/users'); }
|
||||
if (req.user && req.user.id === id) { req.session.message = 'You cannot delete yourself.'; return res.redirect('/admin/users'); }
|
||||
db.get('SELECT id, email, role FROM users WHERE id = ?', [id], (e, user) => {
|
||||
if (e || !user) { req.session.message = 'User not found.'; return res.redirect('/admin/users'); }
|
||||
if (user.role === 'admin') {
|
||||
db.get('SELECT COUNT(*) AS cnt FROM users WHERE role = ?', ['admin'], (err, row) => {
|
||||
const adminCount = row ? row.cnt : 0;
|
||||
if (adminCount <= 1) { req.session.message = 'Cannot delete the last admin.'; return res.redirect('/admin/users'); }
|
||||
db.run('DELETE FROM users WHERE id = ?', [id], (dErr) => {
|
||||
req.session.message = dErr ? 'Failed to delete user.' : `User ${user.email || ''} deleted.`;
|
||||
return res.redirect('/admin/users');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
db.run('DELETE FROM users WHERE id = ?', [id], (dErr) => {
|
||||
req.session.message = dErr ? 'Failed to delete user.' : `User ${user.email || ''} deleted.`;
|
||||
return res.redirect('/admin/users');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
initDb(() => {
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const adminPass = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
db.get('SELECT * FROM users WHERE email = ?', [adminEmail], async (err, user) => {
|
||||
if (!user) {
|
||||
const hash = await bcrypt.hash(adminPass, 10);
|
||||
db.run(
|
||||
'INSERT INTO users (name, email, password_hash, role, email_verified, created_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
|
||||
['Administrator', adminEmail, hash, 'admin', 1]
|
||||
);
|
||||
console.log(`Seeded admin user: ${adminEmail}`);
|
||||
} else if (!user.email_verified) {
|
||||
db.run('UPDATE users SET email_verified = 1 WHERE id = ?', [user.id]);
|
||||
}
|
||||
app.listen(PORT, () => console.log(`TicketSupport server running on http://localhost:${PORT}`));
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/verify/:token', (req, res) => {
|
||||
const { token } = req.params;
|
||||
if (!token) { req.session.message = 'Invalid verification link.'; return res.redirect('/login'); }
|
||||
db.get('SELECT * FROM users WHERE verify_token = ?', [token], (err, user) => {
|
||||
if (err || !user) { req.session.message = 'Invalid or expired verification link.'; return res.redirect('/login'); }
|
||||
if (user.email_verified) { req.session.message = 'Your email is already verified. Please log in.'; return res.redirect('/login'); }
|
||||
const now = new Date();
|
||||
const exp = user.verify_token_expires ? new Date(user.verify_token_expires) : null;
|
||||
if (!exp || exp < now) { req.session.message = 'Verification link has expired. Please request a new one.'; return res.redirect(`/verify/resend?email=${encodeURIComponent(user.email || '')}`); }
|
||||
db.run('UPDATE users SET email_verified = 1, verify_token = NULL, verify_token_expires = NULL WHERE id = ?', [user.id], (uErr) => {
|
||||
req.session.message = uErr ? 'Failed to verify email. Try again.' : 'Your email has been verified. You can now log in.';
|
||||
return res.redirect('/login');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/verify/resend', (req, res) => {
|
||||
const email = (req.query && req.query.email) ? String(req.query.email) : '';
|
||||
res.render('verify_resend', { email });
|
||||
});
|
||||
|
||||
app.post('/verify/resend', (req, res) => {
|
||||
let { email } = req.body;
|
||||
email = (email || '').trim().toLowerCase();
|
||||
if (!email) { req.session.message = 'Please enter your email.'; return res.redirect('/verify/resend'); }
|
||||
db.get('SELECT * FROM users WHERE email = ?', [email], (err, user) => {
|
||||
if (err) { req.session.message = 'Something went wrong.'; return res.redirect(`/verify/resend?email=${encodeURIComponent(email)}`); }
|
||||
if (!user) { req.session.message = 'If an account exists, a verification email has been sent.'; return res.redirect('/login'); }
|
||||
if (user.email_verified) { req.session.message = 'Your email is already verified. Please log in.'; return res.redirect('/login'); }
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
db.run('UPDATE users SET verify_token = ?, verify_token_expires = ? WHERE id = ?', [token, expires, user.id], async (uErr) => {
|
||||
if (uErr) { req.session.message = 'Could not generate verification link. Try again later.'; return res.redirect(`/verify/resend?email=${encodeURIComponent(email)}`); }
|
||||
try { await sendVerificationEmail(email, token, user.name || ''); } catch (e) { console.error('Failed to send verification email:', e.message); }
|
||||
req.session.message = 'Verification email sent. Please check your inbox.'; return res.redirect('/login');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "ticketsupport",
|
||||
"version": "1.0.0",
|
||||
"description": "Simple ticket support system with local and Google authentication, user dashboard, admin panel, and SQLite database",
|
||||
"main": "index.js",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js",
|
||||
"test": "echo \"No tests configured.\""
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"ejs": "^3.1.10",
|
||||
"express-ejs-layouts": "^2.5.1",
|
||||
"express": "^4.21.1",
|
||||
"express-session": "^1.17.3",
|
||||
"nodemailer": "^6.9.15",
|
||||
"method-override": "^3.0.0",
|
||||
"morgan": "^1.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.7"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
:root{
|
||||
--bg:#0b1220;
|
||||
--bg-accent:#0e162a;
|
||||
--panel:#111827;
|
||||
--panel-2:#0d1830;
|
||||
--border:#223255;
|
||||
--text:#e9eef7;
|
||||
--muted:#9fb1c9;
|
||||
--brand:#6366f1;
|
||||
--brand-2:#22d3ee;
|
||||
--success:#10b981;
|
||||
--accent:#22d3ee;
|
||||
--danger:#ef4444;
|
||||
--warn:#f59e0b;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html,body{
|
||||
margin:0;padding:0;color:var(--text);
|
||||
background: linear-gradient(180deg, var(--bg) 0%, var(--bg-accent) 100%);
|
||||
font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif
|
||||
}
|
||||
.container{max-width:960px;margin:0 auto;padding:1rem}
|
||||
|
||||
.navbar{
|
||||
background:linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--panel) 92%, transparent),
|
||||
color-mix(in srgb, var(--panel) 68%, transparent)
|
||||
);
|
||||
border-bottom:1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
backdrop-filter:saturate(120%) blur(6px)
|
||||
}
|
||||
.nav-inner{display:flex;align-items:center;justify-content:space-between}
|
||||
.brand{color:#fff;text-decoration:none;font-weight:800;letter-spacing:.2px}
|
||||
.brand:hover{opacity:.9}
|
||||
a{color:color-mix(in srgb, var(--brand) 68%, #cbd5e1);text-decoration:underline dotted rgba(255,255,255,.18);text-underline-offset:2px}
|
||||
a:hover{color:#ffffff;text-decoration:underline}
|
||||
nav a{color:var(--text);text-decoration:none;margin-left:1rem;opacity:.9}
|
||||
nav a:hover{opacity:1;color:color-mix(in srgb, var(--brand) 40%, #ffffff)}
|
||||
.linklike{background:none;border:none;color:var(--muted);cursor:pointer}
|
||||
.linklike:hover{color:color-mix(in srgb, var(--brand) 35%, #ffffff)}
|
||||
|
||||
.flash{background:rgba(16,185,129,.08);border:1px solid rgba(16,185,129,.45);padding:.6rem .8rem;border-radius:10px;margin:1rem 0;color:#c7f0db}
|
||||
.flash.error{background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.5)}
|
||||
.flash.warn{background:rgba(245,158,11,.1);border-color:rgba(245,158,11,.5)}
|
||||
|
||||
.footer{opacity:.7;font-size:.9rem}
|
||||
|
||||
.card{background:var(--panel);border:1px solid var(--border);padding:1rem;border-radius:14px;box-shadow:0 6px 20px rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.02);margin:.9rem 0}
|
||||
|
||||
label{display:block;margin:.65rem 0;color:var(--muted)}
|
||||
|
||||
input,textarea,select{
|
||||
width:100%;padding:.65rem .8rem;border-radius:10px;border:1px solid var(--border);background:#0c1426;color:var(--text);
|
||||
transition:border-color .15s ease, box-shadow .15s ease, background-color .2s ease
|
||||
}
|
||||
input:focus,textarea:focus,select:focus{
|
||||
outline:none;border-color:color-mix(in srgb, var(--brand) 65%, var(--border));
|
||||
box-shadow:0 0 0 4px color-mix(in srgb, var(--brand) 22%, transparent)
|
||||
}
|
||||
|
||||
button,.btn{
|
||||
background:linear-gradient(180deg, color-mix(in srgb, var(--panel) 85%, #000), color-mix(in srgb, var(--panel) 65%, #000));
|
||||
color:var(--text);
|
||||
border:1px solid var(--border);border-radius:10px;padding:.55rem 1rem;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:.5rem;
|
||||
transition:transform .06s ease, box-shadow .2s ease, filter .2s ease, background-color .2s ease
|
||||
}
|
||||
button:hover,.btn:hover{filter:brightness(1.06);box-shadow:0 6px 18px rgba(0,0,0,.22)}
|
||||
button:active,.btn:active{transform:translateY(1px)}
|
||||
button:focus-visible,.btn:focus-visible{outline:2px solid transparent;box-shadow:0 0 0 3px color-mix(in srgb, var(--brand) 40%, transparent)}
|
||||
|
||||
.btn.primary{
|
||||
background:linear-gradient(135deg, var(--brand), var(--brand-2));
|
||||
border-color:color-mix(in srgb, var(--brand) 65%, #000);
|
||||
color:#ffffff;font-weight:700
|
||||
}
|
||||
|
||||
.btn.danger{background:linear-gradient(180deg,#7f1d1d,#6b1111);border-color:#dc2626;color:#fee2e2}
|
||||
.btn.danger:hover{filter:brightness(1.05)}
|
||||
.btn.danger:active{transform:translateY(1px)}
|
||||
|
||||
.btn.google{background:#fff;color:#111;border-color:#dadce0;display:flex;align-items:center;justify-content:center;gap:.6rem;padding:.65rem 1.1rem;border-radius:999px;font-weight:700;box-shadow:0 1px 1px rgba(0,0,0,.08);transition:transform .05s ease,box-shadow .2s ease,background-color .2s ease,border-color .2s ease}
|
||||
.btn.google .g-icon{width:18px;height:18px;display:block}
|
||||
.btn.google:hover{background:#fff;border-color:#d2d3d7;box-shadow:0 2px 6px rgba(0,0,0,.12),0 4px 12px rgba(0,0,0,.08)}
|
||||
.btn.google:active{background:#f8f9fa;transform:translateY(0)}
|
||||
.btn.google:focus-visible{outline:2px solid transparent;box-shadow:0 0 0 3px rgba(99,102,241,.35)}
|
||||
.oauth{margin-top:.9rem;display:flex;justify-content:center}
|
||||
|
||||
.muted{color:var(--muted)}
|
||||
.small{font-size:.9rem}
|
||||
.flex{display:flex;gap:.6rem}
|
||||
.between{justify-content:space-between}
|
||||
.center{align-items:center}
|
||||
.end{justify-content:flex-end}
|
||||
|
||||
.list{display:flex;flex-direction:column}
|
||||
.list-item{display:flex;align-items:center;justify-content:space-between;padding:.9rem 1.1rem;border:1px solid var(--border);border-radius:12px;background:var(--panel);text-decoration:none;color:var(--text);margin:.55rem 0;transition:transform .06s ease, box-shadow .2s ease, border-color .2s ease}
|
||||
.list-item:hover{transform:translateY(1px);box-shadow:0 8px 24px rgba(0,0,0,.22);border-color:color-mix(in srgb, var(--brand) 35%, var(--border))}
|
||||
.subject{font-weight:700}
|
||||
|
||||
.badge{padding:.25rem .6rem;border-radius:999px;font-size:.8rem;font-weight:700;letter-spacing:.2px;display:inline-block}
|
||||
.badge.open{background:rgba(99,102,241,.14);color:#b3b7ff;border:1px solid rgba(99,102,241,.5)}
|
||||
.badge.answered{background:rgba(16,185,129,.12);color:#86efac;border:1px solid rgba(16,185,129,.45)}
|
||||
.badge.closed{background:rgba(239,68,68,.12);color:#fca5a5;border:1px solid rgba(239,68,68,.5)}
|
||||
.badge.admin{background:rgba(245,158,11,.12);color:#fde68a;border:1px solid rgba(245,158,11,.5)}
|
||||
.badge.banned{background:rgba(239,68,68,.16);color:#fecaca;border:1px solid rgba(239,68,68,.55)}
|
||||
.badge.verified{background:rgba(16,185,129,.12);color:#86efac;border:1px solid rgba(16,185,129,.45)}
|
||||
.badge.unverified{background:rgba(245,158,11,.12);color:#fde68a;border:1px solid rgba(245,158,11,.45)}
|
||||
|
||||
.auth{max-width:460px;margin:2.2rem auto}
|
||||
|
||||
.thread{display:flex;flex-direction:column;gap:.7rem}
|
||||
.message{border-left:3px solid #32405e;padding-left:.9rem}
|
||||
.message.admin{border-color:#16a34a}
|
||||
|
||||
.table{display:grid;gap:.6rem}
|
||||
.row{display:grid;grid-template-columns:2fr 1.2fr .8fr 1fr .6fr;gap:1rem;align-items:center;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:.7rem .9rem;transition:background-color .2s ease, box-shadow .2s ease}
|
||||
.row:hover{box-shadow:0 8px 24px rgba(0,0,0,.22)}
|
||||
.row.head{background:var(--panel-2);border-color:color-mix(in srgb, var(--border) 85%, #000);font-weight:800}
|
||||
|
||||
.filters{display:flex;gap:1rem;align-items:center}
|
||||
|
||||
@media (max-width:720px){
|
||||
.row{grid-template-columns:1fr;}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
const dbPath = path.join(dataDir, 'support.sqlite3');
|
||||
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
function initDb(callback) {
|
||||
db.serialize(() => {
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.run(
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT,
|
||||
google_id TEXT UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
verify_token TEXT,
|
||||
verify_token_expires TEXT,
|
||||
banned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT
|
||||
)`
|
||||
);
|
||||
|
||||
db.all(`PRAGMA table_info(users)`, (tiErr, cols) => {
|
||||
if (!tiErr) {
|
||||
const hasBanned = Array.isArray(cols) && cols.some(c => c.name === 'banned');
|
||||
const hasEmailVerified = Array.isArray(cols) && cols.some(c => c.name === 'email_verified');
|
||||
const hasVerifyToken = Array.isArray(cols) && cols.some(c => c.name === 'verify_token');
|
||||
const hasVerifyTokenExpires = Array.isArray(cols) && cols.some(c => c.name === 'verify_token_expires');
|
||||
if (!hasBanned) {
|
||||
db.run(`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`);
|
||||
}
|
||||
if (!hasEmailVerified) {
|
||||
db.run(`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
|
||||
}
|
||||
if (!hasVerifyToken) {
|
||||
db.run(`ALTER TABLE users ADD COLUMN verify_token TEXT`);
|
||||
}
|
||||
if (!hasVerifyTokenExpires) {
|
||||
db.run(`ALTER TABLE users ADD COLUMN verify_token_expires TEXT`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helpful index for verification lookups
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_users_verify_token ON users(verify_token)');
|
||||
|
||||
db.run(
|
||||
`CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`
|
||||
);
|
||||
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_tickets_user_id ON tickets(user_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status)');
|
||||
|
||||
db.run(
|
||||
`CREATE TABLE IF NOT EXISTS ticket_responses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
is_admin_response INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`
|
||||
);
|
||||
|
||||
if (typeof callback === 'function') callback();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { db, initDb };
|
||||
@@ -0,0 +1,118 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const https = require('https');
|
||||
|
||||
const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || '';
|
||||
|
||||
function buildTransport() {
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : undefined;
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
if (host && user && pass) {
|
||||
return nodemailer.createTransport({
|
||||
host,
|
||||
port: port || 587,
|
||||
secure: Boolean(process.env.SMTP_SECURE === '1' || process.env.SMTP_SECURE === 'true'),
|
||||
auth: { user, pass },
|
||||
});
|
||||
}
|
||||
return null; // fallback to console logging
|
||||
}
|
||||
|
||||
const transporter = buildTransport();
|
||||
|
||||
async function sendVerificationEmail(toEmail, token, name) {
|
||||
const verifyUrl = `${APP_BASE_URL}/verify/${token}`;
|
||||
const from = process.env.SMTP_FROM || 'no-reply@ticketsupport.local';
|
||||
const subject = 'Verify your email address';
|
||||
const text = `Hi${name ? ' ' + name : ''},\n\nPlease verify your email to activate your account.\n\nVerify link: ${verifyUrl}\n\nIf you did not create an account, you can ignore this email.`;
|
||||
const html = `
|
||||
<p>Hi${name ? ' ' + escapeHtml(name) : ''},</p>
|
||||
<p>Please verify your email to activate your account.</p>
|
||||
<p><a href="${verifyUrl}">Verify your email</a></p>
|
||||
<p>If the button above does not work, copy and paste this URL into your browser:</p>
|
||||
<p><code>${verifyUrl}</code></p>
|
||||
<hr/>
|
||||
<p>If you did not create an account, you can ignore this email.</p>
|
||||
`;
|
||||
|
||||
if (transporter) {
|
||||
await transporter.sendMail({ from, to: toEmail, subject, text, html });
|
||||
return; // Prefer SMTP when configured
|
||||
}
|
||||
if (DISCORD_WEBHOOK_URL) {
|
||||
try {
|
||||
await sendToDiscordWebhook(DISCORD_WEBHOOK_URL, {
|
||||
verifyUrl,
|
||||
toEmail,
|
||||
name,
|
||||
});
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error('Failed to send verification to Discord webhook:', e.message);
|
||||
}
|
||||
}
|
||||
// Final fallback: log the link to the server console
|
||||
console.log('[Email Verification] No SMTP/Discord configured. Share this link with the user:', verifyUrl);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function sendToDiscordWebhook(webhookUrl, details) {
|
||||
const { verifyUrl, toEmail, name } = details || {};
|
||||
const payload = {
|
||||
username: 'TicketSupport',
|
||||
avatar_url: 'https://raw.githubusercontent.com/twitter/twemoji/master/assets/72x72/2709.png',
|
||||
content: `Email verification${toEmail ? ` for ${toEmail}` : ''}`,
|
||||
embeds: [
|
||||
{
|
||||
title: 'Verify your email',
|
||||
description: `[Click here to verify](${verifyUrl})`,
|
||||
color: 0x5865f2,
|
||||
fields: [
|
||||
toEmail ? { name: 'Email', value: String(toEmail), inline: true } : undefined,
|
||||
name ? { name: 'Name', value: String(name), inline: true } : undefined,
|
||||
{ name: 'Link', value: verifyUrl },
|
||||
].filter(Boolean),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const data = JSON.stringify(payload);
|
||||
const u = new URL(webhookUrl);
|
||||
const options = {
|
||||
method: 'POST',
|
||||
hostname: u.hostname,
|
||||
path: `${u.pathname}${u.search || ''}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
},
|
||||
};
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = https.request(options, (res) => {
|
||||
// Drain response to free memory
|
||||
let body = '';
|
||||
res.on('data', (chunk) => (body += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) return resolve();
|
||||
return reject(new Error(`HTTP ${res.statusCode}: ${body}`));
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { sendVerificationEmail, APP_BASE_URL };
|
||||
@@ -0,0 +1,47 @@
|
||||
<section>
|
||||
<div class="flex between center">
|
||||
<h1>Admin · Tickets</h1>
|
||||
<div class="flex center">
|
||||
<a class="btn" href="/admin/users">Manage Users</a>
|
||||
<form method="get" action="/admin" class="filters">
|
||||
<label>Status
|
||||
<select name="status" onchange="this.form.submit()">
|
||||
<option value="" <%= !status ? 'selected':'' %>>All</option>
|
||||
<option value="open" <%= status==='open'?'selected':'' %>>open</option>
|
||||
<option value="answered" <%= status==='answered'?'selected':'' %>>answered</option>
|
||||
<option value="closed" <%= status==='closed'?'selected':'' %>>closed</option>
|
||||
</select>
|
||||
</label>
|
||||
<noscript><button type="submit" class="btn">Apply</button></noscript>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (!tickets || tickets.length === 0) { %>
|
||||
<p>No tickets.</p>
|
||||
<% } else { %>
|
||||
<div class="table">
|
||||
<div class="row head">
|
||||
<div>Subject</div>
|
||||
<div>User</div>
|
||||
<div>Status</div>
|
||||
<div>Updated</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<% tickets.forEach(t => { %>
|
||||
<div class="row">
|
||||
<div><strong><%= t.subject %></strong></div>
|
||||
<div class="muted small"><%= t.user_name || '' %> <%= t.user_email %></div>
|
||||
<div><span class="badge <%= t.status %>"><%= t.status %></span></div>
|
||||
<div class="muted small"><%= t.updated_at || t.created_at %></div>
|
||||
<div class="actions">
|
||||
<a class="btn" href="/tickets/<%= t.id %>">View</a>
|
||||
<form method="post" action="/admin/tickets/<%= t.id %>/delete" style="display:inline" onsubmit="return confirm('Delete this ticket? This cannot be undone.')">
|
||||
<button type="submit" class="btn danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
@@ -0,0 +1,102 @@
|
||||
<section>
|
||||
<div class="flex between center">
|
||||
<h1>Admin · Users</h1>
|
||||
<a class="btn" href="/admin">Tickets</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Grant admin by email</h3>
|
||||
<form method="post" action="/admin/users/grant-admin" class="flex">
|
||||
<label style="flex:1">Name (optional)
|
||||
<input type="text" name="name" placeholder="Name" />
|
||||
</label>
|
||||
<label style="flex:1">Email
|
||||
<input type="email" name="email" required placeholder="user@example.com" />
|
||||
</label>
|
||||
<div style="align-self:flex-end">
|
||||
<button type="submit" class="btn primary">Grant admin</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<% if (!users || users.length === 0) { %>
|
||||
<p>No users found.</p>
|
||||
<% } else { %>
|
||||
<div class="table">
|
||||
<div class="row head">
|
||||
<div>Name</div>
|
||||
<div>Email · Auth</div>
|
||||
<div>Role</div>
|
||||
<div>Created</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<% users.forEach(u => { %>
|
||||
<div class="row">
|
||||
<div><%= (u.name && u.name.trim()) ? u.name : '—' %></div>
|
||||
<div class="small muted">
|
||||
<%= u.email || '—' %>
|
||||
<% if (typeof u.has_google !== 'undefined' || typeof u.has_password !== 'undefined') { %>
|
||||
·
|
||||
<% if (u.has_google && u.has_password) { %>
|
||||
<span class="badge">Google + Email</span>
|
||||
<% } else if (u.has_google) { %>
|
||||
<span class="badge">Google</span>
|
||||
<% } else if (u.has_password) { %>
|
||||
<span class="badge">Email</span>
|
||||
<% } else { %>
|
||||
<span class="badge">No login set</span>
|
||||
<% } %>
|
||||
·
|
||||
<% if (u.email_verified) { %>
|
||||
<span class="badge verified" title="Email verified">Verified</span>
|
||||
<% } else { %>
|
||||
<span class="badge unverified" title="Email not yet verified">Unverified</span>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</div>
|
||||
<div>
|
||||
<% if (u.role === 'admin') { %>
|
||||
<span class="badge admin">admin</span>
|
||||
<% } else { %>
|
||||
<span class="badge"><%= u.role %></span>
|
||||
<% } %>
|
||||
<% if (u.banned) { %>
|
||||
<span class="badge banned" title="This user is banned">banned</span>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="small muted"><%= u.created_at || '' %></div>
|
||||
<div class="actions">
|
||||
<% if (u.role !== 'admin') { %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/make-admin" onsubmit="return confirm('Grant admin rights to <%= u.email %>?')">
|
||||
<button type="submit" class="btn">Make admin</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<% if (!currentUser || currentUser.id !== u.id) { %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/unadmin" onsubmit="return confirm('Remove admin rights from <%= u.email %>?')">
|
||||
<button type="submit" class="btn danger">Remove admin</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<span class="small muted">(You)</span>
|
||||
<% } %>
|
||||
<% } %>
|
||||
|
||||
<% if (!currentUser || currentUser.id !== u.id) { %>
|
||||
<% if (u.banned) { %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/unban" onsubmit="return confirm('Unban <%= u.email %>?')">
|
||||
<button type="submit" class="btn">Unban</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/ban" onsubmit="return confirm('Ban <%= u.email %>? They will be unable to sign in.')">
|
||||
<button type="submit" class="btn danger">Ban</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/delete" onsubmit="return confirm('Permanently delete <%= u.email %>? All their tickets and responses will be removed. This cannot be undone.')">
|
||||
<button type="submit" class="btn danger">Delete user</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
@@ -0,0 +1,22 @@
|
||||
<section>
|
||||
<div class="flex between center">
|
||||
<h1>My Tickets</h1>
|
||||
<a class="btn primary" href="/tickets/new">New Ticket</a>
|
||||
</div>
|
||||
|
||||
<% if (!tickets || tickets.length === 0) { %>
|
||||
<p>No tickets yet. Create your first one.</p>
|
||||
<% } else { %>
|
||||
<div class="list">
|
||||
<% tickets.forEach(t => { %>
|
||||
<a class="list-item" href="/tickets/<%= t.id %>">
|
||||
<div>
|
||||
<div class="subject"><%= t.subject %></div>
|
||||
<div class="muted">Updated: <%= t.updated_at || t.created_at %></div>
|
||||
</div>
|
||||
<span class="badge <%= t.status %>"><%= t.status %></span>
|
||||
</a>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title><%= typeof title !== 'undefined' ? title + ' · ' : '' %>TicketSupport</title>
|
||||
<link rel="stylesheet" href="/public/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar">
|
||||
<div class="container nav-inner">
|
||||
<a href="/" class="brand">TicketSupport</a>
|
||||
<nav>
|
||||
<% if (currentUser) { %>
|
||||
<a href="/dashboard">My Tickets</a>
|
||||
<% if (currentUser.role === 'admin') { %>
|
||||
<a href="/admin">Admin</a>
|
||||
<% } %>
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
<button type="submit" class="linklike">Logout (<%= currentUser.name || currentUser.email || 'Account' %>)</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<a href="/login">Login</a>
|
||||
<a href="/register">Register</a>
|
||||
<% } %>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<% if (message) { %>
|
||||
<div class="flash"><%= message %></div>
|
||||
<% } %>
|
||||
<!-- content -->
|
||||
<%- body %>
|
||||
</main>
|
||||
|
||||
<footer class="footer container">
|
||||
<p>© <%= new Date().getFullYear() %> TicketSupport</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<section class="auth">
|
||||
<h1>Login</h1>
|
||||
<% if (typeof query !== 'undefined' && query.error) { %>
|
||||
<div class="flash error">Invalid email or password.</div>
|
||||
<% } %>
|
||||
<% if (typeof query !== 'undefined' && query.unverified) { %>
|
||||
<div class="flash warn">
|
||||
Your email is not verified yet. Please check your inbox.
|
||||
<% if (query.email) { %>
|
||||
You can also <a href="/verify/resend?email=<%= encodeURIComponent(query.email) %>">resend the verification email</a>.
|
||||
<% } else { %>
|
||||
You can also <a href="/verify/resend">resend the verification email</a>.
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (typeof query !== 'undefined' && query.oauth_error) { %>
|
||||
<div class="flash error">Google sign-in failed. Please try again.</div>
|
||||
<% } %>
|
||||
<form method="post" action="/login" class="card">
|
||||
<label>Email
|
||||
<input type="email" name="email" required value="<%= (query && query.email) ? query.email : '' %>" />
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" required />
|
||||
</label>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
<% if (googleEnabled) { %>
|
||||
<div class="oauth">
|
||||
<a class="btn google" href="/auth/google">
|
||||
<img src="https://www.gstatic.com/images/branding/product/2x/googleg_48dp.png" alt="" class="g-icon" />
|
||||
<span>Continue with Google</span>
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
<p>Don't have an account? <a href="/register">Register</a></p>
|
||||
</section>
|
||||
@@ -0,0 +1,16 @@
|
||||
<section class="auth">
|
||||
<h1>Register</h1>
|
||||
<form method="post" action="/register" class="card">
|
||||
<label>Name
|
||||
<input type="text" name="name" />
|
||||
</label>
|
||||
<label>Email
|
||||
<input type="email" name="email" required />
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" required minlength="6" />
|
||||
</label>
|
||||
<button type="submit">Create account</button>
|
||||
</form>
|
||||
<p>Already have an account? <a href="/login">Login</a></p>
|
||||
</section>
|
||||
@@ -0,0 +1,12 @@
|
||||
<section>
|
||||
<h1>New Ticket</h1>
|
||||
<form method="post" action="/tickets" class="card">
|
||||
<label>Subject
|
||||
<input type="text" name="subject" required />
|
||||
</label>
|
||||
<label>Description
|
||||
<textarea name="description" rows="6" required></textarea>
|
||||
</label>
|
||||
<button type="submit" class="btn primary">Create Ticket</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,61 @@
|
||||
<section>
|
||||
<div class="flex between center">
|
||||
<h1><span class="muted">Ticket:</span> <%= ticket.subject %></h1>
|
||||
<span class="badge <%= ticket.status %>"><%= ticket.status %></span>
|
||||
</div>
|
||||
<div class="muted small">
|
||||
Created: <%= ticket.created_at %> · Updated: <%= ticket.updated_at %>
|
||||
<% if (currentUser && currentUser.role === 'admin') { %>
|
||||
· From: <%= ticket.user_name || '' %> (<%= ticket.user_email || '' %>)
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<article class="card">
|
||||
<p><%= ticket.description %></p>
|
||||
</article>
|
||||
|
||||
<h2>Conversation</h2>
|
||||
<div class="thread">
|
||||
<% if (responses && responses.length) { %>
|
||||
<% responses.forEach(r => { %>
|
||||
<div class="message <%= r.is_admin_response ? 'admin' : 'user' %>">
|
||||
<div class="meta">
|
||||
<span class="who"><%= r.name || r.email %></span>
|
||||
<span class="when"><%= r.created_at %></span>
|
||||
<% if (r.is_admin_response) { %><span class="badge admin">admin</span><% } %>
|
||||
</div>
|
||||
<div class="body"><%= r.message %></div>
|
||||
</div>
|
||||
<% }) %>
|
||||
<% } else { %>
|
||||
<p class="muted">No responses yet.</p>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<h3>Add a response</h3>
|
||||
<form method="post" action="/tickets/<%= ticket.id %>/respond" class="card">
|
||||
<textarea name="message" rows="4" required placeholder="Type your message..."></textarea>
|
||||
<div class="flex end">
|
||||
<button type="submit" class="btn">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex between center" style="margin-top:1rem">
|
||||
<a href="<%= currentUser.role === 'admin' ? '/admin' : '/dashboard' %>">← Back</a>
|
||||
<% if (currentUser.role === 'admin') { %>
|
||||
<form method="post" action="/admin/tickets/<%= ticket.id %>/status">
|
||||
<label>Set status
|
||||
<select name="status">
|
||||
<option value="open" <%= ticket.status==='open'?'selected':'' %>>open</option>
|
||||
<option value="answered" <%= ticket.status==='answered'?'selected':'' %>>answered</option>
|
||||
<option value="closed" <%= ticket.status==='closed'?'selected':'' %>>closed</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn">Update</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/tickets/<%= ticket.id %>/delete" onsubmit="return confirm('Delete this ticket? This cannot be undone.')">
|
||||
<button type="submit" class="btn danger">Delete Ticket</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,15 @@
|
||||
<section class="auth">
|
||||
<h1>Resend verification email</h1>
|
||||
<div class="card">
|
||||
<form method="post" action="/verify/resend">
|
||||
<label>Email
|
||||
<input type="email" name="email" required value="<%= email || '' %>" placeholder="you@example.com" />
|
||||
</label>
|
||||
<div class="flex end">
|
||||
<button type="submit" class="btn">Send verification email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<p class="small muted">If your email exists and is not verified yet, we will send a new verification link. Links expire after 24 hours.</p>
|
||||
<p class="small"><a href="/login">Back to login</a></p>
|
||||
</section>
|
||||
Reference in New Issue
Block a user