From edf04410357d2d174c36b250db137981b5ed9673 Mon Sep 17 00:00:00 2001 From: Patrick <147879351+WinniePatGG@users.noreply.github.com> Date: Fri, 1 May 2026 19:31:44 +0200 Subject: [PATCH] first commit --- .env.example | 36 +++ .gitignore | 5 + LICENSE | 21 ++ README.md | 67 ++++++ docker-compose.yml | 22 ++ index.js | 517 ++++++++++++++++++++++++++++++++++++++++ package.json | 31 +++ public/style.css | 126 ++++++++++ server/db.js | 88 +++++++ server/mailer.js | 118 +++++++++ views/admin.ejs | 47 ++++ views/admin_users.ejs | 102 ++++++++ views/dashboard.ejs | 22 ++ views/layout.ejs | 42 ++++ views/login.ejs | 37 +++ views/register.ejs | 16 ++ views/ticket_new.ejs | 12 + views/ticket_view.ejs | 61 +++++ views/verify_resend.ejs | 15 ++ 19 files changed, 1385 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 index.js create mode 100644 package.json create mode 100644 public/style.css create mode 100644 server/db.js create mode 100644 server/mailer.js create mode 100644 views/admin.ejs create mode 100644 views/admin_users.ejs create mode 100644 views/dashboard.ejs create mode 100644 views/layout.ejs create mode 100644 views/login.ejs create mode 100644 views/register.ejs create mode 100644 views/ticket_new.ejs create mode 100644 views/ticket_view.ejs create mode 100644 views/verify_resend.ejs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cde0fbc --- /dev/null +++ b/.env.example @@ -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 " + +# 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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d970cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +.env +data +node_modules +package-lock.json \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..958ab94 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9180a4 --- /dev/null +++ b/README.md @@ -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 "; 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. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c36eb9 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..d7cc56a --- /dev/null +++ b/index.js @@ -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'); + }); + }); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..c91e6f6 --- /dev/null +++ b/package.json @@ -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 +} diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..a2dbe7e --- /dev/null +++ b/public/style.css @@ -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;} +} diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..4756f2c --- /dev/null +++ b/server/db.js @@ -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 }; diff --git a/server/mailer.js b/server/mailer.js new file mode 100644 index 0000000..763fe09 --- /dev/null +++ b/server/mailer.js @@ -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 = ` +

Hi${name ? ' ' + escapeHtml(name) : ''},

+

Please verify your email to activate your account.

+

Verify your email

+

If the button above does not work, copy and paste this URL into your browser:

+

${verifyUrl}

+
+

If you did not create an account, you can ignore this email.

+ `; + + 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, '''); +} + +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 }; diff --git a/views/admin.ejs b/views/admin.ejs new file mode 100644 index 0000000..bf7f20b --- /dev/null +++ b/views/admin.ejs @@ -0,0 +1,47 @@ +
+
+

Admin · Tickets

+
+ Manage Users +
+ + +
+
+
+ + <% if (!tickets || tickets.length === 0) { %> +

No tickets.

+ <% } else { %> +
+
+
Subject
+
User
+
Status
+
Updated
+
+
+ <% tickets.forEach(t => { %> +
+
<%= t.subject %>
+
<%= t.user_name || '' %> <%= t.user_email %>
+
<%= t.status %>
+
<%= t.updated_at || t.created_at %>
+
+ View +
+ +
+
+
+ <% }) %> +
+ <% } %> +
diff --git a/views/admin_users.ejs b/views/admin_users.ejs new file mode 100644 index 0000000..8c7b3e2 --- /dev/null +++ b/views/admin_users.ejs @@ -0,0 +1,102 @@ +
+
+

Admin · Users

+ Tickets +
+ +
+

Grant admin by email

+
+ + +
+ +
+
+
+ + <% if (!users || users.length === 0) { %> +

No users found.

+ <% } else { %> +
+
+
Name
+
Email · Auth
+
Role
+
Created
+
+
+ <% users.forEach(u => { %> +
+
<%= (u.name && u.name.trim()) ? u.name : '—' %>
+
+ <%= u.email || '—' %> + <% if (typeof u.has_google !== 'undefined' || typeof u.has_password !== 'undefined') { %> + · + <% if (u.has_google && u.has_password) { %> + Google + Email + <% } else if (u.has_google) { %> + Google + <% } else if (u.has_password) { %> + Email + <% } else { %> + No login set + <% } %> + · + <% if (u.email_verified) { %> + Verified + <% } else { %> + Unverified + <% } %> + <% } %> +
+
+ <% if (u.role === 'admin') { %> + admin + <% } else { %> + <%= u.role %> + <% } %> + <% if (u.banned) { %> + banned + <% } %> +
+
<%= u.created_at || '' %>
+
+ <% if (u.role !== 'admin') { %> +
+ +
+ <% } else { %> + <% if (!currentUser || currentUser.id !== u.id) { %> +
+ +
+ <% } else { %> + (You) + <% } %> + <% } %> + + <% if (!currentUser || currentUser.id !== u.id) { %> + <% if (u.banned) { %> +
+ +
+ <% } else { %> +
+ +
+ <% } %> +
+ +
+ <% } %> +
+
+ <% }) %> +
+ <% } %> +
diff --git a/views/dashboard.ejs b/views/dashboard.ejs new file mode 100644 index 0000000..512c704 --- /dev/null +++ b/views/dashboard.ejs @@ -0,0 +1,22 @@ +
+
+

My Tickets

+ New Ticket +
+ + <% if (!tickets || tickets.length === 0) { %> +

No tickets yet. Create your first one.

+ <% } else { %> + + <% } %> +
diff --git a/views/layout.ejs b/views/layout.ejs new file mode 100644 index 0000000..0b70e90 --- /dev/null +++ b/views/layout.ejs @@ -0,0 +1,42 @@ + + + + + + <%= typeof title !== 'undefined' ? title + ' · ' : '' %>TicketSupport + + + + + +
+ <% if (message) { %> +
<%= message %>
+ <% } %> + + <%- body %> +
+ +
+

© <%= new Date().getFullYear() %> TicketSupport

+
+ + diff --git a/views/login.ejs b/views/login.ejs new file mode 100644 index 0000000..60f914a --- /dev/null +++ b/views/login.ejs @@ -0,0 +1,37 @@ +
+

Login

+ <% if (typeof query !== 'undefined' && query.error) { %> +
Invalid email or password.
+ <% } %> + <% if (typeof query !== 'undefined' && query.unverified) { %> +
+ Your email is not verified yet. Please check your inbox. + <% if (query.email) { %> + You can also resend the verification email. + <% } else { %> + You can also resend the verification email. + <% } %> +
+ <% } %> + <% if (typeof query !== 'undefined' && query.oauth_error) { %> +
Google sign-in failed. Please try again.
+ <% } %> +
+ + + +
+ <% if (googleEnabled) { %> + + <% } %> +

Don't have an account? Register

+
diff --git a/views/register.ejs b/views/register.ejs new file mode 100644 index 0000000..5691c92 --- /dev/null +++ b/views/register.ejs @@ -0,0 +1,16 @@ +
+

Register

+
+ + + + +
+

Already have an account? Login

+
diff --git a/views/ticket_new.ejs b/views/ticket_new.ejs new file mode 100644 index 0000000..7bb33ae --- /dev/null +++ b/views/ticket_new.ejs @@ -0,0 +1,12 @@ +
+

New Ticket

+
+ + + +
+
diff --git a/views/ticket_view.ejs b/views/ticket_view.ejs new file mode 100644 index 0000000..15da8d5 --- /dev/null +++ b/views/ticket_view.ejs @@ -0,0 +1,61 @@ +
+
+

Ticket: <%= ticket.subject %>

+ <%= ticket.status %> +
+
+ Created: <%= ticket.created_at %> · Updated: <%= ticket.updated_at %> + <% if (currentUser && currentUser.role === 'admin') { %> + · From: <%= ticket.user_name || '' %> (<%= ticket.user_email || '' %>) + <% } %> +
+ +
+

<%= ticket.description %>

+
+ +

Conversation

+
+ <% if (responses && responses.length) { %> + <% responses.forEach(r => { %> +
+
+ <%= r.name || r.email %> + <%= r.created_at %> + <% if (r.is_admin_response) { %>admin<% } %> +
+
<%= r.message %>
+
+ <% }) %> + <% } else { %> +

No responses yet.

+ <% } %> +
+ +

Add a response

+
+ +
+ +
+
+ +
+ ← Back + <% if (currentUser.role === 'admin') { %> +
+ + +
+
+ +
+ <% } %> +
+
diff --git a/views/verify_resend.ejs b/views/verify_resend.ejs new file mode 100644 index 0000000..935fe29 --- /dev/null +++ b/views/verify_resend.ejs @@ -0,0 +1,15 @@ +
+

Resend verification email

+
+
+ +
+ +
+
+
+

If your email exists and is not verified yet, we will send a new verification link. Links expire after 24 hours.

+

Back to login

+