2026-10-26 00:10:07 -07:00
// Admin & Configuration JavaScript
const API_BASE = window . location . pathname . startsWith ( '/web-giao-ban-go' ) ? '/web-giao-ban-go' : ( window . location . pathname . startsWith ( '/web-giao-ban' ) ? '/web-giao-ban' : '' );
let autoRefreshTimer = null ;
let currentUpdateAsset = null ;
document . addEventListener ( 'DOMContentLoaded' , () => {
initTabs ();
loadConfig ();
initLogViewer ();
initUpdateChecker ();
// Handle URL hash tab
const hash = window . location . hash ;
if ( hash === '#tab-update' ) {
switchTab ( 'tab-update' );
checkUpdate ();
} else if ( hash === '#tab-logs' ) {
switchTab ( 'tab-logs' );
2026-10-27 02:04:23 -07:00
} else if ( hash === '#tab-license' ) {
switchTab ( 'tab-license' );
2026-10-26 00:10:07 -07:00
}
2026-10-27 02:04:23 -07:00
initLicenseManager ();
2026-10-26 00:10:07 -07:00
});
function showToast ( msg ) {
const toast = document . getElementById ( 'toast' );
const toastMsg = document . getElementById ( 'toastMsg' );
if ( ! toast || ! toastMsg ) return ;
toastMsg . innerText = msg ;
toast . classList . remove ( 'translate-y-20' , 'opacity-0' );
toast . classList . add ( 'translate-y-0' , 'opacity-100' );
setTimeout (() => {
toast . classList . remove ( 'translate-y-0' , 'opacity-100' );
toast . classList . add ( 'translate-y-20' , 'opacity-0' );
}, 2500 );
}
function initTabs () {
document . querySelectorAll ( '.admin-tab' ). forEach ( btn => {
btn . addEventListener ( 'click' , () => {
const target = btn . getAttribute ( 'data-tab' );
switchTab ( target );
});
});
}
function switchTab ( targetId ) {
document . querySelectorAll ( '.admin-tab' ). forEach ( b => {
const isTarget = b . getAttribute ( 'data-tab' ) === targetId ;
b . classList . toggle ( 'active' , isTarget );
b . classList . toggle ( 'border-indigo-600' , isTarget );
b . classList . toggle ( 'text-indigo-600' , isTarget );
b . classList . toggle ( 'border-transparent' , ! isTarget );
b . classList . toggle ( 'text-slate-500' , ! isTarget );
});
document . querySelectorAll ( '.admin-content' ). forEach ( c => {
c . classList . toggle ( 'hidden' , c . id !== targetId );
});
if ( targetId === 'tab-logs' ) {
fetchLogs ();
}
}
// -------------------------------------------------------------------
// CONFIGURATION
// -------------------------------------------------------------------
async function loadConfig () {
try {
const res = await fetch ( ` ${ API_BASE } /api/admin/config` );
const json = await res . json ();
if ( json . success && json . data ) {
const cfg = json . data ;
// Server
document . getElementById ( 'cfgPort' ). value = cfg . server . port || 3000 ;
document . getElementById ( 'cfgAdminPath' ). value = cfg . server . admin_path || '/admin' ;
// Database
document . getElementById ( 'cfgDbHost' ). value = cfg . database . host || '192.168.50.79' ;
document . getElementById ( 'cfgDbPort' ). value = cfg . database . port || 5437 ;
document . getElementById ( 'cfgDbUser' ). value = cfg . database . user || 'postgres' ;
document . getElementById ( 'cfgDbPassword' ). value = '' ;
document . getElementById ( 'cfgDbDefault' ). value = cfg . database . default_db || '' ;
// Databases Table
renderDbList ( cfg . database . databases || []);
// Gitea Updater
document . getElementById ( 'cfgGiteaUrl' ). value = cfg . updater . gitea_url || 'https://i.dh-his.com' ;
2026-10-26 00:16:27 -07:00
const owner = ( cfg . updater . owner && cfg . updater . owner !== 'hdhiswork' ) ? cfg . updater . owner : 'ongtrieuhau' ;
const repo = ( cfg . updater . repo && cfg . updater . repo !== 'DUAN' ) ? cfg . updater . repo : 'web-giao-ban' ;
document . getElementById ( 'cfgGiteaOwner' ). value = owner ;
document . getElementById ( 'cfgGiteaRepo' ). value = repo ;
2026-10-26 00:10:07 -07:00
document . getElementById ( 'cfgGiteaToken' ). value = '' ;
const ver = cfg . updater . current_version || 'v1.0.0' ;
document . getElementById ( 'appVersionBadge' ). innerText = ver ;
document . getElementById ( 'currentVerDisplay' ). innerText = ver ;
}
} catch ( err ) {
console . error ( 'Lỗi nạp cấu hình:' , err );
}
}
function renderDbList ( list ) {
const tbody = document . getElementById ( 'bodyDbList' );
tbody . innerHTML = '' ;
list . forEach ( db => {
addDbRow ( db . id , db . name );
});
}
function addDbRow ( id = '' , name = '' ) {
const tbody = document . getElementById ( 'bodyDbList' );
const tr = document . createElement ( 'tr' );
tr . className = 'hover:bg-slate-50 transition-colors' ;
tr . innerHTML = `
<td class="py-2 px-2.5">
<input type="text" class="w-full rounded border border-slate-300 px-2 py-1 font-mono text-xs db-row-id" value=" ${ id } " placeholder="Tên CSDL trong Postgres">
</td>
<td class="py-2 px-2.5">
<input type="text" class="w-full rounded border border-slate-300 px-2 py-1 text-xs db-row-name" value=" ${ name } " placeholder="Tên hiển thị dropdown">
</td>
<td class="py-2 px-2 text-center">
<button type="button" class="text-rose-500 hover:text-rose-700 p-1 text-xs btn-del-row" title="Xóa dòng">
<i class="fa-solid fa-trash-can"></i>
</button>
</td>
` ;
tr . querySelector ( '.btn-del-row' ). addEventListener ( 'click' , () => tr . remove ());
tbody . appendChild ( tr );
}
document . getElementById ( 'btnAddDbRow' ) ? . addEventListener ( 'click' , () => addDbRow ());
// Password Toggle
document . getElementById ( 'btnToggleDbPass' ) ? . addEventListener ( 'click' , () => {
const inp = document . getElementById ( 'cfgDbPassword' );
const btn = document . getElementById ( 'btnToggleDbPass' );
if ( inp . type === 'password' ) {
inp . type = 'text' ;
btn . innerText = 'Ẩn' ;
} else {
inp . type = 'password' ;
btn . innerText = 'Hiện' ;
}
});
document . getElementById ( 'btnToggleToken' ) ? . addEventListener ( 'click' , () => {
const inp = document . getElementById ( 'cfgGiteaToken' );
const btn = document . getElementById ( 'btnToggleToken' );
if ( inp . type === 'password' ) {
inp . type = 'text' ;
btn . innerText = 'Ẩn' ;
} else {
inp . type = 'password' ;
btn . innerText = 'Hiện' ;
}
});
// Test Connection
document . getElementById ( 'btnTestConnection' ) ? . addEventListener ( 'click' , async () => {
const resEl = document . getElementById ( 'testConnResult' );
resEl . innerHTML = '<span class="text-blue-600"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Đang thử kết nối...</span>' ;
const body = {
host : document . getElementById ( 'cfgDbHost' ). value ,
port : parseInt ( document . getElementById ( 'cfgDbPort' ). value , 10 ),
user : document . getElementById ( 'cfgDbUser' ). value ,
password : document . getElementById ( 'cfgDbPassword' ). value ,
database : document . getElementById ( 'cfgDbDefault' ). value ,
};
try {
const res = await fetch ( ` ${ API_BASE } /api/admin/test-db` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( body ),
});
const json = await res . json ();
if ( json . success ) {
resEl . innerHTML = '<span class="text-emerald-600"><i class="fa-solid fa-circle-check mr-1"></i> Kết nối thành công!</span>' ;
} else {
resEl . innerHTML = `<span class="text-rose-600"><i class="fa-solid fa-circle-xmark mr-1"></i> ${ json . message } </span>` ;
}
} catch ( err ) {
resEl . innerHTML = `<span class="text-rose-600"><i class="fa-solid fa-circle-xmark mr-1"></i> ${ err . message } </span>` ;
}
});
// Save Config Form
document . getElementById ( 'formConfig' ) ? . addEventListener ( 'submit' , async ( e ) => {
e . preventDefault ();
const btn = document . getElementById ( 'btnSaveConfig' );
btn . disabled = true ;
btn . innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> <span>Đang lưu...</span>' ;
const dbs = [];
document . querySelectorAll ( '#bodyDbList tr' ). forEach ( tr => {
const id = tr . querySelector ( '.db-row-id' ). value . trim ();
const name = tr . querySelector ( '.db-row-name' ). value . trim ();
if ( id ) {
dbs . push ({ id , name : name || id });
}
});
const body = {
server : {
port : parseInt ( document . getElementById ( 'cfgPort' ). value , 10 ),
admin_path : document . getElementById ( 'cfgAdminPath' ). value . trim (),
},
database : {
host : document . getElementById ( 'cfgDbHost' ). value . trim (),
port : parseInt ( document . getElementById ( 'cfgDbPort' ). value , 10 ),
user : document . getElementById ( 'cfgDbUser' ). value . trim (),
password : document . getElementById ( 'cfgDbPassword' ). value ,
default_db : document . getElementById ( 'cfgDbDefault' ). value . trim (),
databases : dbs ,
},
updater : {
enabled : true ,
gitea_url : document . getElementById ( 'cfgGiteaUrl' ). value . trim (),
owner : document . getElementById ( 'cfgGiteaOwner' ). value . trim (),
repo : document . getElementById ( 'cfgGiteaRepo' ). value . trim (),
token : document . getElementById ( 'cfgGiteaToken' ). value ,
current_version : document . getElementById ( 'currentVerDisplay' ). innerText ,
}
};
try {
const res = await fetch ( ` ${ API_BASE } /api/admin/config` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( body ),
});
const json = await res . json ();
if ( json . success ) {
showToast ( 'Đã lưu cấu hình & mã hóa thành công!' );
loadConfig ();
} else {
alert ( 'Lỗi lưu cấu hình: ' + json . message );
}
} catch ( err ) {
alert ( 'Lỗi kết nối server: ' + err . message );
} finally {
btn . disabled = false ;
btn . innerHTML = '<i class="fa-solid fa-floppy-disk"></i> <span>Lưu Cấu Hình & Mã Hóa</span>' ;
}
});
// Save Gitea Config Form
document . getElementById ( 'formGiteaConfig' ) ? . addEventListener ( 'submit' , ( e ) => {
e . preventDefault ();
document . getElementById ( 'formConfig' ). dispatchEvent ( new Event ( 'submit' ));
});
// -------------------------------------------------------------------
// LOG VIEWER
// -------------------------------------------------------------------
function initLogViewer () {
document . getElementById ( 'btnRefreshLogs' ) ? . addEventListener ( 'click' , fetchLogs );
document . getElementById ( 'selectLogLevel' ) ? . addEventListener ( 'change' , fetchLogs );
document . getElementById ( 'btnClearLogs' ) ? . addEventListener ( 'click' , clearLogs );
const chk = document . getElementById ( 'chkAutoRefresh' );
chk ? . addEventListener ( 'change' , () => {
if ( chk . checked ) {
startAutoRefresh ();
} else {
clearInterval ( autoRefreshTimer );
}
});
startAutoRefresh ();
}
function startAutoRefresh () {
clearInterval ( autoRefreshTimer );
autoRefreshTimer = setInterval (() => {
const logsTab = document . getElementById ( 'tab-logs' );
if ( logsTab && ! logsTab . classList . contains ( 'hidden' )) {
fetchLogs ();
}
}, 3000 );
}
async function fetchLogs () {
const level = document . getElementById ( 'selectLogLevel' ) ? . value || 'ALL' ;
const box = document . getElementById ( 'logTerminal' );
const countBadge = document . getElementById ( 'logCountBadge' );
try {
const res = await fetch ( ` ${ API_BASE } /api/admin/logs?limit=300&level= ${ level } ` );
const json = await res . json ();
if ( json . success && Array . isArray ( json . data )) {
countBadge . innerText = ` ${ json . data . length } dòng` ;
if ( json . data . length === 0 ) {
box . innerHTML = '<div class="text-slate-500 italic">Không có dòng log nào.</div>' ;
return ;
}
box . innerHTML = '' ;
json . data . forEach ( entry => {
let colorClass = 'text-slate-300' ;
let badgeColor = 'bg-slate-800 text-slate-300' ;
switch ( entry . level ) {
case 'INFO' :
colorClass = 'text-emerald-400' ;
badgeColor = 'bg-emerald-950 text-emerald-300 border border-emerald-800' ;
break ;
case 'WARN' :
colorClass = 'text-amber-400' ;
badgeColor = 'bg-amber-950 text-amber-300 border border-amber-800' ;
break ;
case 'ERROR' :
colorClass = 'text-rose-400 font-bold' ;
badgeColor = 'bg-rose-950 text-rose-300 border border-rose-800' ;
break ;
case 'SQL' :
colorClass = 'text-cyan-400' ;
badgeColor = 'bg-cyan-950 text-cyan-300 border border-cyan-800' ;
break ;
}
const div = document . createElement ( 'div' );
div . className = 'py-0.5 border-b border-slate-900/60 flex items-start gap-2' ;
div . innerHTML = `
<span class="text-slate-500 text-[10px] whitespace-nowrap"> ${ entry . timestamp } </span>
<span class="text-[10px] font-bold px-1.5 rounded ${ badgeColor } "> ${ entry . level } </span>
<span class=" ${ colorClass } break-all flex-1"> ${ escapeHtml ( entry . message ) } </span>
` ;
box . appendChild ( div );
});
}
} catch ( err ) {
console . error ( 'Lỗi tải log:' , err );
}
}
async function clearLogs () {
if ( ! confirm ( 'Bạn có chắc muốn làm sạch toàn bộ bộ nhớ log không?' )) return ;
try {
await fetch ( ` ${ API_BASE } /api/admin/logs/clear` , { method : 'POST' });
fetchLogs ();
showToast ( 'Đã xóa bộ nhớ log!' );
} catch ( err ) {
alert ( 'Lỗi xóa log: ' + err . message );
}
}
// -------------------------------------------------------------------
// UPDATE CHECKER (GITEA)
// -------------------------------------------------------------------
function initUpdateChecker () {
document . getElementById ( 'btnCheckUpdate' ) ? . addEventListener ( 'click' , checkUpdate );
document . getElementById ( 'btnApplyUpdate' ) ? . addEventListener ( 'click' , applyUpdate );
}
async function checkUpdate () {
const btn = document . getElementById ( 'btnCheckUpdate' );
const latestDisplay = document . getElementById ( 'latestVerDisplay' );
const notesEl = document . getElementById ( 'updateReleaseNotes' );
const statusTitle = document . getElementById ( 'updateStatusTitle' );
const applyArea = document . getElementById ( 'applyUpdateArea' );
btn . disabled = true ;
btn . innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> <span>Đang kiểm tra...</span>' ;
try {
const res = await fetch ( ` ${ API_BASE } /api/update/check` );
const json = await res . json ();
if ( json . success && json . data ) {
const u = json . data ;
latestDisplay . innerText = u . latest_version || u . current_version ;
if ( u . has_update ) {
statusTitle . innerHTML = `<span class="text-emerald-600 font-bold"><i class="fa-solid fa-bell"></i> Có bản cập nhật mới: ${ u . release_title || u . latest_version } </span>` ;
notesEl . innerText = u . release_notes || 'Không có mô tả chi tiết.' ;
if ( u . asset_url ) {
currentUpdateAsset = u ;
applyArea . classList . remove ( 'hidden' );
document . getElementById ( 'updateAssetInfo' ). innerText = `File: ${ u . asset_name } ( ${ ( u . asset_size / ( 1024 * 1024 )). toFixed ( 2 ) } MB)` ;
} else {
applyArea . classList . add ( 'hidden' );
notesEl . innerText += '\n\n(Lưu ý: Chưa tìm thấy file nhị phân tương thích trong bản release).' ;
}
} else {
statusTitle . innerHTML = `<span class="text-slate-600 font-bold"><i class="fa-solid fa-circle-check text-emerald-500"></i> Bạn đang sử dụng phiên bản mới nhất ( ${ u . current_version } )</span>` ;
notesEl . innerText = u . release_notes || 'Hệ thống đã cập nhật đầy đủ các tính năng mới nhất.' ;
applyArea . classList . add ( 'hidden' );
}
} else {
latestDisplay . innerText = 'Lỗi kết nối' ;
notesEl . innerText = json . message || 'Không thể kết nối đến máy chủ Gitea.' ;
}
} catch ( err ) {
latestDisplay . innerText = 'Lỗi' ;
notesEl . innerText = err . message ;
} finally {
btn . disabled = false ;
btn . innerHTML = '<i class="fa-solid fa-arrows-rotate"></i> <span>Kiểm Tra Cập Nhật</span>' ;
}
}
async function applyUpdate () {
if ( ! currentUpdateAsset || ! currentUpdateAsset . asset_url ) return ;
if ( ! confirm ( `Bạn có chắc muốn tự động cập nhật lên phiên bản ${ currentUpdateAsset . latest_version } ?` )) return ;
const btn = document . getElementById ( 'btnApplyUpdate' );
btn . disabled = true ;
btn . innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-1"></i> Đang tải và cài đặt...' ;
try {
const res = await fetch ( ` ${ API_BASE } /api/update/apply` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
asset_url : currentUpdateAsset . asset_url ,
version : currentUpdateAsset . latest_version ,
}),
});
const json = await res . json ();
if ( json . success ) {
alert ( json . message );
location . reload ();
} else {
alert ( 'Lỗi cập nhật: ' + json . message );
}
} catch ( err ) {
alert ( 'Lỗi kết nối cập nhật: ' + err . message );
} finally {
btn . disabled = false ;
btn . innerHTML = '<i class="fa-solid fa-download"></i> <span>Tải & Cập Nhật Tự Động</span>' ;
}
}
function escapeHtml ( str ) {
if ( ! str ) return '' ;
return String ( str )
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /"/g , '"' )
. replace ( /'/g , ''' );
}
2026-10-27 02:04:23 -07:00
// -------------------------------------------------------------------
// LICENSE MANAGEMENT
// -------------------------------------------------------------------
const SAMPLE_LICENSE_KEY = "U2FsdGVkX18Zsq52FPbB7FS1QM7CwWh+t1Esi96gUMFILJD0vZXM69uQBC4sP7TjLVOiJPZt0KIq17iql+hXehLCVSbCEhwKKm+zNab+u2Swah63A52hz0KMJXddLRZ6vanmP2goLTzY7749HsFVRDq+3bdbi8fdUZCpAsSTEiqC2cRTpF5WVh0SHt1wX/i42YSQgOYPeXQtg+lRx/URk4p7lYSVQOYWXglp99kO+Va1ADoNcTgrfz3aaAxAkj0S" ;
function initLicenseManager () {
loadLicenseStatus ();
const btnPaste = document . getElementById ( 'btnPasteSampleKey' );
if ( btnPaste ) {
btnPaste . addEventListener ( 'click' , () => {
const txt = document . getElementById ( 'txtLicenseKey' );
if ( txt ) {
txt . value = SAMPLE_LICENSE_KEY ;
showToast ( 'Đã dán mã bản quyền mẫu' );
}
});
}
const btnActivate = document . getElementById ( 'btnActivateLicense' );
if ( btnActivate ) {
btnActivate . addEventListener ( 'click' , activateLicense );
}
}
async function loadLicenseStatus () {
try {
const res = await fetch ( ` ${ API_BASE } /api/license/status` );
const json = await res . json ();
if ( json . success && json . data ) {
renderLicenseInfo ( json . data );
}
} catch ( err ) {
console . error ( 'Lỗi lấy thông tin bản quyền:' , err );
}
}
function renderLicenseInfo ( lic ) {
const badge = document . getElementById ( 'licenseStatusBadge' );
const alertBox = document . getElementById ( 'licenseAlertBox' );
const alertMsg = document . getElementById ( 'licenseAlertMsg' );
document . getElementById ( 'licTenBV' ). innerText = lic . TenBV || 'Chưa xác định' ;
document . getElementById ( 'licMabvbh' ). innerText = lic . mabvbh || '--' ;
document . getElementById ( 'licDateExpires' ). innerText = lic . DateExpires || '--' ;
document . getElementById ( 'licTitle' ). innerText = lic . title || 'Sử dụng Web giao ban' ;
document . getElementById ( 'licKey' ). innerText = lic . key || 'web_giao_ban' ;
document . getElementById ( 'licDoiTac' ). innerText = lic [ 'Đối tác' ] || '--' ;
document . getElementById ( 'licGhiChu' ). innerText = lic [ 'Ghi chú' ] || 'Không có' ;
if ( lic . is_valid ) {
badge . className = 'text-xs px-2.5 py-1 rounded-full font-bold bg-emerald-100 text-emerald-800 border border-emerald-300 flex items-center gap-1.5' ;
badge . innerHTML = '<i class="fa-solid fa-circle-check text-emerald-600"></i> Hợp Lệ' ;
document . getElementById ( 'licRemainingDays' ). innerHTML = `<span class="text-emerald-600 font-bold"><i class="fa-solid fa-clock"></i> Còn ${ lic . remaining_days } ngày</span>` ;
alertBox . className = 'mt-4 p-3.5 rounded-xl border text-xs flex items-center gap-2.5 bg-emerald-50 border-emerald-200 text-emerald-800' ;
alertBox . innerHTML = `<i class="fa-solid fa-circle-check text-emerald-600 text-base"></i><span> ${ escapeHtml ( lic . status_message || 'Bản quyền đang hoạt động bình thường.' ) } </span>` ;
} else {
badge . className = 'text-xs px-2.5 py-1 rounded-full font-bold bg-rose-100 text-rose-800 border border-rose-300 flex items-center gap-1.5' ;
badge . innerHTML = '<i class="fa-solid fa-triangle-exclamation text-rose-600"></i> Chưa Hợp Lệ' ;
document . getElementById ( 'licRemainingDays' ). innerHTML = `<span class="text-rose-600 font-bold"><i class="fa-solid fa-triangle-exclamation"></i> Không khả dụng</span>` ;
alertBox . className = 'mt-4 p-3.5 rounded-xl border text-xs flex items-center gap-2.5 bg-rose-50 border-rose-200 text-rose-800' ;
alertBox . innerHTML = `<i class="fa-solid fa-circle-xmark text-rose-600 text-base"></i><span> ${ escapeHtml ( lic . status_message || 'Chưa cập nhật key bản quyền hợp lệ.' ) } </span>` ;
}
// Pre-fill textarea if key exists and textarea is empty
const txt = document . getElementById ( 'txtLicenseKey' );
if ( txt && ! txt . value && lic . raw_encrypted ) {
txt . value = lic . raw_encrypted ;
}
}
async function activateLicense () {
const txt = document . getElementById ( 'txtLicenseKey' );
if ( ! txt ) return ;
const keyVal = txt . value . trim ();
if ( ! keyVal ) {
alert ( 'Vui lòng nhập chuỗi key bản quyền (bắt đầu bằng U2FsdGVkX1...)' );
txt . focus ();
return ;
}
const btn = document . getElementById ( 'btnActivateLicense' );
btn . disabled = true ;
btn . innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-1"></i> Đang xác thực...' ;
try {
const res = await fetch ( ` ${ API_BASE } /api/license/activate` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ key : keyVal })
});
const json = await res . json ();
if ( json . success && json . data ) {
showToast ( json . message );
renderLicenseInfo ( json . data );
alert ( 'Kích hoạt thành công!\n\n' + json . message + '\nĐơn vị: ' + json . data . TenBV + '\nHạn sử dụng: ' + json . data . DateExpires );
} else {
alert ( 'Kích hoạt không thành công: ' + ( json . message || 'Lỗi không xác định' ));
loadLicenseStatus ();
}
} catch ( err ) {
alert ( 'Lỗi kết nối máy chủ: ' + err . message );
} finally {
btn . disabled = false ;
btn . innerHTML = '<i class="fa-solid fa-check-double"></i> <span>Kích Hoạt Key Bản Quyền</span>' ;
}
}