| @@ -25,7 +25,8 @@ | |||||
| "Bash(npx playwright *)", | "Bash(npx playwright *)", | ||||
| "Bash(npm init *)", | "Bash(npm init *)", | ||||
| "Bash(npm install *)", | "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 | - **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 | - **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 | ## mite-Import | ||||
| Import von Kunden, Projekten, Leistungen und Zeiteinträgen aus mite-Backups (XML). Erreichbar als eigener Tab in den Account-Einstellungen (nur Admin). | 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 ──────────────────────────────────────────────────────────── | // ─── Erfolgs-State ──────────────────────────────────────────────────────────── | ||||
| .register-success { | .register-success { | ||||
| text-align: center; | text-align: center; | ||||
| @@ -48,7 +48,9 @@ security: | |||||
| access_control: | access_control: | ||||
| - { path: ^/login, roles: PUBLIC_ACCESS } | - { path: ^/login, roles: PUBLIC_ACCESS } | ||||
| - { path: ^/register, roles: PUBLIC_ACCESS } | - { path: ^/register, roles: PUBLIC_ACCESS } | ||||
| - { path: ^/find-account, roles: PUBLIC_ACCESS } | |||||
| - { path: ^/api/register, roles: PUBLIC_ACCESS } | - { path: ^/api/register, roles: PUBLIC_ACCESS } | ||||
| - { path: ^/api/check-slug, roles: PUBLIC_ACCESS } | |||||
| - { path: ^/verify/, roles: PUBLIC_ACCESS } | - { path: ^/verify/, roles: PUBLIC_ACCESS } | ||||
| - { path: ^/invite/, roles: PUBLIC_ACCESS } | - { path: ^/invite/, roles: PUBLIC_ACCESS } | ||||
| - { path: ^/forgot-password, roles: PUBLIC_ACCESS } | - { path: ^/forgot-password, roles: PUBLIC_ACCESS } | ||||
| @@ -2,6 +2,7 @@ | |||||
| namespace App\Controller; | namespace App\Controller; | ||||
| use App\Repository\Central\AccountRepository; | |||||
| use App\Service\RegistrationService; | use App\Service\RegistrationService; | ||||
| use App\Service\SlugGenerator; | use App\Service\SlugGenerator; | ||||
| use Psr\Log\LoggerInterface; | use Psr\Log\LoggerInterface; | ||||
| @@ -17,6 +18,7 @@ class RegistrationController extends AbstractController | |||||
| public function __construct( | public function __construct( | ||||
| private readonly RegistrationService $registrationService, | private readonly RegistrationService $registrationService, | ||||
| private readonly SlugGenerator $slugGenerator, | private readonly SlugGenerator $slugGenerator, | ||||
| private readonly AccountRepository $accountRepo, | |||||
| private readonly TranslatorInterface $translator, | private readonly TranslatorInterface $translator, | ||||
| private readonly string $appDomain, | private readonly string $appDomain, | ||||
| private readonly LoggerInterface $logger, | 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. | * 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'])] | #[Route('/api/register/preview-slug', name: 'api_register_preview_slug', methods: ['POST'])] | ||||
| public function previewSlug(Request $request): JsonResponse | 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 }} | {{ 'app.register.btn_submit'|trans }} | ||||
| </button> | </button> | ||||
| <p class="register-actions__login"> | <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> | </p> | ||||
| </div> | </div> | ||||
| @@ -413,6 +413,19 @@ app: | |||||
| already_registered: "Bereits registriert?" | already_registered: "Bereits registriert?" | ||||
| link_login: "Zur Anmeldung" | 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: | confirm_error: | ||||
| page_title: "Fehler – spawntree Timetracker" | page_title: "Fehler – spawntree Timetracker" | ||||
| title: "Link ungültig" | title: "Link ungültig" | ||||