From 11e98d534d46bb85b3d0dd27b2eb73f0f6c025f0 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Sun, 12 Oct 2025 19:29:47 +1100 Subject: [PATCH 01/10] Normalise rank slugs when loading permissions --- controllers/userController.js | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/controllers/userController.js b/controllers/userController.js index 83314e5c..ee7d8f66 100644 --- a/controllers/userController.js +++ b/controllers/userController.js @@ -563,19 +563,33 @@ export async function getUserPermissions(userData = {}) { permissionSet.add(value); }; - const queueRank = (slug, { direct = false } = {}) => { + const normaliseRankSlug = (slug) => { if (!slug) { + return null; + } + + const trimmed = String(slug).trim(); + if (!trimmed) { + return null; + } + + return trimmed.toLowerCase(); + }; + + const queueRank = (slug, { direct = false } = {}) => { + const normalisedSlug = normaliseRankSlug(slug); + if (!normalisedSlug) { return; } - if (direct && !seenDirectRanks.has(slug)) { - seenDirectRanks.add(slug); - directRankOrder.push(slug); + if (direct && !seenDirectRanks.has(normalisedSlug)) { + seenDirectRanks.add(normalisedSlug); + directRankOrder.push(normalisedSlug); } - if (!queuedRankSet.has(slug)) { - queuedRankSet.add(slug); - queuedRanks.push(slug); + if (!queuedRankSet.has(normalisedSlug)) { + queuedRankSet.add(normalisedSlug); + queuedRanks.push(normalisedSlug); } }; From 711eb7afe300bdd8f35f254ff28348570f929983 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Sun, 12 Oct 2025 19:43:04 +1100 Subject: [PATCH 02/10] Fix dashboard rank view includes --- views/dashboard/ranks/index.ejs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/views/dashboard/ranks/index.ejs b/views/dashboard/ranks/index.ejs index 923a0a65..ad8e07ec 100644 --- a/views/dashboard/ranks/index.ejs +++ b/views/dashboard/ranks/index.ejs @@ -1,18 +1,18 @@ -<%- include("../modules/header.ejs", { +<%- include("../../modules/header.ejs", { pageTitle: pageTitle, pageDescription: "Review rank metadata and manage player permissions." }) %> -<%- include("../modules/navigationBar.ejs") %> +<%- include("../../modules/navigationBar.ejs") %> -<%- include("../partials/miniHeader.ejs", { +<%- include("../../partials/miniHeader.ejs", { headerTitle: "Rank Management", backgroundImage: globalImage }) %>
- <%- include("../modules/dashboard/dashboard-sidebar.ejs") %> + <%- include("../../modules/dashboard/dashboard-sidebar.ejs") %>
From c0cc25e0f82bc1d457f284fb4ffb32be8106454b Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Sun, 12 Oct 2025 20:05:57 +1100 Subject: [PATCH 03/10] Fix rank dashboard footer include path --- views/dashboard/ranks/index.ejs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/dashboard/ranks/index.ejs b/views/dashboard/ranks/index.ejs index ad8e07ec..cfffa58b 100644 --- a/views/dashboard/ranks/index.ejs +++ b/views/dashboard/ranks/index.ejs @@ -753,4 +753,4 @@ })(); -<%- include("../modules/footer.ejs") %> +<%- include("../../modules/footer.ejs") %> From d32f1123b0e6d5c722942ca257532b4cf79d72f8 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Mon, 13 Oct 2025 00:20:17 +1100 Subject: [PATCH 04/10] Allow scrolling in rank configuration modal --- assets/css/dashboard-style.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/assets/css/dashboard-style.css b/assets/css/dashboard-style.css index 461d7e25..62ca480c 100644 --- a/assets/css/dashboard-style.css +++ b/assets/css/dashboard-style.css @@ -274,6 +274,18 @@ span.badge { transform: translateY(-50%) !important; width: 70%; } +.modal-dialog.modal-dialog-scrollable { + top: auto; + transform: none !important; + margin: 1.5rem auto; + max-height: calc(100vh - 3rem); +} +.modal-dialog.modal-dialog-scrollable .modal-content { + max-height: 100%; +} +.modal-dialog.modal-dialog-scrollable .modal-body { + overflow-y: auto; +} .modal-header .close { font-size: 14px; margin-right: 15px; From b54120ea5c8452ba0bfe20931fa2a36aa667e74f Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Mon, 13 Oct 2025 00:43:05 +1100 Subject: [PATCH 05/10] Restore inherited rank resolution --- controllers/userController.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/controllers/userController.js b/controllers/userController.js index ee7d8f66..93c1d59f 100644 --- a/controllers/userController.js +++ b/controllers/userController.js @@ -576,15 +576,25 @@ export async function getUserPermissions(userData = {}) { return trimmed.toLowerCase(); }; + const canonicalRankMap = new Map(); + const queueRank = (slug, { direct = false } = {}) => { const normalisedSlug = normaliseRankSlug(slug); if (!normalisedSlug) { return; } + const trimmedSlug = String(slug).trim(); + + if (!canonicalRankMap.has(normalisedSlug) || direct) { + canonicalRankMap.set(normalisedSlug, trimmedSlug); + } + + const canonicalSlug = canonicalRankMap.get(normalisedSlug) || trimmedSlug; + if (direct && !seenDirectRanks.has(normalisedSlug)) { seenDirectRanks.add(normalisedSlug); - directRankOrder.push(normalisedSlug); + directRankOrder.push(canonicalSlug); } if (!queuedRankSet.has(normalisedSlug)) { @@ -644,7 +654,9 @@ export async function getUserPermissions(userData = {}) { } while (queuedRanks.length) { - const currentRank = queuedRanks.shift(); + const currentNormalisedRank = queuedRanks.shift(); + const currentRank = + canonicalRankMap.get(currentNormalisedRank) || currentNormalisedRank; const groupPermissions = await runQuery( `SELECT permission @@ -662,7 +674,7 @@ export async function getUserPermissions(userData = {}) { if (permission.startsWith("group.")) { const inherited = permission.substring("group.".length).trim(); - if (inherited && inherited !== currentRank) { + if (inherited) { queueRank(inherited); } return; From 680363caa9c9f75f2ff11b09a6fa3d6499cf8ff9 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Mon, 13 Oct 2025 06:50:26 +1100 Subject: [PATCH 06/10] Fix LuckPerms subgroup slug parsing --- controllers/userController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/userController.js b/controllers/userController.js index 93c1d59f..04f35b27 100644 --- a/controllers/userController.js +++ b/controllers/userController.js @@ -617,7 +617,7 @@ export async function getUserPermissions(userData = {}) { directPermissions.forEach(({ permission }) => pushPermission(permission)); const rankRows = await runQuery( - `SELECT SUBSTRING_INDEX(permission, '.', -1) AS rankSlug + `SELECT SUBSTRING(permission, LOCATE('.', permission) + 1) AS rankSlug FROM ${LUCKPERMS_USER_PERMISSIONS_TABLE} WHERE uuid = UNHEX(?) AND permission LIKE 'group.%' From a2d21e92e99596e749d945fbb941bcf31fa35643 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Tue, 14 Oct 2025 22:54:25 +1100 Subject: [PATCH 07/10] Enable rank steps in bridge routines --- api/routes/bridge.js | 165 ++++++++++++++-- routes/dashboard/dashboard.js | 16 +- views/dashboard/bridge.ejs | 355 ++++++++++++++++++++++++++++++---- 3 files changed, 482 insertions(+), 54 deletions(-) diff --git a/api/routes/bridge.js b/api/routes/bridge.js index a7b196c8..eb51494c 100644 --- a/api/routes/bridge.js +++ b/api/routes/bridge.js @@ -6,6 +6,8 @@ const ROUTINE_TABLE = "executorRoutines"; const ROUTINE_STEPS_TABLE = "executorRoutineSteps"; const VALID_STATUSES = ["pending", "processing", "completed", "failed"]; +const ROUTINE_CONFIG_KEY = "_routineConfig"; +const DEFAULT_PLAYER_METADATA_KEY = "player"; function normalizeCommand(command) { if (typeof command !== "string") { @@ -87,6 +89,61 @@ function safeJsonParse(value) { } } +function readRoutineConfig(metadata) { + if (!metadata || typeof metadata !== "object") { + return {}; + } + + const config = metadata[ROUTINE_CONFIG_KEY]; + if (!config || typeof config !== "object") { + return {}; + } + + return { ...config }; +} + +function setRoutineConfig(metadata, updates = {}, options = {}) { + if (!metadata || typeof metadata !== "object") { + return; + } + + const { overwrite = false } = options; + const baseConfig = overwrite ? {} : readRoutineConfig(metadata); + metadata[ROUTINE_CONFIG_KEY] = { ...baseConfig, ...updates }; +} + +function normalizeRankAction(action) { + if (typeof action !== "string") { + return "assign"; + } + + return action.toLowerCase() === "remove" ? "remove" : "assign"; +} + +function normalizePlayerMetadataKey(value) { + if (typeof value !== "string") { + return DEFAULT_PLAYER_METADATA_KEY; + } + + const trimmed = value.trim(); + return trimmed || DEFAULT_PLAYER_METADATA_KEY; +} + +function buildRankCommand(rankSlug, rankAction, playerMetadataKey) { + if (!rankSlug) { + return ""; + } + + const action = normalizeRankAction(rankAction); + const playerKey = normalizePlayerMetadataKey(playerMetadataKey); + + if (action === "remove") { + return `lp user {{${playerKey}}} parent remove ${rankSlug}`; + } + + return `lp user {{${playerKey}}} parent add ${rankSlug}`; +} + export default function bridgeApiRoute(app, config, db, features, lang) { function query(sql, params = []) { return new Promise((resolve, reject) => { @@ -238,19 +295,56 @@ export default function bridgeApiRoute(app, config, db, features, lang) { throw new Error(`Task payload at index ${index} must be an object`); } - if (!task.command) { - throw new Error(`Task payload at index ${index} is missing 'command'`); - } - const taskSlug = task.slug || task.target || inlineSlug; if (!taskSlug) { throw new Error(`Task payload at index ${index} is missing 'slug'`); } - const stepMetadata = toMetadataObject(task.metadata); + const baseMetadata = toMetadataObject(task.metadata); + const stepMetadata = baseMetadata ? { ...baseMetadata } : {}; + const config = readRoutineConfig(stepMetadata); + const explicitType = + typeof task.type === "string" ? task.type.toLowerCase() : ""; + const taskType = explicitType || (config.type ? String(config.type).toLowerCase() : "command"); + + let commandTemplate = typeof task.command === "string" ? task.command : ""; + + if (taskType === "rank") { + const rankSlug = (task.rankSlug || config.rankSlug || "").trim(); + if (!rankSlug) { + throw new Error( + `Task payload at index ${index} is missing 'rankSlug' for rank assignment` + ); + } + + const rankAction = normalizeRankAction(task.rankAction || config.rankAction); + const playerKey = normalizePlayerMetadataKey( + task.playerMetadataKey || config.playerMetadataKey + ); + + commandTemplate = buildRankCommand(rankSlug, rankAction, playerKey); + + setRoutineConfig( + stepMetadata, + { + type: "rank", + rankSlug, + rankAction, + playerMetadataKey: playerKey, + }, + { overwrite: true } + ); + } else { + if (!commandTemplate) { + throw new Error(`Task payload at index ${index} is missing 'command'`); + } + + setRoutineConfig(stepMetadata, { type: "command" }); + } + const combinedMetadata = mergeMetadata(rootMetadata, stepMetadata); const resolvedCommand = normalizeCommand( - applyMetadataPlaceholders(task.command, rootMetadata, stepMetadata) + applyMetadataPlaceholders(commandTemplate, rootMetadata, stepMetadata) ); if (!resolvedCommand) { @@ -552,30 +646,69 @@ export default function bridgeApiRoute(app, config, db, features, lang) { throw new Error(`Routine step at index ${index} must be an object`); } - if (!step.command) { - throw new Error(`Routine step at index ${index} is missing 'command'`); - } - const stepSlug = step.slug || step.target; if (!stepSlug) { throw new Error(`Routine step at index ${index} is missing 'slug'`); } const orderValue = Number(step.order ?? index); - const metadataObject = toMetadataObject(step.metadata); - const sanitizedCommand = normalizeCommand(step.command); + const baseMetadata = toMetadataObject(step.metadata); + const metadataObject = baseMetadata ? { ...baseMetadata } : {}; + const config = readRoutineConfig(metadataObject); + const explicitType = + typeof step.type === "string" ? step.type.toLowerCase() : ""; + const stepType = explicitType || (config.type ? String(config.type).toLowerCase() : "command"); + + let sanitizedCommand = ""; + + if (stepType === "rank") { + const rankSlug = (step.rankSlug || config.rankSlug || "").trim(); + if (!rankSlug) { + throw new Error( + `Routine step at index ${index} is missing 'rankSlug' for rank assignment` + ); + } + + const rankAction = normalizeRankAction(step.rankAction || config.rankAction); + const playerKey = normalizePlayerMetadataKey( + step.playerMetadataKey || config.playerMetadataKey + ); + + sanitizedCommand = normalizeCommand( + buildRankCommand(rankSlug, rankAction, playerKey) + ); - if (!sanitizedCommand) { - throw new Error( - `Routine step at index ${index} must include a command after removing leading slashes` + setRoutineConfig( + metadataObject, + { + type: "rank", + rankSlug, + rankAction, + playerMetadataKey: playerKey, + }, + { overwrite: true } ); + } else { + const commandSource = typeof step.command === "string" ? step.command : ""; + sanitizedCommand = normalizeCommand(commandSource); + + if (!sanitizedCommand) { + throw new Error( + `Routine step at index ${index} must include a command after removing leading slashes` + ); + } + + setRoutineConfig(metadataObject, { type: "command" }); } + const metadataPayload = + metadataObject && Object.keys(metadataObject).length ? metadataObject : null; + return { slug: stepSlug, command: sanitizedCommand, order: Number.isFinite(orderValue) ? orderValue : index, - metadata: metadataObject, + metadata: metadataPayload, }; }); diff --git a/routes/dashboard/dashboard.js b/routes/dashboard/dashboard.js index d74e1f1b..8d0a37e7 100644 --- a/routes/dashboard/dashboard.js +++ b/routes/dashboard/dashboard.js @@ -83,7 +83,12 @@ export default function dashboardSiteRoute(app, config, features, lang) { app.get("/dashboard/bridge", async function (req, res) { if (!hasPermission("zander.web.bridge", req, res, features)) return; - const [pendingResponse, processingResponse, routineResponse] = await Promise.all([ + const [ + pendingResponse, + processingResponse, + routineResponse, + ranksResponse, + ] = await Promise.all([ fetch(`${process.env.siteAddress}/api/bridge/processor/get?status=pending&limit=100`, { headers: { "x-access-token": process.env.apiKey }, }), @@ -93,12 +98,18 @@ export default function dashboardSiteRoute(app, config, features, lang) { fetch(`${process.env.siteAddress}/api/bridge/routine/get`, { headers: { "x-access-token": process.env.apiKey }, }), + features?.ranks + ? fetch(`${process.env.siteAddress}/api/rank/get`, { + headers: { "x-access-token": process.env.apiKey }, + }) + : Promise.resolve(null), ]); - const [pendingTasks, processingTasks, routines] = await Promise.all([ + const [pendingTasks, processingTasks, routines, ranks] = await Promise.all([ pendingResponse.json(), processingResponse.json(), routineResponse.json(), + ranksResponse ? ranksResponse.json() : Promise.resolve({ success: false }), ]); res.view("dashboard/bridge", { @@ -107,6 +118,7 @@ export default function dashboardSiteRoute(app, config, features, lang) { pendingTasks: pendingTasks, processingTasks: processingTasks, routines: routines, + ranks: ranks, features: features, req: req, globalImage: await getGlobalImage(), diff --git a/views/dashboard/bridge.ejs b/views/dashboard/bridge.ejs index 6d154759..055ec85b 100644 --- a/views/dashboard/bridge.ejs +++ b/views/dashboard/bridge.ejs @@ -14,6 +14,7 @@ const pendingList = pendingTasks && pendingTasks.success ? pendingTasks.data : []; const processingList = processingTasks && processingTasks.success ? processingTasks.data : []; const routinesList = routines && routines.success ? routines.data : []; + const ranksDirectory = ranks && ranks.success ? ranks.data : []; %>
@@ -550,10 +551,11 @@
-

Provide a slug and command for each step. Metadata pairs are optional and map to placeholders in your commands.

-
Please add at least one step with a slug and command.
+

Provide a slug for each step. Choose between running commands or assigning LuckPerms ranks. Metadata pairs are optional and map to placeholders in your commands.

+
Please add at least one step with a slug and a configured command or rank assignment.
+
@@ -874,6 +876,7 @@ const clearButton = form ? form.querySelector('[data-routine-clear]') : null; const activeLabel = form ? form.querySelector('[data-routine-active-label]') : null; const routinesDataElement = routineBuilder.querySelector('[data-routines-json]'); + const ranksDataElement = routineBuilder.querySelector('[data-ranks-json]'); let routinesData = []; if (routinesDataElement) { @@ -886,6 +889,22 @@ routinesDataElement.remove(); } + let rankDirectory = []; + if (ranksDataElement) { + try { + const parsed = JSON.parse(ranksDataElement.textContent || '[]'); + if (Array.isArray(parsed)) { + rankDirectory = parsed.filter((rank) => rank && rank.rankSlug); + } + } catch (error) { + console.error('Failed to parse ranks JSON', error); + } + + ranksDataElement.remove(); + } + + const CONFIG_KEY = '_routineConfig'; + const DEFAULT_PLAYER_METADATA_KEY = 'player'; const defaultActiveMessage = 'Creating a new routine.'; function updateActiveLabel(routine) { @@ -901,6 +920,62 @@ } } + function ensureRankOption(select, slug, label) { + if (!select || !slug) { + return; + } + + const options = Array.from(select.options || []); + const exists = options.some((option) => option.value === slug); + + if (!exists) { + const option = document.createElement('option'); + option.value = slug; + option.textContent = label || slug; + select.appendChild(option); + } + } + + function renderRankOptions(select, selectedSlug = '') { + if (!select) { + return; + } + + const currentValue = selectedSlug || select.value || ''; + select.innerHTML = ''; + + rankDirectory.forEach((rank) => { + if (!rank || !rank.rankSlug) { + return; + } + + const option = document.createElement('option'); + option.value = rank.rankSlug; + option.textContent = rank.displayName + ? `${rank.displayName} (${rank.rankSlug})` + : rank.rankSlug; + select.appendChild(option); + }); + + if (currentValue) { + ensureRankOption(select, currentValue, currentValue); + select.value = currentValue; + } + } + + function readRoutineConfig(metadata) { + if (!metadata || typeof metadata !== 'object') { + return {}; + } + + const config = metadata[CONFIG_KEY]; + if (!config || typeof config !== 'object') { + return {}; + } + + return { ...config }; + } + function addStep(initialData = {}) { const defaultOrder = stepsContainer.children.length + 1; const step = document.createElement('div'); @@ -911,18 +986,49 @@ -
+
-
+
+ + +
+
+ +
+
+
+
Commands run without a slash. Use placeholders like {{player}} to pull metadata into the command.
-
- +
+
+
+ + +
+
+ + +
+
+ + +
We'll substitute {{key}} with the metadata value when queueing the LuckPerms command.
+
+
@@ -934,16 +1040,34 @@
`; - step.querySelector('[data-remove-step]').addEventListener('click', function () { - step.remove(); - }); + const removeButton = step.querySelector('[data-remove-step]'); + if (removeButton) { + removeButton.addEventListener('click', function () { + step.remove(); + }); + } const orderInput = step.querySelector('[data-step-order]'); const slugField = step.querySelector('[data-step-slug]'); + const typeSelect = step.querySelector('[data-step-type]'); const commandField = step.querySelector('[data-step-command]'); + const commandRow = step.querySelector('[data-step-command-row]'); + const rankRow = step.querySelector('[data-step-rank-row]'); + const rankSelect = step.querySelector('[data-step-rank]'); + const rankActionSelect = step.querySelector('[data-step-rank-action]'); + const playerKeyField = step.querySelector('[data-step-player-key]'); const metadataKeyField = step.querySelector('[data-step-metadata-key]'); const metadataValueField = step.querySelector('[data-step-metadata-value]'); + renderRankOptions(rankSelect); + + const metadataObject = + initialData.metadata && typeof initialData.metadata === 'object' + ? { ...initialData.metadata } + : {}; + const routineConfig = readRoutineConfig(metadataObject); + const initialType = (initialData.type || routineConfig.type || 'command').toLowerCase(); + if (orderInput) { const orderNumber = Number(initialData.order); orderInput.value = !Number.isNaN(orderNumber) ? orderNumber : defaultOrder; @@ -953,23 +1077,118 @@ slugField.value = initialData.slug; } - if (commandField && initialData.command) { - commandField.value = (initialData.command || '').replace(/^\/+/, ''); + if (typeSelect) { + typeSelect.value = ['rank', 'command'].includes(initialType) + ? initialType + : 'command'; } - if (initialData.metadata && typeof initialData.metadata === 'object') { - const metadataEntries = Object.entries(initialData.metadata); - if (metadataEntries.length) { - const [metadataKey, metadataValue] = metadataEntries[0]; - if (metadataKeyField) { - metadataKeyField.value = metadataKey; + if (commandField) { + const commandValue = typeof initialData.command === 'string' + ? initialData.command.replace(/^\/+/, '') + : ''; + if (commandValue) { + commandField.value = commandValue; + } + } + + if (rankSelect && routineConfig.rankSlug) { + ensureRankOption(rankSelect, routineConfig.rankSlug, routineConfig.rankSlug); + rankSelect.value = routineConfig.rankSlug; + } + + if (rankActionSelect && routineConfig.rankAction) { + rankActionSelect.value = routineConfig.rankAction; + } + + if (playerKeyField) { + playerKeyField.value = + routineConfig.playerMetadataKey || DEFAULT_PLAYER_METADATA_KEY; + } + + const metadataEntries = Object.entries(metadataObject).filter( + ([key]) => key !== CONFIG_KEY + ); + + if (metadataEntries.length) { + const [metadataKey, metadataValue] = metadataEntries[0]; + if (metadataKeyField) { + metadataKeyField.value = metadataKey; + } + if (metadataValueField) { + metadataValueField.value = + metadataValue !== undefined && metadataValue !== null + ? String(metadataValue) + : ''; + } + } + + function updateTypeFields() { + const typeValue = typeSelect ? typeSelect.value : 'command'; + const isRank = typeValue === 'rank'; + + if (commandRow) { + commandRow.classList.toggle('d-none', isRank); + } + + if (commandField) { + if (isRank) { + commandField.setAttribute('disabled', 'disabled'); + commandField.removeAttribute('required'); + commandField.classList.remove('is-invalid'); + } else { + commandField.removeAttribute('disabled'); + commandField.setAttribute('required', 'required'); + } + } + + if (rankRow) { + rankRow.classList.toggle('d-none', !isRank); + } + + if (rankSelect) { + if (isRank) { + rankSelect.removeAttribute('disabled'); + rankSelect.setAttribute('required', 'required'); + } else { + rankSelect.setAttribute('disabled', 'disabled'); + rankSelect.removeAttribute('required'); + rankSelect.classList.remove('is-invalid'); + } + } + + if (playerKeyField) { + if (isRank && !playerKeyField.value.trim()) { + playerKeyField.value = DEFAULT_PLAYER_METADATA_KEY; } - if (metadataValueField) { - metadataValueField.value = metadataValue; + if (isRank) { + playerKeyField.removeAttribute('disabled'); + } else { + playerKeyField.setAttribute('disabled', 'disabled'); + playerKeyField.classList.remove('is-invalid'); } } + + step.dataset.stepType = typeValue; + } + + if (typeSelect) { + typeSelect.addEventListener('change', updateTypeFields); } + updateTypeFields(); + + [slugField, commandField, rankSelect, playerKeyField, metadataKeyField, metadataValueField] + .filter(Boolean) + .forEach((field) => { + field.addEventListener('input', () => { + field.classList.remove('is-invalid'); + }); + field.addEventListener('change', () => { + field.classList.remove('is-invalid'); + }); + }); + stepsContainer.appendChild(step); } @@ -1057,38 +1276,102 @@ const orderField = stepElement.querySelector('[data-step-order]'); const metadataKeyField = stepElement.querySelector('[data-step-metadata-key]'); const metadataValueField = stepElement.querySelector('[data-step-metadata-value]'); + const typeSelect = stepElement.querySelector('[data-step-type]'); + const rankSelect = stepElement.querySelector('[data-step-rank]'); + const rankActionSelect = stepElement.querySelector('[data-step-rank-action]'); + const playerKeyField = stepElement.querySelector('[data-step-player-key]'); + const stepType = typeSelect ? typeSelect.value : 'command'; const slug = slugField ? slugField.value.trim() : ''; - const command = commandField - ? commandField.value.trim().replace(/^\/+/, '') - : ''; - if (commandField) { - commandField.value = command; - } const orderRaw = orderField ? orderField.value.trim() : ''; const metadataKey = metadataKeyField ? metadataKeyField.value.trim() : ''; const metadataValue = metadataValueField ? metadataValueField.value.trim() : ''; - if (!slug || !command) { - hasError = true; - stepElement.classList.add('border-danger'); - return; - } + let stepHasError = false; - stepElement.classList.remove('border-danger'); + [slugField, commandField, rankSelect, playerKeyField] + .filter(Boolean) + .forEach((field) => field.classList.remove('is-invalid')); - const stepData = { - slug, - command, - }; + if (!slug) { + stepHasError = true; + if (slugField) { + slugField.classList.add('is-invalid'); + } + } + const stepData = { slug }; const orderNumber = Number(orderRaw); if (!Number.isNaN(orderNumber)) { stepData.order = orderNumber; } + const metadata = {}; if (metadataKey && metadataValue) { - stepData.metadata = { [metadataKey]: metadataValue }; + metadata[metadataKey] = metadataValue; + } + + if (stepType === 'rank') { + const rankSlugValue = rankSelect ? rankSelect.value.trim() : ''; + const rankAction = rankActionSelect ? rankActionSelect.value : 'assign'; + const playerKeyRaw = playerKeyField ? playerKeyField.value.trim() : ''; + const playerKey = playerKeyRaw || DEFAULT_PLAYER_METADATA_KEY; + + if (!rankSlugValue) { + stepHasError = true; + if (rankSelect) { + rankSelect.classList.add('is-invalid'); + } + } + + if (playerKeyField && !playerKeyRaw) { + playerKeyField.value = playerKey; + } + + const commandTemplate = rankSlugValue + ? rankAction === 'remove' + ? `lp user {{${playerKey}}} parent remove ${rankSlugValue}` + : `lp user {{${playerKey}}} parent add ${rankSlugValue}` + : ''; + + stepData.command = commandTemplate; + + metadata[CONFIG_KEY] = { + type: 'rank', + rankSlug: rankSlugValue, + rankAction, + playerMetadataKey: playerKey, + }; + } else { + const commandValue = commandField + ? commandField.value.trim().replace(/^\/+/, '') + : ''; + + if (commandField) { + commandField.value = commandValue; + } + + if (!commandValue) { + stepHasError = true; + if (commandField) { + commandField.classList.add('is-invalid'); + } + } + + stepData.command = commandValue; + metadata[CONFIG_KEY] = { type: 'command' }; + } + + if (stepHasError || !stepData.command) { + hasError = true; + stepElement.classList.add('border-danger'); + return; + } + + stepElement.classList.remove('border-danger'); + + if (Object.keys(metadata).length) { + stepData.metadata = metadata; } steps.push(stepData); From 8e9abfbc2f0bac233cb48e9aa67e6a9c1a6d2d90 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Tue, 14 Oct 2025 23:03:53 +1100 Subject: [PATCH 08/10] Integrate Tebex webhooks with bridge tasks --- README.md | 8 +- api/routes/bridge.js | 14 +- api/routes/index.js | 14 +- api/routes/tebex.js | 646 +++++++++++++++++++++++++++++++++++++++++++ tebex.json | 41 +++ 5 files changed, 709 insertions(+), 14 deletions(-) create mode 100644 api/routes/tebex.js create mode 100644 tebex.json diff --git a/README.md b/README.md index 689112d2..1677e441 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,10 @@ # zander-web The web component of the Zander project that contains database, API and website. -Documentation: [https://modularsoft.org/docs/products/zander](https://modularsoft.org/docs/products/zander) \ No newline at end of file +Documentation: [https://modularsoft.org/docs/products/zander](https://modularsoft.org/docs/products/zander) + +## Tebex bridge integration + +The `/api/tebex/webhook` endpoint accepts Tebex purchase webhooks and queues bridge tasks for any packages configured in `tebex.json`. Update the mapping file with your package IDs, the commands or rank assignments to trigger, and (optionally) override the target slug or priority per action. + +Secure the webhook by setting the `TEBEX_WEBHOOK_SECRET` environment variable (or the legacy `tebexWebhookSecret`). Requests must include the matching token via an `Authorization`, `X-Tebex-Secret`, or `X-Webhook-Secret` header. diff --git a/api/routes/bridge.js b/api/routes/bridge.js index eb51494c..b4401d33 100644 --- a/api/routes/bridge.js +++ b/api/routes/bridge.js @@ -9,7 +9,7 @@ const VALID_STATUSES = ["pending", "processing", "completed", "failed"]; const ROUTINE_CONFIG_KEY = "_routineConfig"; const DEFAULT_PLAYER_METADATA_KEY = "player"; -function normalizeCommand(command) { +export function normalizeCommand(command) { if (typeof command !== "string") { return ""; } @@ -17,7 +17,7 @@ function normalizeCommand(command) { return command.trim().replace(/^\/+/, "").trim(); } -function toMetadataObject(value) { +export function toMetadataObject(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } @@ -25,7 +25,7 @@ function toMetadataObject(value) { return value; } -function mergeMetadata(...sources) { +export function mergeMetadata(...sources) { const merged = {}; let hasEntries = false; @@ -48,7 +48,7 @@ function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function applyMetadataPlaceholders(command, ...metadataSources) { +export function applyMetadataPlaceholders(command, ...metadataSources) { let resolved = typeof command === "string" ? command : ""; const mergedMetadata = mergeMetadata(...metadataSources); @@ -112,7 +112,7 @@ function setRoutineConfig(metadata, updates = {}, options = {}) { metadata[ROUTINE_CONFIG_KEY] = { ...baseConfig, ...updates }; } -function normalizeRankAction(action) { +export function normalizeRankAction(action) { if (typeof action !== "string") { return "assign"; } @@ -120,7 +120,7 @@ function normalizeRankAction(action) { return action.toLowerCase() === "remove" ? "remove" : "assign"; } -function normalizePlayerMetadataKey(value) { +export function normalizePlayerMetadataKey(value) { if (typeof value !== "string") { return DEFAULT_PLAYER_METADATA_KEY; } @@ -129,7 +129,7 @@ function normalizePlayerMetadataKey(value) { return trimmed || DEFAULT_PLAYER_METADATA_KEY; } -function buildRankCommand(rankSlug, rankAction, playerMetadataKey) { +export function buildRankCommand(rankSlug, rankAction, playerMetadataKey) { if (!rankSlug) { return ""; } diff --git a/api/routes/index.js b/api/routes/index.js index f310ea25..3c737af1 100644 --- a/api/routes/index.js +++ b/api/routes/index.js @@ -8,9 +8,10 @@ import webApiRoute from "./web.js"; import filterApiRoute from "./filter.js"; import rankApiRoute from "./ranks.js"; import reportApiRoute from "./report.js"; -import shopApiRoute from "./shopdirectory.js"; -import vaultApiRoute from "./vault.js"; -import bridgeApiRoute from "./bridge.js"; +import shopApiRoute from "./shopdirectory.js"; +import vaultApiRoute from "./vault.js"; +import bridgeApiRoute from "./bridge.js"; +import tebexApiRoute from "./tebex.js"; export default (app, client, moment, config, db, features, lang) => { announcementApiRoute(app, config, db, features, lang); @@ -23,9 +24,10 @@ export default (app, client, moment, config, db, features, lang) => { webApiRoute(app, config, db, features, lang); rankApiRoute(app, config, db, features, lang); filterApiRoute(app, client, config, db, features, lang); - shopApiRoute(app, config, db, features, lang); - vaultApiRoute(app, config, db, features, lang); - bridgeApiRoute(app, config, db, features, lang); + shopApiRoute(app, config, db, features, lang); + vaultApiRoute(app, config, db, features, lang); + bridgeApiRoute(app, config, db, features, lang); + tebexApiRoute(app, config, db, features, lang); app.get("/api/heartbeat", async function (req, res) { return res.send({ diff --git a/api/routes/tebex.js b/api/routes/tebex.js new file mode 100644 index 00000000..0b40c085 --- /dev/null +++ b/api/routes/tebex.js @@ -0,0 +1,646 @@ +import { createRequire } from "module"; +import { + normalizeCommand, + toMetadataObject, + mergeMetadata, + applyMetadataPlaceholders, + normalizeRankAction, + normalizePlayerMetadataKey, + buildRankCommand, +} from "./bridge.js"; +import { isFeatureEnabled } from "../common.js"; + +const require = createRequire(import.meta.url); + +const TASK_TABLE = "executorTasks"; +const DEFAULT_PRIORITY = 0; +const SECRET_HEADER_CANDIDATES = [ + "x-tebex-secret", + "x-webhook-secret", + "x-authorization", + "authorization", +]; + +function loadConfig() { + try { + const config = require("../../tebex.json"); + if (!config || typeof config !== "object") { + return { packages: [] }; + } + return config; + } catch (error) { + console.warn("[tebex] Unable to load tebex.json configuration", error); + return { packages: [] }; + } +} + +const tebexConfig = loadConfig(); + +function coerceString(value) { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + + return null; +} + +function buildPackageIndex(config) { + const packages = Array.isArray(config?.packages) ? config.packages : []; + const idMap = new Map(); + const nameMap = new Map(); + + const recordId = (id, entry) => { + const normalized = coerceString(id); + if (!normalized || idMap.has(normalized)) { + return; + } + + idMap.set(normalized, entry); + }; + + const recordName = (name, entry) => { + const normalized = coerceString(name)?.toLowerCase(); + if (!normalized || nameMap.has(normalized)) { + return; + } + + nameMap.set(normalized, entry); + }; + + packages.forEach((entry) => { + if (!entry || typeof entry !== "object") { + return; + } + + recordId(entry.packageId ?? entry.id, entry); + + if (Array.isArray(entry.packageIds)) { + entry.packageIds.forEach((id) => recordId(id, entry)); + } + + if (Array.isArray(entry.aliases)) { + entry.aliases.forEach((alias) => { + if (typeof alias === "number" || (typeof alias === "string" && /\d/.test(alias))) { + recordId(alias, entry); + } + recordName(alias, entry); + }); + } + + recordName(entry.displayName ?? entry.packageName ?? entry.slug, entry); + }); + + return { packages, idMap, nameMap }; +} + +const packageIndex = buildPackageIndex(tebexConfig); +const defaultTargetSlug = coerceString(tebexConfig?.defaultTargetSlug); + +function findPackageConfig(packagePayload) { + if (!packagePayload || typeof packagePayload !== "object") { + return null; + } + + const idCandidates = [ + packagePayload.packageId, + packagePayload.id, + packagePayload.package_id, + packagePayload.product_id, + packagePayload.option_id, + packagePayload.packageID, + ]; + + for (const id of idCandidates) { + const normalized = coerceString(id); + if (normalized && packageIndex.idMap.has(normalized)) { + return packageIndex.idMap.get(normalized); + } + } + + const nameCandidates = [ + packagePayload.name, + packagePayload.packageName, + packagePayload.package_name, + packagePayload.product_name, + packagePayload.displayName, + ]; + + for (const name of nameCandidates) { + const normalized = coerceString(name)?.toLowerCase(); + if (normalized && packageIndex.nameMap.has(normalized)) { + return packageIndex.nameMap.get(normalized); + } + } + + return null; +} + +function collectPlayerSources(payload) { + const sources = []; + const pushCandidate = (candidate) => { + if (!candidate) { + return; + } + + if (typeof candidate === "string") { + sources.push({ username: candidate }); + return; + } + + if (typeof candidate !== "object") { + return; + } + + sources.push(candidate); + + if (candidate.meta && typeof candidate.meta === "object") { + sources.push(candidate.meta); + } + + if (candidate.data && typeof candidate.data === "object") { + sources.push(candidate.data); + } + + if (candidate.account && typeof candidate.account === "object") { + sources.push(candidate.account); + } + }; + + pushCandidate(payload.player); + pushCandidate(payload.customer); + pushCandidate(payload.user); + pushCandidate(payload.buyer); + pushCandidate(payload.checkout); + pushCandidate(payload.meta); + pushCandidate(payload.player?.meta); + pushCandidate(payload.customer?.meta); + pushCandidate(payload.player?.data); + pushCandidate(payload.customer?.data); + + const inlineUsername = coerceString(payload.username ?? payload.playerName ?? payload.ign); + if (inlineUsername) { + sources.push({ username: inlineUsername }); + } + + const inlineUuid = coerceString(payload.uuid ?? payload.playerUuid ?? payload.player_uuid); + if (inlineUuid) { + sources.push({ uuid: inlineUuid }); + } + + return sources; +} + +function pickFromSources(sources, keys) { + for (const source of sources) { + for (const key of keys) { + if (!source || typeof source !== "object") { + continue; + } + + const value = source[key]; + const normalized = coerceString(value); + if (normalized) { + return normalized; + } + } + } + + return null; +} + +function resolvePlayer(payload) { + const sources = collectPlayerSources(payload); + const username = pickFromSources(sources, [ + "username", + "ign", + "player", + "name", + "nickname", + "playerName", + "minecraftUsername", + "inGameName", + ]); + + const uuid = pickFromSources(sources, [ + "uuid", + "playerUuid", + "player_uuid", + "playerUuidFormatted", + "id", + "playerId", + ]); + + const accountId = pickFromSources(sources, [ + "accountId", + "account_id", + "userId", + "user_id", + "customerId", + "customer_id", + ]); + + const email = pickFromSources(sources, ["email", "mail"]); + + return { + username, + uuid, + accountId, + email, + }; +} + +function buildPurchaseMetadata(payload, playerInfo) { + const metadata = {}; + + if (playerInfo.username) { + metadata.player = playerInfo.username; + } + + if (playerInfo.uuid) { + metadata.playerUuid = playerInfo.uuid; + } + + if (playerInfo.accountId) { + metadata.playerAccountId = playerInfo.accountId; + } + + if (playerInfo.email) { + metadata.playerEmail = playerInfo.email; + } + + const purchaseId = coerceString( + payload.transactionId ?? + payload.id ?? + payload.payment_id ?? + payload.purchase_id ?? + payload.reference ?? + payload.transaction + ); + + if (purchaseId) { + metadata.tebexPurchaseId = purchaseId; + } + + const currency = coerceString( + payload.currency ?? + payload.currencyIso ?? + payload.currencyCode ?? + payload.currency_iso ?? + payload.currency_code + ); + + if (currency) { + metadata.tebexCurrency = currency; + } + + return Object.keys(metadata).length ? metadata : null; +} + +function buildPackageMetadata(packagePayload) { + const metadata = {}; + + const packageId = coerceString( + packagePayload.id ?? + packagePayload.packageId ?? + packagePayload.package_id ?? + packagePayload.product_id + ); + + if (packageId) { + metadata.tebexPackageId = packageId; + } + + const packageName = coerceString( + packagePayload.name ?? + packagePayload.packageName ?? + packagePayload.package_name ?? + packagePayload.displayName + ); + + if (packageName) { + metadata.tebexPackageName = packageName; + } + + const variantId = coerceString(packagePayload.variant_id ?? packagePayload.variantId); + if (variantId) { + metadata.tebexVariantId = variantId; + } + + const expiry = + packagePayload.expiry ?? + packagePayload.expires_at ?? + packagePayload.expiry_date ?? + packagePayload.expireDate; + + if (expiry) { + metadata.tebexPackageExpiry = expiry; + } + + const price = packagePayload.price ?? packagePayload.cost ?? packagePayload.amount; + if (price !== null && price !== undefined) { + metadata.tebexPackagePrice = price; + } + + return Object.keys(metadata).length ? metadata : null; +} + +function resolveTargetSlug(action, packageConfig) { + const candidates = [ + action?.target, + action?.slug, + action?.targetSlug, + packageConfig?.target, + packageConfig?.targetSlug, + packageConfig?.slug, + defaultTargetSlug, + ]; + + for (const candidate of candidates) { + const normalized = coerceString(candidate); + if (normalized) { + return normalized; + } + } + + return null; +} + +function resolvePriority(action, packageConfig) { + const priorityCandidates = [action?.priority, packageConfig?.priority]; + for (const candidate of priorityCandidates) { + if (candidate === null || candidate === undefined) { + continue; + } + + const value = Number(candidate); + if (!Number.isNaN(value)) { + return value; + } + } + + return DEFAULT_PRIORITY; +} + +function resolveActionType(action) { + const typeValue = coerceString(action?.type)?.toLowerCase(); + if (typeValue === "rank") { + return "rank"; + } + + if (typeValue === "command") { + return "command"; + } + + if (coerceString(action?.rankSlug)) { + return "rank"; + } + + return "command"; +} + +function buildActionCommand(action, packageConfig) { + const actionType = resolveActionType(action); + + if (actionType === "rank") { + const rankSlug = coerceString(action?.rankSlug ?? packageConfig?.rankSlug); + if (!rankSlug) { + throw new Error(`Rank actions require a rankSlug in the Tebex package config`); + } + + const rankAction = normalizeRankAction(action?.rankAction ?? packageConfig?.rankAction); + const playerKey = normalizePlayerMetadataKey( + action?.playerMetadataKey ?? packageConfig?.playerMetadataKey + ); + + return buildRankCommand(rankSlug, rankAction, playerKey); + } + + const template = coerceString(action?.command ?? packageConfig?.command); + if (!template) { + throw new Error(`Command actions require a command string in the Tebex package config`); + } + + return template; +} + +function normalizeActions(packageConfig) { + if (Array.isArray(packageConfig?.actions) && packageConfig.actions.length > 0) { + return packageConfig.actions; + } + + const fallback = {}; + if (packageConfig.rankSlug) { + fallback.type = "rank"; + } else if (packageConfig.command) { + fallback.type = "command"; + } + + if (!fallback.type) { + return []; + } + + return [fallback]; +} + +function getSecretFromHeaders(req) { + for (const header of SECRET_HEADER_CANDIDATES) { + const value = req.headers?.[header]; + if (!value) { + continue; + } + + if (Array.isArray(value)) { + if (value.length) { + return coerceString(value[0]); + } + continue; + } + + const normalized = coerceString(value); + if (!normalized) { + continue; + } + + if (normalized.toLowerCase().startsWith("bearer ")) { + return coerceString(normalized.slice(7)); + } + + return normalized; + } + + return null; +} + +export default function tebexApiRoute(app, config, db, features, lang) { + const query = (sql, params = []) => { + return new Promise((resolve, reject) => { + db.query(sql, params, (error, results) => { + if (error) { + reject(error); + } else { + resolve(results); + } + }); + }); + }; + + app.post("/api/tebex/webhook", async function (req, res) { + isFeatureEnabled(features.bridge, res, lang); + + if (!features?.bridge) { + return; + } + + if (!tebexConfig || !Array.isArray(packageIndex.packages)) { + return res.send({ + success: false, + message: `Tebex configuration is not available`, + }); + } + + const expectedSecret = coerceString( + process.env.TEBEX_WEBHOOK_SECRET ?? process.env.tebexWebhookSecret + ); + if (expectedSecret) { + const provided = getSecretFromHeaders(req); + if (!provided || provided !== expectedSecret) { + return res.status(401).send({ + success: false, + message: `Invalid Tebex webhook secret`, + }); + } + } + + if (!req.body || typeof req.body !== "object") { + return res.status(400).send({ + success: false, + message: `Webhook payload must be a JSON object`, + }); + } + + const packages = Array.isArray(req.body.packages) + ? req.body.packages + : req.body.package + ? [req.body.package] + : []; + + if (!packages.length) { + return res.send({ + success: true, + message: `No packages included in Tebex payload`, + data: { + queuedTasks: 0, + unmatchedPackages: [], + }, + }); + } + + const playerInfo = resolvePlayer(req.body); + if (!playerInfo.username) { + return res.status(400).send({ + success: false, + message: `Unable to resolve purchaser username from Tebex payload`, + }); + } + + const purchaseMetadata = buildPurchaseMetadata(req.body, playerInfo); + const unmatchedPackages = []; + const queuedTaskIds = []; + + try { + for (const packagePayload of packages) { + const packageConfig = findPackageConfig(packagePayload); + if (!packageConfig) { + unmatchedPackages.push({ + packageId: + coerceString(packagePayload?.id ?? packagePayload?.packageId ?? packagePayload?.package_id) || + null, + packageName: + coerceString( + packagePayload?.name ?? + packagePayload?.packageName ?? + packagePayload?.package_name ?? + packagePayload?.displayName + ) || null, + }); + continue; + } + + const packageMetadata = buildPackageMetadata(packagePayload); + const actions = normalizeActions(packageConfig); + + if (!actions.length) { + continue; + } + + for (const action of actions) { + const targetSlug = resolveTargetSlug(action, packageConfig); + if (!targetSlug) { + throw new Error( + `Unable to determine a target slug for Tebex package '${ + packageConfig.displayName || packageConfig.packageName || packageConfig.packageId + }'` + ); + } + + const metadata = mergeMetadata( + purchaseMetadata, + packageMetadata, + toMetadataObject(packageConfig.metadata), + toMetadataObject(action.metadata) + ); + + const commandTemplate = buildActionCommand(action, packageConfig); + + const resolvedCommand = normalizeCommand( + applyMetadataPlaceholders( + commandTemplate, + purchaseMetadata, + packageMetadata, + packageConfig.metadata, + action.metadata + ) + ); + + if (!resolvedCommand) { + throw new Error(`Resolved command text is empty for Tebex package action`); + } + + const priority = resolvePriority(action, packageConfig); + + const result = await query( + `INSERT INTO ${TASK_TABLE} (slug, command, status, routineSlug, metadata, priority) VALUES (?, ?, 'pending', NULL, ?, ?)`, + [targetSlug, resolvedCommand, metadata ? JSON.stringify(metadata) : null, priority] + ); + + queuedTaskIds.push(result.insertId); + } + } + } catch (error) { + return res.status(500).send({ + success: false, + message: `${error}`, + }); + } + + return res.send({ + success: true, + message: `Queued ${queuedTaskIds.length} bridge task${queuedTaskIds.length === 1 ? "" : "s"} from Tebex webhook`, + data: { + queuedTasks: queuedTaskIds.length, + taskIds: queuedTaskIds, + unmatchedPackages, + player: playerInfo, + }, + }); + }); +} diff --git a/tebex.json b/tebex.json new file mode 100644 index 00000000..bf5afa88 --- /dev/null +++ b/tebex.json @@ -0,0 +1,41 @@ +{ + "defaultTargetSlug": "proxy", + "packages": [ + { + "packageId": 4028837, + "displayName": "Diamond Subscription", + "priority": 5, + "metadata": { + "tebexGrant": "subscription" + }, + "actions": [ + { + "type": "rank", + "rankSlug": "diamond", + "rankAction": "assign", + "metadata": { + "tebexPackageType": "subscription" + } + } + ] + }, + { + "packageId": 4028840, + "displayName": "Diamond Permanent", + "priority": 5, + "metadata": { + "tebexGrant": "permanent" + }, + "actions": [ + { + "type": "rank", + "rankSlug": "diamond", + "rankAction": "assign", + "metadata": { + "tebexPackageType": "permanent" + } + } + ] + } + ] +} From 3d48c3c7bf119d2eb0bb5967158eaa2d3193793e Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Tue, 14 Oct 2025 23:16:15 +1100 Subject: [PATCH 09/10] Refine bridge routine builder layout --- assets/css/dashboard-style.css | 114 +++++++++++++++++++++++++ views/dashboard/bridge.ejs | 148 ++++++++++++++++++++------------- 2 files changed, 203 insertions(+), 59 deletions(-) diff --git a/assets/css/dashboard-style.css b/assets/css/dashboard-style.css index 62ca480c..f3dd9b44 100644 --- a/assets/css/dashboard-style.css +++ b/assets/css/dashboard-style.css @@ -294,6 +294,120 @@ span.badge { .modal-content { border-radius: 3px; } + +.bridge-routine-modal .modal-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.bridge-routine-modal .modal-body { + overflow-y: auto; + padding-bottom: 2rem; +} + +.routine-builder { + background: #f8f9fc; + border: 1px solid #e3e7f1; + border-radius: 1rem; + padding: 1.5rem; +} + +@media (min-width: 992px) { + .routine-builder { + padding: 2rem; + } +} + +.routine-builder-toolbar h6 { + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + font-size: 0.75rem; + color: #5a5f73; +} + +.routine-steps { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.routine-step { + border: 1px solid #e3e7f1; + border-radius: 1rem; +} + +.routine-step .card-body { + padding: 1.5rem; +} + +@media (min-width: 992px) { + .routine-step .card-body { + padding: 2rem; + } +} + +.routine-step-summary { + border-bottom: 1px solid #edf0f7; + padding-bottom: 1.5rem; +} + +.routine-step-actions { + min-width: 140px; + flex-shrink: 0; +} + +@media (max-width: 575.98px) { + .routine-step-summary { + border-bottom: none; + padding-bottom: 0; + } + + .routine-step-actions { + width: 100%; + justify-content: flex-end; + } + + .routine-step-actions .btn { + width: 100%; + } + + .routine-step-section { + margin-top: 1.25rem; + } +} + +.routine-step-badge { + border-radius: 999px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 0.7rem; + padding: 0.45rem 0.85rem; +} + +.routine-step-section { + background: #fbfcff; + border: 1px solid #edf0f7; + border-radius: 0.9rem; + padding: 1rem; +} + +@media (min-width: 768px) { + .routine-step-section { + padding: 1.25rem; + } +} + +.routine-step-section .form-text { + color: #6c7288; +} + +.routine-step-error { + border-color: #dc3545 !important; + box-shadow: 0 0 0 0.15rem rgba(220, 53, 69, 0.1); +} .timeline { list-style: none; padding: 0 0 8px; diff --git a/views/dashboard/bridge.ejs b/views/dashboard/bridge.ejs index 055ec85b..937e9abd 100644 --- a/views/dashboard/bridge.ejs +++ b/views/dashboard/bridge.ejs @@ -510,7 +510,7 @@