diff --git a/httpdocs/assets/scripts/account.js b/httpdocs/assets/scripts/account.js new file mode 100644 index 0000000..154c72a --- /dev/null +++ b/httpdocs/assets/scripts/account.js @@ -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); + } + }); + } +}); diff --git a/httpdocs/assets/styles/components/_account.scss b/httpdocs/assets/styles/components/_account.scss index 9fdbfa3..6305919 100644 --- a/httpdocs/assets/styles/components/_account.scss +++ b/httpdocs/assets/styles/components/_account.scss @@ -67,6 +67,9 @@ width: 100%; margin: $space-8 auto; padding: 0 $space-6; + display: flex; + flex-direction: column; + gap: $space-6; } // ─── Karte ─────────────────────────────────────────────────────────────────── @@ -101,6 +104,12 @@ .account-form__hint { font-size: $font-size-xs; color: $color-text-muted; + + &--owner { + font-size: $font-size-sm; + line-height: 1.55; + margin-top: $space-1; + } } .account-form__link { @@ -153,4 +162,4 @@ } &--error { background: #c83232; } -} \ No newline at end of file +} diff --git a/httpdocs/config/packages/security.yaml b/httpdocs/config/packages/security.yaml index e37466b..63be903 100644 --- a/httpdocs/config/packages/security.yaml +++ b/httpdocs/config/packages/security.yaml @@ -19,6 +19,7 @@ security: security: false main: lazy: true + user_checker: App\Security\ArchivedUserChecker provider: app_user_provider access_denied_handler: App\Security\AccessDeniedHandler form_login: diff --git a/httpdocs/migrations/central/Version20260523231929.php b/httpdocs/migrations/central/Version20260523231929.php new file mode 100644 index 0000000..cc32019 --- /dev/null +++ b/httpdocs/migrations/central/Version20260523231929.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/httpdocs/src/Controller/AccountController.php b/httpdocs/src/Controller/AccountController.php index 2d6dfcc..1055c66 100644 --- a/httpdocs/src/Controller/AccountController.php +++ b/httpdocs/src/Controller/AccountController.php @@ -37,11 +37,21 @@ class AccountController extends AbstractController $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', [ 'account' => $account, 'user' => $user, 'isAdmin' => $isAdmin, 'tab' => $tab, + 'adminUsers' => $adminUsers, + 'superAdminUserId' => $account->getSuperAdminUser()?->getId(), 'intervalOptions' => [ 1 => 'Minuten', 15 => 'Viertelstunde', @@ -80,6 +90,46 @@ class AccountController extends AbstractController 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'])] public function updateUser(Request $request): JsonResponse { @@ -122,4 +172,4 @@ class AccountController extends AbstractController return $this->json(['ok' => true]); } -} \ No newline at end of file +} diff --git a/httpdocs/src/Controller/TeamController.php b/httpdocs/src/Controller/TeamController.php index dd1a481..2d961a0 100644 --- a/httpdocs/src/Controller/TeamController.php +++ b/httpdocs/src/Controller/TeamController.php @@ -138,6 +138,10 @@ class TeamController extends AbstractController 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()); $this->em->flush(); @@ -246,6 +250,10 @@ class TeamController extends AbstractController 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(); if ($this->timeEntryRepo->countByUserId($userId) > 0) { return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); diff --git a/httpdocs/src/Entity/Central/Account.php b/httpdocs/src/Entity/Central/Account.php index 805582e..9878fea 100644 --- a/httpdocs/src/Entity/Central/Account.php +++ b/httpdocs/src/Entity/Central/Account.php @@ -29,6 +29,11 @@ class Account #[ORM\Column] 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'])] private Collection $accountUsers; @@ -46,18 +51,27 @@ class Account public function getSlug(): string { return $this->slug; } 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 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; } - /** Gibt alle User zurück, die Admin dieses Accounts sind */ + /** Gibt alle aktiven (nicht archivierten) Admin-AccountUser zurück */ public function getAdmins(): Collection { 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; } -} \ No newline at end of file +} diff --git a/httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php b/httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php new file mode 100644 index 0000000..19b58a7 --- /dev/null +++ b/httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php @@ -0,0 +1,73 @@ +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], + ]; + } +} \ No newline at end of file diff --git a/httpdocs/src/Security/ArchivedUserChecker.php b/httpdocs/src/Security/ArchivedUserChecker.php new file mode 100644 index 0000000..0983072 --- /dev/null +++ b/httpdocs/src/Security/ArchivedUserChecker.php @@ -0,0 +1,36 @@ +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.'); + } + } +} \ No newline at end of file diff --git a/httpdocs/src/Service/RegistrationService.php b/httpdocs/src/Service/RegistrationService.php index 0a887ed..fb70164 100644 --- a/httpdocs/src/Service/RegistrationService.php +++ b/httpdocs/src/Service/RegistrationService.php @@ -112,6 +112,7 @@ class RegistrationService $accountUser->setUser($user); $accountUser->setRole(AccountUser::ROLE_ADMIN); $this->centralEm->persist($accountUser); + $account->setSuperAdminUser($user); $this->centralEm->flush(); diff --git a/httpdocs/templates/_nav.html.twig b/httpdocs/templates/_nav.html.twig index 96fdef5..a355e92 100644 --- a/httpdocs/templates/_nav.html.twig +++ b/httpdocs/templates/_nav.html.twig @@ -2,40 +2,40 @@ {% set currentRoute = app.request.attributes.get('_route') %} \ No newline at end of file + + + diff --git a/httpdocs/templates/account/index.html.twig b/httpdocs/templates/account/index.html.twig index af614db..9bf20b3 100644 --- a/httpdocs/templates/account/index.html.twig +++ b/httpdocs/templates/account/index.html.twig @@ -7,6 +7,13 @@ {% block body %} + +
@@ -41,8 +48,8 @@ + Subdomain: {{ account.slug }}.{{ app.request.host|split('.')|slice(1)|join('.') }} — ändert sich nicht. +
@@ -121,99 +128,41 @@ {% endif %}
+ + {# ── Besitzer des Accounts (nur Admin, Account-Tab) ─────────────────── #} + {% if tab == 'account' and isAdmin %} +
+
+ + + + +
+
+ {% endif %} +
- +{% endblock %} -{% endblock %} \ No newline at end of file +{% block javascripts %} + {{ parent() }} + {{ encore_entry_script_tags('account') }} +{% endblock %} diff --git a/httpdocs/templates/team/index.html.twig b/httpdocs/templates/team/index.html.twig index 3ebdb45..b7feb5c 100644 --- a/httpdocs/templates/team/index.html.twig +++ b/httpdocs/templates/team/index.html.twig @@ -151,7 +151,7 @@