feat: Migrate Arbiter and Modpack Version Checker to monorepo

WHAT WAS DONE:
- Migrated Arbiter (discord-oauth-arbiter) code to services/arbiter/
- Migrated Modpack Version Checker code to services/modpack-version-checker/
- Created .env.example for Arbiter with all required environment variables
- Moved systemd service file to services/arbiter/deploy/
- Organized directory structure per Gemini monorepo recommendations

WHY:
- Consolidate all service code in one repository
- Prepare for Gemini code review (Panel v1.12 compatibility check)
- Enable service-prefixed Git tagging (arbiter-v2.1.0, modpack-v1.0.0)
- Support npm workspaces for shared dependencies

SERVICES MIGRATED:
1. Arbiter (Discord OAuth bot) - Originally written by Gemini + Claude
   - Full source code from ops-manual docs/implementation/
   - Created comprehensive .env.example
   - Ready for Panel v1.12 compatibility verification

2. Modpack Version Checker (Python CLI tool)
   - Full source code from ops-manual docs/tasks/
   - Written for Panel v1.11, needs Gemini review for v1.12
   - Never had code review before

STILL TODO:
- Whitelist Manager - Pull from Billing VPS (38.68.14.188)
  - Currently deployed and running
  - Needs Panel v1.12 API compatibility fix (Task #86)
  - Requires SSH access to pull code

NEXT STEPS:
- Gemini code review for Panel v1.12 API compatibility
- Create package.json for each service
- Test npm workspaces integration
- Deploy after verification

FILES:
- services/arbiter/ (25 new files, full application)
- services/modpack-version-checker/ (21 new files, full application)

Signed-off-by: The Golden Chronicler <claude@firefrostgaming.com>
This commit is contained in:
Claude (The Golden Chronicler #50)
2026-03-31 21:52:42 +00:00
parent 4efdd44691
commit 04e9b407d5
47 changed files with 6366 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
// src/cmsService.js
// Ghost CMS Admin API integration for member management
const api = require('@tryghost/admin-api');
const cms = new api({
url: process.env.CMS_URL,
key: process.env.CMS_ADMIN_KEY,
version: 'v5.0'
});
/**
* Find a Ghost member by their email address
* @param {string} email - Email address to search for
* @returns {Promise<Object>} - Ghost member object
* @throws {Error} - If member not found
*/
async function findMemberByEmail(email) {
// We use the browse method with a filter to find the exact match
const members = await cms.members.browse({ filter: `email:'${email}'` });
if (members.length === 0) {
throw new Error('Member not found');
}
// Return the first match
return members[0];
}
/**
* Update a Ghost member's discord_id custom field
* @param {string} email - Member's email address
* @param {string} discordId - Discord user ID (snowflake)
* @returns {Promise<Object>} - Updated member object
*/
async function updateMemberDiscordId(email, discordId) {
const members = await cms.members.browse({ filter: `email:'${email}'` });
if (members.length === 0) {
throw new Error('Member not found in CMS');
}
const updated = await cms.members.edit({
id: members[0].id,
custom_fields: [
{ name: 'discord_id', value: discordId }
]
});
console.log(`[Ghost] Updated discord_id for ${email}`);
return updated;
}
module.exports = {
findMemberByEmail,
updateMemberDiscordId
};

View File

@@ -0,0 +1,46 @@
// src/database.js
// SQLite database initialization and maintenance for Firefrost Arbiter
const Database = require('better-sqlite3');
const db = new Database('linking.db');
// Create tables if they don't exist
db.exec(`
CREATE TABLE IF NOT EXISTS link_tokens (
token TEXT PRIMARY KEY,
email TEXT NOT NULL,
tier TEXT NOT NULL,
subscription_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
used INTEGER DEFAULT 0
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id TEXT NOT NULL,
target_user TEXT NOT NULL,
action TEXT NOT NULL,
reason TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Cleanup function - removes tokens older than 24 hours
function cleanupExpiredTokens() {
const stmt = db.prepare(`
DELETE FROM link_tokens
WHERE created_at < datetime('now', '-1 day')
`);
const info = stmt.run();
console.log(`[Database] Cleaned up ${info.changes} expired tokens.`);
}
// Run cleanup once every 24 hours (86400000 ms)
setInterval(cleanupExpiredTokens, 86400000);
// Run cleanup on startup to clear any that expired while app was down
cleanupExpiredTokens();
module.exports = db;

View File

@@ -0,0 +1,104 @@
// src/discordService.js
// Discord bot client initialization and role management functions
const { Client, GatewayIntentBits } = require('discord.js');
const rolesConfig = require('../config/roles.json');
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
});
// Initialize the Discord bot login
client.login(process.env.DISCORD_BOT_TOKEN);
client.on('ready', () => {
console.log(`[Discord] Bot logged in as ${client.user.tag}`);
});
/**
* Assign a Discord role to a user based on their subscription tier
* @param {string} userId - Discord user ID (snowflake)
* @param {string} tier - Subscription tier name (e.g., 'awakened', 'fire_elemental')
* @returns {Promise<boolean>} - Success status
*/
async function assignDiscordRole(userId, tier) {
try {
const guild = client.guilds.cache.get(process.env.GUILD_ID);
if (!guild) throw new Error('Guild not found.');
// Fetch the member. If they aren't in the server, this throws an error.
const member = await guild.members.fetch(userId);
const roleId = rolesConfig[tier];
if (!roleId) throw new Error(`No role mapping found for tier: ${tier}`);
const role = guild.roles.cache.get(roleId);
if (!role) throw new Error(`Role ID ${roleId} not found in server.`);
await member.roles.add(role);
console.log(`[Discord] Assigned role ${tier} to user ${userId}`);
return true;
} catch (error) {
console.error(`[Discord] Failed to assign role to ${userId}:`, error.message);
return false;
}
}
/**
* Remove all subscription roles from a user (used for cancellations or before upgrades)
* @param {string} userId - Discord user ID (snowflake)
* @returns {Promise<boolean>} - Success status
*/
async function removeAllSubscriptionRoles(userId) {
try {
const guild = client.guilds.cache.get(process.env.GUILD_ID);
if (!guild) throw new Error('Guild not found.');
const member = await guild.members.fetch(userId);
// Extract all role IDs from the config
const allRoleIds = Object.values(rolesConfig);
// discord.js allows removing an array of role IDs at once
await member.roles.remove(allRoleIds);
console.log(`[Discord] Removed all subscription roles from ${userId}`);
return true;
} catch (error) {
console.error(`[Discord] Failed to remove roles for ${userId}:`, error.message);
throw error;
}
}
/**
* Update subscription roles (remove old, add new) - used for tier changes
* @param {string} userId - Discord user ID
* @param {string|null} newTier - New tier name, or null for cancellation
*/
async function updateSubscriptionRoles(userId, newTier = null) {
try {
const guild = client.guilds.cache.get(process.env.GUILD_ID);
const member = await guild.members.fetch(userId);
// 1. Remove ALL possible subscription roles
const allRoleIds = Object.values(rolesConfig);
await member.roles.remove(allRoleIds);
// 2. Add the new role (if not cancelled)
if (newTier && rolesConfig[newTier]) {
const newRole = guild.roles.cache.get(rolesConfig[newTier]);
if (newRole) await member.roles.add(newRole);
}
console.log(`[Discord] Updated roles for ${userId} to ${newTier || 'none'}`);
} catch (error) {
console.error(`[Discord] Role update failed for ${userId}:`, error);
}
}
module.exports = {
client,
rolesConfig,
assignDiscordRole,
removeAllSubscriptionRoles,
updateSubscriptionRoles
};

View File

@@ -0,0 +1,49 @@
// src/email.js
// Email service using Nodemailer for subscription linking notifications
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
secure: false, // Use STARTTLS
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
/**
* Send Discord linking email to subscriber
* @param {string} name - Customer name
* @param {string} email - Customer email address
* @param {string} token - Secure linking token
* @returns {Promise} - Nodemailer send result
*/
async function sendLinkingEmail(name, email, token) {
const link = `${process.env.APP_URL}/link?token=${token}`;
const textBody = `Hi ${name},
Thanks for subscribing to Firefrost Gaming!
To access your game servers, please connect your Discord account:
${link}
This link expires in 24 hours. Once connected, you'll see your server channels in Discord with IPs pinned at the top.
Questions? Join us in Discord: https://firefrostgaming.com/discord
- The Firefrost Team
🔥❄️`;
return transporter.sendMail({
from: `"Firefrost Gaming" <${process.env.SMTP_USER}>`,
to: email,
subject: 'Welcome to Firefrost Gaming! 🔥❄️ One More Step...',
text: textBody
});
}
module.exports = { sendLinkingEmail };

View File

@@ -0,0 +1,101 @@
// Firefrost Arbiter v2.0.0
// Discord Role Management & OAuth Gateway
// Built: March 30, 2026
const VERSION = '2.0.0';
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const rateLimit = require('express-rate-limit');
const { client } = require('./discordService');
const db = require('./database');
const app = express();
const PORT = process.env.PORT || 3500;
// Trust reverse proxy (Nginx) for secure cookies
app.set('trust proxy', 1);
// Middleware - Body Parsers
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Middleware - Session Configuration
app.use(session({
store: new SQLiteStore({ db: 'sessions.db', dir: './' }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // true if HTTPS
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7 // 1 week
}
}));
// Middleware - Rate Limiting
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per window
message: 'Too many requests from this IP, please try again after 15 minutes',
standardHeaders: true,
legacyHeaders: false,
});
// Apply rate limiting to specific routes
app.use('/auth', apiLimiter);
app.use('/webhook', apiLimiter);
app.use('/admin/api', apiLimiter);
// Routes
app.use('/webhook', require('./routes/webhook'));
app.use('/auth', require('./routes/oauth'));
app.use('/admin', require('./routes/adminAuth'));
app.use('/admin', require('./routes/admin'));
// Health Check Endpoint
app.get('/health', async (req, res) => {
let dbStatus = 'down';
try {
db.prepare('SELECT 1').get();
dbStatus = 'ok';
} catch (e) {
console.error('[Health] Database check failed:', e);
}
const status = {
uptime: process.uptime(),
discord: client.isReady() ? 'ok' : 'down',
database: dbStatus,
timestamp: new Date().toISOString()
};
const httpStatus = (status.discord === 'ok' && status.database === 'ok') ? 200 : 503;
res.status(httpStatus).json(status);
});
// Root endpoint
app.get('/', (req, res) => {
res.send('Firefrost Arbiter - Discord Role Management System');
});
// Start Server
app.listen(PORT, () => {
console.log(`[Server] Listening on port ${PORT}`);
console.log(`[Server] Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`[Server] Health check: http://localhost:${PORT}/health`);
});
// Discord Bot Ready Event
client.on('ready', () => {
console.log(`[Discord] Bot ready as ${client.user.tag}`);
});
// Graceful Shutdown
process.on('SIGTERM', () => {
console.log('[Server] SIGTERM received, shutting down gracefully...');
client.destroy();
process.exit(0);
});

View File

@@ -0,0 +1,27 @@
// src/middleware/auth.js
// Authentication middleware for admin panel access control
/**
* Require admin authentication - checks if logged-in user is in Trinity whitelist
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Express next function
*/
function requireAdmin(req, res, next) {
// This assumes your existing OAuth flow stores the logged-in user's ID in a session
const userId = req.session?.discordId;
if (!userId) {
return res.redirect('/admin/login');
}
const adminIds = process.env.ADMIN_DISCORD_IDS.split(',');
if (adminIds.includes(userId)) {
return next();
}
return res.status(403).send('Forbidden: You do not have admin access.');
}
module.exports = { requireAdmin };

View File

@@ -0,0 +1,33 @@
// src/middleware/validateWebhook.js
// Zod-based payload validation for Paymenter webhooks
const { z } = require('zod');
const webhookSchema = z.object({
event: z.string(),
customer_email: z.string().email(),
customer_name: z.string().optional(),
tier: z.string(),
product_id: z.string().optional(),
subscription_id: z.string().optional(),
discord_id: z.string().optional().nullable()
});
/**
* Validate webhook payload structure using Zod
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Express next function
*/
function validateBillingPayload(req, res, next) {
try {
req.body = webhookSchema.parse(req.body);
next();
} catch (error) {
// Log the validation error for debugging, but return 400
console.error('[Webhook] Validation Error:', error.errors);
return res.status(400).json({ error: 'Invalid payload structure' });
}
}
module.exports = validateBillingPayload;

View File

@@ -0,0 +1,35 @@
// src/middleware/verifyWebhook.js
// HMAC SHA256 webhook signature verification for Paymenter webhooks
const crypto = require('crypto');
/**
* Verify webhook signature to prevent unauthorized requests
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Express next function
*/
function verifyBillingWebhook(req, res, next) {
const signature = req.headers['x-signature']; // Check your provider's exact header name
const payload = JSON.stringify(req.body);
const secret = process.env.WEBHOOK_SECRET;
if (!signature || !secret) {
console.error('[Webhook] Missing signature or secret');
return res.status(401).json({ error: 'Invalid webhook signature' });
}
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
console.error('[Webhook] Signature verification failed');
return res.status(401).json({ error: 'Invalid webhook signature' });
}
next();
}
module.exports = verifyBillingWebhook;

View File

@@ -0,0 +1,79 @@
// src/routes/admin.js
// Admin panel routes for manual role assignment and audit logs
const express = require('express');
const path = require('path');
const router = express.Router();
const { requireAdmin } = require('../middleware/auth');
const db = require('../database');
const { assignDiscordRole, removeAllSubscriptionRoles, rolesConfig } = require('../discordService');
const { findMemberByEmail } = require('../cmsService');
// Apply admin protection to all routes
router.use(requireAdmin);
// 1. Render the admin UI
router.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../views/admin.html'));
});
// 2. Get tier list for dropdown population
router.get('/api/tiers', (req, res) => {
res.json(rolesConfig);
});
// 3. Search API endpoint - find user by email
router.get('/api/search', async (req, res) => {
const { email } = req.query;
try {
const cmsUser = await findMemberByEmail(email);
res.json(cmsUser);
} catch (error) {
console.error('[Admin] Search failed:', error);
res.status(404).json({ error: 'User not found' });
}
});
// 4. Manual role assignment endpoint
router.post('/api/assign', async (req, res) => {
const { targetDiscordId, action, tier, reason } = req.body;
const adminId = req.session.discordId;
try {
if (action === 'add') {
await assignDiscordRole(targetDiscordId, tier);
} else if (action === 'remove_all') {
await removeAllSubscriptionRoles(targetDiscordId);
}
// Log the action to audit log
const stmt = db.prepare(`
INSERT INTO audit_logs (admin_id, target_user, action, reason)
VALUES (?, ?, ?, ?)
`);
stmt.run(adminId, targetDiscordId, `${action}_${tier || 'all'}`, reason);
console.log(`[Admin] ${adminId} performed ${action} on ${targetDiscordId}`);
res.json({ success: true, message: 'Action completed and logged.' });
} catch (error) {
console.error('[Admin] Assignment failed:', error);
res.status(500).json({ error: error.message });
}
});
// 5. Audit log retrieval endpoint
router.get('/api/audit-log', async (req, res) => {
try {
const stmt = db.prepare(`
SELECT * FROM audit_logs
ORDER BY timestamp DESC LIMIT 50
`);
const logs = stmt.all();
res.json(logs);
} catch (error) {
console.error('[Admin] Failed to fetch audit logs:', error);
res.status(500).json({ error: 'Failed to fetch logs' });
}
});
module.exports = router;

View File

@@ -0,0 +1,55 @@
// src/routes/adminAuth.js
// Discord OAuth authentication for admin panel access
const express = require('express');
const router = express.Router();
// Admin login - redirect to Discord OAuth
router.get('/login', (req, res) => {
const redirectUri = encodeURIComponent(`${process.env.APP_URL}/admin/callback`);
res.redirect(`https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=identify`);
});
// OAuth callback - set session and redirect to dashboard
router.get('/callback', async (req, res) => {
const { code } = req.query;
try {
// Exchange code for Discord access token
const tokenRes = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
body: new URLSearchParams({
client_id: process.env.DISCORD_CLIENT_ID,
client_secret: process.env.DISCORD_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: `${process.env.APP_URL}/admin/callback`,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
const tokenData = await tokenRes.json();
// Get Discord user profile
const userRes = await fetch('https://discord.com/api/users/@me', {
headers: { authorization: `Bearer ${tokenData.access_token}` },
});
const userData = await userRes.json();
// Set session
req.session.discordId = userData.id;
console.log(`[Admin Auth] ${userData.username} logged in`);
res.redirect('/admin');
} catch (error) {
console.error('[Admin Auth] Login failed:', error);
res.status(500).send('Admin login failed. Please try again.');
}
});
// Logout - destroy session
router.get('/logout', (req, res) => {
req.session.destroy();
res.redirect('/');
});
module.exports = router;

View File

@@ -0,0 +1,92 @@
// src/routes/oauth.js
// Discord OAuth linking flow for subscription users
const express = require('express');
const db = require('../database');
const { updateMemberDiscordId } = require('../cmsService');
const { assignDiscordRole } = require('../discordService');
const templates = require('../utils/templates');
const router = express.Router();
// 1. The entry point from the email link
router.get('/link', (req, res) => {
const { token } = req.query;
const tokenData = db.prepare(`
SELECT * FROM link_tokens
WHERE token = ? AND used = 0 AND created_at >= datetime('now', '-1 day')
`).get(token);
if (!tokenData) {
// Check if token exists but is expired or used
const expiredToken = db.prepare('SELECT * FROM link_tokens WHERE token = ?').get(token);
if (expiredToken && expiredToken.used === 1) {
return res.status(400).send(templates.getUsedPage());
}
if (expiredToken) {
return res.status(400).send(templates.getExpiredPage());
}
return res.status(400).send(templates.getInvalidPage());
}
const redirectUri = encodeURIComponent(`${process.env.APP_URL}/auth/callback`);
const authUrl = `https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=identify&state=${token}`;
res.redirect(authUrl);
});
// 2. The callback from Discord OAuth
router.get('/callback', async (req, res) => {
const { code, state: token } = req.query;
const tokenData = db.prepare('SELECT * FROM link_tokens WHERE token = ? AND used = 0').get(token);
if (!tokenData) {
return res.status(400).send(templates.getInvalidPage());
}
try {
// Exchange code for Discord access token
const tokenRes = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
body: new URLSearchParams({
client_id: process.env.DISCORD_CLIENT_ID,
client_secret: process.env.DISCORD_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: `${process.env.APP_URL}/auth/callback`,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
const oauthData = await tokenRes.json();
// Get Discord user profile
const userRes = await fetch('https://discord.com/api/users/@me', {
headers: { authorization: `Bearer ${oauthData.access_token}` },
});
const userData = await userRes.json();
// Update Ghost CMS with Discord ID
await updateMemberDiscordId(tokenData.email, userData.id);
// Assign Discord role
const roleAssigned = await assignDiscordRole(userData.id, tokenData.tier);
if (!roleAssigned) {
// User not in server
return res.status(400).send(templates.getNotInServerPage());
}
// Mark token as used
db.prepare('UPDATE link_tokens SET used = 1 WHERE token = ?').run(token);
console.log(`[OAuth] Successfully linked ${userData.id} to ${tokenData.email}`);
res.send(templates.getSuccessPage());
} catch (error) {
console.error('[OAuth] Callback Error:', error);
res.status(500).send(templates.getServerErrPage());
}
});
module.exports = router;

View File

@@ -0,0 +1,62 @@
// src/routes/webhook.js
// Paymenter webhook handler for subscription events
const express = require('express');
const crypto = require('crypto');
const db = require('../database');
const { sendLinkingEmail } = require('../email');
const { assignDiscordRole, updateSubscriptionRoles } = require('../discordService');
const verifyBillingWebhook = require('../middleware/verifyWebhook');
const validateBillingPayload = require('../middleware/validateWebhook');
const router = express.Router();
router.post('/billing', verifyBillingWebhook, validateBillingPayload, async (req, res) => {
const { event, discord_id, tier, customer_email, customer_name, subscription_id } = req.body;
console.log(`[Webhook] Received ${event} for ${customer_email}`);
try {
if (event === 'subscription.created') {
if (discord_id) {
// User already linked - assign role immediately
await assignDiscordRole(discord_id, tier);
return res.status(200).json({ message: 'Role assigned' });
} else {
// User not linked yet - generate token and send email
const token = crypto.randomBytes(32).toString('hex');
const stmt = db.prepare(`
INSERT INTO link_tokens (token, email, tier, subscription_id)
VALUES (?, ?, ?, ?)
`);
stmt.run(token, customer_email, tier, subscription_id);
await sendLinkingEmail(customer_name || 'Subscriber', customer_email, token);
console.log(`[Webhook] Sent linking email to ${customer_email}`);
return res.status(200).json({ message: 'Email sent' });
}
}
if (event === 'subscription.upgraded' || event === 'subscription.downgraded') {
if (discord_id) {
await updateSubscriptionRoles(discord_id, tier);
return res.status(200).json({ message: 'Roles updated' });
}
}
if (event === 'subscription.cancelled') {
if (discord_id) {
await updateSubscriptionRoles(discord_id, null); // Remove all roles
return res.status(200).json({ message: 'Roles removed' });
}
}
res.status(200).json({ message: 'Event acknowledged' });
} catch (error) {
console.error('[Webhook] Processing failed:', error);
return res.status(500).json({ error: 'Internal error' });
}
});
module.exports = router;

View File

@@ -0,0 +1,65 @@
// src/utils/templates.js
// HTML templates for user-facing success and error pages
const baseHtml = (title, content) => `
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} - Firefrost Gaming</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
</head>
<body>
<main class="container" style="text-align: center; margin-top: 10vh;">
<article>
${content}
</article>
</main>
</body>
</html>`;
const getSuccessPage = () => baseHtml('Success', `
<h1>🔥 Account Linked Successfully! ❄️</h1>
<p>Your Discord account has been connected and your roles are assigned.</p>
<p>You can close this window and head back to Discord to see your new channels!</p>
`);
const getExpiredPage = () => baseHtml('Link Expired', `
<h2 style="color: #ffb703;">⏳ This Link Has Expired</h2>
<p>For security, linking URLs expire after 24 hours.</p>
<p>Please log in to the website to request a new Discord linking email, or contact support.</p>
`);
const getInvalidPage = () => baseHtml('Invalid Link', `
<h2 style="color: #e63946;">❌ Invalid Link</h2>
<p>We couldn't recognize this secure token. The URL might be malformed or incomplete.</p>
<p>Please make sure you copied the entire link from your email.</p>
`);
const getUsedPage = () => baseHtml('Already Linked', `
<h2 style="color: #8eca91;">✅ Already Linked</h2>
<p>This specific token has already been used to link an account.</p>
<p>If you do not see your roles in Discord, please open a support ticket.</p>
`);
const getServerErrPage = () => baseHtml('System Error', `
<h2 style="color: #e63946;">⚠️ System Error</h2>
<p>Something went wrong communicating with the Discord or CMS servers.</p>
<p>Please try clicking the link in your email again in a few minutes.</p>
`);
const getNotInServerPage = () => baseHtml('Join Server First', `
<h2 style="color: #ffb703;">👋 One Quick Thing...</h2>
<p>It looks like you aren't in our Discord server yet!</p>
<p>Please <a href="https://firefrostgaming.com/discord">click here to join the server</a>, then click the secure link in your email again to receive your roles.</p>
`);
module.exports = {
getSuccessPage,
getExpiredPage,
getInvalidPage,
getUsedPage,
getServerErrPage,
getNotInServerPage
};

View File

@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Panel - Firefrost Gaming</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
</head>
<body>
<main class="container">
<nav>
<ul><li><strong>🔥❄️ Firefrost Admin Panel</strong></li></ul>
<ul><li><a href="/admin/logout">Logout</a></li></ul>
</nav>
<!-- Search User Section -->
<section>
<h2>Search User</h2>
<form id="searchForm">
<input type="email" id="searchEmail" placeholder="Enter subscriber email from Ghost CMS" required>
<button type="submit">Search</button>
</form>
<div id="searchResults"></div>
</section>
<!-- Manual Role Assignment Section -->
<section>
<h2>Manual Role Assignment</h2>
<form id="assignForm">
<input type="text" id="targetId" placeholder="Discord User ID (snowflake)" required>
<select id="action" required>
<option value="" disabled selected>Select Action...</option>
<option value="add">Add Role</option>
<option value="remove_all">Remove All Subscription Roles</option>
</select>
<select id="tier" required>
<!-- Populated dynamically by JavaScript -->
</select>
<input type="text" id="reason" placeholder="Reason (e.g., 'Support ticket #123', 'Refund')" required>
<button type="submit">Execute & Log</button>
</form>
</section>
<!-- Audit Log Section -->
<section>
<h2>Recent Actions (Audit Log)</h2>
<figure>
<table role="grid">
<thead>
<tr>
<th scope="col">Timestamp</th>
<th scope="col">Admin ID</th>
<th scope="col">Target User</th>
<th scope="col">Action</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody id="auditLogs">
<tr><td colspan="5">Loading...</td></tr>
</tbody>
</table>
</figure>
</section>
</main>
<script>
// --- 1. Load Tier Dropdown ---
async function loadTiers() {
try {
const response = await fetch('/admin/api/tiers');
const tiers = await response.json();
const tierSelect = document.getElementById('tier');
tierSelect.innerHTML = Object.keys(tiers).map(tierKey =>
`<option value="${tierKey}">${tierKey.replace(/_/g, ' ').toUpperCase()}</option>`
).join('');
} catch (error) {
console.error('Failed to load tiers:', error);
}
}
// --- 2. Search Functionality ---
document.getElementById('searchForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('searchEmail').value;
const resultsDiv = document.getElementById('searchResults');
resultsDiv.innerHTML = '<em>Searching...</em>';
try {
const response = await fetch(`/admin/api/search?email=${encodeURIComponent(email)}`);
if (!response.ok) throw new Error('User not found in CMS.');
const user = await response.json();
// Assuming Ghost CMS custom field is named 'discord_id'
const discordId = user.labels?.find(l => l.name === 'discord_id')?.value ||
user.custom_fields?.find(f => f.name === 'discord_id')?.value ||
'Not Linked';
resultsDiv.innerHTML = `
<article>
<p><strong>Name:</strong> ${user.name || 'Unknown'}</p>
<p><strong>Email:</strong> ${user.email}</p>
<p><strong>Discord ID:</strong> ${discordId}</p>
</article>
`;
// Auto-fill assignment form if Discord ID exists
if (discordId !== 'Not Linked') {
document.getElementById('targetId').value = discordId;
}
} catch (error) {
resultsDiv.innerHTML = `<p style="color: #ff6b6b;">${error.message}</p>`;
}
});
// --- 3. Role Assignment Functionality ---
document.getElementById('assignForm').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = e.target.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Processing...';
const payload = {
targetDiscordId: document.getElementById('targetId').value,
action: document.getElementById('action').value,
tier: document.getElementById('tier').value,
reason: document.getElementById('reason').value
};
try {
const response = await fetch('/admin/api/assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Assignment failed');
alert('✅ Success: ' + result.message);
e.target.reset();
loadAuditLogs(); // Refresh logs
} catch (error) {
alert('❌ Error: ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Execute & Log';
}
});
// --- 4. Audit Log Display ---
async function loadAuditLogs() {
const logContainer = document.getElementById('auditLogs');
try {
const response = await fetch('/admin/api/audit-log');
const logs = await response.json();
if (logs.length === 0) {
logContainer.innerHTML = '<tr><td colspan="5">No audit logs yet.</td></tr>';
return;
}
logContainer.innerHTML = logs.map(log => `
<tr>
<td>${new Date(log.timestamp).toLocaleString()}</td>
<td>${log.admin_id}</td>
<td>${log.target_user}</td>
<td>${log.action}</td>
<td>${log.reason}</td>
</tr>
`).join('');
} catch (error) {
logContainer.innerHTML = '<tr><td colspan="5">Failed to load logs.</td></tr>';
}
}
// Initialize on page load
loadTiers();
loadAuditLogs();
</script>
</body>
</html>