PageSourceSearch

http://dstarbrasil.com.br/assets/digital-lab-operator.js?v=1.4.6-20260921061746-1789971320

js dstarbrasil.com.br collected 2026-10-04 15:29:06 UTC 32,216 bytes, 1,582 lines download raw bytes

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.