1/* XLX999 DIGITAL LAB V1.2 â contas e administração */ 2(()=>{ 3'use strict'; 4 5const root=document.getElementById('digitalLab'); 6if(!root)return; 7 8/* V1.2.1: cadastro começa recolhido */ 9const registerDisclosure=root.querySelector('.dlab-register-box'); 10 11if(registerDisclosure){ 12 registerDisclosure.removeAttribute('open'); 13} 14 15const api='/api/digital-lab-operator.php'; 16const $=id=>document.getElementById(id); 17 18const locked=$('dlabOperatorLocked'); 19const unlocked=$('dlabOperatorUnlocked'); 20 21const loginForm=$('dlabOperatorLoginForm'); 22const loginCall=$('dlabLoginCall'); 23const password=$('dlabOperatorPassword'); 24const remember=$('dlabRememberMe'); 25 26const registerForm=$('dlabRegisterForm'); 27const registerCall=$('dlabRegisterCall'); 28const birthDay=$('dlabBirthDay'); 29const birthMonth=$('dlabBirthMonth'); 30const birthdayConsent=$('dlabBirthdayConsent'); 31const registerResult=$('dlabRegisterResult'); 32 33const accountTitle=$('dlabAccountTitle'); 34const roleBadge=$('dlabRoleBadge'); 35const state=$('dlabOperatorState'); 36 37const usersBtn=$('dlabUsersBtn'); 38const resetBtn=$('dlabResetBtn'); 39const collaboratorsBtn=$('dlabCollaboratorsBtn'); 40const auditBtn=$('dlabAuditBtn'); 41const myPasswordBtn=$('dlabMyPasswordBtn'); 42const setBirthBtn=$('dlabSetBirthBtn'); 43const birthWarning=$('dlabBirthWarning'); 44const userNotice=$('dlabUserNotice'); 45 46const soundToggle=$('dlabSoundToggle'); 47const unreadBadge=$('dlabUnreadBadge'); 48const logout=$('dlabOperatorLogout'); 49 50const form=$('dlabMessageForm'); 51const dest=$('dlabMessageDest'); 52const message=$('dlabMessageText'); 53const count=$('dlabMessageCount'); 54const send=$('dlabMessageSend'); 55const historyWrap=$('dlabPrivateHistory'); 56const history=$('dlabOperatorMessages'); 57 58const dialog=$('dlabAccountDialog'); 59const dialogTitle=$('dlabDialogTitle'); 60const dialogBody=$('dlabDialogBody'); 61const dialogClose=$('dlabDialogClose'); 62 63const toast=$('dlabToast'); 64 65let csrf=''; 66let account=null; 67let permissions={}; 68let timer=null; 69let toastTimer=null; 70let audioCtx=null; 71let lastInboundId=null; 72let unread=0; 73 74const CALL=/^(?=[A-Z0-9]*[0-9])[A-Z0-9]{3,8}$/; 75const APRS_CALL=/^[A-Z0-9]{3,8}(?:-[0-9]{1,2})?$/; 76 77function notify(text){ 78 if(!toast)return; 79 80 toast.textContent=text; 81 toast.hidden=false; 82 83 clearTimeout(toastTimer); 84 85 toastTimer=setTimeout(()=>{ 86 toast.hidden=true; 87 },5000); 88} 89 90function cleanBaseCall(v){ 91 return String(v??'') 92 .toUpperCase() 93 .replace(/[^A-Z0-9]/g,'') 94 .slice(0,8); 95} 96 97function cleanAprsCall(v){ 98 return String(v??'') 99 .toUpperCase() 100 .replace(/[^A-Z0-9-]/g,'') 101 .slice(0,9); 102} 103 104function monthName(m){ 105 return [ 106 '', 107 'Janeiro','Fevereiro','Março','Abril', 108 'Maio','Junho','Julho','Agosto', 109 'Setembro','Outubro','Novembro','Dezembro' 110 ][Number(m)]||'â'; 111} 112 113function statusName(v){ 114 return ({ 115 awaiting_ack:'Waiting for ACK', 116 acked:'ACK recebido', 117 rejected:'Rejeitada', 118 received:'Recebida', 119 sent:'Enviada' 120 })[v]||String(v||'â'); 121} 122 123async function jsonFetch(url,options={}){ 124 const response=await fetch(url,{ 125 cache:'no-store', 126 credentials:'same-origin', 127 ...options 128 }); 129 130 let data={}; 131 132 try{ 133 data=await __XLX_TECH_16__on(); 134 }catch(_){ 135 data={ok:false,error:'invalid_response'}; 136 } 137 138 if(!response.ok){ 139 const err=new Error( 140 data.error||`HTTP ${response.status}` 141 ); 142 143 err.data=data; 144 throw err; 145 } 146 147 return data; 148} 149 150async function post(action,payload={}){ 151 return jsonFetch(api,{ 152 method:'POST', 153 headers:{ 154 'Content-Type':'application/json', 155 'Accept':'application/json', 156 ...(csrf?{'X-CSRF-Token':csrf}:{}) 157 }, 158 body:JSON.stringify({ 159 action, 160 ...payload 161 }) 162 }); 163} 164 165function loadSoundPref(){ 166 try{ 167 return localStorage.getItem( 168 'xlxmodern_dlab_sound' 169 )!=='0'; 170 }catch(_){ 171 return true; 172 } 173} 174 175let soundEnabled=loadSoundPref(); 176 177function saveSoundPref(){ 178 try{ 179 localStorage.setItem( 180 'xlxmodern_dlab_sound', 181 soundEnabled?'1':'0' 182 ); 183 }catch(_){} 184} 185 186function updateSoundButton(){ 187 if(!soundToggle)return; 188 189 soundToggle.textContent=soundEnabled 190 ? 'ð Som ligado' 191 : 'ð Som desligado'; 192 193 soundToggle.setAttribute( 194 'aria-pressed', 195 soundEnabled?'true':'false' 196 ); 197} 198 199function updateUnread(){
200 unreadBadge.hidden=unread<1; 201 202 if(unread>0){ 203 unreadBadge.textContent= 204 unread===1 ? '1 nova' : `${unread} novas`; 205 } 206} 207 208async function ensureAudio(){ 209 if(!soundEnabled)return null; 210 211 const AudioClass= 212 window.AudioContext|| 213 window.webkitAudioContext; 214 215 if(!AudioClass)return null; 216 217 if(!audioCtx){ 218 audioCtx=new AudioClass(); 219 } 220 221 if(audioCtx.state==='suspended'){ 222 try{ 223 await audioCtx.resume(); 224 }catch(_){} 225 } 226 227 return audioCtx; 228} 229 230async function playMessageChime(){ 231 if(!soundEnabled)return; 232 233 const ctx=await ensureAudio(); 234 235 if(!ctx||ctx.state!=='running')return; 236 237 const start=ctx.currentTime+.01; 238 239 function note(freq,offset,duration,level){ 240 const osc=ctx.createOscillator(); 241 const gain=ctx.createGain(); 242 243 osc.type='sine'; 244 245 osc.frequency.setValueAtTime( 246 freq, 247 start+offset 248 ); 249 250 gain.gain.setValueAtTime( 251 .0001, 252 start+offset 253 ); 254 255 gain.gain.exponentialRampToValueAtTime( 256 level, 257 start+offset+.018 258 ); 259 260 gain.gain.exponentialRampToValueAtTime( 261 .0001, 262 start+offset+duration 263 ); 264 265 osc.connect(gain); 266 gain.connect(ctx.destination); 267 268 osc.start(start+offset); 269 osc.stop(start+offset+duration+.03); 270 } 271 272 note(740,0,.13,.07); 273 note(988,.105,.20,.055); 274} 275 276function inspectIncoming(rows){ 277 const incoming=(Array.isArray(rows)?rows:[]) 278 .filter(x=>x.direction==='in') 279 .map(x=>({ 280 ...x, 281 numericId:Number(x.id)||0 282 })) 283 .filter(x=>x.numericId>0) 284 .sort((a,b)=>
284a.numericId-b.numericId); 285 286 if(!incoming.length)return; 287 288 const newest=incoming[incoming.length-1]; 289 290 if(lastInboundId===null){ 291 lastInboundId=newest.numericId; 292 return; 293 } 294 295 const fresh=incoming.filter( 296 x=>x.numericId>lastInboundId 297 ); 298 299 if(!fresh.length)return; 300 301 lastInboundId=newest.numericId; 302 unread+=fresh.length; 303 updateUnread(); 304 305 void playMessageChime(); 306 307 const last=fresh[fresh.length-1]; 308 309 notify( 310 `ð New APRS message from ${String(last.peer||'station')}.` 311 ); 312} 313 314function prepareAprsReply(rawCall){ 315 const call=cleanAprsCall(rawCall); 316 317 if(!APRS_CALL.test(call)){ 318 notify('Invalid APRS callsign.'); 319 return; 320 } 321 322 if( 323 !account || 324 !permissions.send_aprs || 325 !form || 326 form.hidden 327 ){ 328 notify( 329 'Sign in to your account to reply from the dashboard.' 330 ); 331 return; 332 } 333 334 dest.value=call; 335 336 form.scrollIntoView({ 337 behavior:'smooth', 338 block:'center' 339 }); 340 341 window.setTimeout(()=>{ 342 message.focus(); 343 },300); 344 345 notify( 346 `${call} colocado em APRS recipient.` 347 ); 348} 349 350function renderMessages(rows){ 351 history.replaceChildren(); 352 353 if(!Array.isArray(rows)||!rows.length){ 354 const e=document.createElement('div'); 355 e.className='dlab-empty'; 356 e.textContent='No messages.'; 357 history.append(e); 358 return; 359 } 360 361 rows.slice(0,20).forEach(x=>{ 362 const row=document.createElement('div'); 363 row.className='dlab-private-message'; 364 365 if(x.status==='acked'){ 366 row.classList.add('is-acked'); 367 } 368 369 if(x.status==='rejected'){ 370 row.classList.add('is-rejected'); 371 } 372 373 const d=document.createElement('span'); 374 d.className='dir'; 375 d.textContent= 376 x.direction==='out' 377 ? 'ENVIADA' 378 : 'RECEBIDA'; 379 380 const peer=document.createElement('button'); 381 peer.type='button'; 382 peer.className='peer dlab-peer-button'; 383 peer.textContent=String(x.peer||'â'); 384 385 if(x.peer){ 386 peer.title= 387 `Responder para ${String(x.peer)}`; 388 389 peer.setAttribute( 390 'aria-label', 391 `Responder para ${String(x.peer)}` 392 ); 393 394 peer.addEventListener( 395 'click', 396 ()=>prepareAprsReply(x.peer) 397 ); 398 }else{ 399 peer.disabled=true; 400 } 401 402 const b=document.createElement('span'); 403 b.className='body'; 404 b.textContent=String(x.body||''); 405 406 const st=document.createElement('span'); 407 st.className='status'; 408 st.textContent=statusName(x.status); 409 410 row.append(d,peer,b,st); 411 history.append(row); 412 }); 413} 414 415function setLoggedOut(){ 416 account=null; 417 permissions={}; 418 csrf=''; 419 420 locked.hidden=false; 421 unlocked.hidden=true; 422 423 lastInboundId=null; 424 unread=0; 425 426 updateUnread(); 427} 428 429function applyPermissions(){ 430 const role=String(account?.role||'USER'); 431 432 roleBadge.textContent= 433 role==='ADMIN' 434 ? 'ADMIN' 435 : role==='COLLABORATOR' 436 ? 'COLABORADOR' 437 : 'USER'; 438 439 roleBadge.dataset.role=role; 440 441 usersBtn.hidden=!permissions.manage_users; 442 resetBtn.hidden=!permissions.reset_password; 443 collaboratorsBtn.hidden= 444 !permissions.manage_collaborators; 445 auditBtn.hidden=!permissions.audit; 446 447 const canSend=Boolean( 448 permissions.send_aprs 449 ); 450 451 const canViewPrivate= 452 role==='ADMIN'; 453 454 form.hidden=!canSend; 455 historyWrap.hidden=!canViewPrivate; 456 soundToggle.hidden=!canViewPrivate; 457 unreadBadge.hidden= 458 !canViewPrivate || unread<1; 459 460 userNotice.hidden= 461 role==='ADMIN'; 462 463 birthWarning.hidden= 464 Boolean(account?.birth_complete); 465 466 accountTitle.textContent= 467 `${account.callsign} autenticado`; 468 469 if(role==='ADMIN'){ 470 state.textContent= 471 'Administrator ⢠full access'; 472 }else if(role==='COLLABORATOR'){ 473 state.textContent= 474 'Colaborador ⢠recuperação de usuários'; 475 }else{ 476 state.textContent= 477 'Active account'; 478 } 479} 480 481async function refresh(){ 482 try{ 483 const data=await jsonFetch( 484 `${api}?action=status` 485 ); 486 487 if(!data.auth){ 488 setLoggedOut(); 489 return; 490 } 491 492 locked.hidden=true; 493 unlocked.hidden=false; 494 495 csrf=String(data.csrf||''); 496 account=data.account||{}; 497 permissions=data.permissions||{}; 498 499 applyPermissions(); 500 501 if(permissions.send_aprs){ 502 const op=data.operator||{}; 503
504 const connected=Boolean(op.connected); 505 const tx=Boolean(op.tx_enabled); 506 507 const roleName= 508 String(account?.role||'USER').toUpperCase(); 509 510 const roleLabel= 511 roleName==='ADMIN' 512 ? 'Administrador' 513 : roleName==='COLLABORATOR' 514 ? 'Colaborador' 515 : 'User'; 516 517 state.textContent=connected 518 ? ( 519 tx 520 ? `${roleLabel} ⢠APRS-IS verificado ⢠TX habilitado` 521 : `${roleLabel} ⢠APRS-IS verificado ⢠TX bloqueado` 522 ) 523 : `${roleLabel} ⢠APRS-IS não está pronto`; 524 525 send.disabled=!(connected&&tx); 526 527 if(roleName==='ADMIN'){ 528 const rows=op.messages||[]; 529 inspectIncoming(rows); 530 renderMessages(rows); 531 } 532 } 533 534 }catch(_){ 535 if(account){ 536 state.textContent= 537 'Unable to check access.'; 538 } 539 } 540} 541 542function showCredential(call,passwordValue,title='New password'){ 543 dialogTitle.textContent=title; 544 dialogBody.replaceChildren(); 545 546 const box=document.createElement('div'); 547 box.className='dlab-generated-password'; 548 549 const callEl=document.createElement('strong'); 550 callEl.textContent=call; 551 552 const label=document.createElement('span'); 553 label.textContent= 554 'This password is shown only now.'; 555 556 const code=document.createElement('code'); 557 code.textContent=passwordValue; 558 559 const actions=document.createElement('div'); 560 actions.className='dlab-dialog-actions'; 561 562 const copy=document.createElement('button'); 563 copy.type='button'; 564 copy.className='dlab-btn dlab-btn-primary'; 565 copy.textContent='Copy password'; 566 567 const copyMessage=document.createElement('button'); 568 copyMessage.type='button'; 569 copyMessage.className='dlab-btn dlab-btn-ghost'; 570 copyMessage.textContent= 571 'Copy message to send'; 572 573 copy.addEventListener('click',async()=>{ 574 try{ 575 await navigator.clipboard.writeText( 576 passwordValue 577 ); 578 notify('Password copied.'); 579 }catch(_){ 580 window.prompt( 581 'Copy the password:', 582 passwordValue 583 ); 584 } 585 }); 586 587 copyMessage.addEventListener( 588 'click', 589 async()=>{ 590 const text= 591 `Hello ${call}. Your new Digital Lab access password for XLX999 is: ${passwordValue}. Keep this password for future access.`; 592 593 try{ 594 await navigator.clipboard.writeText(text); 595 notify('Prepared message copied.'); 596 }catch(_){ 597 window.prompt( 598 'Copy the message:', 599 text 600 ); 601 } 602 } 603 ); 604 605 actions.append(copy,copyMessage); 606 607 box.append( 608 callEl, 609 label, 610 code, 611 actions 612 ); 613 614 dialogBody.append(box); 615 616 if(!dialog.open){ 617 __XLX_TECH_17__owModal(); 618 } 619} 620 621function openDialog(title){ 622 dialogTitle.textContent=title; 623 dialogBody.replaceChildren(); 624 625 if(!dialog.open){ 626 __XLX_TECH_18__owModal(); 627 } 628} 629 630function createField(labelText,type='text'){ 631 const wrap=document.createElement('label'); 632 wrap.className='dlab-dialog-field'; 633 634 const label=document.createElement('span'); 635 label.textContent=labelText; 636 637 const input=document.createElement('input'); 638 input.type=type; 639 640 wrap.append(label,input); 641 642 return {wrap,input}; 643} 644 645function createBirthFields(){ 646 const row=document.createElement('div'); 647 row.className='dlab-birthday-grid'; 648 649 const day=createField('Dia','number'); 650 day.input.min='1'; 651 day.input.max='31'; 652 653 const monthWrap=document.createElement('label'); 654 monthWrap.className='dlab-dialog-field'; 655 656 const monthLabel=document.createElement('span'); 657 monthLabel.textContent='Mês'; 658 659 const month=document.createElement('select'); 660 661 const blank=document.createElement('option'); 662 blank.value=''; 663 blank.textContent='Selecione'; 664 month.append(blank); 665 666 for(let i=1;i<=12;i++){ 667 const op=document.createElement('option'); 668 op.value=String(i); 669 op.textContent=monthName(i); 670 month.append(op); 671 } 672 673 monthWrap.append(monthLabel,month); 674 row.append(day.wrap,monthWrap); 675 676 return { 677 row, 678 day:day.input, 679 month 680 }; 681} 682 683async function openReset(prefill=''){ 684 openDialog('Recover password'); 685 686 const intro=document.createElement('p'); 687 intro.className='dlab-dialog-text'; 688 intro.textContent= 689 'Ask the operator for the callsign and real birthday day/month. The old password will be invalidated and a new one generated.'; 690 691 const formEl=document.createElement('form'); 692 formEl.className='dlab-dialog-form'; 693 694 const call=createField('Callsign'); 695 call.input.value=cleanBaseCall(prefill); 696 call.input.maxLength=8; 697 698 const birth=createBirthFields(); 699 700 const submit=document.createElement('button'); 701 submit.type='submit'; 702 submit.className='dlab-btn dlab-btn-primary'; 703 submit.textContent='Generate new password'; 704 705 formEl.append( 706 call.wrap, 707 birth.row, 708 submit 709 ); 710 711 formEl.addEventListener('submit',async ev=>{ 712 ev.preventDefault(); 713 714 const c=cleanBaseCall(call.input.value); 715 716 try{ 717 const data=await post( 718 'reset_password', 719 { 720 callsign:c, 721 birth_day:Number(birth.day.value), 722 birth_month:Number(birth.month.value) 723 } 724 ); 725 726 showCredential( 727 data.callsign, 728 data.password, 729 'Password reset' 730 ); 731 732 }catch(err){ 733 const code=err?.data?.error; 734 735 const labels={ 736 account_not_found:'Callsign is not registered.', 737 birthday_mismatch:'Birthday day or month does not match.', 738 forbidden:'Your access level does not allow resetting this account.', 739 admin_reset_denied:'The ADMIN password must be changed from the account itself.' 740 }; 741 742 notify( 743 labels[code]|| 744 'Unable to generate the new password.' 745 ); 746 } 747 }); 748 749 dialogBody.append(intro,formEl); 750} 751 752async function loadUsers(){ 753 const data=await jsonFetch( 754 `${api}?action=users` 755 ); 756 757 return Array.isArray(data.users) 758 ? data.users 759 : []; 760} 761 762function userRow(u){ 763 const row=document.createElement('div'); 764 row.className='dlab-user-row'; 765 766 const info=document.createElement('div'); 767 768 const name=document.createElement('strong'); 769 name.textContent=u.callsign; 770 771 const meta=document.createElement('span'); 772 773 const bd= 774 u.birth_day&&u.birth_month 775 ? `${String(u.birth_day).padStart(2,'0')}/${String(u.birth_month).padStart(2,'0')}` 776 : 'birthday pending'; 777 778 meta.textContent= 779 `${u.role} ⢠${u.status} ⢠${bd}`; 780 781 info.append(name,meta); 782 783 const actions=document.createElement('div'); 784 actions.className='dlab-row-actions'; 785 786 if(u.role!=='ADMIN'){ 787 const reset=document.createElement('button'); 788 reset.type='button'; 789 reset.textContent='Password'; 790 791 reset.addEventListener( 792 'click', 793 ()=>openReset(u.callsign) 794 ); 795 796 const role=document.createElement('button'); 797 role.type='button'; 798 799 role.textContent= 800 u.role==='COLLABORATOR' 801 ? 'Make user' 802 : 'Tornar colaborador'; 803 804 role.addEventListener('click',async()=>{ 805 const newRole= 806 u.role==='COLLABORATOR' 807 ? 'USER' 808 : 'COLLABORATOR'; 809 810 if(!confirm( 811 `Alterar ${u.callsign} para ${newRole}?` 812 ))return; 813
814 try{ 815 await post( 816 'set_role', 817 { 818 callsign:u.callsign, 819 role:newRole 820 } 821 ); 822 823 notify('Permissão atualizada.'); 824 await openUsers(); 825 826 }catch(_){ 827 notify( 828 'Não foi possÃvel alterar a permissão.' 829 ); 830 } 831 }); 832 833 const status=document.createElement('button'); 834 status.type='button'; 835 836 status.textContent= 837 u.status==='ACTIVE' 838 ? 'Bloquear' 839 : 'Ativar'; 840 841 status.addEventListener('click',async()=>{ 842 const newStatus= 843 u.status==='ACTIVE' 844 ? 'BLOCKED' 845 : 'ACTIVE'; 846 847 if(!confirm( 848 `${newStatus==='BLOCKED'?'Bloquear':'Ativar'} ${u.callsign}?` 849 ))return; 850 851 try{ 852 await post( 853 'set_status', 854 { 855 callsign:u.callsign, 856 status:newStatus 857 } 858 ); 859 860 notify('Status atualizado.'); 861 await openUsers(); 862 863 }catch(_){ 864 notify( 865 'Não foi possÃvel alterar o status.' 866 ); 867 } 868 }); 869 870 actions.append(reset,role,status); 871 } 872 873 row.append(info,actions); 874 875 return row; 876} 877 878async function openUsers(){ 879 openDialog('Users'); 880 881 const create=document.createElement('details'); 882 create.className='dlab-admin-create'; 883 884 const summary=document.createElement('summary'); 885 summary.textContent='Register manually'; 886 887 const formEl=document.createElement('form'); 888 formEl.className='dlab-dialog-form'; 889 890 const call=createField('Callsign'); 891 call.input.maxLength=8; 892 893 const birth=createBirthFields(); 894 895 const roleWrap=document.createElement('label'); 896 roleWrap.className='dlab-dialog-field'; 897 898 const roleLabel=document.createElement('span'); 899 roleLabel.textContent='NÃvel'; 900 901 const role=document.createElement('select'); 902 903 [ 904 ['USER','User'], 905 ['COLLABORATOR','Colaborador'] 906 ].forEach(([value,text])=>{ 907 const op=document.createElement('option'); 908 op.value=value; 909 op.textContent=text; 910 role.append(op); 911 }); 912 913 roleWrap.append(roleLabel,role); 914 915 const submit=document.createElement('button'); 916 submit.type='submit'; 917 submit.className='dlab-btn dlab-btn-primary'; 918 submit.textContent='Register and generate password'; 919 920 formEl.append( 921 call.wrap, 922 birth.row, 923 roleWrap, 924 submit 925 ); 926 927 formEl.addEventListener('submit',async ev=>{ 928 ev.preventDefault(); 929 930 try{ 931 const data=await post( 932 'admin_create_user', 933 { 934 callsign:cleanBaseCall(call.input.value), 935 birth_day:Number(birth.day.value), 936 birth_month:Number(birth.month.value), 937 role:role.value 938 } 939 ); 940 941 showCredential( 942 data.callsign, 943 data.password, 944 'Account created' 945 ); 946 947 }catch(err){ 948 notify( 949 err?.data?.error==='account_exists' 950 ? 'This callsign is already registered.' 951 : 'Unable to register.' 952 ); 953 } 954 }); 955 956 create.append(summary,formEl); 957 958 const list=document.createElement('div'); 959 list.className='dlab-user-list'; 960 961 dialogBody.append(create,list); 962 963 try{ 964 const users=await loadUsers(); 965 966 users.forEach(u=>{ 967 list.append(userRow(u)); 968 }); 969 970 }catch(_){ 971 notify('Não foi possÃvel carregar usuários.'); 972 } 973} 974 975async function openCollaborators(){ 976 openDialog('Colaboradores'); 977 978 const intro=document.createElement('p'); 979 intro.className='dlab-dialog-text'; 980 intro.textContent= 981 'A collaborator can recover USER passwords, but cannot create administrators, send APRS from the dashboard, or change settings.'; 982 983 const list=document.createElement('div'); 984 list.className='dlab-user-list'; 985 986 dialogBody.append(intro,list); 987 988 try{ 989 const users=await loadUsers(); 990 991 const collabs=users.filter( 992 u=>u.role==='COLLABORATOR' 993 ); 994 995 if(!collabs.length){ 996 const empty=document.createElement('div'); 997 empty.className='dlab-empty'; 998 empty.textContent='No collaborators registered.'; 999 list.append(empty); 1000 return; 1001 } 1002 1003 collabs.forEach(u=>{ 1004 list.append(userRow(u)); 1005 }); 1006 1007 }catch(_){ 1008 notify( 1009 'Não foi possÃvel carregar colaboradores.' 1010 ); 1011 } 1012} 1013 1014async function openAudit(){ 1015 openDialog('Auditoria'); 1016 1017 try{ 1018 const data=await jsonFetch( 1019 `${api}?action=audit` 1020 ); 1021 1022 const rows=Array.isArray(data.audit) 1023 ? data.audit 1024 : []; 1025 1026 const list=document.createElement('div'); 1027 list.className='dlab-audit-list'; 1028 1029 if(!rows.length){ 1030 const empty=document.createElement('div'); 1031 empty.className='dlab-empty'; 1032 empty.textContent='No records.'; 1033 list.append(empty); 1034 } 1035 1036 rows.forEach(x=>{ 1037 const row=document.createElement('div'); 1038 row.className='dlab-audit-row'; 1039 1040 const action=document.createElement('strong'); 1041 action.textContent=x.action||'â'; 1042 1043 const meta=document.createElement('span'); 1044 meta.textContent= 1045 `${x.ts||''} ⢠${x.actor_callsign||''}`+ 1046 `${x.target_callsign?' â '+x.target_callsign:''}`; 1047 1048 row.append(action,meta); 1049 list.append(row); 1050 }); 1051 1052 dialogBody.append(list); 1053 1054 }catch(_){ 1055 notify('Não foi possÃvel carregar auditoria.'); 1056 } 1057} 1058 1059function openMyPassword(){ 1060 openDialog('Change my password'); 1061 1062 const text=document.createElement('p'); 1063 text.className='dlab-dialog-text'; 1064 text.textContent= 1065 'Enter your current password. The system will automatically generate a new password and invalidate the previous one.'; 1066 1067 const formEl=document.createElement('form'); 1068 formEl.className='dlab-dialog-form'; 1069 1070 const current=createField( 1071 'Current password', 1072 'password' 1073 ); 1074 1075 const submit=document.createElement('button'); 1076 submit.type='submit'; 1077 submit.className='dlab-btn dlab-btn-primary'; 1078 submit.textContent='Generate new password'; 1079 1080 formEl.append( 1081 current.wrap, 1082 submit 1083 ); 1084 1085 formEl.addEventListener('submit',async ev=>{ 1086 ev.preventDefault(); 1087 1088 try{ 1089 const data=await post( 1090 'self_rotate_password', 1091 { 1092 current_password:current.input.value 1093 } 1094 ); 1095 1096 showCredential( 1097 data.callsign, 1098 data.password,
1099 'New password' 1100 ); 1101 1102 }catch(_){ 1103 notify('Current password is incorrect.'); 1104 } 1105 }); 1106 1107 dialogBody.append(text,formEl); 1108} 1109 1110function openSetBirth(){ 1111 openDialog('Complete birthday'); 1112 1113 const text=document.createElement('p'); 1114 text.className='dlab-dialog-text'; 1115 text.textContent= 1116 'Enter your real birthday day and month. We do not ask for the year. This data will be used to help recover your password.'; 1117 1118 const birth=createBirthFields(); 1119 1120 const submit=document.createElement('button'); 1121 submit.type='button'; 1122 submit.className='dlab-btn dlab-btn-primary'; 1123 submit.textContent='Save birthday'; 1124 1125 submit.addEventListener('click',async()=>{ 1126 try{ 1127 await post( 1128 'set_birth', 1129 { 1130 birth_day:Number(birth.day.value), 1131 birth_month:Number(birth.month.value) 1132 } 1133 ); 1134 1135 notify('Birthday saved.'); 1136 dialog.close(); 1137 await refresh(); 1138 1139 }catch(_){ 1140 notify( 1141 'Informe um dia e mês válidos.' 1142 ); 1143 } 1144 }); 1145 1146 dialogBody.append( 1147 text, 1148 birth.row, 1149 submit 1150 ); 1151} 1152 1153loginForm?.addEventListener('submit',async ev=>{ 1154 ev.preventDefault(); 1155 1156 void ensureAudio(); 1157 1158 const call=cleanBaseCall( 1159 loginCall.value 1160 ); 1161 1162 loginCall.value=call; 1163 1164 if(!CALL.test(call)){ 1165 notify('Enter a valid callsign.'); 1166 return; 1167 } 1168 1169 try{ 1170 const data=await jsonFetch(api,{ 1171 method:'POST', 1172 headers:{ 1173 'Content-Type':'application/json', 1174 'Accept':'application/json' 1175 }, 1176 body:JSON.stringify({ 1177 action:'login', 1178 callsign:call, 1179 password:String(password.value||''), 1180 remember:Boolean(remember.checked) 1181 }) 1182 }); 1183 1184 password.value=''; 1185 csrf=String(data.csrf||''); 1186 1187 notify('Signed in.'); 1188 1189 await refresh(); 1190 1191 }catch(err){ 1192 notify( 1193 err?.data?.error==='rate_limited' 1194 ? 'Muitas tentativas. Aguarde alguns minutos.' 1195 : 'Invalid callsign or password.' 1196 ); 1197 } 1198}); 1199 1200registerForm?.addEventListener( 1201 'submit', 1202 async ev=>{ 1203 ev.preventDefault(); 1204 1205 const call=cleanBaseCall( 1206 registerCall.value 1207 ); 1208 1209 registerCall.value=call; 1210 1211 if(!CALL.test(call)){ 1212 notify('Enter a valid callsign.'); 1213 return; 1214 } 1215 1216 try{ 1217 const data=await jsonFetch(api,{ 1218 method:'POST', 1219 headers:{ 1220 'Content-Type':'application/json', 1221 'Accept':'application/json' 1222 }, 1223 body:JSON.stringify({ 1224 action:'register', 1225 callsign:call, 1226 birth_day:Number(birthDay.value), 1227 birth_month:Number(birthMonth.value), 1228 birthday_consent:Boolean( 1229 birthdayConsent.checked 1230 ) 1231 }) 1232 }); 1233 1234 showCredential( 1235 data.callsign, 1236 data.password, 1237 'Access created' 1238 ); 1239 1240 }catch(err){ 1241 const code=err?.data?.error; 1242 1243 const labels={ 1244 not_recently_seen: 1245 'The callsign has not been identified yet. Send a D-PRS beacon on module B or an APRS message to N0LAB-10 and try again.', 1246 account_exists: 1247 'This callsign already has an account.', 1248 invalid_birthday: 1249 'Informe seu dia e mês reais corretamente.', 1250 birthday_consent_required: 1251 'Confirme a autorização de uso do dia e mês.', 1252 rate_limited: 1253 'Muitas tentativas de cadastro. Aguarde.' 1254 }; 1255 1256 notify( 1257 labels[code]|| 1258 'Unable to create access.' 1259 ); 1260 } 1261 } 1262); 1263 1264logout?.addEventListener('click',async()=>{ 1265 try{ 1266 await post('logout'); 1267 }catch(_){} 1268 1269 setLoggedOut(); 1270 notify('Signed out.'); 1271}); 1272 1273dest?.addEventListener('input',()=>{ 1274 dest.value=cleanAprsCall(dest.value); 1275}); 1276 1277message?.addEventListener('input',()=>{ 1278 count.textContent=String( 1279 message.value.length 1280 ); 1281}); 1282 1283form?.addEventListener('submit',async ev=>{ 1284 ev.preventDefault(); 1285 1286 if(!permissions.send_aprs){ 1287 notify('Your access level does not allow APRS transmission from the dashboard.'); 1288 return; 1289 } 1290 1291 const to=cleanAprsCall(dest.value); 1292 const text=String(message.value||'').trim(); 1293 1294 if(!APRS_CALL.test(to)){ 1295 notify('Invalid APRS recipient.'); 1296 return; 1297 } 1298 1299 if( 1300 !text || 1301 text.length>60 || 1302 /[^\x20-\x7E]/.test(text) || 1303 /[{}]/.test(text) 1304 ){ 1305 notify( 1306 'Use de 1 a 60 caracteres APRS sem acentos.' 1307 ); 1308 return; 1309 } 1310 1311 send.disabled=true; 1312 1313 try{ 1314 const data=await post( 1315 'send', 1316 { 1317 dest:to, 1318 message:text 1319 } 1320 ); 1321 1322 message.value=''; 1323 count.textContent='0'; 1324 1325 notify( 1326 `Message sent to ${to}. ID ${data.msg_id} â waiting for ACK.` 1327 ); 1328 1329 await refresh(); 1330 1331 }catch(err){ 1332 notify( 1333 err?.data?.error==='rate_limited' 1334 ? 'Wait before sending another message.' 1335 : 'Unable to send the message.' 1336 ); 1337 1338 await refresh(); 1339 } 1340}); 1341 1342usersBtn?.addEventListener( 1343 'click', 1344 ()=>void openUsers() 1345); 1346 1347resetBtn?.addEventListener( 1348 'click', 1349 ()=>void openReset() 1350); 1351 1352collaboratorsBtn?.addEventListener( 1353 'click', 1354 ()=>void openCollaborators() 1355); 1356 1357auditBtn?.addEventListener( 1358 'click', 1359 ()=>void openAudit() 1360); 1361 1362myPasswordBtn?.addEventListener( 1363 'click', 1364 openMyPassword 1365); 1366 1367setBirthBtn?.addEventListener( 1368 'click', 1369 openSetBirth 1370); 1371 1372dialogClose?.addEventListener( 1373 'click', 1374 ()=>dialog.close() 1375); 1376 1377dialog?.addEventListener('click',ev=>{ 1378 if(ev.target===dialog){ 1379 dialog.close(); 1380 } 1381}); 1382 1383soundToggle?.addEventListener('click',()=>{ 1384 soundEnabled=!soundEnabled; 1385 saveSoundPref(); 1386 updateSoundButton(); 1387 1388 if(soundEnabled){ 1389 void ensureAudio(); 1390 notify('Som ativado.'); 1391 }else{ 1392 notify('Som desativado.'); 1393 } 1394}); 1395 1396document.querySelectorAll( 1397 '.dlab-command-action'
1398).forEach(btn=>{ 1399 btn.addEventListener('click',()=>{ 1400 const command=String( 1401 btn.dataset.command||'' 1402 ).trim(); 1403 1404 if(!command)return; 1405 1406 if( 1407 !account || 1408 !permissions.send_aprs || 1409 !form || 1410 form.hidden 1411 ){ 1412 notify( 1413 `Para usar ${command}, envie esse texto pelo seu rádio ou aplicativo APRS para N0LAB-10.` 1414 ); 1415 return; 1416 } 1417 1418 message.value=command; 1419 count.textContent=String( 1420 command.length 1421 ); 1422 1423 form.scrollIntoView({ 1424 behavior:'smooth', 1425 block:'center' 1426 }); 1427 1428 window.setTimeout(()=>{ 1429 message.focus(); 1430 1431 if(command.startsWith('LAST ')){ 1432 try{ 1433 message.setSelectionRange( 1434 5, 1435 message.value.length 1436 ); 1437 }catch(_){} 1438 } 1439 },300); 1440 1441 notify( 1442 `${command} placed in the Message field. Choose the APRS recipient and send it.` 1443 ); 1444 }); 1445}); 1446 1447 1448// Em Recent stations: 1449// clique especificamente no indicativo prepara resposta. 1450// Clique no restante do card continua abrindo o mapa. 1451root.addEventListener( 1452 'click', 1453 event=>{ 1454 if( 1455 !account || 1456 !permissions.send_aprs 1457 ){ 1458 return; 1459 } 1460 1461 const callElement= 1462 event.target.closest( 1463 '.dlab-station-call strong' 1464 ); 1465 1466 if(!callElement)return; 1467 1468 event.preventDefault(); 1469 event.stopPropagation(); 1470 1471 prepareAprsReply( 1472 callElement.textContent 1473 ); 1474 }, 1475 true 1476); 1477 1478root.addEventListener( 1479 'pointerdown', 1480 ()=>{ 1481 if(soundEnabled){ 1482 void ensureAudio(); 1483 } 1484 }, 1485 {once:true} 1486); 1487 1488document.addEventListener( 1489 'visibilitychange', 1490 ()=>{ 1491 if(!document.hidden){ 1492 unread=0; 1493 updateUnread(); 1494 } 1495 } 1496); 1497 1498updateSoundButton(); 1499updateUnread(); 1500setLoggedOut(); 1501 1502refresh(); 1503 1504timer=setInterval( 1505 refresh, 1506 5000 1507); 1508 1509window.addEventListener( 1510 'beforeunload', 1511 ()=>clearInterval(timer), 1512 {once:true} 1513); 1514 1515 1516/* XLX_APRS_FAQ_V4_ACCORDION */ 1517var dlabFaqButtons=root.querySelectorAll('.dlab-faq-toggle'); 1518 1519function dlabFaqClose(button){ 1520 var panelId=button.getAttribute('aria-controls'); 1521 var panel=panelId 1522 ? document.getElementById(panelId) 1523 : null; 1524 var icon=button.querySelector('.dlab-faq-icon'); 1525 1526 button.setAttribute('aria-expanded','false'); 1527 1528 if(panel){ 1529 panel.hidden=true; 1530 } 1531 1532 if(icon){ 1533 icon.textContent='+'; 1534 } 1535} 1536 1537function dlabFaqOpen(button){ 1538 var panelId=button.getAttribute('aria-controls'); 1539 var panel=panelId 1540 ? document.getElementById(panelId) 1541 : null; 1542 var icon=button.querySelector('.dlab-faq-icon'); 1543 1544 for(var i=0;i<dlabFaqButtons.length;i++){ 1545 if(dlabFaqButtons[i]!==button){ 1546 dlabFaqClose(dlabFaqButtons[i]); 1547 } 1548 } 1549 1550 button.setAttribute('aria-expanded','true'); 1551 1552 if(panel){ 1553 panel.hidden=false; 1554 } 1555 1556 if(icon){ 1557 icon.textContent='â'; 1558 } 1559} 1560 1561for(var dlabFaqIndex=0; 1562 dlabFaqIndex<dlabFaqButtons.length; 1563 dlabFaqIndex++){ 1564 1565 (function(button){ 1566 dlabFaqClose(button); 1567 1568 button.addEventListener('click',function(){ 1569 var expanded= 1570 button.getAttribute('aria-expanded')==='true'; 1571 1572 if(expanded){ 1573 dlabFaqClose(button); 1574 }else{ 1575 dlabFaqOpen(button); 1576 } 1577 }); 1578 })(dlabFaqButtons[dlabFaqIndex]); 1579} 1580 1581 1582})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.