settings site changes
This commit is contained in:
@@ -42,7 +42,10 @@
|
|||||||
"Bash(curl -sI http://100.114.205.53:12752/js/util.js)",
|
"Bash(curl -sI http://100.114.205.53:12752/js/util.js)",
|
||||||
"Bash(curl -sI http://100.114.205.53:12752/js/notifications.js)",
|
"Bash(curl -sI http://100.114.205.53:12752/js/notifications.js)",
|
||||||
"Bash(docker exec *)",
|
"Bash(docker exec *)",
|
||||||
"Bash(curl -sI http://100.114.205.53:12752/api/notifications/alerts)"
|
"Bash(curl -sI http://100.114.205.53:12752/api/notifications/alerts)",
|
||||||
|
"Bash(git log *)",
|
||||||
|
"Bash(curl *)",
|
||||||
|
"Bash(docker inspect *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ TRANSCRIPT_DM_TO_CREATOR=false # DM the transcript file to the ticket
|
|||||||
# --- Settings site & internal API ---
|
# --- Settings site & internal API ---
|
||||||
SETTINGS_PORT=12752 # Port for the settings web UI
|
SETTINGS_PORT=12752 # Port for the settings web UI
|
||||||
SETTINGS_ADMIN_PASSWORD= # Password to access the settings UI
|
SETTINGS_ADMIN_PASSWORD= # Password to access the settings UI
|
||||||
|
SETTINGS_ADMIN_PASSWORD_2= # Optional second password with identical access (leave blank to disable)
|
||||||
SETTINGS_DOMAIN=tickets.indifferentketchup.com # Domain for the settings site (update when domain changes)
|
SETTINGS_DOMAIN=tickets.indifferentketchup.com # Domain for the settings site (update when domain changes)
|
||||||
INTERNAL_API_PORT=12753 # Internal port for bot<->settings IPC (not exposed externally)
|
INTERNAL_API_PORT=12753 # Internal port for bot<->settings IPC (not exposed externally)
|
||||||
INTERNAL_API_SECRET= # Shared secret between bot and settings site (generate a random string)
|
INTERNAL_API_SECRET= # Shared secret between bot and settings site (generate a random string)
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ Single Node process. Entry: `broccolini-discord.js`.
|
|||||||
1. Module load: env validation, Discord `Client` created, `interactionCreate` / `messageCreate` listeners registered, `client.login(...)` called.
|
1. Module load: env validation, Discord `Client` created, `interactionCreate` / `messageCreate` listeners registered, `client.login(...)` called.
|
||||||
2. Public Express app (`app`) is defined at module scope with a **503 gate** — any `/api/*` request before `appReady` returns 503.
|
2. Public Express app (`app`) is defined at module scope with a **503 gate** — any `/api/*` request before `appReady` returns 503.
|
||||||
3. `client.once('ready')` (fires after Discord handshake): connects MongoDB, mounts bOSScord routes on `/api` (only if `BOSSCORD_API_KEY` set), calls `app.listen(CONFIG.PORT, CONFIG.HEALTHCHECK_HOST)`, sets `appReady = true`, then starts all background `setInterval`s.
|
3. `client.once('ready')` (fires after Discord handshake): connects MongoDB, mounts bOSScord routes on `/api` (only if `BOSSCORD_API_KEY` set), calls `app.listen(CONFIG.PORT, CONFIG.HEALTHCHECK_HOST)`, sets `appReady = true`, then starts all background `setInterval`s.
|
||||||
4. The **internal** Express app (`internalApp`) listens separately on `127.0.0.1:INTERNAL_API_PORT` at module load, guarded by `INTERNAL_API_SECRET`.
|
4. The **internal** Express app (`internalApp`) listens separately on `0.0.0.0:INTERNAL_API_PORT` inside the bot container at module load, guarded by `INTERNAL_API_SECRET`. Not publicly exposed — reachable only from peers on the `broccoli-net` docker network (notably the settings-site container).
|
||||||
|
|
||||||
### Two HTTP surfaces
|
### Two HTTP surfaces
|
||||||
- **Public (`app`)** — `GET /` healthcheck + `/api/*` (bOSScord consumer). CORS origin is `process.env.BOSSCORD_CLIENT_ORIGIN` (default `http://100.114.205.53:3081`). Rate-limited 60 req/min/IP. Auth: `Authorization: Bearer ${BOSSCORD_API_KEY}`.
|
- **Public (`app`)** — `GET /` healthcheck + `/api/*` (bOSScord consumer). CORS origin is `process.env.BOSSCORD_CLIENT_ORIGIN` (default `http://100.114.205.53:3081`). Rate-limited 60 req/min/IP. Auth: `Authorization: Bearer ${BOSSCORD_API_KEY}`.
|
||||||
- **Internal (`internalApp`)** — `127.0.0.1` only, `/internal/*`. Rate-limited 10 req/min. Auth: `x-internal-secret` header. `POST /config` enforces an explicit `ALLOWED_CONFIG_KEYS` allowlist; unknown keys return 400. `POST /restart` exits the process so the container supervisor restarts it.
|
- **Internal (`internalApp`)** — `broccoli-net` only (binds `0.0.0.0` inside the bot container; no host `ports:` publish), `/internal/*`. Rate-limited 10 req/min. Auth: `x-internal-secret` header. `POST /config` enforces an explicit `ALLOWED_CONFIG_KEYS` allowlist; unknown keys return 400. `POST /restart` exits the process so the container supervisor restarts it.
|
||||||
|
|
||||||
`routes/internalApi.js` is required at module scope by `broccolini-discord.js` *before* the parent's `module.exports` populates — reaching back to the parent (e.g., `trackInterval`, `trackTimeout`, `clearGmailPollInterval`) must use a **lazy `require('../broccolini-discord')` inside the handler**, not a top-level destructure.
|
`routes/internalApi.js` is required at module scope by `broccolini-discord.js` *before* the parent's `module.exports` populates — reaching back to the parent (e.g., `trackInterval`, `trackTimeout`, `clearGmailPollInterval`) must use a **lazy `require('../broccolini-discord')` inside the handler**, not a top-level destructure.
|
||||||
|
|
||||||
|
|||||||
@@ -342,8 +342,14 @@ internalApp.use('/internal', internalApi);
|
|||||||
let httpServer = null;
|
let httpServer = null;
|
||||||
let internalServer = null;
|
let internalServer = null;
|
||||||
if (CONFIG.INTERNAL_API_SECRET) {
|
if (CONFIG.INTERNAL_API_SECRET) {
|
||||||
internalServer = internalApp.listen(CONFIG.INTERNAL_API_PORT, '127.0.0.1', () => {
|
// Must bind all-interfaces inside the bot container: the settings-site is a
|
||||||
console.log(`[internalApi] listening on 127.0.0.1:${CONFIG.INTERNAL_API_PORT}`);
|
// separate container on broccoli-net and reaches this API over the docker
|
||||||
|
// bridge, not loopback. Not publicly exposed — docker-compose.yml has no
|
||||||
|
// `ports:` publish for INTERNAL_API_PORT, so ingress is limited to peers on
|
||||||
|
// broccoli-net, still gated by INTERNAL_API_SECRET and rate-limited.
|
||||||
|
// Do NOT flip this back to 127.0.0.1 — see commits d134f5f / 33b1f27.
|
||||||
|
internalServer = internalApp.listen(CONFIG.INTERNAL_API_PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`[internalApi] listening on 0.0.0.0:${CONFIG.INTERNAL_API_PORT}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn('[internalApi] INTERNAL_API_SECRET not set — internal API disabled.');
|
console.warn('[internalApi] INTERNAL_API_SECRET not set — internal API disabled.');
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* One-time script to generate a Gmail OAuth2 refresh token.
|
* One-time script to generate a Gmail OAuth2 refresh token.
|
||||||
* Run once, copy the refresh token into .env, then delete this file.
|
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node get-refresh-token.js
|
* node get-refresh-token.js
|
||||||
*
|
*
|
||||||
* Prerequisites:
|
* Prerequisites:
|
||||||
* - GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET set in .env (or exported in shell)
|
* - GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET set in .env
|
||||||
* - In Google Cloud Console → OAuth 2.0 Client → Authorized redirect URIs:
|
* - In Google Cloud Console → OAuth 2.0 Client → Authorized redirect URIs:
|
||||||
* add http://localhost:3000/oauth2callback
|
* add http://localhost:3000/oauth2callback
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Script prints an auth URL. Open it in any browser (same host or remote).
|
||||||
|
* 2a. Same-host browser: redirect to localhost:3000 is captured automatically.
|
||||||
|
* 2b. Remote browser (e.g. Windows, script running on Linux over SSH):
|
||||||
|
* the browser fails to load localhost:3000/oauth2callback?code=... — copy
|
||||||
|
* the full URL (or just the code= value) from the address bar and paste
|
||||||
|
* it into the terminal prompt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const { google } = require('googleapis');
|
const { google } = require('googleapis');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const url = require('url');
|
const url = require('url');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
const CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
|
const CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
|
||||||
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
|
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
|
||||||
@@ -41,29 +49,37 @@ console.log('\n=== Gmail OAuth2 Token Generator ===\n');
|
|||||||
console.log('1. Open this URL in your browser:\n');
|
console.log('1. Open this URL in your browser:\n');
|
||||||
console.log(authUrl);
|
console.log(authUrl);
|
||||||
console.log('\n2. Authorize the app with your support Gmail account.');
|
console.log('\n2. Authorize the app with your support Gmail account.');
|
||||||
console.log('3. You will be redirected to localhost — this script will capture the token.\n');
|
console.log('\n3a. If the browser runs on this host, the redirect is captured automatically.');
|
||||||
|
console.log('3b. If the browser is on a different machine, it will fail to load');
|
||||||
|
console.log(' http://localhost:3000/oauth2callback?code=... — copy the full URL');
|
||||||
|
console.log(' (or just the code= value) from the address bar and paste it below.\n');
|
||||||
|
|
||||||
// Temporary local server to capture the callback
|
let done = false;
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
const parsed = url.parse(req.url, true);
|
function extractCode(input) {
|
||||||
if (parsed.pathname !== '/oauth2callback') {
|
const trimmed = (input || '').trim();
|
||||||
res.end('Not found');
|
if (!trimmed) return null;
|
||||||
return;
|
try {
|
||||||
|
// If the user pastes a full URL, pull the `code` query param.
|
||||||
|
// URL() treats localhost URLs fine; searchParams auto-decodes.
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
return parsed.searchParams.get('code');
|
||||||
|
} catch {
|
||||||
|
// Not a URL — assume the user pasted the raw code.
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = parsed.query.code;
|
async function finish(code, source, httpRespond) {
|
||||||
if (!code) {
|
if (done) return;
|
||||||
res.end('No code received.');
|
done = true;
|
||||||
server.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tokens } = await oauth2Client.getToken(code);
|
const { tokens } = await oauth2Client.getToken(code);
|
||||||
res.end('<h2>Success! Check your terminal for the refresh token.</h2><p>You can close this tab.</p>');
|
if (httpRespond) {
|
||||||
server.close();
|
httpRespond('<h2>Success! Check your terminal for the refresh token.</h2><p>You can close this tab.</p>');
|
||||||
|
}
|
||||||
console.log('\n=== SUCCESS ===\n');
|
console.log(`\n=== SUCCESS (via ${source}) ===\n`);
|
||||||
console.log('Add this to your .env:\n');
|
console.log('Add this to your .env:\n');
|
||||||
console.log(`REFRESH_TOKEN=${tokens.refresh_token}`);
|
console.log(`REFRESH_TOKEN=${tokens.refresh_token}`);
|
||||||
if (!tokens.refresh_token) {
|
if (!tokens.refresh_token) {
|
||||||
@@ -73,12 +89,51 @@ const server = http.createServer(async (req, res) => {
|
|||||||
console.log('\nAll tokens (for reference):');
|
console.log('\nAll tokens (for reference):');
|
||||||
console.log(JSON.stringify(tokens, null, 2));
|
console.log(JSON.stringify(tokens, null, 2));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.end('Error exchanging code: ' + err.message);
|
if (httpRespond) httpRespond('Error exchanging code: ' + err.message);
|
||||||
server.close();
|
console.error('\nError exchanging code:', err.response?.data || err.message);
|
||||||
console.error('Error:', err);
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
try { server.close(); } catch {}
|
||||||
|
try { rl.close(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const parsed = url.parse(req.url, true);
|
||||||
|
if (parsed.pathname !== '/oauth2callback') {
|
||||||
|
res.end('Not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const code = parsed.query.code;
|
||||||
|
if (!code) {
|
||||||
|
res.end('No code received.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await finish(code, 'browser redirect', (body) => res.end(body));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (err) => {
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
console.log('Port 3000 is in use — auto-capture disabled. Use the paste prompt below.\n');
|
||||||
|
} else {
|
||||||
|
console.error('HTTP server error:', err.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(3000, () => {
|
server.listen(3000, () => {
|
||||||
console.log('Waiting for OAuth callback on http://localhost:3000 ...\n');
|
console.log('Waiting for OAuth callback on http://localhost:3000 (or paste below) ...\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
rl.question('Paste redirect URL or code here: ', async (answer) => {
|
||||||
|
if (done) return;
|
||||||
|
const code = extractCode(answer);
|
||||||
|
if (!code) {
|
||||||
|
console.error('Could not parse a code from that input.');
|
||||||
|
process.exitCode = 1;
|
||||||
|
try { server.close(); } catch {}
|
||||||
|
rl.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await finish(code, 'pasted input');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Intentionally no trust-proxy: loopback-only; global rate-limit bucket.
|
// Intentionally no trust-proxy: reachable only from peers on broccoli-net
|
||||||
|
// (not publicly exposed). Rate limit keys off the raw connection IP, which
|
||||||
|
// inside the docker bridge is the peer container's address.
|
||||||
const internalLimiter = rateLimit({
|
const internalLimiter = rateLimit({
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
max: 10,
|
max: 10,
|
||||||
@@ -40,7 +42,7 @@ router.use((req, res, next) => {
|
|||||||
router.get('/config', (req, res) => {
|
router.get('/config', (req, res) => {
|
||||||
const map = readAllConfig();
|
const map = readAllConfig();
|
||||||
const obj = {};
|
const obj = {};
|
||||||
const REDACTED = ['DISCORD_TOKEN', 'REFRESH_TOKEN', 'GOOGLE_CLIENT_SECRET', 'MONGODB_URI', 'INTERNAL_API_SECRET', 'SETTINGS_ADMIN_PASSWORD'];
|
const REDACTED = ['DISCORD_TOKEN', 'REFRESH_TOKEN', 'GOOGLE_CLIENT_SECRET', 'MONGODB_URI', 'INTERNAL_API_SECRET', 'SETTINGS_ADMIN_PASSWORD', 'SETTINGS_ADMIN_PASSWORD_2'];
|
||||||
for (const [k, v] of map) {
|
for (const [k, v] of map) {
|
||||||
obj[k] = REDACTED.includes(k) ? '••••••••' : v;
|
obj[k] = REDACTED.includes(k) ? '••••••••' : v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
SETTINGS_PORT=12752
|
SETTINGS_PORT=12752
|
||||||
SETTINGS_ADMIN_PASSWORD=
|
SETTINGS_ADMIN_PASSWORD=
|
||||||
|
SETTINGS_ADMIN_PASSWORD_2=
|
||||||
SETTINGS_DOMAIN=tickets.indifferentketchup.com
|
SETTINGS_DOMAIN=tickets.indifferentketchup.com
|
||||||
INTERNAL_API_PORT=12753
|
INTERNAL_API_PORT=12753
|
||||||
INTERNAL_API_SECRET=
|
INTERNAL_API_SECRET=
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ The settings site is a thin HTTPS-oriented proxy in front of the bot's internal
|
|||||||
browser ──► settings server.js (:SETTINGS_PORT, default 12752)
|
browser ──► settings server.js (:SETTINGS_PORT, default 12752)
|
||||||
│ session auth (SETTINGS_ADMIN_PASSWORD)
|
│ session auth (SETTINGS_ADMIN_PASSWORD)
|
||||||
▼
|
▼
|
||||||
bot internalApp (127.0.0.1:INTERNAL_API_PORT, default 12753)
|
bot internalApp (broccoli-net only, INTERNAL_API_PORT, default 12753)
|
||||||
│ header auth (x-internal-secret = INTERNAL_API_SECRET)
|
│ header auth (x-internal-secret = INTERNAL_API_SECRET)
|
||||||
▼
|
▼
|
||||||
routes/internalApi.js in /opt/broccolini-bot
|
routes/internalApi.js in /opt/broccolini-bot
|
||||||
@@ -48,6 +48,9 @@ browser ──► settings server.js (:SETTINGS_PORT, default 12752)
|
|||||||
|
|
||||||
Every response-shape change in the bot's `/internal/*` handlers (`routes/internalApi.js`) is a breaking change here. The bot also gates `POST /internal/config` on an `ALLOWED_CONFIG_KEYS` allowlist — **adding a new field to the UI requires adding the key to that Set in the bot first**, otherwise the save returns 400 for that key.
|
Every response-shape change in the bot's `/internal/*` handlers (`routes/internalApi.js`) is a breaking change here. The bot also gates `POST /internal/config` on an `ALLOWED_CONFIG_KEYS` allowlist — **adding a new field to the UI requires adding the key to that Set in the bot first**, otherwise the save returns 400 for that key.
|
||||||
|
|
||||||
|
### Second admin password
|
||||||
|
`server.js` accepts an optional `SETTINGS_ADMIN_PASSWORD_2`. If set, the `/login` handler grants the same session for either password; no audit distinction between them. The primary `SETTINGS_ADMIN_PASSWORD` is still required at startup — only the second is optional. Both are redacted by the bot's `GET /internal/config` response.
|
||||||
|
|
||||||
### Session cookie requires HTTPS
|
### Session cookie requires HTTPS
|
||||||
`server.js:20-26` sets `cookie.secure: true`. Browsers will refuse to persist the session cookie over plain HTTP, so login silently fails when not behind an HTTPS reverse proxy (`SETTINGS_DOMAIN` is the deployed domain). If you're reproducing a login bug, check this first before debugging auth logic. The `session secret` falls back to `'fallback-secret-change-me'` when `INTERNAL_API_SECRET` is unset — don't rely on the fallback in any environment that matters.
|
`server.js:20-26` sets `cookie.secure: true`. Browsers will refuse to persist the session cookie over plain HTTP, so login silently fails when not behind an HTTPS reverse proxy (`SETTINGS_DOMAIN` is the deployed domain). If you're reproducing a login bug, check this first before debugging auth logic. The `session secret` falls back to `'fallback-secret-change-me'` when `INTERNAL_API_SECRET` is unset — don't rely on the fallback in any environment that matters.
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ body::before {
|
|||||||
|
|
||||||
/* Sections */
|
/* Sections */
|
||||||
.section { margin-bottom: 20px; }
|
.section { margin-bottom: 20px; }
|
||||||
|
.section.hidden { display: none; }
|
||||||
.section-header {
|
.section-header {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -1023,4 +1024,49 @@ button.ss-chip {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Landing / home card grid */
|
||||||
|
.landing-section { margin-bottom: 20px; }
|
||||||
|
.landing-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.landing-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 150ms ease, background 150ms ease;
|
||||||
|
}
|
||||||
|
.landing-card:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.landing-card-body { flex: 1; min-width: 0; }
|
||||||
|
.landing-card h3 {
|
||||||
|
font-family: var(--font-title);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.landing-card p {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.landing-card .chevron {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
.landing-card:hover .chevron { transform: translateX(3px); }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1026
settings-site/public/css/main.css.bak-20260421
Normal file
1026
settings-site/public/css/main.css.bak-20260421
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,8 @@
|
|||||||
<div class="sidebar-backdrop" id="sidebar-backdrop" aria-hidden="true"></div>
|
<div class="sidebar-backdrop" id="sidebar-backdrop" aria-hidden="true"></div>
|
||||||
<nav class="sidebar" id="sidebar">
|
<nav class="sidebar" id="sidebar">
|
||||||
<div class="logo">Broccolini Settings</div>
|
<div class="logo">Broccolini Settings</div>
|
||||||
<a href="/" class="active">Core</a>
|
<a href="/" class="active">Home</a>
|
||||||
|
<a href="/core">Core</a>
|
||||||
<a href="/channels">Channels</a>
|
<a href="/channels">Channels</a>
|
||||||
<a href="/categories">Categories</a>
|
<a href="/categories">Categories</a>
|
||||||
<a href="/gmail">Gmail</a>
|
<a href="/gmail">Gmail</a>
|
||||||
@@ -47,6 +48,25 @@
|
|||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|
||||||
|
<!-- 0. Landing -->
|
||||||
|
<div class="section landing-section" id="s-landing">
|
||||||
|
<div class="landing-grid">
|
||||||
|
<a class="landing-card" href="/core"><div class="landing-card-body"><h3>Core</h3><p>Discord credentials and guild</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/channels"><div class="landing-card-body"><h3>Channels</h3><p>Log, transcript, and alert channels</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/categories"><div class="landing-card-body"><h3>Categories</h3><p>Ticket categories and escalation</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/gmail"><div class="landing-card-body"><h3>Gmail</h3><p>Google OAuth and email settings</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/behavior"><div class="landing-card-body"><h3>Ticket Behavior</h3><p>Automation, limits, and messages</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/threads"><div class="landing-card-body"><h3>Staff Threads</h3><p>Private staff discussion threads</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/pins"><div class="landing-card-body"><h3>Pin Messages</h3><p>Auto-pin welcome and escalations</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/notifications"><div class="landing-card-body"><h3>Notifications</h3><p>Surge, patterns, unclaimed, chat</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/logging"><div class="landing-card-body"><h3>Logging</h3><p>Log channel configuration</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/automation"><div class="landing-card-body"><h3>Automation</h3><p>Polling intervals and timers</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/appearance"><div class="landing-card-body"><h3>Appearance</h3><p>Colors, labels, emojis</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/staff"><div class="landing-card-body"><h3>Staff</h3><p>Staff IDs, emojis, admin</p></div><span class="chevron">›</span></a>
|
||||||
|
<a class="landing-card" href="/advanced"><div class="landing-card-body"><h3>Advanced</h3><p>Ports, URLs, game list, branding</p></div><span class="chevron">›</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 1. Core -->
|
<!-- 1. Core -->
|
||||||
<div class="section" id="s-core">
|
<div class="section" id="s-core">
|
||||||
<div class="section-header"><h2>Core</h2><p>Discord bot credentials and guild</p><span class="chevron">▼</span></div>
|
<div class="section-header"><h2>Core</h2><p>Discord bot credentials and guild</p><span class="chevron">▼</span></div>
|
||||||
|
|||||||
484
settings-site/public/index.html.bak-20260421
Normal file
484
settings-site/public/index.html.bak-20260421
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Broccolini Settings</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loading" class="loading"><div class="spinner"></div></div>
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="sidebar-backdrop" id="sidebar-backdrop" aria-hidden="true"></div>
|
||||||
|
<nav class="sidebar" id="sidebar">
|
||||||
|
<div class="logo">Broccolini Settings</div>
|
||||||
|
<a href="/" class="active">Core</a>
|
||||||
|
<a href="/channels">Channels</a>
|
||||||
|
<a href="/categories">Categories</a>
|
||||||
|
<a href="/gmail">Gmail</a>
|
||||||
|
<a href="/behavior">Ticket Behavior</a>
|
||||||
|
<a href="/threads">Staff Threads</a>
|
||||||
|
<a href="/pins">Pin Messages</a>
|
||||||
|
<a href="/notifications">Notifications</a>
|
||||||
|
<a href="/logging">Logging</a>
|
||||||
|
<a href="/automation">Automation</a>
|
||||||
|
<a href="/appearance">Appearance</a>
|
||||||
|
<a href="/staff">Staff</a>
|
||||||
|
<a href="/advanced">Advanced</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Top bar -->
|
||||||
|
<div class="topbar">
|
||||||
|
<button type="button" class="menu-toggle" id="menu-toggle" aria-label="Toggle navigation" aria-expanded="false" aria-controls="sidebar">
|
||||||
|
<span class="menu-toggle-bars" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<div class="status">
|
||||||
|
<span class="dot" id="bot-status-dot"></span>
|
||||||
|
<span id="bot-status-text">Checking...</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="logout-btn">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="main">
|
||||||
|
|
||||||
|
<!-- 1. Core -->
|
||||||
|
<div class="section" id="s-core">
|
||||||
|
<div class="section-header"><h2>Core</h2><p>Discord bot credentials and guild</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field"><label>Discord Token</label><input type="password" data-key="DISCORD_TOKEN" placeholder="Bot token"></div>
|
||||||
|
<div class="field"><label>Application ID</label><input type="text" data-key="DISCORD_APPLICATION_ID"></div>
|
||||||
|
<div class="field"><label>Guild ID</label><input type="text" data-key="DISCORD_GUILD_ID"></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Channels -->
|
||||||
|
<div class="section" id="s-channels">
|
||||||
|
<div class="section-header"><h2>Channels</h2><p>Channel assignments for logging, transcripts, and alerts</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field"><label>Transcript Channel</label><input type="text" data-key="TRANSCRIPT_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Logging Channel</label><input type="text" data-key="LOGGING_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Debugging Channel</label><input type="text" data-key="DEBUGGING_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Backup/Export Channel</label><input type="text" data-key="BACKUP_EXPORT_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Account Info Channel</label><input type="text" data-key="ACCOUNT_INFO_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Gmail Log Channel</label><input type="text" data-key="GMAIL_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Automation Log Channel</label><input type="text" data-key="AUTOMATION_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Rename Log Channel</label><input type="text" data-key="RENAME_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Security Log Channel</label><input type="text" data-key="SECURITY_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>All Staff Channel</label><input type="text" data-key="ALL_STAFF_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Chat Alert Channel</label><input type="text" data-key="ALL_STAFF_CHAT_ALERT_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>User Patterns Channel</label><input type="text" data-key="USER_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Game Patterns Channel</label><input type="text" data-key="GAME_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Tag Patterns Channel</label><input type="text" data-key="TAG_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Escalation Patterns Channel</label><input type="text" data-key="ESCALATION_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Staff Patterns Channel</label><input type="text" data-key="STAFF_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Combined Patterns Channel</label><input type="text" data-key="COMBINED_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Categories -->
|
||||||
|
<div class="section" id="s-categories">
|
||||||
|
<div class="section-header"><h2>Categories</h2><p>Ticket category assignments and escalation targets</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field"><label>Email Ticket Category</label><input type="text" data-key="TICKET_CATEGORY_ID" data-smart="category"></div>
|
||||||
|
<div class="field"><label>Discord Ticket Category</label><input type="text" data-key="DISCORD_TICKET_CATEGORY_ID" data-smart="category"></div>
|
||||||
|
<div class="field"><label>Email T2 Category</label><input type="text" data-key="EMAIL_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
|
||||||
|
<div class="field"><label>Discord T2 Category</label><input type="text" data-key="DISCORD_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
|
||||||
|
<div class="field"><label>Email T3 Category</label><input type="text" data-key="EMAIL_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
|
||||||
|
<div class="field"><label>Discord T3 Category</label><input type="text" data-key="DISCORD_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
|
||||||
|
<div class="field"><label>Staff Notification Category</label><input type="text" data-key="STAFF_NOTIFICATION_CATEGORY_ID" data-smart="category"></div>
|
||||||
|
<div class="field"><label>Category Name</label><input type="text" data-key="TICKET_CATEGORY_NAME"></div>
|
||||||
|
<div class="field"><label>T2 Category Name</label><input type="text" data-key="TICKET_T2_CATEGORY_NAME"></div>
|
||||||
|
<div class="field"><label>T3 Category Name</label><input type="text" data-key="TICKET_T3_CATEGORY_NAME"></div>
|
||||||
|
<div class="field"><label>Discord Thread Channel</label><input type="text" data-key="DISCORD_THREAD_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
<div class="field"><label>Email Thread Channel</label><input type="text" data-key="EMAIL_THREAD_CHANNEL_ID" data-smart="channel"></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. Gmail -->
|
||||||
|
<div class="section" id="s-gmail">
|
||||||
|
<div class="section-header"><h2>Gmail</h2><p>Google OAuth credentials and email settings</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field"><label>Google Client ID</label><input type="text" data-key="GOOGLE_CLIENT_ID"></div>
|
||||||
|
<div class="field"><label>Google Client Secret</label><input type="password" data-key="GOOGLE_CLIENT_SECRET"></div>
|
||||||
|
<div class="field"><label>Refresh Token</label><input type="password" data-key="REFRESH_TOKEN"></div>
|
||||||
|
<div class="field"><label>Support Email</label><input type="email" data-key="MY_EMAIL"></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. Ticket Behavior -->
|
||||||
|
<div class="section" id="s-behavior">
|
||||||
|
<div class="section-header"><h2>Ticket Behavior</h2><p>Automation, limits, and messages</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field"><label>Auto-Close</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_CLOSE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
<div class="field"><label>Auto-Close Hours</label><input type="number" data-key="AUTO_CLOSE_AFTER_HOURS"></div>
|
||||||
|
<div class="field"><label>Reminders</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="REMINDER_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
<div class="field"><label>Reminder Hours</label><input type="number" data-key="REMINDER_AFTER_HOURS"></div>
|
||||||
|
<div class="field"><label>Priority System</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PRIORITY_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
<div class="field"><label>Claim Timeout</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="CLAIM_TIMEOUT_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
<div class="field"><label>Claim Timeout Hours</label><input type="number" data-key="CLAIM_TIMEOUT_HOURS"></div>
|
||||||
|
<div class="field"><label>Auto-Unclaim</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_UNCLAIM_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
<div class="field"><label>Auto-Unclaim Hours</label><input type="number" data-key="AUTO_UNCLAIM_AFTER_HOURS"></div>
|
||||||
|
<div class="field"><label>Allow Claim Overwrite</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="ALLOW_CLAIM_OVERWRITE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
<div class="field"><label>Global Ticket Limit</label><input type="number" data-key="GLOBAL_TICKET_LIMIT"></div>
|
||||||
|
<div class="field"><label>Rate Limit (per user)</label><input type="number" data-key="RATE_LIMIT_TICKETS_PER_USER"></div>
|
||||||
|
<div class="field"><label>Rate Limit Window (min)</label><input type="number" data-key="RATE_LIMIT_WINDOW_MINUTES"></div>
|
||||||
|
<div class="field"><label>Role to Ping</label><input type="text" data-key="ROLE_ID_TO_PING" data-smart="role"></div>
|
||||||
|
<div class="field full-width"><label>Welcome Message</label><textarea data-key="TICKET_WELCOME_MESSAGE" rows="3"></textarea></div>
|
||||||
|
<div class="field full-width"><label>Claimed Message</label><textarea data-key="TICKET_CLAIMED_MESSAGE" rows="2"></textarea><div class="hint">Variables: {staff_mention}, {staff_name}</div></div>
|
||||||
|
<div class="field full-width"><label>Unclaimed Message</label><textarea data-key="TICKET_UNCLAIMED_MESSAGE" rows="2"></textarea></div>
|
||||||
|
<div class="field full-width"><label>Escalation Message</label><textarea data-key="ESCALATION_MESSAGE" rows="3"></textarea><div class="hint">Variables: {support_name}</div></div>
|
||||||
|
<div class="field full-width"><label>Reminder Message</label><textarea data-key="REMINDER_MESSAGE" rows="2"></textarea><div class="hint">Variables: {ping}, {hours}</div></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. Staff Threads -->
|
||||||
|
<div class="section" id="s-threads">
|
||||||
|
<div class="section-header"><h2>Staff Threads</h2><p>Private staff discussion threads on ticket channels</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field"><label>Enabled</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_THREAD_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
<div class="field"><label>Thread Name</label><input type="text" data-key="STAFF_THREAD_NAME"></div>
|
||||||
|
<div class="field"><label>Auto-Add Role</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_THREAD_AUTO_ADD_ROLE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
<div class="field"><label>Staff Thread Role</label><input type="text" data-key="STAFF_THREAD_ROLE_ID" data-smart="role"></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 7. Pin Messages -->
|
||||||
|
<div class="section" id="s-pins">
|
||||||
|
<div class="section-header"><h2>Pin Messages</h2><p>Auto-pin welcome and escalation messages</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field"><label>Pin Initial Message</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_INITIAL_MESSAGE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
<div class="field"><label>Pin Escalation Message</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_ESCALATION_MESSAGE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
<div class="field"><label>Suppress Pin Notice</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_SUPPRESS_SYSTEM_MESSAGE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 8. Notifications -->
|
||||||
|
<div class="section" id="s-notifications">
|
||||||
|
<div class="section-header"><h2>Notifications</h2><p>Threshold milestones and trigger conditions by alert category</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body">
|
||||||
|
<input type="hidden" data-key="NOTIFICATION_THRESHOLDS_JSON">
|
||||||
|
|
||||||
|
<div class="notif-tabs" role="tablist" aria-label="Notification categories">
|
||||||
|
<button type="button" class="notif-tab-btn active" data-notif-tab="surge">Surge</button>
|
||||||
|
<button type="button" class="notif-tab-btn" data-notif-tab="patterns">Patterns</button>
|
||||||
|
<button type="button" class="notif-tab-btn" data-notif-tab="unclaimed">Unclaimed</button>
|
||||||
|
<button type="button" class="notif-tab-btn" data-notif-tab="chat">Chat</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notif-panel" data-notif-panel="surge">
|
||||||
|
<div class="notif-toggle-row">
|
||||||
|
<div class="notif-toggle-group">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-master>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label">Master (all categories)</span>
|
||||||
|
</div>
|
||||||
|
<div class="notif-toggle-group">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-category-toggle="surge">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label">All in category</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Surge alerts fire when active ticket conditions cross thresholds — high volume, unclaimed queues, no staff online. Each alert escalates through its threshold list, spacing out pings as the condition persists. The counter resets when the condition clears.</p>
|
||||||
|
<div class="notif-editor">
|
||||||
|
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="surge"></select></div>
|
||||||
|
<div class="hint notif-alert-description" data-notif-description="surge"></div>
|
||||||
|
<div class="notif-per-alert-row">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-alert>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||||
|
</div>
|
||||||
|
<div class="notif-chips" data-notif-chips="surge"></div>
|
||||||
|
<div class="notif-input-row">
|
||||||
|
<input type="text" class="notif-threshold-input" data-notif-input="surge" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||||
|
<button type="button" class="notif-add-btn" data-notif-add="surge">Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-presets" data-notif-presets="surge"></div>
|
||||||
|
</div>
|
||||||
|
<details class="notif-trigger">
|
||||||
|
<summary>Trigger conditions</summary>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field"><label>Surge Role</label><input type="text" data-key="SURGE_ROLE_ID" data-smart="role"></div>
|
||||||
|
<div class="field"><label>Ticket Surge Count</label><input type="number" data-key="SURGE_TICKET_COUNT"></div>
|
||||||
|
<div class="field"><label>Ticket Window (min)</label><input type="number" data-key="SURGE_TICKET_WINDOW_MINUTES"></div>
|
||||||
|
<div class="field"><label>Game Surge Count</label><input type="number" data-key="SURGE_GAME_TICKET_COUNT"></div>
|
||||||
|
<div class="field"><label>Game Window (min)</label><input type="number" data-key="SURGE_GAME_TICKET_WINDOW_MINUTES"></div>
|
||||||
|
<div class="field"><label>Stale Count</label><input type="number" data-key="SURGE_STALE_COUNT"></div>
|
||||||
|
<div class="field"><label>Stale Hours</label><input type="number" data-key="SURGE_STALE_HOURS"></div>
|
||||||
|
<div class="field"><label>Needs Response Count</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_COUNT"></div>
|
||||||
|
<div class="field"><label>Needs Response Hours</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_HOURS"></div>
|
||||||
|
<div class="field"><label>Unclaimed Count</label><input type="number" data-key="SURGE_UNCLAIMED_COUNT"></div>
|
||||||
|
<div class="field"><label>Unclaimed Minutes</label><input type="number" data-key="SURGE_UNCLAIMED_MINUTES"></div>
|
||||||
|
<div class="field"><label>Tier 3 Unclaimed (min)</label><input type="number" data-key="SURGE_TIER3_UNCLAIMED_MINUTES"></div>
|
||||||
|
<div class="field"><label>Cooldown (min)</label><input type="number" data-key="SURGE_COOLDOWN_MINUTES"></div>
|
||||||
|
<div class="field"><label>DND = Available</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_DND_COUNTS_AS_AVAILABLE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
|
<div class="field"><label>No-Staff Cooldown (min)</label><input type="number" data-key="SURGE_NO_STAFF_COOLDOWN_MINUTES"></div>
|
||||||
|
<div class="field"><label>No-Staff Ticket Threshold</label><input type="number" data-key="SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notif-panel hidden" data-notif-panel="patterns">
|
||||||
|
<div class="notif-toggle-row">
|
||||||
|
<div class="notif-toggle-group">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-master>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label">Master (all categories)</span>
|
||||||
|
</div>
|
||||||
|
<div class="notif-toggle-group">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-category-toggle="patterns">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label">All in category</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Pattern alerts detect trends over time — surges by game, escalation rates, staff behavior. Each alert fires once per threshold crossed within its window (daily/weekly/monthly) and won't repeat until the next window resets.</p>
|
||||||
|
<div class="notif-editor">
|
||||||
|
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="patterns"></select></div>
|
||||||
|
<div class="hint notif-alert-description" data-notif-description="patterns"></div>
|
||||||
|
<div class="notif-per-alert-row">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-alert>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||||
|
</div>
|
||||||
|
<div class="notif-chips" data-notif-chips="patterns"></div>
|
||||||
|
<div class="notif-input-row">
|
||||||
|
<input type="text" class="notif-threshold-input" data-notif-input="patterns" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||||
|
<button type="button" class="notif-add-btn" data-notif-add="patterns">Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-presets" data-notif-presets="patterns"></div>
|
||||||
|
</div>
|
||||||
|
<details class="notif-trigger">
|
||||||
|
<summary>Trigger conditions</summary>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field"><label>Check Interval (min)</label><input type="number" data-key="PATTERN_CHECK_INTERVAL_MINUTES"></div>
|
||||||
|
<div class="field"><label>User Ticket Threshold</label><input type="number" data-key="PATTERN_USER_TICKET_THRESHOLD"></div>
|
||||||
|
<div class="field"><label>Game Ticket Threshold</label><input type="number" data-key="PATTERN_GAME_TICKET_THRESHOLD"></div>
|
||||||
|
<div class="field"><label>Staff Stale Ping Threshold</label><input type="number" data-key="PATTERN_STAFF_STALE_PING_THRESHOLD"></div>
|
||||||
|
<div class="field"><label>Escalation Threshold</label><input type="number" data-key="PATTERN_ESCALATION_THRESHOLD"></div>
|
||||||
|
<div class="field"><label>Rapid Close Seconds</label><input type="number" data-key="PATTERN_RAPID_CLOSE_SECONDS"></div>
|
||||||
|
<div class="field"><label>Unclaimed Hours</label><input type="number" data-key="PATTERN_UNCLAIMED_HOURS"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notif-panel hidden" data-notif-panel="unclaimed">
|
||||||
|
<div class="notif-toggle-row">
|
||||||
|
<div class="notif-toggle-group">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-master>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label">Master (all categories)</span>
|
||||||
|
</div>
|
||||||
|
<div class="notif-toggle-group">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-category-toggle="unclaimed">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label">All in category</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Per-ticket reminders sent to staff notification channels when a ticket remains unclaimed. Each threshold fires once per ticket. Escalating a ticket resets the threshold list so reminders restart for the new tier.</p>
|
||||||
|
<div class="notif-editor">
|
||||||
|
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="unclaimed"></select></div>
|
||||||
|
<div class="hint notif-alert-description" data-notif-description="unclaimed"></div>
|
||||||
|
<div class="notif-per-alert-row">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-alert>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||||
|
</div>
|
||||||
|
<div class="notif-chips" data-notif-chips="unclaimed"></div>
|
||||||
|
<div class="notif-input-row">
|
||||||
|
<input type="text" class="notif-threshold-input" data-notif-input="unclaimed" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||||
|
<button type="button" class="notif-add-btn" data-notif-add="unclaimed">Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-presets" data-notif-presets="unclaimed"></div>
|
||||||
|
</div>
|
||||||
|
<details class="notif-trigger">
|
||||||
|
<summary>Trigger conditions</summary>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field full-width"><p class="hint">Unclaimed notifications use threshold milestones only.</p></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notif-panel hidden" data-notif-panel="chat">
|
||||||
|
<div class="notif-toggle-row">
|
||||||
|
<div class="notif-toggle-group">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-master>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label">Master (all categories)</span>
|
||||||
|
</div>
|
||||||
|
<div class="notif-toggle-group">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-category-toggle="chat">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label">All in category</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Monitors configured chat channels for unresponded user messages. Fires at escalating intervals while the condition persists. Resets when a staff member responds.</p>
|
||||||
|
<div class="notif-editor">
|
||||||
|
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="chat"></select></div>
|
||||||
|
<div class="hint notif-alert-description" data-notif-description="chat"></div>
|
||||||
|
<div class="notif-per-alert-row">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" data-notif-alert>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||||
|
</div>
|
||||||
|
<div class="notif-chips" data-notif-chips="chat"></div>
|
||||||
|
<div class="notif-input-row">
|
||||||
|
<input type="text" class="notif-threshold-input" data-notif-input="chat" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||||
|
<button type="button" class="notif-add-btn" data-notif-add="chat">Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-presets" data-notif-presets="chat"></div>
|
||||||
|
</div>
|
||||||
|
<details class="notif-trigger">
|
||||||
|
<summary>Trigger conditions</summary>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field"><label>Chat Alert Message Count</label><input type="number" data-key="CHAT_ALERT_MESSAGE_COUNT"></div>
|
||||||
|
<div class="field"><label>Chat No-Response Hours</label><input type="number" data-key="CHAT_ALERT_HOURS_WITHOUT_RESPONSE"></div>
|
||||||
|
<div class="field"><label>Chat Alert Cooldown (min)</label><input type="number" data-key="CHAT_ALERT_COOLDOWN_MINUTES"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 10. Logging -->
|
||||||
|
<div class="section" id="s-logging">
|
||||||
|
<div class="section-header"><h2>Logging</h2><p>Log channel configuration (channels set in Channels section)</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field full-width"><p class="logging-hint">Log channels are configured in the <a href="/channels">Channels</a> section. This section shows which logs are active based on configured channels.</p></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 11. Automation -->
|
||||||
|
<div class="section" id="s-automation">
|
||||||
|
<div class="section-header"><h2>Automation</h2><p>Polling intervals and timer durations</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field"><label>Gmail Poll Interval (sec)</label><select data-key="GMAIL_POLL_INTERVAL_SECONDS">
|
||||||
|
<option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="45">45s</option>
|
||||||
|
<option value="60">1m</option><option value="120">2m</option><option value="180">3m</option><option value="240">4m</option>
|
||||||
|
<option value="300">5m</option><option value="600">10m</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="field"><label>Force-Close Timer (sec)</label><select data-key="FORCE_CLOSE_TIMER_SECONDS">
|
||||||
|
<option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="45">45s</option>
|
||||||
|
<option value="60">1m</option><option value="120">2m</option><option value="180">3m</option><option value="240">4m</option>
|
||||||
|
<option value="300">5m</option><option value="600">10m</option>
|
||||||
|
</select></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 12. Appearance -->
|
||||||
|
<div class="section" id="s-appearance">
|
||||||
|
<div class="section-header"><h2>Appearance</h2><p>Embed colors, button labels, and emojis</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field"><label>Open Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_OPEN"><span>Open tickets</span></div></div>
|
||||||
|
<div class="field"><label>Closed Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_CLOSED"><span>Closed tickets</span></div></div>
|
||||||
|
<div class="field"><label>Claimed Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_CLAIMED"><span>Claimed tickets</span></div></div>
|
||||||
|
<div class="field"><label>Escalated Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_ESCALATED"><span>Escalated tickets</span></div></div>
|
||||||
|
<div class="field"><label>Info Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_INFO"><span>Info embeds</span></div></div>
|
||||||
|
<div class="field"><label>Close Button Label</label><input type="text" data-key="BUTTON_LABEL_CLOSE"></div>
|
||||||
|
<div class="field"><label>Claim Button Label</label><input type="text" data-key="BUTTON_LABEL_CLAIM"></div>
|
||||||
|
<div class="field"><label>Unclaim Button Label</label><input type="text" data-key="BUTTON_LABEL_UNCLAIM"></div>
|
||||||
|
<div class="field"><label>Close Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLOSE"></div>
|
||||||
|
<div class="field"><label>Claim Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLAIM"></div>
|
||||||
|
<div class="field"><label>Unclaim Emoji</label><input type="text" data-key="BUTTON_EMOJI_UNCLAIM"></div>
|
||||||
|
<div class="field"><label>High Priority Emoji</label><input type="text" data-key="PRIORITY_HIGH_EMOJI"></div>
|
||||||
|
<div class="field"><label>Medium Priority Emoji</label><input type="text" data-key="PRIORITY_MEDIUM_EMOJI"></div>
|
||||||
|
<div class="field"><label>Low Priority Emoji</label><input type="text" data-key="PRIORITY_LOW_EMOJI"></div>
|
||||||
|
<div class="field"><label>Claimer Emoji Fallback</label><input type="text" data-key="CLAIMER_EMOJI_FALLBACK"></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 13. Staff -->
|
||||||
|
<div class="section" id="s-staff">
|
||||||
|
<div class="section-header"><h2>Staff</h2><p>Staff IDs, emojis, and admin settings</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field full-width"><label>Staff IDs (comma-separated)</label><input type="text" data-key="STAFF_IDS" data-smart="multi-member"></div>
|
||||||
|
<div class="field"><label>Admin ID</label><input type="text" data-key="ADMIN_ID" data-smart="member"></div>
|
||||||
|
<div class="field full-width"><label>Staff Emojis (userId:emoji, comma-separated)</label><input type="text" data-key="STAFF_EMOJIS"><div class="hint">Format: 123456:emoji,789012:emoji</div></div>
|
||||||
|
<div class="field full-width"><label>Additional Staff Roles (comma-separated)</label><input type="text" data-key="ADDITIONAL_STAFF_ROLES"><div class="hint">Role IDs with staff permissions</div></div>
|
||||||
|
<div class="field full-width"><label>Blacklisted Roles (comma-separated)</label><input type="text" data-key="BLACKLISTED_ROLES"><div class="hint">Role IDs that cannot open tickets</div></div>
|
||||||
|
<div class="field full-width"><label>Unclaimed Reminder Thresholds (hours, comma-separated)</label><input type="text" data-key="UNCLAIMED_REMINDER_THRESHOLDS"><div class="hint">e.g. 1,2,4</div></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 14. Advanced -->
|
||||||
|
<div class="section" id="s-advanced">
|
||||||
|
<div class="section-header"><h2>Advanced</h2><p>Ports, URLs, game list, branding</p><span class="chevron">▼</span></div>
|
||||||
|
<div class="section-body"><div class="field-grid">
|
||||||
|
<div class="field"><label>Bot Port</label><input type="number" data-key="DISCORD_ONLY_PORT"></div>
|
||||||
|
<div class="field"><label>Healthcheck Host</label><input type="text" data-key="HEALTHCHECK_HOST" placeholder="leave empty for all interfaces"></div>
|
||||||
|
<div class="field"><label>Settings Port</label><input type="number" data-key="SETTINGS_PORT"></div>
|
||||||
|
<div class="field"><label>Settings Domain</label><input type="text" data-key="SETTINGS_DOMAIN"></div>
|
||||||
|
<div class="field"><label>Internal API Port</label><input type="number" data-key="INTERNAL_API_PORT"></div>
|
||||||
|
<div class="field"><label>Support Name</label><input type="text" data-key="SUPPORT_NAME"></div>
|
||||||
|
<div class="field"><label>Logo URL</label><input type="text" data-key="LOGO_URL"></div>
|
||||||
|
<div class="field full-width"><label>Game List (comma-separated)</label><textarea data-key="GAME_LIST" rows="3"></textarea></div>
|
||||||
|
<div class="field full-width"><label>Email Signature (HTML, use \n for breaks)</label><textarea data-key="EMAIL_SIGNATURE" rows="3"></textarea></div>
|
||||||
|
<div class="field full-width"><label>Close Subject Prefix</label><input type="text" data-key="TICKET_CLOSE_SUBJECT_PREFIX"></div>
|
||||||
|
<div class="field full-width"><label>Close Message (email body)</label><textarea data-key="TICKET_CLOSE_MESSAGE" rows="2"></textarea></div>
|
||||||
|
<div class="field full-width"><label>Discord Close Message</label><textarea data-key="DISCORD_CLOSE_MESSAGE" rows="2"></textarea></div>
|
||||||
|
<div class="field full-width"><label>Transcript Message</label><textarea data-key="DISCORD_TRANSCRIPT_MESSAGE" rows="2"></textarea><div class="hint">Variables: {channel_name}, {email}, {date_opened}, {date_closed}</div></div>
|
||||||
|
<div class="field full-width"><label>Auto-Close Message</label><textarea data-key="DISCORD_AUTO_CLOSE_MESSAGE" rows="2"></textarea></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save bar -->
|
||||||
|
<div id="save-bar" class="save-bar">
|
||||||
|
<span id="change-count">0 unsaved changes</span>
|
||||||
|
<div class="save-actions">
|
||||||
|
<button type="button" id="save-btn">Save</button>
|
||||||
|
<button type="button" id="save-restart-btn" class="danger">Save & Restart Now</button>
|
||||||
|
<button type="button" id="schedule-restart-btn" class="secondary">Schedule restart...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedule modal -->
|
||||||
|
<div id="schedule-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="schedule-modal-title">
|
||||||
|
<div class="modal-card">
|
||||||
|
<h3 id="schedule-modal-title">Schedule restart</h3>
|
||||||
|
<input type="datetime-local" id="schedule-datetime" aria-label="Restart date and time">
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" id="schedule-confirm-btn">Schedule</button>
|
||||||
|
<button type="button" id="schedule-cancel-btn" class="secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script defer src="/js/util.js"></script>
|
||||||
|
<script defer src="/js/router.js"></script>
|
||||||
|
<script defer src="/js/fields.js"></script>
|
||||||
|
<script defer src="/js/notifications.js"></script>
|
||||||
|
<script defer src="/js/discord.js"></script>
|
||||||
|
<script defer src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
162
settings-site/public/js/app.js.bak-20260421
Normal file
162
settings-site/public/js/app.js.bak-20260421
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
document.getElementById('loading').classList.remove('hidden');
|
||||||
|
try {
|
||||||
|
await Util.fetchCsrfToken();
|
||||||
|
const [config] = await Promise.all([
|
||||||
|
fetch('/api/config', { credentials: 'same-origin' }).then(r => r.json()),
|
||||||
|
DiscordFields.fetchGuildData()
|
||||||
|
]);
|
||||||
|
Fields.setSavedConfig(config);
|
||||||
|
document.getElementById('bot-status-dot').className = 'dot online';
|
||||||
|
document.getElementById('bot-status-text').textContent = 'Connected';
|
||||||
|
Fields.populateFields(config);
|
||||||
|
Notifications.initNotificationsEditor(config);
|
||||||
|
Fields.initSmartSelects(config);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('bot-status-dot').className = 'dot offline';
|
||||||
|
document.getElementById('bot-status-text').textContent = 'Unreachable';
|
||||||
|
}
|
||||||
|
document.getElementById('loading').classList.add('hidden');
|
||||||
|
setupSectionToggles();
|
||||||
|
Fields.setupSaveBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSectionToggles() {
|
||||||
|
document.querySelectorAll('.section-header').forEach(header => {
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
header.closest('.section').classList.toggle('collapsed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScheduleModal() {
|
||||||
|
const modal = document.getElementById('schedule-modal');
|
||||||
|
const dt = document.getElementById('schedule-datetime');
|
||||||
|
const min = Util.formatLocalDateTime(new Date(Date.now() + 60000));
|
||||||
|
dt.min = min;
|
||||||
|
dt.value = min;
|
||||||
|
Util.openModal(modal, { initialFocus: '#schedule-datetime' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmScheduledRestart() {
|
||||||
|
const dt = document.getElementById('schedule-datetime').value;
|
||||||
|
if (!dt) return;
|
||||||
|
await fetch('/api/restart', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() })
|
||||||
|
});
|
||||||
|
Util.closeModal(document.getElementById('schedule-modal'));
|
||||||
|
Util.showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
try {
|
||||||
|
await fetch('/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: Util.csrfHeaders()
|
||||||
|
});
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupActionButtons() {
|
||||||
|
document.getElementById('save-btn')?.addEventListener('click', () => Fields.saveConfig('save'));
|
||||||
|
document.getElementById('save-restart-btn')?.addEventListener('click', () => Fields.saveConfig('restart'));
|
||||||
|
document.getElementById('schedule-restart-btn')?.addEventListener('click', openScheduleModal);
|
||||||
|
document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart);
|
||||||
|
document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => {
|
||||||
|
Util.closeModal(document.getElementById('schedule-modal'));
|
||||||
|
});
|
||||||
|
document.getElementById('logout-btn')?.addEventListener('click', doLogout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMobileNav() {
|
||||||
|
const toggle = document.getElementById('menu-toggle');
|
||||||
|
const backdrop = document.getElementById('sidebar-backdrop');
|
||||||
|
|
||||||
|
toggle?.addEventListener('click', () => {
|
||||||
|
Util.setSidebarOpen(!document.body.classList.contains('sidebar-open'));
|
||||||
|
});
|
||||||
|
backdrop?.addEventListener('click', () => Util.setSidebarOpen(false));
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
|
||||||
|
Util.setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (!Util.isMobileViewport() && document.body.classList.contains('sidebar-open')) {
|
||||||
|
Util.setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let healthPollHandle = null;
|
||||||
|
|
||||||
|
function setBotStatus(online) {
|
||||||
|
const dot = document.getElementById('bot-status-dot');
|
||||||
|
const text = document.getElementById('bot-status-text');
|
||||||
|
if (!dot || !text) return;
|
||||||
|
dot.className = online ? 'dot online' : 'dot offline';
|
||||||
|
text.textContent = online ? 'Connected' : 'Unreachable';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollHealth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/healthz', { credentials: 'same-origin' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setBotStatus(Boolean(data.bot));
|
||||||
|
} else {
|
||||||
|
setBotStatus(false);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
setBotStatus(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextHealthPoll() {
|
||||||
|
if (document.hidden) return;
|
||||||
|
healthPollHandle = setTimeout(async () => {
|
||||||
|
await pollHealth();
|
||||||
|
scheduleNextHealthPoll();
|
||||||
|
}, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHealthPolling() {
|
||||||
|
if (healthPollHandle) clearTimeout(healthPollHandle);
|
||||||
|
scheduleNextHealthPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHealthPolling() {
|
||||||
|
if (healthPollHandle) {
|
||||||
|
clearTimeout(healthPollHandle);
|
||||||
|
healthPollHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupHealthPolling() {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) stopHealthPolling();
|
||||||
|
else startHealthPolling();
|
||||||
|
});
|
||||||
|
window.addEventListener('pagehide', stopHealthPolling);
|
||||||
|
startHealthPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
Router.setupSidebarRouting();
|
||||||
|
setupActionButtons();
|
||||||
|
setupMobileNav();
|
||||||
|
await init();
|
||||||
|
Router.navigate(location.pathname, false);
|
||||||
|
setupHealthPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.App = { init };
|
||||||
|
})();
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const ROUTES = {
|
const ROUTES = {
|
||||||
'/': 's-core',
|
'/': 's-landing',
|
||||||
|
'/core': 's-core',
|
||||||
'/channels': 's-channels',
|
'/channels': 's-channels',
|
||||||
'/categories': 's-categories',
|
'/categories': 's-categories',
|
||||||
'/gmail': 's-gmail',
|
'/gmail': 's-gmail',
|
||||||
@@ -32,11 +33,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupSidebarRouting() {
|
function setupSidebarRouting() {
|
||||||
const sidebar = document.querySelector('.sidebar');
|
// Delegated at document so the same handler covers sidebar links AND
|
||||||
if (!sidebar) return;
|
// landing-grid cards without duplication. Scoped by the compound selector.
|
||||||
|
document.addEventListener('click', e => {
|
||||||
sidebar.addEventListener('click', e => {
|
const a = e.target.closest('.sidebar a, .landing-grid a');
|
||||||
const a = e.target.closest('a');
|
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(a.getAttribute('href'));
|
navigate(a.getAttribute('href'));
|
||||||
|
|||||||
52
settings-site/public/js/router.js.bak-20260421
Normal file
52
settings-site/public/js/router.js.bak-20260421
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const ROUTES = {
|
||||||
|
'/': 's-core',
|
||||||
|
'/channels': 's-channels',
|
||||||
|
'/categories': 's-categories',
|
||||||
|
'/gmail': 's-gmail',
|
||||||
|
'/behavior': 's-behavior',
|
||||||
|
'/threads': 's-threads',
|
||||||
|
'/pins': 's-pins',
|
||||||
|
'/notifications': 's-notifications',
|
||||||
|
'/logging': 's-logging',
|
||||||
|
'/automation': 's-automation',
|
||||||
|
'/appearance': 's-appearance',
|
||||||
|
'/staff': 's-staff',
|
||||||
|
'/advanced': 's-advanced'
|
||||||
|
};
|
||||||
|
|
||||||
|
function navigate(path, updateHistory = true) {
|
||||||
|
const sectionId = ROUTES[path] || ROUTES['/'];
|
||||||
|
const normalizedPath = ROUTES[path] ? path : '/';
|
||||||
|
if (updateHistory) history.pushState({}, '', normalizedPath);
|
||||||
|
|
||||||
|
document.querySelectorAll('.section').forEach(section => {
|
||||||
|
section.classList.toggle('hidden', section.id !== sectionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.sidebar a').forEach(link => {
|
||||||
|
link.classList.toggle('active', link.getAttribute('href') === normalizedPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSidebarRouting() {
|
||||||
|
const sidebar = document.querySelector('.sidebar');
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
sidebar.addEventListener('click', e => {
|
||||||
|
const a = e.target.closest('a');
|
||||||
|
if (!a) return;
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(a.getAttribute('href'));
|
||||||
|
if (Util.isMobileViewport()) Util.setSidebarOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
navigate(location.pathname, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Router = { ROUTES, navigate, setupSidebarRouting };
|
||||||
|
})();
|
||||||
@@ -20,6 +20,7 @@ const PORT = parseInt(process.env.SETTINGS_PORT) || 12752;
|
|||||||
const INTERNAL_URL = process.env.INTERNAL_API_URL || `http://127.0.0.1:${process.env.INTERNAL_API_PORT || 12753}/internal`;
|
const INTERNAL_URL = process.env.INTERNAL_API_URL || `http://127.0.0.1:${process.env.INTERNAL_API_PORT || 12753}/internal`;
|
||||||
const SECRET = process.env.INTERNAL_API_SECRET;
|
const SECRET = process.env.INTERNAL_API_SECRET;
|
||||||
const ADMIN_PASSWORD = process.env.SETTINGS_ADMIN_PASSWORD;
|
const ADMIN_PASSWORD = process.env.SETTINGS_ADMIN_PASSWORD;
|
||||||
|
const ADMIN_PASSWORD_2 = process.env.SETTINGS_ADMIN_PASSWORD_2;
|
||||||
const SESSION_SECRET = process.env.SESSION_SECRET;
|
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||||||
const IS_PROD = process.env.NODE_ENV === 'production';
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
@@ -65,7 +66,11 @@ app.use(express.urlencoded({ extended: true, limit: '64kb' }));
|
|||||||
app.use(session({
|
app.use(session({
|
||||||
secret: SESSION_SECRET,
|
secret: SESSION_SECRET,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
// Required true: csrf-csrf binds its token signature to req.sessionID. With
|
||||||
|
// `false`, the session cookie isn't sent until the session is modified, so
|
||||||
|
// each pre-login request gets a fresh sessionID and CSRF validation always
|
||||||
|
// fails. See the "audit" commit (33b1f27) which inadvertently flipped this.
|
||||||
|
saveUninitialized: true,
|
||||||
cookie: {
|
cookie: {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: IS_PROD,
|
secure: IS_PROD,
|
||||||
@@ -175,7 +180,9 @@ app.get('/login', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/login', loginLimiter, (req, res) => {
|
app.post('/login', loginLimiter, (req, res) => {
|
||||||
if (safeEqual(req.body.password, ADMIN_PASSWORD)) {
|
const matchesPrimary = safeEqual(req.body.password, ADMIN_PASSWORD);
|
||||||
|
const matchesSecondary = ADMIN_PASSWORD_2 && safeEqual(req.body.password, ADMIN_PASSWORD_2);
|
||||||
|
if (matchesPrimary || matchesSecondary) {
|
||||||
req.session.authed = true;
|
req.session.authed = true;
|
||||||
return res.json({ ok: true });
|
return res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user