wwebjs-api/public/dashboard/app.js
2026-07-13 19:53:15 +03:00

397 lines
12 KiB
JavaScript

(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()
})()