From eea6119e3617153f77ec3fb226e8e75e0122737d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20K=C3=BChn?= Date: Fri, 1 May 2026 10:06:14 +0200 Subject: [PATCH] Add admin user list view for simon@kuehn.de MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADMIN_EMAIL env var controls who has admin access - GET /api/admin/users returns all users (id, email, username, registered); returns 403 for non-admins - GET /api/me now includes is_admin flag - Menu shows "Nutzer/Users/Użytkownicy" button for admins that opens a table with name, email, and registration date for all users Co-Authored-By: Claude Sonnet 4.6 --- .env | 1 + app.js | 1000 ++++++++++++++++++++++++++++ public/app.js | 33 +- src/Controller/AdminController.php | 37 + src/Controller/AuthController.php | 11 +- templates/app.html.twig | 20 + 6 files changed, 1095 insertions(+), 7 deletions(-) create mode 100644 app.js create mode 100644 src/Controller/AdminController.php diff --git a/.env b/.env index c36a3ba..940479f 100644 --- a/.env +++ b/.env @@ -15,4 +15,5 @@ MAILER_FROM=noreply@example.com ###> app ### APP_URL=http://localhost DEFAULT_URI=http://localhost/dd +ADMIN_EMAIL=simon@kuehn.de ###< app ### diff --git a/app.js b/app.js new file mode 100644 index 0000000..b97b273 --- /dev/null +++ b/app.js @@ -0,0 +1,1000 @@ +var TODAY = new Date(); TODAY.setHours(0,0,0,0); +var goals = [], prefs, selDay = {}, addAmt = {}, renamingId = null, renameVal = '', collapsed = {}; +var userName = '', isAdmin = false; + +var STRINGS = { + de: { + hint:'Menü → "Zum Startbildschirm" für App-Icon', + emptyLine1:'Noch keine Ziele.',emptyLine2:'Tippe auf + um zu starten.', + noEntry:'Noch kein Eintrag',log:'Eintragen', + noch:'Noch',dAbbr:'T',endet:'endet',todayShort:'heute',total:'total', + todayHeading:'Heute',done2:'Gemacht',dailyGoal:'Tagesziel',remaining:'Noch', + history:'Verlauf — heute & gestern bearbeitbar', + legBuf:'Puffer',legDone:'Erreicht',legPartial:'Teilweise',legMissed:'Verpasst', + delGoal:'Ziel löschen', + qbLabel:'Quick-Buchen',openLabel:'Offen',doneToday:'Heute erledigt', + hello:'Hallo {n}!', + confirmDelete:'Ziel wirklich löschen?',errDelete:'Fehler beim Löschen',errSave:'Speicherfehler', + loginTitle:'Anmelden',emailLabel:'E-Mail',passwordLabel:'Passwort',loginBtn:'Anmelden', + forgotPw:'Passwort vergessen?', + loginErrEmpty:'Bitte E-Mail und Passwort eingeben', + loginErrWrong:'Falsche E-Mail oder Passwort',loginErrRate:'Zu viele Versuche',loginErrConn:'Verbindungsfehler', + forgotTitle:'Passwort vergessen',forgotSub:'Wir schicken dir einen Reset-Link', + sendLink:'Link senden',back:'Zurück', + emailSentTitle:'E-Mail gesendet', + emailSentSub:'Falls die Adresse bekannt ist, erhältst du in Kürze einen Reset-Link.', + ok:'OK', + resetTitle:'Neues Passwort',newPwLabel:'Neues Passwort',setPw:'Passwort setzen',min8:'mind. 8 Zeichen', + pwChangedTitle:'Passwort geändert',pwChangedSub:'Du kannst dich jetzt anmelden.', + changeNameTitle:'Name ändern',yourName:'Dein Name',save:'Speichern',cancel:'Abbrechen', + errNameEmpty:'Name darf nicht leer sein',errNameSave:'Fehler beim Speichern',nameSaved:'Name gespeichert', + changePwTitle:'Passwort ändern',currentPwLabel:'Aktuelles Passwort', + newPwConfLabel:'Neues Passwort bestätigen',changePwBtn:'Ändern', + errPwMismatch:'Die neuen Passwörter stimmen nicht überein',pwChanged:'Passwort geändert', + registerTitle:'Konto erstellen',registerSub:'Du wurdest eingeladen', + namePlaceholder:'Wie sollen wir dich nennen?',pwConfLabel:'Passwort bestätigen', + pwPlaceholder:'Passwort wiederholen',registerBtn:'Registrieren', + errPwMismatch2:'Passwörter stimmen nicht überein',errFillAll:'Bitte alle Felder ausfüllen', + newGoalTitle:'Neues Ziel',exerciseLabel:'Übung / Gewohnheit',exercisePlaceholder:'Liegestütz, Plank …', + unitLabel:'Einheit',unitDefault:'Stück',daysLabel:'Dauer in Tagen', + dailyLabel:'Tagesziel',weeklyLabel:'Wochenziel',startGoal:'Ziel starten',errCreate:'Fehler beim Erstellen', + dataMenuTitle:'Daten verwalten',dataMenuSub:'Export, Import und Backup', + exportLabel:'Exportieren',exportSub:'Alle Ziele als JSON-Datei speichern', + importLabel:'Importieren',importSub:'Backup laden oder zusammenführen', + inviteLabel:'Freund einladen',inviteSub:'Einladungslink generieren', + inviteListLabel:'Meine Einladungen',inviteListSub:'Status aller gesendeten Einladungen', + changeName:'Name ändern',changePw:'Passwort ändern',logout:'Abmelden',close:'Schließen', + clearAll:'Alle Daten löschen',clearAllSub:'Kann nicht rückgängig gemacht werden', + confirmClear:'Alle Daten löschen?', + inviteFormTitle:'Freund einladen',inviteFormSub:'Link gilt 7 Tage und kann nur einmal verwendet werden', + inviteNameLabel:'Name (für deine Übersicht)',inviteNamePlaceholder:'z.B. Max', + generateLink:'Link generieren',inviteLinkTitle:'Einladungslink', + copyLink:'Link kopieren',linkCopied:'Link kopiert!',errGenerate:'Fehler beim Generieren', + noInvites:'Noch keine Einladungen verschickt', + statusPending:'Ausstehend',statusUsed:'Angenommen',statusExpired:'Abgelaufen', + errLoad:'Fehler beim Laden', + confirmImport:'{n} Ziel(e) importieren?',importDone:'{n} Ziel(e) importiert.',invalidFormat:'Ungültiges Format', + linkLabel:'Link',doneLabel:'gemacht',ofLabel:'von', + gestern:'Gestern',heute:'Heute', + expiresAt:'läuft ab:',acceptedBy:'→', + adminLabel:'Nutzer',adminColName:'Name',adminColEmail:'E-Mail',adminColRegistered:'Registriert', + }, + en: { + hint:'Menu → "Add to Home Screen" for app icon', + emptyLine1:'No goals yet.',emptyLine2:'Tap + to get started.', + noEntry:'No entries yet',log:'Log', + noch:'Left',dAbbr:'d',endet:'ends',todayShort:'today',total:'total', + todayHeading:'Today',done2:'Done',dailyGoal:'Daily goal',remaining:'Left', + history:'History — today & yesterday editable', + legBuf:'Buffer',legDone:'Reached',legPartial:'Partial',legMissed:'Missed', + delGoal:'Delete goal', + qbLabel:'Quick-log',openLabel:'Open',doneToday:'Done today', + hello:'Hello {n}!', + confirmDelete:'Really delete this goal?',errDelete:'Delete failed',errSave:'Save failed', + loginTitle:'Sign in',emailLabel:'E-Mail',passwordLabel:'Password',loginBtn:'Sign in', + forgotPw:'Forgot password?', + loginErrEmpty:'Please enter email and password', + loginErrWrong:'Wrong email or password',loginErrRate:'Too many attempts',loginErrConn:'Connection error', + forgotTitle:'Forgot password',forgotSub:"We'll send you a reset link", + sendLink:'Send link',back:'Back', + emailSentTitle:'Email sent', + emailSentSub:"If the address is registered, you'll receive a reset link shortly.", + ok:'OK', + resetTitle:'New password',newPwLabel:'New password',setPw:'Set password',min8:'at least 8 characters', + pwChangedTitle:'Password changed',pwChangedSub:'You can now sign in.', + changeNameTitle:'Change name',yourName:'Your name',save:'Save',cancel:'Cancel', + errNameEmpty:'Name cannot be empty',errNameSave:'Save failed',nameSaved:'Name saved', + changePwTitle:'Change password',currentPwLabel:'Current password', + newPwConfLabel:'Confirm new password',changePwBtn:'Change', + errPwMismatch:"The new passwords don't match",pwChanged:'Password changed', + registerTitle:'Create account',registerSub:"You've been invited", + namePlaceholder:'What should we call you?',pwConfLabel:'Confirm password', + pwPlaceholder:'Repeat password',registerBtn:'Register', + errPwMismatch2:"Passwords don't match",errFillAll:'Please fill in all fields', + newGoalTitle:'New goal',exerciseLabel:'Exercise / Habit',exercisePlaceholder:'Push-ups, Plank …', + unitLabel:'Unit',unitDefault:'reps',daysLabel:'Duration (days)', + dailyLabel:'Daily goal',weeklyLabel:'Weekly goal',startGoal:'Start goal',errCreate:'Create failed', + dataMenuTitle:'Manage data',dataMenuSub:'Export, Import and Backup', + exportLabel:'Export',exportSub:'Save all goals as JSON file', + importLabel:'Import',importSub:'Load or merge backup', + inviteLabel:'Invite friend',inviteSub:'Generate invite link', + inviteListLabel:'My invitations',inviteListSub:'Status of all sent invitations', + changeName:'Change name',changePw:'Change password',logout:'Sign out',close:'Close', + clearAll:'Delete all data',clearAllSub:'Cannot be undone', + confirmClear:'Delete all data?', + inviteFormTitle:'Invite friend',inviteFormSub:'Link valid 7 days, single use', + inviteNameLabel:'Name (for your reference)',inviteNamePlaceholder:'e.g. Max', + generateLink:'Generate link',inviteLinkTitle:'Invite link', + copyLink:'Copy link',linkCopied:'Link copied!',errGenerate:'Generate failed', + noInvites:'No invitations sent yet', + statusPending:'Pending',statusUsed:'Accepted',statusExpired:'Expired', + errLoad:'Load failed', + confirmImport:'Import {n} goal(s)?',importDone:'{n} goal(s) imported.',invalidFormat:'Invalid format', + linkLabel:'Link',doneLabel:'done',ofLabel:'of', + gestern:'Yesterday',heute:'Today', + expiresAt:'expires:',acceptedBy:'→', + adminLabel:'Users',adminColName:'Name',adminColEmail:'Email',adminColRegistered:'Registered', + }, + pl: { + hint:'Menu → "Dodaj do ekranu głównego" aby zainstalować', + emptyLine1:'Brak celów.',emptyLine2:'Dotknij +, aby zacząć.', + noEntry:'Brak wpisów',log:'Zapisz', + noch:'Jeszcze',dAbbr:'d',endet:'kończy',todayShort:'dziś',total:'łącznie', + todayHeading:'Dziś',done2:'Wykonano',dailyGoal:'Cel dzienny',remaining:'Pozostało', + history:'Historia — dziś i wczoraj edytowalne', + legBuf:'Zapas',legDone:'Osiągnięto',legPartial:'Częściowo',legMissed:'Pominięto', + delGoal:'Usuń cel', + qbLabel:'Szybki wpis',openLabel:'Otwarte',doneToday:'Dziś ukończone', + hello:'Cześć {n}!', + confirmDelete:'Na pewno usunąć cel?',errDelete:'Błąd podczas usuwania',errSave:'Błąd zapisu', + loginTitle:'Zaloguj się',emailLabel:'E-Mail',passwordLabel:'Hasło',loginBtn:'Zaloguj', + forgotPw:'Zapomniałeś hasła?', + loginErrEmpty:'Podaj e-mail i hasło', + loginErrWrong:'Błędny e-mail lub hasło',loginErrRate:'Zbyt wiele prób',loginErrConn:'Błąd połączenia', + forgotTitle:'Zapomniane hasło',forgotSub:'Wyślemy Ci link do resetu', + sendLink:'Wyślij link',back:'Wróć', + emailSentTitle:'E-mail wysłany', + emailSentSub:'Jeśli adres jest zarejestrowany, wkrótce otrzymasz link resetujący.', + ok:'OK', + resetTitle:'Nowe hasło',newPwLabel:'Nowe hasło',setPw:'Ustaw hasło',min8:'min. 8 znaków', + pwChangedTitle:'Hasło zmienione',pwChangedSub:'Możesz się teraz zalogować.', + changeNameTitle:'Zmień nazwę',yourName:'Twoje imię',save:'Zapisz',cancel:'Anuluj', + errNameEmpty:'Imię nie może być puste',errNameSave:'Błąd zapisu',nameSaved:'Imię zapisano', + changePwTitle:'Zmień hasło',currentPwLabel:'Aktualne hasło', + newPwConfLabel:'Potwierdź nowe hasło',changePwBtn:'Zmień', + errPwMismatch:'Nowe hasła się nie zgadzają',pwChanged:'Hasło zmienione', + registerTitle:'Utwórz konto',registerSub:'Zostałeś zaproszony', + namePlaceholder:'Jak mamy Cię nazywać?',pwConfLabel:'Potwierdź hasło', + pwPlaceholder:'Powtórz hasło',registerBtn:'Zarejestruj', + errPwMismatch2:'Hasła się nie zgadzają',errFillAll:'Wypełnij wszystkie pola', + newGoalTitle:'Nowy cel',exerciseLabel:'Ćwiczenie / Nawyk',exercisePlaceholder:'Pompki, Plank …', + unitLabel:'Jednostka',unitDefault:'szt.',daysLabel:'Czas trwania (dni)', + dailyLabel:'Cel dzienny',weeklyLabel:'Cel tygodniowy',startGoal:'Rozpocznij cel',errCreate:'Błąd tworzenia', + dataMenuTitle:'Zarządzaj danymi',dataMenuSub:'Eksport, import i kopia zapasowa', + exportLabel:'Eksportuj',exportSub:'Zapisz wszystkie cele jako plik JSON', + importLabel:'Importuj',importSub:'Załaduj lub połącz kopię zapasową', + inviteLabel:'Zaproś znajomego',inviteSub:'Wygeneruj link zaproszenia', + inviteListLabel:'Moje zaproszenia',inviteListSub:'Status wszystkich wysłanych zaproszeń', + changeName:'Zmień nazwę',changePw:'Zmień hasło',logout:'Wyloguj',close:'Zamknij', + clearAll:'Usuń wszystkie dane',clearAllSub:'Nie można cofnąć', + confirmClear:'Usunąć wszystkie dane?', + inviteFormTitle:'Zaproś znajomego',inviteFormSub:'Link ważny 7 dni, jednorazowy', + inviteNameLabel:'Nazwa (dla Twojej ewidencji)',inviteNamePlaceholder:'np. Max', + generateLink:'Wygeneruj link',inviteLinkTitle:'Link zaproszenia', + copyLink:'Kopiuj link',linkCopied:'Link skopiowany!',errGenerate:'Błąd generowania', + noInvites:'Brak wysłanych zaproszeń', + statusPending:'Oczekujący',statusUsed:'Zaakceptowany',statusExpired:'Wygasły', + errLoad:'Błąd ładowania', + confirmImport:'Importować {n} cel(e)?',importDone:'Zaimportowano {n} cel(e).',invalidFormat:'Nieprawidłowy format', + linkLabel:'Link',doneLabel:'wykonano',ofLabel:'z', + gestern:'Wczoraj',heute:'Dziś', + expiresAt:'wygasa:',acceptedBy:'→', + adminLabel:'Użytkownicy',adminColName:'Nazwa',adminColEmail:'E-mail',adminColRegistered:'Rejestracja', + } +}; + +var LOCALE = 'de'; + +function tr(key){ return (STRINGS[LOCALE]||STRINGS.de)[key]||STRINGS.de[key]||key; } +function ldoc(){ return LOCALE==='de'?'de-DE':LOCALE==='pl'?'pl-PL':'en-GB'; } + +function setLocale(lang, save){ + LOCALE = lang; + if(save){ + api('PATCH','me',{locale:lang}).catch(function(){}); + localStorage.setItem('zt_locale',lang); + } + render(); updateHeader(); +} + +function loadPref(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } } +function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); } +prefs = loadPref('zt_p','{}'); + +function tOff(g){ return Math.round((TODAY - new Date(g.start))/86400000); } +function o2d(g,i){ var d=new Date(new Date(g.start).getTime()+i*86400000); d.setHours(0,0,0,0); return d; } +function dTot(g,o){ return (g.sets[String(o)]||[]).reduce(function(a,b){return a+b.amount;},0); } +function fd(d){ return d.toLocaleDateString(ldoc(),{weekday:'short',day:'numeric',month:'short'}); } +function fs(d){ return d.toLocaleDateString(ldoc(),{day:'numeric',month:'short'}); } +function editable(g,o){ var t=tOff(g); return o===t||o===t-1; } +function now(){ var n=new Date(); return String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0'); } + +function heuteColor(tdone,daily){ + if(tdone===0) return 'var(--red)'; + if(tdone>=daily*1.1) return 'var(--blue)'; + if(tdone>=daily) return 'var(--green)'; + return 'var(--amber)'; +} + +function isCollapsed(id){ return collapsed[id]!==false; } +function toggleCollapse(id){ + var wasCollapsed=isCollapsed(id); + collapsed[id]=!wasCollapsed; + if(wasCollapsed){ + var g=goals.filter(function(x){return x.id===id;})[0]; + if(g) selDay[id]=tOff(g); + } + render(); +} + +function calc(g){ + var t=tOff(g), tot=g.daily*g.days; + var dr=Math.max(0,g.days-t-1); + var sd=new Date(g.start); sd.setHours(0,0,0,0); + var end=new Date(sd.getTime()+g.days*86400000); + var past=0; + for(var i=0;i=pd}; +} + +function dcls(g,i){ + var t=tOff(g); if(i>t) return 'dot df'; + var v=dTot(g,i); + var c=v===0?'dot dm':v>=g.daily*1.1?'dot db':v>=g.daily?'dot dd':'dot dp'; + return c+(editable(g,i)?' de':' dl'); +} +function dlbl(g,i){ + var t=tOff(g); if(i>t) return String(i+1); + var v=dTot(g,i); + if(v===0) return '✕'; if(v>=g.daily*1.1) return '+'; if(v>=g.daily) return '✓'; + return Math.round(v/g.daily*100)+'%'; +} + +// ── API ────────────────────────────────────────────────────────────────────── + +function api(method, path, body){ + var opts = {method:method, credentials:'include', headers:{'Content-Type':'application/json'}}; + if(body) opts.body = JSON.stringify(body); + return fetch('api/' + path, opts).then(function(res){ + return res.json().then(function(data){ + if(!res.ok){ var e=new Error(data.error||'Fehler'); e.status=res.status; throw e; } + return data; + }); + }); +} + +function loadGoals(){ + return api('GET','goals').then(function(data){ return data; }); +} + +function saveGoal(g){ + api('PATCH','goals/'+g.id,{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets}) + .catch(function(e){ + if(e.status===401){ showLogin(); } + else showToast(tr('errSave')); + }); +} + +// ── Toast ───────────────────────────────────────────────────────────────────── + +function showToast(msg){ + var t=document.createElement('div'); t.className='toast'; t.textContent=msg; + document.body.appendChild(t); setTimeout(function(){t.remove();},3000); +} + +// ── Goal-Aktionen ───────────────────────────────────────────────────────────── + +function addSet(gid,off){ + var g=goals.filter(function(x){return x.id===gid;})[0]; + if(!g||!editable(g,off)) return; + var k=gid+'_'+off, amt=parseInt(addAmt[k]||'0',10); + if(amt<=0) return; + if(!g.sets[String(off)]) g.sets[String(off)]=[]; + g.sets[String(off)].push({amount:amt,time:off===tOff(g)?now():'—'}); + addAmt[k]=''; saveGoal(g); render(); +} +function remSet(gid,off,idx){ + var g=goals.filter(function(x){return x.id===gid;})[0]; + if(!g||!editable(g,off)) return; + g.sets[String(off)].splice(idx,1); saveGoal(g); render(); +} +function delGoal(id){ + if(!confirm(tr('confirmDelete'))) return; + goals=goals.filter(function(g){return g.id!==id;}); + render(); + api('DELETE','goals/'+id).catch(function(){ showToast(tr('errDelete')); }); +} +function selD(gid,off){ + var g=goals.filter(function(x){return x.id===gid;})[0]; + if(!g||!editable(g,off)) return; + selDay[gid]=selDay[gid]===off?null:off; render(); +} +function startRen(id){ + var g=goals.filter(function(x){return x.id===id;})[0]; if(!g) return; + renamingId=id; renameVal=g.name; render(); + setTimeout(function(){ var el=document.getElementById('ri'+id); if(el){el.focus();el.select();} },50); +} +function commitRen(id){ + var g=goals.filter(function(x){return x.id===id;})[0]; + if(g&&renameVal.trim()){g.name=renameVal.trim(); saveGoal(g);} + renamingId=null; render(); +} +function cancelRen(){ renamingId=null; render(); } + +// ── Template-Helper ─────────────────────────────────────────────────────────── + +function tpl(id){ + var el=document.getElementById(id).content.cloneNode(true).firstElementChild; + el.querySelectorAll('[data-t]').forEach(function(n){ n.textContent=tr(n.dataset.t); }); + el.querySelectorAll('[data-ph]').forEach(function(n){ n.placeholder=tr(n.dataset.ph); }); + el.querySelectorAll('[data-val]').forEach(function(n){ n.value=tr(n.dataset.val); }); + return el; +} + +// ── Overlays ────────────────────────────────────────────────────────────────── + +var OV_CSS='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;justify-content:center;animation:fi .2s ease'; + +function closeOv(){ + var o=document.getElementById('ov'); + o.style.display='none'; + o.innerHTML=''; +} + +function showSheet(content, dismissable){ + var o=document.getElementById('ov'); + o.style.cssText=OV_CSS; + var sheet=tpl('tpl-sheet'); + sheet.appendChild(content); + o.innerHTML=''; + o.appendChild(sheet); + o.onclick=dismissable!==false?function(e){if(e.target===o)closeOv();}:null; +} + +// ── Login ───────────────────────────────────────────────────────────────────── + +function showLogin(err){ + var c=tpl('tpl-login'); + if(err){ var e=c.querySelector('.login-err'); e.textContent=err; e.style.display=''; } + showSheet(c,false); + var email=c.querySelector('.lf-email'), pass=c.querySelector('.lf-pass'), sub=c.querySelector('.lf-sub'); + setTimeout(function(){email.focus();},50); + email.onkeydown=function(e){if(e.key==='Enter')pass.focus();}; + pass.onkeydown=function(e){if(e.key==='Enter')sub.click();}; + c.querySelector('.lf-fgt').onclick=function(){showForgotPassword();}; + sub.onclick=function(){ + var ev=email.value.trim(), pv=pass.value; + if(!ev||!pv){ var errEl=c.querySelector('.login-err'); errEl.textContent=tr('loginErrEmpty'); errEl.style.display=''; return; } + sub.disabled=true; sub.textContent='…'; + api('POST','login',{email:ev,password:pv}) + .then(function(){ return loadGoals(); }) + .then(function(g){ goals=g; closeOv(); render(); }) + .catch(function(err){ + sub.disabled=false; sub.textContent=tr('loginBtn'); + showLogin(err.status===401?tr('loginErrWrong'):err.status===429?tr('loginErrRate'):tr('loginErrConn')); + }); + }; +} + +// ── Passwort vergessen ──────────────────────────────────────────────────────── + +function showForgotPassword(){ + var c=tpl('tpl-forgot-pw'); + showSheet(c,false); + var email=c.querySelector('.fp-email'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.fp-sub'); + setTimeout(function(){email.focus();},50); + c.querySelector('.fp-back').onclick=function(){showLogin();}; + sub.onclick=function(){ + var ev=email.value.trim(); if(!ev) return; + sub.disabled=true; sub.textContent='…'; + api('POST','reset-request',{email:ev}) + .then(function(){ + var conf=tpl('tpl-email-sent'); + conf.querySelector('.es-ok').onclick=function(){showLogin();}; + showSheet(conf,false); + }) + .catch(function(err){ + sub.disabled=false; sub.textContent=tr('sendLink'); + errEl.textContent=err.message||'Fehler'; errEl.style.display=''; + }); + }; +} + +// ── Passwort zurücksetzen ───────────────────────────────────────────────────── + +function showResetPassword(selector,token){ + var c=tpl('tpl-reset-pw'); + showSheet(c,false); + var pass=c.querySelector('.rp-pass'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.rp-sub'); + setTimeout(function(){pass.focus();},50); + sub.onclick=function(){ + var pv=pass.value; if(!pv) return; + sub.disabled=true; sub.textContent='…'; + api('POST','reset-password',{selector:selector,token:token,password:pv}) + .then(function(){ + var conf=tpl('tpl-pw-changed'); + conf.querySelector('.pc-ok').onclick=function(){showLogin();}; + showSheet(conf,false); + }) + .catch(function(err){ + sub.disabled=false; sub.textContent=tr('setPw'); + errEl.textContent=err.message||'Fehler'; errEl.style.display=''; + }); + }; +} + +// ── Passwort ändern ─────────────────────────────────────────────────────────── + +function showChangePassword(){ + var c=tpl('tpl-change-pw'); + showSheet(c,true); + var oldP=c.querySelector('.cp-old'), newP=c.querySelector('.cp-new'), newP2=c.querySelector('.cp-new2'); + var errEl=c.querySelector('.login-err'), sub=c.querySelector('.cp-sub'); + setTimeout(function(){oldP.focus();},50); + c.querySelector('.cp-can').onclick=closeOv; + sub.onclick=function(){ + var o=oldP.value, n=newP.value, n2=newP2.value; + if(!o||!n||!n2) return; + if(n!==n2){ errEl.textContent=tr('errPwMismatch'); errEl.style.display=''; return; } + sub.disabled=true; sub.textContent='…'; + api('POST','change-password',{old_password:o,new_password:n}) + .then(function(){ showToast(tr('pwChanged')); closeOv(); }) + .catch(function(err){ + sub.disabled=false; sub.textContent=tr('changePwBtn'); + errEl.textContent=err.message||'Fehler'; errEl.style.display=''; + }); + }; +} + +// ── Registrierung ───────────────────────────────────────────────────────────── + +function showRegister(token){ + var c=tpl('tpl-register'); + showSheet(c,false); + var nameInp=c.querySelector('.rg-name'), email=c.querySelector('.rg-email'); + var pass=c.querySelector('.rg-pass'), pass2=c.querySelector('.rg-pass2'); + var errEl=c.querySelector('.login-err'), sub=c.querySelector('.rg-sub'); + setTimeout(function(){nameInp.focus();},50); + nameInp.onkeydown=function(e){if(e.key==='Enter')email.focus();}; + email.onkeydown=function(e){if(e.key==='Enter')pass.focus();}; + pass.onkeydown=function(e){if(e.key==='Enter')pass2.focus();}; + pass2.onkeydown=function(e){if(e.key==='Enter')sub.click();}; + function checkMatch(){ if(pass2.value&&pass.value!==pass2.value){ errEl.textContent=tr('errPwMismatch2'); errEl.style.display=''; } else { errEl.style.display='none'; } } + pass.oninput=checkMatch; pass2.oninput=checkMatch; + sub.onclick=function(){ + var nv=nameInp.value.trim(), ev=email.value.trim(), pv=pass.value; + if(!nv||!ev||!pv){ errEl.textContent=tr('errFillAll'); errEl.style.display=''; return; } + if(pv!==pass2.value){ errEl.textContent=tr('errPwMismatch2'); errEl.style.display=''; return; } + sub.disabled=true; sub.textContent='…'; + api('POST','register',{name:nv,email:ev,password:pv,token:token}) + .then(function(r){ userName=r.name||''; return loadGoals(); }) + .then(function(g){ goals=g; closeOv(); updateHeader(); render(); }) + .catch(function(err){ + sub.disabled=false; sub.textContent=tr('registerBtn'); + errEl.textContent=err.message||'Fehler'; errEl.style.display=''; + }); + }; +} + +// ── Neues Ziel ──────────────────────────────────────────────────────────────── + +function openNew(){ + var c=tpl('tpl-new-goal'); + showSheet(c,true); + var name=c.querySelector('.ng-name'), unit=c.querySelector('.ng-unit'); + var daily=c.querySelector('.ng-daily'), weekly=c.querySelector('.ng-weekly'); + var days=c.querySelector('.ng-days'), sub=c.querySelector('.ng-sub'); + daily.addEventListener('input',function(){ + if(daily.value) weekly.value=Math.round(parseFloat(daily.value)*7*100)/100; + }); + weekly.addEventListener('input',function(){ + if(weekly.value) daily.value=Math.round(parseFloat(weekly.value)/7*100)/100; + }); + setTimeout(function(){name.focus();},50); + c.querySelector('.ng-can').onclick=closeOv; + sub.onclick=function(){ + var nv=(name.value||'').trim(), uv=(unit.value||'').trim()||tr('unitDefault'); + var dv=parseFloat(daily.value)||1, dyv=parseInt(days.value,10)||30; + if(!nv){ name.focus(); return; } + sub.disabled=true; + api('POST','goals',{name:nv,unit:uv,daily:dv,days:dyv,start:TODAY.toISOString()}) + .then(function(r){ + goals.push({id:r.id,name:r.name,unit:r.unit,daily:r.daily,days:r.days,start:r.start,sets:r.sets||{}}); + closeOv(); render(); + }).catch(function(e){ + sub.disabled=false; + if(e.status===401){ closeOv(); showLogin(); } + else showToast(tr('errCreate')); + }); + }; +} + +// ── Daten-Menü ──────────────────────────────────────────────────────────────── + +function openData(){ + var c=tpl('tpl-data-menu'); + showSheet(c,true); + c.querySelector('.dm-cls').onclick=closeOv; + c.querySelector('.dm-name').onclick=function(){ + var nc=tpl('tpl-change-name'); + showSheet(nc,true); + var inp=nc.querySelector('.cn-name'), errEl=nc.querySelector('.login-err'), sub=nc.querySelector('.cn-sub'); + inp.value=userName; + setTimeout(function(){inp.focus();inp.select();},50); + nc.querySelector('.cn-can').onclick=closeOv; + sub.onclick=function(){ + var nv=inp.value.trim(); + if(!nv){ errEl.textContent=tr('errNameEmpty'); errEl.style.display=''; return; } + sub.disabled=true; sub.textContent='…'; + api('PATCH','me',{name:nv}) + .then(function(r){ userName=r.name; closeOv(); render(); showToast(tr('nameSaved')); }) + .catch(function(){ sub.disabled=false; sub.textContent=tr('save'); showToast(tr('errNameSave')); }); + }; + }; + c.querySelector('.dm-cpw').onclick=function(){ closeOv(); showChangePassword(); }; + c.querySelector('.dm-lgout').onclick=function(){ + api('POST','logout').then(function(){ goals=[]; closeOv(); render(); showLogin(); }); + }; + + var adminBtn=c.querySelector('.dm-admin'); + if(isAdmin){ adminBtn.style.display=''; adminBtn.onclick=function(){ closeOv(); openAdmin(); }; } + + c.querySelector('.dm-inv').onclick=function(){ + var ic=tpl('tpl-invite-form'); + showSheet(ic,true); + var invName=ic.querySelector('.inv-name'); + setTimeout(function(){invName.focus();},50); + ic.querySelector('.inv-cancel').onclick=closeOv; + ic.querySelector('.inv-gen').onclick=function(){ + var note=invName.value.trim(), btn=this; + btn.disabled=true; btn.textContent='…'; + api('POST','invite',{note:note}).then(function(res){ + var lc=tpl('tpl-invite-link'); + lc.querySelector('.stitle').textContent=tr('inviteLinkTitle')+(note?' — '+note:''); + var urlInp=lc.querySelector('.il-url'); + urlInp.value=res.url; + showSheet(lc,true); + lc.querySelector('.il-close').onclick=closeOv; + lc.querySelector('.il-copy').onclick=function(){ + navigator.clipboard.writeText(res.url).then(function(){ showToast(tr('linkCopied')); closeOv(); }); + }; + setTimeout(function(){urlInp.select();},50); + }).catch(function(){ + btn.disabled=false; btn.textContent=tr('generateLink'); + showToast(tr('errGenerate')); + }); + }; + }; + + c.querySelector('.dm-invlist').onclick=function(){ + api('GET','invites').then(function(list){ + var statusLabel={'pending':tr('statusPending'),'used':tr('statusUsed'),'expired':tr('statusExpired')}; + var statusColor={'pending':'var(--amber)','used':'var(--green)','expired':'var(--red)'}; + var lc=tpl('tpl-invite-list'); + var body=lc.querySelector('.dpanel-body'); + if(!list.length){ + var empty=document.createElement('div'); + empty.className='nosets'; empty.style.padding='16px'; + empty.textContent=tr('noInvites'); + body.appendChild(empty); + } else { + for(var i=0;i' + +''+escHtml(u.email)+'' + +''+date+''; + body.appendChild(row); + }); + showSheet(c,true); + c.querySelector('.au-close').onclick=closeOv; + }).catch(function(){ showToast(tr('errLoad')); }); +} + +function escHtml(s){ return String(s).replace(/&/g,'&').replace(//g,'>'); } + +// ── Card-Bausteine ──────────────────────────────────────────────────────────── + +function buildNameWrap(g){ + if(renamingId===g.id){ + var el=tpl('tpl-name-edit'); + var inp=el.querySelector('.ren-input'); + inp.id='ri'+g.id; inp.value=g.name; inp.dataset.g=g.id; + return el; + } + var el=tpl('tpl-name-view'); + el.querySelector('.goal-name').textContent=g.name; + el.querySelector('.btn-ren').dataset.g=g.id; + return el; +} + +function buildPanel(g,off){ + var t=tOff(g), sets=g.sets[String(off)]||[], tot=dTot(g,off); + var lbl=off===t?tr('heute'):tr('gestern'), k=g.id+'_'+off; + var el=tpl('tpl-panel'); + el.querySelector('.dpanel-title').textContent=lbl+' — '+fd(o2d(g,off)); + el.querySelector('.dpanel-sub').textContent=tot+' / '+g.daily+' '+g.unit; + var body=el.querySelector('.dpanel-body'); + if(sets.length){ + for(var i=0;i0?'var(--blue)':c.st===0?'var(--green)':c.dailyDelta<=0?'var(--green)':c.dailyDelta<=g.daily*.2?'var(--amber)':'var(--red)'; + var bc,bt,bufStr=(c.buf>0?'+':'')+c.buf; + if(c.ok&&c.surplus>0){bc='b-buf';bt=bufStr;} + else if(c.ok){bc='b-done';bt=bufStr;} + else if(c.dailyDelta<=0){bc='b-ok';bt=bufStr;} + else if(c.dailyDelta<=g.daily*.2){bc='b-warn';bt=bufStr;} + else{bc='b-danger';bt=bufStr;} + + var el; + if(isCollapsed(g.id)){ + el=tpl('tpl-card-collapsed'); + if(c.ok) el.classList.add('done'); + el.querySelector('.card-hdr').dataset.g=g.id; + var bd=el.querySelector('.card-bd'); + bd.insertBefore(buildNameWrap(g),bd.firstElementChild); + var hc=heuteColor(c.tdone,g.daily); + el.querySelector('.m-dr').textContent=c.dr; + el.querySelector('.m-end').textContent=fs(c.end); + var mH=el.querySelector('.m-heute'); mH.textContent=c.tdone+'/'+g.daily; mH.style.color=hc; + el.querySelector('.m-total').textContent=c.done+'/'+c.tot; + var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt; + var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc; + return el; + } + + el=tpl('tpl-card-expanded'); + if(c.ok) el.classList.add('done'); + el.querySelector('.card-hdr').dataset.g=g.id; + var bd=el.querySelector('.card-bd'); + bd.insertBefore(buildNameWrap(g),bd.firstElementChild); + el.querySelector('.m-dr').textContent=c.dr; + el.querySelector('.m-end').textContent=fs(c.end); + var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt; + var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc; + el.querySelector('.pr-done').textContent=c.done+' '+g.unit+' '+tr('doneLabel'); + el.querySelector('.pr-pct').textContent=c.pct+'% '+tr('ofLabel')+' '+c.tot; + el.querySelector('.sv-tdone').textContent=c.tdone; + el.querySelector('.sv-daily').textContent=g.daily; + el.querySelector('.sv-st').textContent=c.st; + el.querySelector('.sv-noch').style.color=heuteColor(c.tdone,g.daily); + el.querySelectorAll('.sunit').forEach(function(u){ u.textContent=g.unit; }); + + var sel=selDay[g.id]!=null?selDay[g.id]:t; + var dotsWrap=el.querySelector('.dots-wrap'); + for(var i=0;i0?' ry':''); + if(ed){ dot.dataset.g=g.id; dot.dataset.d=i; } + dot.textContent=dlbl(g,i); + dotsWrap.appendChild(dot); + } + + if(sel!=null) el.insertBefore(buildPanel(g,sel),el.querySelector('.card-foot')); + el.querySelector('.btn-del').dataset.g=g.id; + return el; +} + +// ── Quick-Buchen ────────────────────────────────────────────────────────────── + +function buildQuickBook(){ + var active=goals.filter(function(g){ var c=calc(g); return tOff(g)=g.days) units+=Math.floor(g.days/30); + } + var gold=Math.floor(units/25); units%=25; + var silver=Math.floor(units/5); var bronze=units%5; + return{gold:gold,silver:silver,bronze:bronze}; +} + +function render(){ + var m=document.getElementById('main'); + var frag=document.createDocumentFragment(); + + if(!prefs.hd){ + var hint=tpl('tpl-hint'); + hint.querySelector('.hclose').onclick=function(){ prefs.hd=1; saveP(); hint.remove(); }; + frag.appendChild(hint); + } + + var aw=calcAwards(); + if(aw.gold||aw.silver||aw.bronze){ + var awards=document.createElement('div'); awards.className='awards'; + var medals=[['🥇',aw.gold],['🥈',aw.silver],['🥉',aw.bronze]]; + for(var mi=0;mi= 1000; + document.querySelectorAll('.btn-sw-fill').forEach(function(b){ b.style.display = show ? '' : 'none'; }); + } + + function fmt(ms){ return (ms / 1000).toFixed(2) + 's'; } + + function tick(){ + swEl.textContent = fmt(Date.now() - start + elapsed); + updateFillBtns(); + raf = requestAnimationFrame(tick); + } + + swEl.addEventListener('click', function(){ + if(state === 0){ + start = Date.now(); elapsed = 0; + swEl.classList.add('running'); + state = 1; tick(); + } else if(state === 1){ + cancelAnimationFrame(raf); + elapsed += Date.now() - start; + swEl.textContent = fmt(elapsed); + swEl.classList.remove('running'); + state = 2; updateFillBtns(); + } else { + cancelAnimationFrame(raf); + elapsed = 0; swEl.textContent = '0.00s'; + swEl.classList.remove('running'); + state = 0; updateFillBtns(); + } + }); + + document.addEventListener('click', function(e){ + if(!e.target.classList.contains('btn-sw-fill')) return; + var inp = e.target.closest('.add-row, .qb-row').querySelector('.num-in'); + if(inp) inp.value = Math.floor(getMs() / 1000); + }); + + return { getMs: getMs }; +})(); diff --git a/public/app.js b/public/app.js index 31fbde1..b97b273 100644 --- a/public/app.js +++ b/public/app.js @@ -1,6 +1,6 @@ var TODAY = new Date(); TODAY.setHours(0,0,0,0); var goals = [], prefs, selDay = {}, addAmt = {}, renamingId = null, renameVal = '', collapsed = {}; -var userName = ''; +var userName = '', isAdmin = false; var STRINGS = { de: { @@ -57,6 +57,7 @@ var STRINGS = { linkLabel:'Link',doneLabel:'gemacht',ofLabel:'von', gestern:'Gestern',heute:'Heute', expiresAt:'läuft ab:',acceptedBy:'→', + adminLabel:'Nutzer',adminColName:'Name',adminColEmail:'E-Mail',adminColRegistered:'Registriert', }, en: { hint:'Menu → "Add to Home Screen" for app icon', @@ -112,6 +113,7 @@ var STRINGS = { linkLabel:'Link',doneLabel:'done',ofLabel:'of', gestern:'Yesterday',heute:'Today', expiresAt:'expires:',acceptedBy:'→', + adminLabel:'Users',adminColName:'Name',adminColEmail:'Email',adminColRegistered:'Registered', }, pl: { hint:'Menu → "Dodaj do ekranu głównego" aby zainstalować', @@ -167,6 +169,7 @@ var STRINGS = { linkLabel:'Link',doneLabel:'wykonano',ofLabel:'z', gestern:'Wczoraj',heute:'Dziś', expiresAt:'wygasa:',acceptedBy:'→', + adminLabel:'Użytkownicy',adminColName:'Nazwa',adminColEmail:'E-mail',adminColRegistered:'Rejestracja', } }; @@ -534,6 +537,9 @@ function openData(){ api('POST','logout').then(function(){ goals=[]; closeOv(); render(); showLogin(); }); }; + var adminBtn=c.querySelector('.dm-admin'); + if(isAdmin){ adminBtn.style.display=''; adminBtn.onclick=function(){ closeOv(); openAdmin(); }; } + c.querySelector('.dm-inv').onclick=function(){ var ic=tpl('tpl-invite-form'); showSheet(ic,true); @@ -633,6 +639,29 @@ function openData(){ }; } +// ── Admin ───────────────────────────────────────────────────────────────────── + +function openAdmin(){ + api('GET','admin/users').then(function(rows){ + var c=tpl('tpl-admin-users'); + var body=c.querySelector('.au-body'); + rows.forEach(function(u){ + var row=document.createElement('tr'); + row.style.borderBottom='1px solid var(--border)'; + var name=u.username||'—'; + var date=new Date(u.registered*1000).toLocaleDateString(ldoc(),{day:'numeric',month:'short',year:'numeric'}); + row.innerHTML=''+escHtml(name)+'' + +''+escHtml(u.email)+'' + +''+date+''; + body.appendChild(row); + }); + showSheet(c,true); + c.querySelector('.au-close').onclick=closeOv; + }).catch(function(){ showToast(tr('errLoad')); }); +} + +function escHtml(s){ return String(s).replace(/&/g,'&').replace(//g,'>'); } + // ── Card-Bausteine ──────────────────────────────────────────────────────────── function buildNameWrap(g){ @@ -895,7 +924,7 @@ if(resetSelector&&resetToken){ applyLocale(null); render(); showResetPassword(resetSelector,resetToken); } else { api('GET','me') - .then(function(r){ userName=r.name||''; applyLocale(r.locale); updateHeader(); return loadGoals(); }) + .then(function(r){ userName=r.name||''; isAdmin=r.is_admin||false; applyLocale(r.locale); updateHeader(); return loadGoals(); }) .then(function(g){ goals=g; render(); }) .catch(function(){ applyLocale(null); render(); diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php new file mode 100644 index 0000000..1f2b8fd --- /dev/null +++ b/src/Controller/AdminController.php @@ -0,0 +1,37 @@ +getUser(); + if (!$user instanceof User) { + return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED); + } + + if ($user->getEmail() !== ($_ENV['ADMIN_EMAIL'] ?? '')) { + return new JsonResponse(['error' => 'Forbidden'], Response::HTTP_FORBIDDEN); + } + + $rows = $this->em->getConnection()->fetchAllAssociative( + 'SELECT id, email, username, registered FROM users ORDER BY registered ASC' + ); + + return new JsonResponse($rows); + } +} diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php index 5043d2c..8cb977e 100644 --- a/src/Controller/AuthController.php +++ b/src/Controller/AuthController.php @@ -34,11 +34,12 @@ class AuthController extends AbstractController return new JsonResponse(['ok' => false], Response::HTTP_UNAUTHORIZED); } return new JsonResponse([ - 'ok' => true, - 'email' => $user->getEmail(), - 'id' => $user->getId(), - 'name' => $user->getUsername() ?? '', - 'locale' => $user->getLocale(), + 'ok' => true, + 'email' => $user->getEmail(), + 'id' => $user->getId(), + 'name' => $user->getUsername() ?? '', + 'locale' => $user->getLocale(), + 'is_admin' => $user->getEmail() === ($_ENV['ADMIN_EMAIL'] ?? ''), ]); } diff --git a/templates/app.html.twig b/templates/app.html.twig index 9d09a69..a5dbc7f 100644 --- a/templates/app.html.twig +++ b/templates/app.html.twig @@ -280,6 +280,7 @@
+
@@ -336,6 +337,25 @@ + +