| @@ -0,0 +1,115 @@ | |||||
| // account.js | |||||
| document.addEventListener('DOMContentLoaded', () => { | |||||
| const toast = document.getElementById('account-toast'); | |||||
| function showToast(msg, isError = false) { | |||||
| toast.textContent = msg; | |||||
| toast.classList.toggle('account-toast--error', isError); | |||||
| toast.classList.add('account-toast--visible'); | |||||
| setTimeout(() => toast.classList.remove('account-toast--visible'), 3000); | |||||
| } | |||||
| async function patchJson(url, data) { | |||||
| const res = await fetch(url, { | |||||
| method: 'PATCH', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify(data), | |||||
| }); | |||||
| const json = await res.json(); | |||||
| if (!res.ok) throw new Error(json.error ?? 'Fehler'); | |||||
| return json; | |||||
| } | |||||
| // ── Account-Formular ────────────────────────────────────────────────────── | |||||
| const btnAccountSave = document.getElementById('btn-account-save'); | |||||
| if (btnAccountSave) { | |||||
| btnAccountSave.addEventListener('click', async () => { | |||||
| try { | |||||
| await patchJson('/api/account', { | |||||
| name: document.getElementById('account-name').value.trim(), | |||||
| trackingInterval: parseInt(document.getElementById('account-interval').value, 10), | |||||
| }); | |||||
| showToast('Gespeichert.'); | |||||
| } catch (e) { | |||||
| showToast(e.message, true); | |||||
| } | |||||
| }); | |||||
| } | |||||
| // ── Besitzer des Accounts ───────────────────────────────────────────────── | |||||
| const superadminSelect = document.getElementById('superadmin-select'); | |||||
| if (superadminSelect && !superadminSelect.disabled) { | |||||
| superadminSelect.addEventListener('change', async () => { | |||||
| const selectedName = superadminSelect.options[superadminSelect.selectedIndex].text; | |||||
| if (!confirm(`${selectedName} zum neuen Kontoinhaber machen?`)) { | |||||
| // Auswahl zurücksetzen | |||||
| superadminSelect.value = superadminSelect.dataset.original; | |||||
| return; | |||||
| } | |||||
| try { | |||||
| await patchJson('/api/account/superadmin', { | |||||
| userId: parseInt(superadminSelect.value, 10), | |||||
| }); | |||||
| showToast('Kontoinhaber geändert. Seite wird neu geladen…'); | |||||
| setTimeout(() => window.location.reload(), 1500); | |||||
| } catch (e) { | |||||
| showToast(e.message, true); | |||||
| superadminSelect.value = superadminSelect.dataset.original; | |||||
| } | |||||
| }); | |||||
| // Original-Wert merken für Rollback | |||||
| superadminSelect.dataset.original = superadminSelect.value; | |||||
| } | |||||
| // ── Passwort-Toggle ─────────────────────────────────────────────────────── | |||||
| const btnPwToggle = document.getElementById('btn-pw-toggle'); | |||||
| const pwSection = document.getElementById('pw-section'); | |||||
| if (btnPwToggle && pwSection) { | |||||
| btnPwToggle.addEventListener('click', (e) => { | |||||
| e.preventDefault(); | |||||
| const open = !pwSection.hidden; | |||||
| pwSection.hidden = open; | |||||
| btnPwToggle.textContent = open ? 'ändern' : 'abbrechen'; | |||||
| }); | |||||
| } | |||||
| // ── Benutzer-Formular ───────────────────────────────────────────────────── | |||||
| const btnUserSave = document.getElementById('btn-user-save'); | |||||
| if (btnUserSave) { | |||||
| btnUserSave.addEventListener('click', async () => { | |||||
| const data = { | |||||
| firstName: document.getElementById('user-firstname').value.trim(), | |||||
| lastName: document.getElementById('user-lastname').value.trim(), | |||||
| email: document.getElementById('user-email').value.trim(), | |||||
| }; | |||||
| if (pwSection && !pwSection.hidden) { | |||||
| const pwNew = document.getElementById('user-pw-new').value; | |||||
| const pwRepeat = document.getElementById('user-pw-repeat').value; | |||||
| if (pwNew !== pwRepeat) { | |||||
| showToast('Die Passwörter stimmen nicht überein.', true); | |||||
| return; | |||||
| } | |||||
| data.currentPassword = document.getElementById('user-pw-current').value; | |||||
| data.newPassword = pwNew; | |||||
| } | |||||
| try { | |||||
| await patchJson('/api/account/user', data); | |||||
| showToast('Gespeichert.'); | |||||
| if (pwSection) { | |||||
| pwSection.hidden = true; | |||||
| document.getElementById('btn-pw-toggle').textContent = 'ändern'; | |||||
| ['user-pw-current', 'user-pw-new', 'user-pw-repeat'].forEach(id => { | |||||
| document.getElementById(id).value = ''; | |||||
| }); | |||||
| } | |||||
| } catch (e) { | |||||
| showToast(e.message, true); | |||||
| } | |||||
| }); | |||||
| } | |||||
| }); | |||||
| @@ -67,6 +67,9 @@ | |||||
| width: 100%; | width: 100%; | ||||
| margin: $space-8 auto; | margin: $space-8 auto; | ||||
| padding: 0 $space-6; | padding: 0 $space-6; | ||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: $space-6; | |||||
| } | } | ||||
| // ─── Karte ─────────────────────────────────────────────────────────────────── | // ─── Karte ─────────────────────────────────────────────────────────────────── | ||||
| @@ -101,6 +104,12 @@ | |||||
| .account-form__hint { | .account-form__hint { | ||||
| font-size: $font-size-xs; | font-size: $font-size-xs; | ||||
| color: $color-text-muted; | color: $color-text-muted; | ||||
| &--owner { | |||||
| font-size: $font-size-sm; | |||||
| line-height: 1.55; | |||||
| margin-top: $space-1; | |||||
| } | |||||
| } | } | ||||
| .account-form__link { | .account-form__link { | ||||
| @@ -153,4 +162,4 @@ | |||||
| } | } | ||||
| &--error { background: #c83232; } | &--error { background: #c83232; } | ||||
| } | |||||
| } | |||||
| @@ -19,6 +19,7 @@ security: | |||||
| security: false | security: false | ||||
| main: | main: | ||||
| lazy: true | lazy: true | ||||
| user_checker: App\Security\ArchivedUserChecker | |||||
| provider: app_user_provider | provider: app_user_provider | ||||
| access_denied_handler: App\Security\AccessDeniedHandler | access_denied_handler: App\Security\AccessDeniedHandler | ||||
| form_login: | form_login: | ||||
| @@ -0,0 +1,35 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace DoctrineMigrations; | |||||
| use Doctrine\DBAL\Schema\Schema; | |||||
| use Doctrine\Migrations\AbstractMigration; | |||||
| /** | |||||
| * Auto-generated Migration: Please modify to your needs! | |||||
| */ | |||||
| final class Version20260523231929 extends AbstractMigration | |||||
| { | |||||
| public function getDescription(): string | |||||
| { | |||||
| return ''; | |||||
| } | |||||
| public function up(Schema $schema): void | |||||
| { | |||||
| // this up() migration is auto-generated, please modify it to your needs | |||||
| $this->addSql('ALTER TABLE account ADD super_admin_user_id INT DEFAULT NULL'); | |||||
| $this->addSql('ALTER TABLE account ADD CONSTRAINT FK_7D3656A4B8CD8675 FOREIGN KEY (super_admin_user_id) REFERENCES `user` (id) ON DELETE SET NULL'); | |||||
| $this->addSql('CREATE INDEX IDX_7D3656A4B8CD8675 ON account (super_admin_user_id)'); | |||||
| } | |||||
| public function down(Schema $schema): void | |||||
| { | |||||
| // this down() migration is auto-generated, please modify it to your needs | |||||
| $this->addSql('ALTER TABLE account DROP FOREIGN KEY FK_7D3656A4B8CD8675'); | |||||
| $this->addSql('DROP INDEX IDX_7D3656A4B8CD8675 ON account'); | |||||
| $this->addSql('ALTER TABLE account DROP super_admin_user_id'); | |||||
| } | |||||
| } | |||||
| @@ -37,11 +37,21 @@ class AccountController extends AbstractController | |||||
| $tab = 'user'; | $tab = 'user'; | ||||
| } | } | ||||
| // Aktive Admins für das Superadmin-Dropdown (nur im Account-Tab relevant) | |||||
| $adminUsers = $isAdmin | |||||
| ? array_map( | |||||
| fn($au) => ['id' => $au->getUser()->getId(), 'name' => $au->getUser()->getFullName()], | |||||
| $account->getAdmins()->toArray() | |||||
| ) | |||||
| : []; | |||||
| return $this->render('account/index.html.twig', [ | return $this->render('account/index.html.twig', [ | ||||
| 'account' => $account, | 'account' => $account, | ||||
| 'user' => $user, | 'user' => $user, | ||||
| 'isAdmin' => $isAdmin, | 'isAdmin' => $isAdmin, | ||||
| 'tab' => $tab, | 'tab' => $tab, | ||||
| 'adminUsers' => $adminUsers, | |||||
| 'superAdminUserId' => $account->getSuperAdminUser()?->getId(), | |||||
| 'intervalOptions' => [ | 'intervalOptions' => [ | ||||
| 1 => 'Minuten', | 1 => 'Minuten', | ||||
| 15 => 'Viertelstunde', | 15 => 'Viertelstunde', | ||||
| @@ -80,6 +90,46 @@ class AccountController extends AbstractController | |||||
| return $this->json(['ok' => true, 'name' => $account->getName()]); | return $this->json(['ok' => true, 'name' => $account->getName()]); | ||||
| } | } | ||||
| #[Route('/api/account/superadmin', name: 'api_account_superadmin', methods: ['PATCH'])] | |||||
| public function updateSuperAdmin(Request $request): JsonResponse | |||||
| { | |||||
| $account = $this->tenantContext->getAccount(); | |||||
| /** @var User $currentUser */ | |||||
| $currentUser = $this->getUser(); | |||||
| $accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $currentUser]); | |||||
| if (!$accountUser?->isAdmin()) { | |||||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||||
| } | |||||
| // Nur der aktuelle Superadmin darf den Besitzer übertragen | |||||
| if ($account->getSuperAdminUser()?->getId() !== $currentUser->getId()) { | |||||
| return $this->json(['error' => 'Nur der aktuelle Kontoinhaber kann diese Funktion nutzen.'], 403); | |||||
| } | |||||
| $data = json_decode($request->getContent(), true) ?? []; | |||||
| $userId = (int) ($data['userId'] ?? 0); | |||||
| if ($userId === $currentUser->getId()) { | |||||
| return $this->json(['error' => 'Du bist bereits Kontoinhaber.'], 400); | |||||
| } | |||||
| $newOwner = $this->userRepo->find($userId); | |||||
| if ($newOwner === null) { | |||||
| return $this->json(['error' => 'Benutzer nicht gefunden.'], 404); | |||||
| } | |||||
| $newAccountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $newOwner]); | |||||
| if ($newAccountUser === null || !$newAccountUser->isAdmin() || $newAccountUser->isArchived()) { | |||||
| return $this->json(['error' => 'Der Benutzer muss aktiver Administrator sein.'], 400); | |||||
| } | |||||
| $account->setSuperAdminUser($newOwner); | |||||
| $this->em->flush(); | |||||
| return $this->json(['ok' => true, 'newOwnerName' => $newOwner->getFullName()]); | |||||
| } | |||||
| #[Route('/api/account/user', name: 'api_account_user_update', methods: ['PATCH'])] | #[Route('/api/account/user', name: 'api_account_user_update', methods: ['PATCH'])] | ||||
| public function updateUser(Request $request): JsonResponse | public function updateUser(Request $request): JsonResponse | ||||
| { | { | ||||
| @@ -122,4 +172,4 @@ class AccountController extends AbstractController | |||||
| return $this->json(['ok' => true]); | return $this->json(['ok' => true]); | ||||
| } | } | ||||
| } | |||||
| } | |||||
| @@ -138,6 +138,10 @@ class TeamController extends AbstractController | |||||
| return $this->json(['error' => 'Du kannst dich nicht selbst archivieren.'], 400); | return $this->json(['error' => 'Du kannst dich nicht selbst archivieren.'], 400); | ||||
| } | } | ||||
| if ($account->getSuperAdminUser()?->getId() === $accountUser->getUser()->getId()) { | |||||
| return $this->json(['error' => 'Der Kontoinhaber kann nicht archiviert werden.'], 403); | |||||
| } | |||||
| $accountUser->setArchivedAt(new \DateTimeImmutable()); | $accountUser->setArchivedAt(new \DateTimeImmutable()); | ||||
| $this->em->flush(); | $this->em->flush(); | ||||
| @@ -246,6 +250,10 @@ class TeamController extends AbstractController | |||||
| return $this->json(['error' => 'Du kannst dich nicht selbst entfernen.'], 400); | return $this->json(['error' => 'Du kannst dich nicht selbst entfernen.'], 400); | ||||
| } | } | ||||
| if ($account->getSuperAdminUser()?->getId() === $accountUser->getUser()->getId()) { | |||||
| return $this->json(['error' => 'Der Kontoinhaber kann nicht entfernt werden.'], 403); | |||||
| } | |||||
| $userId = $accountUser->getUser()->getId(); | $userId = $accountUser->getUser()->getId(); | ||||
| if ($this->timeEntryRepo->countByUserId($userId) > 0) { | if ($this->timeEntryRepo->countByUserId($userId) > 0) { | ||||
| return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); | return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); | ||||
| @@ -29,6 +29,11 @@ class Account | |||||
| #[ORM\Column] | #[ORM\Column] | ||||
| private \DateTimeImmutable $createdAt; | private \DateTimeImmutable $createdAt; | ||||
| /** Der ursprüngliche Ersteller des Accounts – kann nicht gelöscht werden */ | |||||
| #[ORM\ManyToOne(targetEntity: User::class)] | |||||
| #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] | |||||
| private ?User $superAdminUser = null; | |||||
| #[ORM\OneToMany(targetEntity: AccountUser::class, mappedBy: 'account', cascade: ['persist', 'remove'])] | #[ORM\OneToMany(targetEntity: AccountUser::class, mappedBy: 'account', cascade: ['persist', 'remove'])] | ||||
| private Collection $accountUsers; | private Collection $accountUsers; | ||||
| @@ -46,18 +51,27 @@ class Account | |||||
| public function getSlug(): string { return $this->slug; } | public function getSlug(): string { return $this->slug; } | ||||
| public function setSlug(string $slug): static { $this->slug = $slug; return $this; } | public function setSlug(string $slug): static { $this->slug = $slug; return $this; } | ||||
| public function getTrackingInterval(): int { return $this->trackingInterval; } | |||||
| public function setTrackingInterval(int $v): static { $this->trackingInterval = $v; return $this; } | |||||
| public function getTrackingInterval(): int { return $this->trackingInterval; } | |||||
| public function setTrackingInterval(int $v): static { $this->trackingInterval = $v; return $this; } | |||||
| public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } | public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } | ||||
| public function getSuperAdminUser(): ?User { return $this->superAdminUser; } | |||||
| public function setSuperAdminUser(?User $user): static { $this->superAdminUser = $user; return $this; } | |||||
| public function isSuperAdmin(User $user): bool | |||||
| { | |||||
| return $this->superAdminUser !== null | |||||
| && $this->superAdminUser->getId() === $user->getId(); | |||||
| } | |||||
| public function getAccountUsers(): Collection { return $this->accountUsers; } | public function getAccountUsers(): Collection { return $this->accountUsers; } | ||||
| /** Gibt alle User zurück, die Admin dieses Accounts sind */ | |||||
| /** Gibt alle aktiven (nicht archivierten) Admin-AccountUser zurück */ | |||||
| public function getAdmins(): Collection | public function getAdmins(): Collection | ||||
| { | { | ||||
| return $this->accountUsers->filter( | return $this->accountUsers->filter( | ||||
| fn(AccountUser $au) => $au->getRole() === AccountUser::ROLE_ADMIN | |||||
| fn(AccountUser $au) => $au->getRole() === AccountUser::ROLE_ADMIN && !$au->isArchived() | |||||
| ); | ); | ||||
| } | } | ||||
| @@ -67,4 +81,4 @@ class Account | |||||
| } | } | ||||
| public function __toString(): string { return $this->name; } | public function __toString(): string { return $this->name; } | ||||
| } | |||||
| } | |||||
| @@ -0,0 +1,73 @@ | |||||
| <?php | |||||
| namespace App\EventSubscriber; | |||||
| use App\Repository\Central\AccountUserRepository; | |||||
| use App\Service\TenantContext; | |||||
| use Symfony\Component\EventDispatcher\EventSubscriberInterface; | |||||
| use Symfony\Component\HttpFoundation\JsonResponse; | |||||
| use Symfony\Component\HttpFoundation\RedirectResponse; | |||||
| use Symfony\Component\HttpKernel\Event\RequestEvent; | |||||
| use Symfony\Component\HttpKernel\KernelEvents; | |||||
| use Symfony\Component\Routing\RouterInterface; | |||||
| use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; | |||||
| class ArchivedUserSubscriber implements EventSubscriberInterface | |||||
| { | |||||
| public function __construct( | |||||
| private readonly TokenStorageInterface $tokenStorage, | |||||
| private readonly AccountUserRepository $accountUserRepo, | |||||
| private readonly TenantContext $tenantContext, | |||||
| private readonly RouterInterface $router, | |||||
| ) {} | |||||
| public function onKernelRequest(RequestEvent $event): void | |||||
| { | |||||
| if (!$event->isMainRequest()) { | |||||
| return; | |||||
| } | |||||
| $token = $this->tokenStorage->getToken(); | |||||
| $user = $token?->getUser(); | |||||
| if ($user === null) { | |||||
| return; | |||||
| } | |||||
| $account = $this->tenantContext->getAccount(); | |||||
| if ($account === null) { | |||||
| return; | |||||
| } | |||||
| $accountUser = $this->accountUserRepo->findOneBy([ | |||||
| 'account' => $account, | |||||
| 'user' => $user, | |||||
| ]); | |||||
| if ($accountUser === null || !$accountUser->isArchived()) { | |||||
| return; | |||||
| } | |||||
| // Token + Session löschen | |||||
| $this->tokenStorage->setToken(null); | |||||
| $request = $event->getRequest(); | |||||
| if ($request->hasSession()) { | |||||
| $request->getSession()->invalidate(); | |||||
| } | |||||
| // API: 401, sonst Redirect zu Login | |||||
| if (str_starts_with($request->getPathInfo(), '/api/')) { | |||||
| $event->setResponse(new JsonResponse(['error' => 'Konto deaktiviert.'], 401)); | |||||
| } else { | |||||
| $event->setResponse(new RedirectResponse($this->router->generate('app_login'))); | |||||
| } | |||||
| } | |||||
| public static function getSubscribedEvents(): array | |||||
| { | |||||
| // Prio 6: nach Firewall (8), nach TenantRequestSubscriber (20) | |||||
| return [ | |||||
| KernelEvents::REQUEST => ['onKernelRequest', 6], | |||||
| ]; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,36 @@ | |||||
| <?php | |||||
| namespace App\Security; | |||||
| use App\Repository\Central\AccountUserRepository; | |||||
| use App\Service\TenantContext; | |||||
| use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException; | |||||
| use Symfony\Component\Security\Core\User\UserCheckerInterface; | |||||
| use Symfony\Component\Security\Core\User\UserInterface; | |||||
| class ArchivedUserChecker implements UserCheckerInterface | |||||
| { | |||||
| public function __construct( | |||||
| private readonly AccountUserRepository $accountUserRepo, | |||||
| private readonly TenantContext $tenantContext, | |||||
| ) {} | |||||
| public function checkPreAuth(UserInterface $user): void {} | |||||
| public function checkPostAuth(UserInterface $user): void | |||||
| { | |||||
| $account = $this->tenantContext->getAccount(); | |||||
| if ($account === null) { | |||||
| return; | |||||
| } | |||||
| $accountUser = $this->accountUserRepo->findOneBy([ | |||||
| 'account' => $account, | |||||
| 'user' => $user, | |||||
| ]); | |||||
| if ($accountUser !== null && $accountUser->isArchived()) { | |||||
| throw new CustomUserMessageAccountStatusException('Dein Konto wurde deaktiviert.'); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -112,6 +112,7 @@ class RegistrationService | |||||
| $accountUser->setUser($user); | $accountUser->setUser($user); | ||||
| $accountUser->setRole(AccountUser::ROLE_ADMIN); | $accountUser->setRole(AccountUser::ROLE_ADMIN); | ||||
| $this->centralEm->persist($accountUser); | $this->centralEm->persist($accountUser); | ||||
| $account->setSuperAdminUser($user); | |||||
| $this->centralEm->flush(); | $this->centralEm->flush(); | ||||
| @@ -2,40 +2,40 @@ | |||||
| {% set currentRoute = app.request.attributes.get('_route') %} | {% set currentRoute = app.request.attributes.get('_route') %} | ||||
| <nav class="main-nav"> | <nav class="main-nav"> | ||||
| <div class="main-nav__left"> | |||||
| <a href="{{ path('timetracking_week') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'timetracking' %} main-nav__item--active{% endif %}"> | |||||
| Zeit erfassen | |||||
| </a> | |||||
| <span class="main-nav__item main-nav__item--disabled">Reports</span> | |||||
| </div> | |||||
| <div class="main-nav__right"> | |||||
| {% if isCurrentUserMemberOrAdmin() %} | |||||
| <a href="{{ path('client_index') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'client' %} main-nav__item--active{% endif %}"> | |||||
| Kunden | |||||
| </a> | |||||
| <a href="{{ path('project_index') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'project' %} main-nav__item--active{% endif %}"> | |||||
| Projekte | |||||
| </a> | |||||
| <a href="{{ path('service_index') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'service' %} main-nav__item--active{% endif %}"> | |||||
| Leistungen | |||||
| </a> | |||||
| {% endif %} | |||||
| {% if isCurrentUserAdmin() %} | |||||
| <a href="{{ path('team_index') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'team' %} main-nav__item--active{% endif %}"> | |||||
| Team | |||||
| </a> | |||||
| <a href="{{ path('account_index') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'account' %} main-nav__item--active{% endif %}"> | |||||
| Account | |||||
| </a> | |||||
| {% endif %} | |||||
| <a href="{{ path('app_logout') }}" class="main-nav__item"> | |||||
| Abmelden | |||||
| </a> | |||||
| </div> | |||||
| </nav> | |||||
| <div class="main-nav__left"> | |||||
| <a href="{{ path('timetracking_week') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'timetracking' %} main-nav__item--active{% endif %}"> | |||||
| Zeit erfassen | |||||
| </a> | |||||
| <span class="main-nav__item main-nav__item--disabled">Reports</span> | |||||
| </div> | |||||
| <div class="main-nav__right"> | |||||
| {% if isCurrentUserMemberOrAdmin() %} | |||||
| <a href="{{ path('client_index') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'client' %} main-nav__item--active{% endif %}"> | |||||
| Kunden | |||||
| </a> | |||||
| <a href="{{ path('project_index') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'project' %} main-nav__item--active{% endif %}"> | |||||
| Projekte | |||||
| </a> | |||||
| <a href="{{ path('service_index') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'service' %} main-nav__item--active{% endif %}"> | |||||
| Leistungen | |||||
| </a> | |||||
| {% endif %} | |||||
| {% if isCurrentUserAdmin() %} | |||||
| <a href="{{ path('team_index') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'team' %} main-nav__item--active{% endif %}"> | |||||
| Team | |||||
| </a> | |||||
| {% endif %} | |||||
| <a href="{{ path('account_index') }}" | |||||
| class="main-nav__item{% if currentRoute starts with 'account' %} main-nav__item--active{% endif %}"> | |||||
| Account | |||||
| </a> | |||||
| <a href="{{ path('app_logout') }}" class="main-nav__item"> | |||||
| Abmelden | |||||
| </a> | |||||
| </div> | |||||
| </nav> | |||||
| @@ -7,6 +7,13 @@ | |||||
| {% block body %} | {% block body %} | ||||
| <script> | |||||
| window.ACCOUNT = { | |||||
| tab: '{{ tab }}', | |||||
| isSuperAdmin: {{ superAdminUserId is not null and superAdminUserId == user.id ? 'true' : 'false' }}, | |||||
| }; | |||||
| </script> | |||||
| <div class="account-page"> | <div class="account-page"> | ||||
| <div class="account-header"> | <div class="account-header"> | ||||
| @@ -41,8 +48,8 @@ | |||||
| <input type="text" id="account-name" class="input" | <input type="text" id="account-name" class="input" | ||||
| value="{{ account.name|e('html_attr') }}" /> | value="{{ account.name|e('html_attr') }}" /> | ||||
| <span class="account-form__hint"> | <span class="account-form__hint"> | ||||
| Subdomain: <strong>{{ account.slug }}.{{ app.request.host|split('.')|slice(1)|join('.') }}</strong> — ändert sich nicht. | |||||
| </span> | |||||
| Subdomain: <strong>{{ account.slug }}.{{ app.request.host|split('.')|slice(1)|join('.') }}</strong> — ändert sich nicht. | |||||
| </span> | |||||
| </div> | </div> | ||||
| <label class="account-form__label" for="account-interval">Zeitintervall</label> | <label class="account-form__label" for="account-interval">Zeitintervall</label> | ||||
| @@ -121,99 +128,41 @@ | |||||
| {% endif %} | {% endif %} | ||||
| </div> | </div> | ||||
| {# ── Besitzer des Accounts (nur Admin, Account-Tab) ─────────────────── #} | |||||
| {% if tab == 'account' and isAdmin %} | |||||
| <div class="account-card account-card--owner"> | |||||
| <div class="account-form__grid"> | |||||
| <label class="account-form__label" for="superadmin-select">Besitzer des Accounts</label> | |||||
| <div class="account-form__field"> | |||||
| <select id="superadmin-select" class="select" | |||||
| {% if superAdminUserId != user.id %}disabled{% endif %}> | |||||
| {% for admin in adminUsers %} | |||||
| <option value="{{ admin.id }}"{% if admin.id == superAdminUserId %} selected{% endif %}> | |||||
| {{ admin.name }} | |||||
| </option> | |||||
| {% endfor %} | |||||
| </select> | |||||
| <p class="account-form__hint account-form__hint--owner"> | |||||
| Der Besitzer des Accounts ist für die Verwaltung der Zahlungsdaten zuständig. | |||||
| Nur er kann den Account kündigen. | |||||
| </p> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| {% endif %} | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <div class="account-toast" id="account-toast"></div> | <div class="account-toast" id="account-toast"></div> | ||||
| <script> | |||||
| (function () { | |||||
| const toast = document.getElementById('account-toast'); | |||||
| function showToast(msg, isError = false) { | |||||
| toast.textContent = msg; | |||||
| toast.classList.toggle('account-toast--error', isError); | |||||
| toast.classList.add('account-toast--visible'); | |||||
| setTimeout(() => toast.classList.remove('account-toast--visible'), 3000); | |||||
| } | |||||
| async function patchJson(url, data) { | |||||
| const res = await fetch(url, { | |||||
| method: 'PATCH', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify(data), | |||||
| }); | |||||
| const json = await res.json(); | |||||
| if (!res.ok) throw new Error(json.error ?? 'Fehler'); | |||||
| return json; | |||||
| } | |||||
| // ── Account-Formular ────────────────────────────────────────────────────── | |||||
| const btnAccountSave = document.getElementById('btn-account-save'); | |||||
| if (btnAccountSave) { | |||||
| btnAccountSave.addEventListener('click', async () => { | |||||
| try { | |||||
| await patchJson('/api/account', { | |||||
| name: document.getElementById('account-name').value.trim(), | |||||
| trackingInterval: parseInt(document.getElementById('account-interval').value, 10), | |||||
| }); | |||||
| showToast('Gespeichert.'); | |||||
| } catch (e) { | |||||
| showToast(e.message, true); | |||||
| } | |||||
| }); | |||||
| } | |||||
| // ── Passwort-Toggle ─────────────────────────────────────────────────────── | |||||
| const btnPwToggle = document.getElementById('btn-pw-toggle'); | |||||
| const pwSection = document.getElementById('pw-section'); | |||||
| if (btnPwToggle && pwSection) { | |||||
| btnPwToggle.addEventListener('click', (e) => { | |||||
| e.preventDefault(); | |||||
| const open = !pwSection.hidden; | |||||
| pwSection.hidden = open; | |||||
| btnPwToggle.textContent = open ? 'ändern' : 'abbrechen'; | |||||
| }); | |||||
| } | |||||
| // ── Benutzer-Formular ──────────────────────────────────────────────────── | |||||
| const btnUserSave = document.getElementById('btn-user-save'); | |||||
| if (btnUserSave) { | |||||
| btnUserSave.addEventListener('click', async () => { | |||||
| const data = { | |||||
| firstName: document.getElementById('user-firstname').value.trim(), | |||||
| lastName: document.getElementById('user-lastname').value.trim(), | |||||
| email: document.getElementById('user-email').value.trim(), | |||||
| }; | |||||
| if (pwSection && !pwSection.hidden) { | |||||
| const pwNew = document.getElementById('user-pw-new').value; | |||||
| const pwRepeat = document.getElementById('user-pw-repeat').value; | |||||
| if (pwNew !== pwRepeat) { | |||||
| showToast('Die Passwörter stimmen nicht überein.', true); | |||||
| return; | |||||
| } | |||||
| data.currentPassword = document.getElementById('user-pw-current').value; | |||||
| data.newPassword = pwNew; | |||||
| } | |||||
| try { | |||||
| await patchJson('/api/account/user', data); | |||||
| showToast('Gespeichert.'); | |||||
| if (pwSection) { | |||||
| pwSection.hidden = true; | |||||
| document.getElementById('btn-pw-toggle').textContent = 'ändern'; | |||||
| ['user-pw-current', 'user-pw-new', 'user-pw-repeat'].forEach(id => { | |||||
| document.getElementById(id).value = ''; | |||||
| }); | |||||
| } | |||||
| } catch (e) { | |||||
| showToast(e.message, true); | |||||
| } | |||||
| }); | |||||
| } | |||||
| })(); | |||||
| </script> | |||||
| {% endblock %} | |||||
| {% endblock %} | |||||
| {% block javascripts %} | |||||
| {{ parent() }} | |||||
| {{ encore_entry_script_tags('account') }} | |||||
| {% endblock %} | |||||
| @@ -151,7 +151,7 @@ | |||||
| <div class="crud-list" id="team-list-archived" data-tab-panel="archived" hidden> | <div class="crud-list" id="team-list-archived" data-tab-panel="archived" hidden> | ||||
| {% for au in archivedUsers %} | {% for au in archivedUsers %} | ||||
| <div class="crud-row crud-row--archived" id="au-{{ au.id }}"> | |||||
| <div class="crud-row crud-row--archived" id="au-{{ au.id }}" data-id="{{ au.id }}"> | |||||
| <div class="crud-row__display"> | <div class="crud-row__display"> | ||||
| <div class="crud-row__info"> | <div class="crud-row__info"> | ||||
| <span class="crud-row__name">{{ au.user.fullName }}</span> | <span class="crud-row__name">{{ au.user.fullName }}</span> | ||||
| @@ -24,6 +24,7 @@ Encore | |||||
| .addEntry('crud', './assets/scripts/crud.js') | .addEntry('crud', './assets/scripts/crud.js') | ||||
| .addEntry('registration', './assets/scripts/registration.js') | .addEntry('registration', './assets/scripts/registration.js') | ||||
| .addEntry('team', './assets/scripts/team.js') | .addEntry('team', './assets/scripts/team.js') | ||||
| .addEntry('account', './assets/scripts/account.js') | |||||
| // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. | // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. | ||||
| .splitEntryChunks() | .splitEntryChunks() | ||||