first commit

This commit is contained in:
Patrick
2026-05-01 19:31:44 +02:00
commit edf0441035
19 changed files with 1385 additions and 0 deletions
+36
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.idea
.env
data
node_modules
package-lock.json
+21
View File
@@ -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.
+67
View File
@@ -0,0 +1,67 @@
# WinniePatGG's Ticket Support
![Docker Pulls](https://img.shields.io/docker/pulls/winniepat/ticketsupport)
![GitHub License](https://img.shields.io/github/license/WinniePatGG/TicketSupport)
![Docker Stars](https://img.shields.io/docker/stars/winniepat/ticketsupport)
## 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.
+22
View File
@@ -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
+517
View File
@@ -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');
});
});
});
+31
View File
@@ -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
}
+126
View File
@@ -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;}
}
+88
View File
@@ -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 };
+118
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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 };
+47
View File
@@ -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>
+102
View File
@@ -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>
+22
View File
@@ -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>
+42
View File
@@ -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>
+37
View File
@@ -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>
+16
View File
@@ -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>
+12
View File
@@ -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>
+61
View File
@@ -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' %>">&larr; 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>
+15
View File
@@ -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>