diff --git a/.env.example b/.env.example
index 2d3b82e..b2f32e3 100644
--- a/.env.example
+++ b/.env.example
@@ -46,6 +46,8 @@ AUTO_START_SESSIONS=TRUE
SESSIONS_PATH=./sessions
# OPTIONAL, ENABLE SWAGGER ENDPOINT FOR API DOCUMENTATION
ENABLE_SWAGGER_ENDPOINT=TRUE
+# OPTIONAL, ENABLE SIMPLE WEB UI FOR SESSION MANAGEMENT AT /dashboard (FALSE BY DEFAULT)
+ENABLE_WEB_UI=FALSE
## Reverse Proxy / Load Balancer ##
# OPTIONAL, BASE PATH FOR MOUNTING ROUTES (e.g., /api/v1/whatsapp)
diff --git a/Dockerfile b/Dockerfile
index ef2d74a..7bfdc66 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -44,6 +44,7 @@ COPY server.js ./
COPY LICENSE ./
COPY swagger.json ./
COPY src/ ./src/
+COPY public/ ./public/
EXPOSE 3000
diff --git a/README.md b/README.md
index dfa1e3e..a197785 100644
--- a/README.md
+++ b/README.md
@@ -159,6 +159,10 @@ By setting the `DISABLED_CALLBACKS` environment variable you can specify what ev
By setting the `ENABLE_WEBHOOK` environment to `FALSE` you can disable webhook dispatching. This will help you if you want to switch to websocket method(see below).
+### Web dashboard
+
+The service ships with a simple web UI for session management. It is disabled by default — set the `ENABLE_WEB_UI` environment variable to `TRUE`, restart the service and go to the `/dashboard` endpoint. The dashboard lets you start, stop, restart and terminate sessions, scan the pairing QR code (or request a pairing code for a phone number) and inspect connected session details. If the `API_KEY` environment variable is set, enter the key in the dashboard header — it is stored in your browser's localStorage and sent as the `x-api-key` header with every request.
+
### Scanning QR code
In order to validate a new WhatsApp Web instance you need to scan the QR code using your mobile phone. Official documentation can be found at (https://faq.whatsapp.com/1079327266110265/?cms_platform=android) page. The service itself delivers the QR code content as a webhook event or you can use the REST endpoints (`/session/qr/:sessionId` or `/session/qr/:sessionId/image` to get the QR code as a png image).
diff --git a/public/dashboard/app.js b/public/dashboard/app.js
new file mode 100644
index 0000000..af70b22
--- /dev/null
+++ b/public/dashboard/app.js
@@ -0,0 +1,397 @@
+(function () {
+ const API_BASE = window.location.pathname.slice(0, window.location.pathname.lastIndexOf('/dashboard'))
+ const STORAGE_KEY = 'wwebjsApiKey'
+ // must match sessionNameValidation in src/middleware.js
+ const SESSION_ID_PATTERN = /^[\w-]+$/
+ const POLL_INTERVAL_MS = 5000
+ const QR_POLL_INTERVAL_MS = 3000
+
+ const $ = (id) => document.getElementById(id)
+
+ let pollTimer = null
+ let qrTimer = null
+ let qrSessionId = null
+ let qrObjectUrl = null
+ let authFailed = false
+
+ async function apiFetch (path, options = {}) {
+ const headers = Object.assign({}, options.headers)
+ const apiKey = window.localStorage.getItem(STORAGE_KEY)
+ if (apiKey) {
+ headers['x-api-key'] = apiKey
+ }
+ const response = await window.fetch(API_BASE + path, Object.assign({}, options, { headers, cache: 'no-store' }))
+ if (response.status === 403) {
+ authFailed = true
+ $('apiKeyBanner').classList.remove('hidden')
+ throw new Error('Invalid or missing API key')
+ }
+ return response
+ }
+
+ async function apiJson (path, options) {
+ const response = await apiFetch(path, options)
+ return response.json()
+ }
+
+ function showToast (message, isError) {
+ const toast = document.createElement('div')
+ toast.className = 'toast' + (isError ? ' toast-error' : '')
+ toast.textContent = message
+ $('toasts').appendChild(toast)
+ setTimeout(() => toast.remove(), 4000)
+ }
+
+ /*
+ * Sessions list
+ */
+
+ async function refreshSessions () {
+ const body = await apiJson('/session/getSessions')
+ if (!body.success) {
+ throw new Error(body.error || 'Failed to fetch sessions')
+ }
+ const ids = body.result.sort()
+ const statuses = await Promise.all(
+ ids.map((id) => apiJson('/session/status/' + encodeURIComponent(id)).catch(() => null))
+ )
+ renderSessions(ids, statuses)
+ }
+
+ function renderSessions (ids, statuses) {
+ const tbody = $('sessionsBody')
+ tbody.textContent = ''
+ $('emptyState').classList.toggle('hidden', ids.length > 0)
+ ids.forEach((id, index) => {
+ const status = statuses[index]
+ const connected = Boolean(status && status.success && status.state === 'CONNECTED')
+ const row = document.createElement('tr')
+
+ const idCell = document.createElement('td')
+ idCell.className = 'session-id'
+ idCell.textContent = id
+ row.appendChild(idCell)
+
+ const statusCell = document.createElement('td')
+ const badge = document.createElement('span')
+ badge.className = 'badge ' + (connected ? 'badge-connected' : 'badge-pending')
+ badge.textContent = (status && status.state) || 'STARTING'
+ statusCell.appendChild(badge)
+ row.appendChild(statusCell)
+
+ const actionsCell = document.createElement('td')
+ actionsCell.className = 'actions-cell'
+ const actions = document.createElement('div')
+ actions.className = 'actions'
+ if (connected) {
+ actions.appendChild(actionButton('Details', () => openDetailsModal(id)))
+ } else {
+ actions.appendChild(actionButton('Show QR', () => openQrModal(id)))
+ }
+ actions.appendChild(actionButton('Restart', () => sessionAction(id, 'restart')))
+ actions.appendChild(actionButton('Stop', () => sessionAction(id, 'stop')))
+ actions.appendChild(actionButton('Terminate', () => sessionAction(id, 'terminate', 'Terminate session "' + id + '"? This logs out the linked device.'), 'danger'))
+ actionsCell.appendChild(actions)
+ row.appendChild(actionsCell)
+
+ tbody.appendChild(row)
+ })
+ }
+
+ function actionButton (label, onClick, extraClass) {
+ const button = document.createElement('button')
+ button.type = 'button'
+ button.className = 'secondary' + (extraClass ? ' ' + extraClass : '')
+ button.textContent = label
+ button.addEventListener('click', onClick)
+ return button
+ }
+
+ async function sessionAction (id, action, confirmMessage) {
+ if (confirmMessage && !window.confirm(confirmMessage)) {
+ return
+ }
+ try {
+ const body = await apiJson('/session/' + action + '/' + encodeURIComponent(id))
+ if (body.success) {
+ showToast(id + ': ' + (body.message || action + ' completed'))
+ } else {
+ showToast(id + ': ' + (body.error || body.message || action + ' failed'), true)
+ }
+ } catch (error) {
+ showToast(error.message, true)
+ }
+ refreshNow()
+ }
+
+ async function globalAction (path, confirmMessage) {
+ if (confirmMessage && !window.confirm(confirmMessage)) {
+ return
+ }
+ try {
+ const body = await apiJson(path)
+ showToast(body.message || body.error || 'Done', !body.success)
+ } catch (error) {
+ showToast(error.message, true)
+ }
+ refreshNow()
+ }
+
+ /*
+ * Polling
+ */
+
+ function refreshNow () {
+ clearTimeout(pollTimer)
+ pollLoop()
+ }
+
+ async function pollLoop () {
+ if (!document.hidden && !authFailed) {
+ try {
+ await refreshSessions()
+ } catch {
+ // errors surface through the API key banner or action toasts
+ }
+ }
+ pollTimer = setTimeout(pollLoop, POLL_INTERVAL_MS)
+ }
+
+ /*
+ * Add session
+ */
+
+ function openStartModal () {
+ $('newSessionId').value = ''
+ $('addSessionError').classList.add('hidden')
+ $('startModal').classList.remove('hidden')
+ $('newSessionId').focus()
+ }
+
+ function closeStartModal () {
+ $('startModal').classList.add('hidden')
+ }
+
+ async function startNewSession (event) {
+ event.preventDefault()
+ const input = $('newSessionId')
+ const errorEl = $('addSessionError')
+ const id = input.value.trim()
+ if (!SESSION_ID_PATTERN.test(id)) {
+ errorEl.textContent = 'Session id should be alphanumerical or -'
+ errorEl.classList.remove('hidden')
+ return
+ }
+ errorEl.classList.add('hidden')
+ const button = $('startSubmitBtn')
+ button.disabled = true
+ button.textContent = 'Starting…'
+ try {
+ const body = await apiJson('/session/start/' + encodeURIComponent(id))
+ if (body.success) {
+ showToast(id + ': ' + body.message)
+ closeStartModal()
+ openQrModal(id)
+ } else {
+ showToast(id + ': ' + (body.error || body.message), true)
+ }
+ } catch (error) {
+ showToast(error.message, true)
+ }
+ button.disabled = false
+ button.textContent = 'Start'
+ refreshNow()
+ }
+
+ /*
+ * QR modal
+ */
+
+ function openQrModal (id) {
+ clearTimeout(qrTimer)
+ qrSessionId = id
+ $('qrSessionName').textContent = id
+ $('qrImage').classList.add('hidden')
+ $('qrConnected').classList.add('hidden')
+ $('qrWaiting').textContent = 'Waiting for QR code…'
+ $('qrWaiting').classList.remove('hidden')
+ $('pairingCode').classList.add('hidden')
+ $('pairingPhone').value = ''
+ $('qrModal').classList.remove('hidden')
+ qrTick()
+ }
+
+ async function qrTick () {
+ try {
+ const status = await apiJson('/session/status/' + encodeURIComponent(qrSessionId))
+ if (status.message === 'session_not_found') {
+ $('qrImage').classList.add('hidden')
+ $('qrWaiting').classList.remove('hidden')
+ $('qrWaiting').textContent = 'Session no longer exists'
+ refreshNow()
+ return
+ }
+ if (status.success && status.state === 'CONNECTED') {
+ $('qrWaiting').classList.add('hidden')
+ $('qrImage').classList.add('hidden')
+ $('qrConnected').classList.remove('hidden')
+ refreshNow()
+ return
+ }
+ const response = await apiFetch('/session/qr/' + encodeURIComponent(qrSessionId) + '/image')
+ const contentType = response.headers.get('content-type') || ''
+ // the endpoint responds 200 with a JSON body when the QR is not ready
+ if (contentType.startsWith('image/png')) {
+ const blob = await response.blob()
+ if (qrObjectUrl) {
+ URL.revokeObjectURL(qrObjectUrl)
+ }
+ qrObjectUrl = URL.createObjectURL(blob)
+ const image = $('qrImage')
+ image.src = qrObjectUrl
+ image.classList.remove('hidden')
+ $('qrWaiting').classList.add('hidden')
+ }
+ } catch {
+ // keep polling; auth errors surface through the API key banner
+ }
+ qrTimer = setTimeout(qrTick, QR_POLL_INTERVAL_MS)
+ }
+
+ function closeQrModal () {
+ clearTimeout(qrTimer)
+ if (qrObjectUrl) {
+ URL.revokeObjectURL(qrObjectUrl)
+ qrObjectUrl = null
+ }
+ $('qrModal').classList.add('hidden')
+ refreshNow()
+ }
+
+ async function requestPairingCode () {
+ const phoneNumber = $('pairingPhone').value.replace(/\D/g, '')
+ if (!phoneNumber) {
+ showToast('Enter the phone number in international format, digits only', true)
+ return
+ }
+ const button = $('pairingBtn')
+ button.disabled = true
+ try {
+ const body = await apiJson('/session/requestPairingCode/' + encodeURIComponent(qrSessionId), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ phoneNumber, showNotification: true })
+ })
+ if (body.success) {
+ const codeEl = $('pairingCode')
+ codeEl.textContent = body.result
+ codeEl.classList.remove('hidden')
+ } else {
+ showToast(body.error || body.message || 'Failed to request pairing code', true)
+ }
+ } catch (error) {
+ showToast(error.message, true)
+ }
+ button.disabled = false
+ }
+
+ /*
+ * Details modal
+ */
+
+ async function openDetailsModal (id) {
+ $('detailsSessionName').textContent = id
+ const list = $('detailsList')
+ list.textContent = ''
+ $('detailsModal').classList.remove('hidden')
+ try {
+ const status = await apiJson('/session/status/' + encodeURIComponent(id))
+ addDetail(list, 'State', status.state || status.message)
+ if (status.success && status.state === 'CONNECTED') {
+ const body = await apiJson('/client/getClassInfo/' + encodeURIComponent(id))
+ const info = body.sessionInfo || {}
+ addDetail(list, 'Phone number', info.wid && info.wid.user)
+ addDetail(list, 'Push name', info.pushname)
+ addDetail(list, 'Platform', info.platform)
+ }
+ } catch (error) {
+ showToast(error.message, true)
+ }
+ }
+
+ function addDetail (list, label, value) {
+ const dt = document.createElement('dt')
+ dt.textContent = label
+ const dd = document.createElement('dd')
+ dd.textContent = value || '—'
+ list.appendChild(dt)
+ list.appendChild(dd)
+ }
+
+ /*
+ * API key
+ */
+
+ function saveApiKey () {
+ const value = $('apiKeyInput').value.trim()
+ if (value) {
+ window.localStorage.setItem(STORAGE_KEY, value)
+ } else {
+ window.localStorage.removeItem(STORAGE_KEY)
+ }
+ authFailed = false
+ $('apiKeyBanner').classList.add('hidden')
+ showToast('API key saved')
+ refreshNow()
+ }
+
+ // the swagger endpoint is optional (ENABLE_SWAGGER_ENDPOINT); only show the link when it responds
+ async function checkApiDocs () {
+ try {
+ const response = await window.fetch(API_BASE + '/api-docs', { method: 'HEAD', cache: 'no-store' })
+ if (response.ok) {
+ $('apiDocsLink').classList.remove('hidden')
+ }
+ } catch {
+ // endpoint unavailable; keep the link hidden
+ }
+ }
+
+ function init () {
+ $('apiKeyInput').value = window.localStorage.getItem(STORAGE_KEY) || ''
+ checkApiDocs()
+ $('apiKeySaveBtn').addEventListener('click', saveApiKey)
+ $('addSessionBtn').addEventListener('click', openStartModal)
+ $('addSessionForm').addEventListener('submit', startNewSession)
+ $('startCloseBtn').addEventListener('click', closeStartModal)
+ $('refreshBtn').addEventListener('click', () => refreshNow())
+ $('terminateInactiveBtn').addEventListener('click', () => globalAction('/session/terminateInactive', 'Terminate all inactive sessions?'))
+ $('terminateAllBtn').addEventListener('click', () => globalAction('/session/terminateAll', 'Terminate ALL sessions? This logs out every linked device.'))
+ $('qrCloseBtn').addEventListener('click', closeQrModal)
+ $('pairingBtn').addEventListener('click', requestPairingCode)
+ $('detailsCloseBtn').addEventListener('click', () => $('detailsModal').classList.add('hidden'))
+ $('startModal').addEventListener('click', (event) => {
+ if (event.target === $('startModal')) {
+ closeStartModal()
+ }
+ })
+ $('qrModal').addEventListener('click', (event) => {
+ if (event.target === $('qrModal')) {
+ closeQrModal()
+ }
+ })
+ $('detailsModal').addEventListener('click', (event) => {
+ if (event.target === $('detailsModal')) {
+ $('detailsModal').classList.add('hidden')
+ }
+ })
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden) {
+ refreshNow()
+ }
+ })
+ pollLoop()
+ }
+
+ init()
+})()
diff --git a/public/dashboard/index.html b/public/dashboard/index.html
new file mode 100644
index 0000000..3326a6f
--- /dev/null
+++ b/public/dashboard/index.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+ WWebJS API Dashboard
+
+
+
+
+ WWebJS API Dashboard
+
+
+
+ Invalid or missing API key — enter it above and press Save.
+
+
+
+
+
+
+
+
+ | Session |
+ Status |
+ Actions |
+
+
+
+
+ No active sessions. Start one above.
+
+
+
+
+
+
+
+
+
+
Waiting for QR code…
+
![WhatsApp QR code]()
+
✓ Connected
+
Scan with WhatsApp on your phone: Settings → Linked devices → Link a device.
+
+ Pair with phone number instead
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/dashboard/style.css b/public/dashboard/style.css
new file mode 100644
index 0000000..b6c9f47
--- /dev/null
+++ b/public/dashboard/style.css
@@ -0,0 +1,396 @@
+:root {
+ --accent: #00a884;
+ --accent-dark: #008f72;
+ --danger: #dc3545;
+ --bg: #f0f2f5;
+ --surface: #ffffff;
+ --text: #111b21;
+ --muted: #667781;
+ --border: #e9edef;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+}
+
+header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+ padding: 14px 24px;
+ background: var(--accent);
+ color: #fff;
+}
+
+header h1 {
+ font-size: 18px;
+ margin: 0;
+}
+
+.header-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.header-link {
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.6);
+ border-radius: 6px;
+ padding: 7px 14px;
+ font-size: 14px;
+ text-decoration: none;
+}
+
+.header-link:hover {
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.api-key-controls {
+ display: flex;
+ gap: 8px;
+}
+
+.api-key-controls input {
+ border: none;
+ border-radius: 6px;
+ padding: 8px 10px;
+ min-width: 260px;
+}
+
+.api-key-controls button {
+ background: #fff;
+ color: var(--accent-dark);
+ font-weight: 600;
+}
+
+.banner {
+ background: #fff3cd;
+ color: #8a6d1a;
+ padding: 10px 24px;
+ font-size: 14px;
+}
+
+main {
+ max-width: 960px;
+ margin: 24px auto;
+ padding: 0 16px;
+ display: grid;
+ gap: 16px;
+}
+
+.toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.start-form {
+ display: flex;
+ gap: 8px;
+ width: 100%;
+}
+
+.start-form input {
+ flex: 1;
+ padding: 8px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 14px;
+}
+
+.global-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.field-error {
+ color: var(--danger);
+ font-size: 13px;
+ margin: 0;
+}
+
+button {
+ border: none;
+ border-radius: 6px;
+ padding: 8px 14px;
+ background: var(--accent);
+ color: #fff;
+ font-size: 14px;
+ cursor: pointer;
+}
+
+button:hover {
+ background: var(--accent-dark);
+}
+
+button:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+button.secondary {
+ background: var(--surface);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+
+button.secondary:hover {
+ background: var(--bg);
+}
+
+button.secondary.danger {
+ color: var(--danger);
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ background: var(--surface);
+ border-radius: 10px;
+ overflow: hidden;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
+}
+
+th,
+td {
+ text-align: left;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+ font-size: 14px;
+}
+
+thead th {
+ color: var(--muted);
+ text-transform: uppercase;
+ font-size: 12px;
+ letter-spacing: 0.04em;
+}
+
+tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.session-id {
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+
+.actions-col,
+td.actions-cell {
+ text-align: right;
+}
+
+.actions {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.badge {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.badge-connected {
+ background: #d9fdd3;
+ color: #0b6b48;
+}
+
+.badge-pending {
+ background: #fff3cd;
+ color: #8a6d1a;
+}
+
+.empty {
+ color: var(--muted);
+ text-align: center;
+ padding: 24px;
+ background: var(--surface);
+ border-radius: 10px;
+ margin: 0;
+}
+
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(17, 27, 33, 0.55);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ z-index: 100;
+}
+
+.modal {
+ background: var(--surface);
+ border-radius: 12px;
+ max-width: 420px;
+ width: 100%;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border);
+}
+
+.modal-header h2 {
+ font-size: 16px;
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+.close-btn {
+ background: none;
+ color: var(--muted);
+ font-size: 22px;
+ line-height: 1;
+ padding: 0 4px;
+}
+
+.close-btn:hover {
+ background: none;
+ color: var(--text);
+}
+
+.modal-body {
+ padding: 18px;
+ display: grid;
+ gap: 12px;
+ justify-items: center;
+}
+
+#qrImage {
+ width: 264px;
+ height: 264px;
+ image-rendering: pixelated;
+}
+
+.qr-placeholder {
+ width: 264px;
+ height: 264px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg);
+ border-radius: 8px;
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.qr-success {
+ font-size: 20px;
+ color: #0b6b48;
+ font-weight: 700;
+ padding: 40px 0;
+}
+
+.hint {
+ font-size: 12px;
+ color: var(--muted);
+ margin: 0;
+ text-align: center;
+}
+
+details {
+ width: 100%;
+ font-size: 14px;
+}
+
+summary {
+ cursor: pointer;
+ color: var(--accent-dark);
+}
+
+.pairing-form {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.pairing-form input {
+ flex: 1;
+ padding: 8px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 14px;
+}
+
+.pairing-code {
+ font-size: 26px;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+ text-align: center;
+ margin-top: 12px;
+}
+
+#detailsList {
+ width: 100%;
+ margin: 0;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 6px 16px;
+ font-size: 14px;
+}
+
+#detailsList dt {
+ color: var(--muted);
+}
+
+#detailsList dd {
+ margin: 0;
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+
+#toasts {
+ position: fixed;
+ top: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: grid;
+ gap: 8px;
+ justify-items: center;
+ z-index: 200;
+}
+
+.toast {
+ background: #111b21;
+ color: #fff;
+ padding: 10px 16px;
+ border-radius: 8px;
+ font-size: 14px;
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
+ max-width: 340px;
+ overflow-wrap: anywhere;
+}
+
+.toast-error {
+ background: var(--danger);
+}
+
+.hidden {
+ display: none !important;
+}
diff --git a/src/config.js b/src/config.js
index 625dc6c..2d0e73f 100644
--- a/src/config.js
+++ b/src/config.js
@@ -11,6 +11,7 @@ const maxAttachmentSize = parseInt(process.env.MAX_ATTACHMENT_SIZE) || 10000000
const setMessagesAsSeen = (process.env.SET_MESSAGES_AS_SEEN || '').toLowerCase() === 'true'
const disabledCallbacks = process.env.DISABLED_CALLBACKS ? process.env.DISABLED_CALLBACKS.split('|') : []
const enableSwaggerEndpoint = (process.env.ENABLE_SWAGGER_ENDPOINT || '').toLowerCase() === 'true'
+const enableWebUI = (process.env.ENABLE_WEB_UI || '').toLowerCase() === 'true'
const webVersion = process.env.WEB_VERSION
const webVersionCacheType = process.env.WEB_VERSION_CACHE_TYPE || 'none'
const rateLimitMax = parseInt(process.env.RATE_LIMIT_MAX) || 1000
@@ -39,6 +40,7 @@ module.exports = {
setMessagesAsSeen,
disabledCallbacks,
enableSwaggerEndpoint,
+ enableWebUI,
webVersion,
webVersionCacheType,
rateLimitMax,
diff --git a/src/routes.js b/src/routes.js
index a0953d1..3438c63 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -1,8 +1,9 @@
const express = require('express')
+const path = require('path')
const routes = express.Router()
const swaggerUi = require('swagger-ui-express')
const swaggerDocument = require('../swagger.json')
-const { enableLocalCallbackExample, enableSwaggerEndpoint } = require('./config')
+const { enableLocalCallbackExample, enableSwaggerEndpoint, enableWebUI } = require('./config')
const middleware = require('./middleware')
const healthController = require('./controllers/healthController')
@@ -262,4 +263,22 @@ if (enableSwaggerEndpoint) {
routes.get('/api-docs', swaggerUi.setup(swaggerDocument) /* #swagger.ignore = true */)
}
+/**
+ * ================
+ * WEB UI ENDPOINTS
+ * ================
+ */
+if (enableWebUI) {
+ routes.get('/dashboard', (req, res, next) => {
+ // #swagger.ignore = true
+ // redirect to the trailing-slash URL so relative asset paths resolve correctly
+ const urlPath = req.originalUrl.split('?')[0]
+ if (!urlPath.endsWith('/')) {
+ return res.redirect(301, urlPath + '/')
+ }
+ next()
+ })
+ routes.use('/dashboard', express.static(path.join(__dirname, '../public/dashboard')))
+}
+
module.exports = { routes }
diff --git a/tests/dashboard.test.js b/tests/dashboard.test.js
new file mode 100644
index 0000000..ae7c874
--- /dev/null
+++ b/tests/dashboard.test.js
@@ -0,0 +1,41 @@
+const request = require('supertest')
+
+// Mock your application's environment variables
+process.env.API_KEY = 'test_api_key'
+process.env.SESSIONS_PATH = './sessions_test'
+process.env.ENABLE_WEB_UI = 'TRUE'
+
+const app = require('../src/app')
+jest.mock('qrcode-terminal')
+
+describe('Web UI dashboard', () => {
+ it('should serve the dashboard page', async () => {
+ const response = await request(app).get('/dashboard/')
+ expect(response.status).toBe(200)
+ expect(response.headers['content-type']).toMatch(/html/)
+ expect(response.text).toContain('WWebJS API Dashboard')
+ })
+
+ it('should redirect /dashboard to /dashboard/', async () => {
+ const response = await request(app).get('/dashboard')
+ expect(response.status).toBe(301)
+ expect(response.headers.location).toBe('/dashboard/')
+ })
+
+ it('should serve the dashboard assets', async () => {
+ const response = await request(app).get('/dashboard/app.js')
+ expect(response.status).toBe(200)
+ expect(response.headers['content-type']).toMatch(/javascript/)
+ })
+
+ it('should not serve the dashboard when disabled', async () => {
+ process.env.ENABLE_WEB_UI = 'FALSE'
+ let disabledApp
+ jest.isolateModules(() => {
+ disabledApp = require('../src/app')
+ })
+ const response = await request(disabledApp).get('/dashboard/')
+ expect(response.status).toBe(404)
+ process.env.ENABLE_WEB_UI = 'TRUE'
+ })
+})