| @@ -25,7 +25,8 @@ | |||
| "Bash(npx playwright *)", | |||
| "Bash(npm init *)", | |||
| "Bash(npm install *)", | |||
| "Bash(NODE_TLS_REJECT_UNAUTHORIZED=0 node test.mjs)" | |||
| "Bash(NODE_TLS_REJECT_UNAUTHORIZED=0 node test.mjs)", | |||
| "Bash(open *)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -338,6 +338,16 @@ Billable/Non-Billable Trennung via `Service.billable`. Revenue-Berechnung nutzt | |||
| - **Brand-Farbe**: Billable-Balken nutzen `--color-primary`, Non-Billable grau | |||
| - **Donut-Charts**: Drei nebeneinander (3-Spalten-Grid, responsive 1-Spalte), eigene Legende mit Farb-Dot, Name, Wert und Prozent. 8-Farben-Palette, „Rest" in Grau | |||
| ## Account-Finder (Anmelde-Übergangsseite) | |||
| Seite unter `/find-account` zur Weiterleitung auf die richtige Subdomain-Login-Seite. Nutzt das Register-Card-Design. | |||
| - **Route**: `/find-account` (`app_find_account`) in `RegistrationController`, PUBLIC_ACCESS | |||
| - **Template**: `templates/registration/find_account.html.twig` (embed von `register-card.html.twig`) | |||
| - **Styles**: `.find-account-input` in `_register.scss` (Input mit `.domain`-Suffix) | |||
| - **Slug-Validierung**: `POST /api/check-slug` prüft via `AccountRepository::findBySlug()` ob der Account existiert, bevor weitergeleitet wird. Fehlermeldung bei unbekanntem Slug. | |||
| - **Links**: Register-Seite verlinkt „Zur Anmeldung" auf `/find-account`, Find-Account verlinkt „Jetzt registrieren" auf `/register` | |||
| ## mite-Import | |||
| Import von Kunden, Projekten, Leistungen und Zeiteinträgen aus mite-Backups (XML). Erreichbar als eigener Tab in den Account-Einstellungen (nur Admin). | |||
| @@ -150,6 +150,38 @@ | |||
| } | |||
| } | |||
| // ─── Find Account ──────────────────────────────────────────────────────── | |||
| .find-account-input { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 0; | |||
| border: 1px solid $color-input-border; | |||
| border-radius: $radius-sm; | |||
| background: $color-input-bg; | |||
| transition: border-color $transition-fast; | |||
| &:focus-within { | |||
| border-color: var(--color-primary); | |||
| box-shadow: $shadow-focus; | |||
| } | |||
| } | |||
| .find-account-input__field { | |||
| flex: 1; | |||
| border: none !important; | |||
| box-shadow: none !important; | |||
| background: transparent; | |||
| min-width: 0; | |||
| } | |||
| .find-account-input__suffix { | |||
| padding: 0 $space-4 0 0; | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| white-space: nowrap; | |||
| user-select: none; | |||
| } | |||
| // ─── Erfolgs-State ──────────────────────────────────────────────────────────── | |||
| .register-success { | |||
| text-align: center; | |||
| @@ -48,7 +48,9 @@ security: | |||
| access_control: | |||
| - { path: ^/login, roles: PUBLIC_ACCESS } | |||
| - { path: ^/register, roles: PUBLIC_ACCESS } | |||
| - { path: ^/find-account, roles: PUBLIC_ACCESS } | |||
| - { path: ^/api/register, roles: PUBLIC_ACCESS } | |||
| - { path: ^/api/check-slug, roles: PUBLIC_ACCESS } | |||
| - { path: ^/verify/, roles: PUBLIC_ACCESS } | |||
| - { path: ^/invite/, roles: PUBLIC_ACCESS } | |||
| - { path: ^/forgot-password, roles: PUBLIC_ACCESS } | |||
| @@ -2,6 +2,7 @@ | |||
| namespace App\Controller; | |||
| use App\Repository\Central\AccountRepository; | |||
| use App\Service\RegistrationService; | |||
| use App\Service\SlugGenerator; | |||
| use Psr\Log\LoggerInterface; | |||
| @@ -17,6 +18,7 @@ class RegistrationController extends AbstractController | |||
| public function __construct( | |||
| private readonly RegistrationService $registrationService, | |||
| private readonly SlugGenerator $slugGenerator, | |||
| private readonly AccountRepository $accountRepo, | |||
| private readonly TranslatorInterface $translator, | |||
| private readonly string $appDomain, | |||
| private readonly LoggerInterface $logger, | |||
| @@ -30,9 +32,32 @@ class RegistrationController extends AbstractController | |||
| ]); | |||
| } | |||
| #[Route('/find-account', name: 'app_find_account')] | |||
| public function findAccount(): Response | |||
| { | |||
| return $this->render('registration/find_account.html.twig', [ | |||
| 'appDomain' => $this->appDomain, | |||
| ]); | |||
| } | |||
| /** | |||
| * Live-Vorschau des Slugs während der User tippt. | |||
| */ | |||
| #[Route('/api/check-slug', name: 'api_check_slug', methods: ['POST'])] | |||
| public function checkSlug(Request $request): JsonResponse | |||
| { | |||
| $data = json_decode($request->getContent(), true); | |||
| $slug = trim($data['slug'] ?? ''); | |||
| if ($slug === '') { | |||
| return $this->json(['exists' => false]); | |||
| } | |||
| $account = $this->accountRepo->findBySlug($slug); | |||
| return $this->json(['exists' => $account !== null]); | |||
| } | |||
| #[Route('/api/register/preview-slug', name: 'api_register_preview_slug', methods: ['POST'])] | |||
| public function previewSlug(Request $request): JsonResponse | |||
| { | |||
| @@ -0,0 +1,95 @@ | |||
| {# templates/registration/find_account.html.twig #} | |||
| <!DOCTYPE html> | |||
| <html lang="de"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <title>{{ 'app.find_account.page_title'|trans }}</title> | |||
| {{ encore_entry_link_tags('app') }} | |||
| </head> | |||
| <body class="register-body"> | |||
| {% embed '_components/register-card.html.twig' %} | |||
| {% block content %} | |||
| <div class="register-card__brand"> | |||
| <a href="{{ path('app_home') }}">spawntree Timetracker</a> | |||
| </div> | |||
| <h1 class="register-card__title">{{ 'app.find_account.title'|trans }}</h1> | |||
| <p class="register-card__sub">{{ 'app.find_account.subtitle'|trans }}</p> | |||
| <div id="find-account-error" class="register-errors" role="alert"></div> | |||
| <form id="find-account-form" novalidate> | |||
| <div class="register-field"> | |||
| <label class="register-field__label" for="account-slug">{{ 'app.find_account.label_slug'|trans }}</label> | |||
| <div class="find-account-input"> | |||
| <input class="input find-account-input__field" | |||
| type="text" | |||
| id="account-slug" | |||
| name="slug" | |||
| placeholder="{{ 'app.find_account.placeholder_slug'|trans }}" | |||
| autocomplete="off" | |||
| autocapitalize="none" | |||
| spellcheck="false" | |||
| autofocus | |||
| required /> | |||
| <span class="find-account-input__suffix">.{{ appDomain }}</span> | |||
| </div> | |||
| </div> | |||
| <div class="register-actions"> | |||
| <button type="submit" class="btn btn-primary register-actions__submit"> | |||
| {{ 'app.find_account.btn_submit'|trans }} | |||
| </button> | |||
| <p class="register-actions__login"> | |||
| {{ 'app.find_account.no_account'|trans }} <a href="{{ path('app_register') }}">{{ 'app.find_account.link_register'|trans }}</a> | |||
| </p> | |||
| </div> | |||
| </form> | |||
| {% endblock %} | |||
| {% endembed %} | |||
| <script> | |||
| document.getElementById('find-account-form').addEventListener('submit', function(e) { | |||
| e.preventDefault(); | |||
| var slug = document.getElementById('account-slug').value.trim().toLowerCase(); | |||
| var errorEl = document.getElementById('find-account-error'); | |||
| var btn = document.querySelector('.register-actions__submit'); | |||
| errorEl.textContent = ''; | |||
| if (!slug) { | |||
| errorEl.innerHTML = '<p>{{ 'app.find_account.error_empty'|trans }}</p>'; | |||
| return; | |||
| } | |||
| if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(slug)) { | |||
| errorEl.innerHTML = '<p>{{ 'app.find_account.error_invalid'|trans }}</p>'; | |||
| return; | |||
| } | |||
| btn.disabled = true; | |||
| fetch('/api/check-slug', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ slug: slug }), | |||
| }) | |||
| .then(function(r) { return r.json(); }) | |||
| .then(function(data) { | |||
| if (data.exists) { | |||
| window.location.href = 'https://' + slug + '.{{ appDomain }}/login'; | |||
| } else { | |||
| errorEl.innerHTML = '<p>{{ 'app.find_account.error_not_found'|trans }}</p>'; | |||
| btn.disabled = false; | |||
| } | |||
| }) | |||
| .catch(function() { | |||
| errorEl.innerHTML = '<p>{{ 'app.find_account.error_not_found'|trans }}</p>'; | |||
| btn.disabled = false; | |||
| }); | |||
| }); | |||
| </script> | |||
| </body> | |||
| </html> | |||
| @@ -81,7 +81,7 @@ | |||
| {{ 'app.register.btn_submit'|trans }} | |||
| </button> | |||
| <p class="register-actions__login"> | |||
| {{ 'app.register.already_registered'|trans }} <a href="{{ path('app_home') }}">{{ 'app.register.link_login'|trans }}</a> | |||
| {{ 'app.register.already_registered'|trans }} <a href="{{ path('app_find_account') }}">{{ 'app.register.link_login'|trans }}</a> | |||
| </p> | |||
| </div> | |||
| @@ -413,6 +413,19 @@ app: | |||
| already_registered: "Bereits registriert?" | |||
| link_login: "Zur Anmeldung" | |||
| find_account: | |||
| page_title: "Anmelden – spawntree Timetracker" | |||
| title: "Anmelden" | |||
| subtitle: "Gib den Namen deines Accounts ein, um zur Anmeldeseite weitergeleitet zu werden." | |||
| label_slug: "Account-Name" | |||
| placeholder_slug: "meine-firma" | |||
| btn_submit: "Weiter zur Anmeldung" | |||
| error_empty: "Bitte gib deinen Account-Namen ein." | |||
| error_invalid: "Der Account-Name darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten." | |||
| error_not_found: "Dieser Account wurde nicht gefunden. Bitte überprüfe den Namen." | |||
| no_account: "Noch kein Konto?" | |||
| link_register: "Jetzt registrieren" | |||
| confirm_error: | |||
| page_title: "Fehler – spawntree Timetracker" | |||
| title: "Link ungültig" | |||