397 lines
12 KiB
JavaScript
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()
|
|
})()
|