Add admin user list view for simon@kuehn.de

- 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 <noreply@anthropic.com>
This commit is contained in:
Simon Kühn 2026-05-01 10:06:14 +02:00
parent b537066a19
commit eea6119e36
6 changed files with 1095 additions and 7 deletions

1
.env
View file

@ -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 ###

1000
app.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -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='<td style="padding:7px 12px">'+escHtml(name)+'</td>'
+'<td style="padding:7px 12px;color:var(--text2)">'+escHtml(u.email)+'</td>'
+'<td style="padding:7px 12px;color:var(--text2);font-size:.85em">'+date+'</td>';
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// ── 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();

View file

@ -0,0 +1,37 @@
<?php
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/admin')]
class AdminController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
) {}
#[Route('/users', methods: ['GET'])]
public function users(): JsonResponse
{
$user = $this->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);
}
}

View file

@ -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'] ?? ''),
]);
}

View file

@ -280,6 +280,7 @@
<div class="ddiv"></div>
<button class="dbtn dm-inv"><span class="dico">🔗</span><span class="dlbl"><span data-t="inviteLabel"></span><span class="dsub" data-t="inviteSub"></span></span></button>
<button class="dbtn dm-invlist"><span class="dico">📋</span><span class="dlbl"><span data-t="inviteListLabel"></span><span class="dsub" data-t="inviteListSub"></span></span></button>
<button class="dbtn dm-admin" style="display:none"><span class="dico">👥</span><span class="dlbl" data-t="adminLabel"></span></button>
<div class="ddiv"></div>
<button class="dbtn dm-name"><span class="dico">✏️</span><span class="dlbl" data-t="changeName"></span></button>
<button class="dbtn dm-cpw"><span class="dico">🔑</span><span class="dlbl" data-t="changePw"></span></button>
@ -336,6 +337,25 @@
</div>
</template>
<template id="tpl-admin-users">
<div>
<div class="stitle" data-t="adminLabel"></div>
<div class="dpanel-body" style="overflow-x:auto;padding:0">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="border-bottom:1px solid var(--border)">
<th style="padding:8px 12px;text-align:left;font-weight:600" data-t="adminColName"></th>
<th style="padding:8px 12px;text-align:left;font-weight:600" data-t="adminColEmail"></th>
<th style="padding:8px 12px;text-align:left;font-weight:600;color:var(--text2)" data-t="adminColRegistered"></th>
</tr>
</thead>
<tbody class="au-body"></tbody>
</table>
</div>
<div class="factions"><button class="btn-c au-close" data-t="close"></button></div>
</div>
</template>
<script src="app.js?v={{ jsv }}"></script>
</body>
</html>