From 082f3015d38241818b62ce51958f01d47c7e5c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kud=C4=9Blka?= Date: Sun, 26 Jul 2026 12:39:25 +0200 Subject: [PATCH 01/10] feat(logging): Mark core entity properties as loggable --- src/Model/Entities/AccountTrait.php | 3 +++ src/Model/Entities/AclResourceTrait.php | 3 +++ src/Model/Entities/AclRoleTrait.php | 7 +++++++ src/Model/Entities/AclTrait.php | 3 +++ src/Model/Entities/ConfigurationTrait.php | 3 +++ src/Model/Entities/IdentityTrait.php | 8 ++++++++ src/Model/Entities/ProfileTrait.php | 2 ++ src/Model/Entities/SsoTrait.php | 8 ++++++++ 8 files changed, 37 insertions(+) diff --git a/src/Model/Entities/AccountTrait.php b/src/Model/Entities/AccountTrait.php index 40cab66..59ac97c 100644 --- a/src/Model/Entities/AccountTrait.php +++ b/src/Model/Entities/AccountTrait.php @@ -2,6 +2,7 @@ namespace ADT\FancyAdmin\Model\Entities; +use ADT\DoctrineLoggable\Attributes\LoggableProperty; use ADT\FancyAdmin\Model\Entities\Traits\CreatedAt; use ADT\FancyAdmin\Model\Entities\Traits\CreatedByNullable; use ADT\FancyAdmin\Model\Entities\Traits\UpdatedAt; @@ -17,12 +18,14 @@ trait AccountTrait use UpdatedAt; use UpdatedBy; #[Column(nullable: false)] + #[LoggableProperty] protected string $name; #[ORM\OneToMany(targetEntity: 'Account', mappedBy: 'parent')] protected Collection $accounts; #[ORM\ManyToOne(targetEntity: 'Account')] + #[LoggableProperty] protected ?Account $parent = null; public function getName(): string diff --git a/src/Model/Entities/AclResourceTrait.php b/src/Model/Entities/AclResourceTrait.php index fb7eee4..aa01e4c 100644 --- a/src/Model/Entities/AclResourceTrait.php +++ b/src/Model/Entities/AclResourceTrait.php @@ -4,14 +4,17 @@ namespace ADT\FancyAdmin\Model\Entities; +use ADT\DoctrineLoggable\Attributes\LoggableProperty; use Doctrine\ORM\Mapping as ORM; trait AclResourceTrait { #[ORM\Column(unique: true, nullable: false)] + #[LoggableProperty] protected string $name; #[ORM\Column] + #[LoggableProperty] protected string $title; public function getName(): string diff --git a/src/Model/Entities/AclRoleTrait.php b/src/Model/Entities/AclRoleTrait.php index 79236b5..979d949 100644 --- a/src/Model/Entities/AclRoleTrait.php +++ b/src/Model/Entities/AclRoleTrait.php @@ -4,6 +4,7 @@ namespace ADT\FancyAdmin\Model\Entities; +use ADT\DoctrineLoggable\Attributes\LoggableProperty; use ADT\FancyAdmin\Model\Entities\Enums\AclRoleTypeEnum; use ADT\FancyAdmin\Model\Entities\Traits\CreatedAt; use ADT\FancyAdmin\Model\Entities\Traits\CreatedByNullable; @@ -22,21 +23,27 @@ trait AclRoleTrait use UpdatedBy; #[ORM\Column(unique: true, nullable: false)] + #[LoggableProperty] protected string $name; #[ORM\OneToMany(targetEntity: 'Acl', mappedBy: 'role')] + #[LoggableProperty] protected Collection $acls; #[ORM\Column(nullable: true)] + #[LoggableProperty] protected ?string $context = null; #[ORM\Column(nullable: false)] + #[LoggableProperty] protected AclRoleTypeEnum $type; #[ORM\Column(nullable: false, options: ["default" => 0])] + #[LoggableProperty] protected bool $isAdmin = false; #[ORM\Column(nullable: false, options: ["default" => 0])] + #[LoggableProperty] protected bool $needsSso = false; public function __construct() diff --git a/src/Model/Entities/AclTrait.php b/src/Model/Entities/AclTrait.php index 7a49c8b..e56eb35 100644 --- a/src/Model/Entities/AclTrait.php +++ b/src/Model/Entities/AclTrait.php @@ -4,6 +4,7 @@ namespace ADT\FancyAdmin\Model\Entities; +use ADT\DoctrineLoggable\Attributes\LoggableProperty; use ADT\FancyAdmin\Model\Entities\Traits\CreatedAt; use ADT\FancyAdmin\Model\Entities\Traits\CreatedBy; use ADT\FancyAdmin\Model\Entities\Traits\IsActive; @@ -21,10 +22,12 @@ trait AclTrait #[ORM\ManyToOne(targetEntity: 'AclRole')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[LoggableProperty] protected AclRole $role; #[ORM\ManyToOne(targetEntity: 'AclResource')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[LoggableProperty] protected AclResource $resource; public function getRole(): AclRole diff --git a/src/Model/Entities/ConfigurationTrait.php b/src/Model/Entities/ConfigurationTrait.php index 252d71c..f6856b8 100644 --- a/src/Model/Entities/ConfigurationTrait.php +++ b/src/Model/Entities/ConfigurationTrait.php @@ -4,6 +4,7 @@ namespace ADT\FancyAdmin\Model\Entities; +use ADT\DoctrineLoggable\Attributes\LoggableProperty; use ADT\FancyAdmin\Model\Entities\Enums\ConfigurationType; use ADT\FancyAdmin\Model\Entities\Enums\ConfigurationTypeEnum; use ADT\FancyAdmin\Model\Entities\Traits\UpdatedAt; @@ -28,10 +29,12 @@ trait ConfigurationTrait protected ConfigurationTypeEnum $type = ConfigurationTypeEnum::TYPE_PLAINTEXT; #[Column(name: '`value`', type: Types::TEXT, nullable: true)] + #[LoggableProperty] protected ?string $value = null; #[OneToOne(targetEntity: 'File', cascade: ['persist'], orphanRemoval: true)] #[JoinColumn(nullable: true)] + #[LoggableProperty] protected ?File $file; #[Column(name: '`options`', type: Types::TEXT, nullable: true)] diff --git a/src/Model/Entities/IdentityTrait.php b/src/Model/Entities/IdentityTrait.php index 3bfa551..f8e3920 100644 --- a/src/Model/Entities/IdentityTrait.php +++ b/src/Model/Entities/IdentityTrait.php @@ -32,9 +32,11 @@ trait IdentityTrait abstract public function getId(); #[ORM\Column(nullable: true)] + #[LoggableProperty] protected ?string $firstName = null; #[ORM\Column(nullable: true)] + #[LoggableProperty] protected ?string $lastName = null; #[ORM\Column(nullable:true)] @@ -42,12 +44,15 @@ abstract public function getId(); protected ?string $email = null; #[ORM\Column(nullable: true)] + #[LoggableProperty] protected ?string $username = null; #[ORM\Column(nullable: true)] + #[LoggableProperty] protected ?string $context = null; #[ORM\Column(nullable:true)] + #[LoggableProperty] protected ?string $phoneNumber = null; #[ORM\Column(nullable: true)] @@ -63,6 +68,7 @@ abstract public function getId(); #[ORM\ManyToOne(targetEntity: 'Sso')] #[JoinColumn(nullable: true)] + #[LoggableProperty] protected ?Sso $sso = null; #[ManyToMany(targetEntity: 'AclRole')] @@ -72,10 +78,12 @@ abstract public function getId(); protected Collection $roles; #[ORM\Column(nullable: true)] + #[LoggableProperty] protected ?DateTimeImmutable $anonymizedAt = null; #[ORM\ManyToOne(targetEntity: 'Identity')] #[JoinColumn(nullable: true)] + #[LoggableProperty] protected ?Identity $anonymizedBy = null; protected string $authToken; diff --git a/src/Model/Entities/ProfileTrait.php b/src/Model/Entities/ProfileTrait.php index 330f1b4..1741f11 100644 --- a/src/Model/Entities/ProfileTrait.php +++ b/src/Model/Entities/ProfileTrait.php @@ -2,6 +2,7 @@ namespace ADT\FancyAdmin\Model\Entities; +use ADT\DoctrineLoggable\Attributes\LoggableProperty; use ADT\FancyAdmin\Model\Entities\Traits\CreatedAt; use ADT\FancyAdmin\Model\Entities\Traits\CreatedByNullable; use ADT\FancyAdmin\Model\Entities\Traits\IsActive; @@ -34,6 +35,7 @@ trait ProfileTrait #[ManyToMany(targetEntity: 'AclRole')] #[JoinColumn(onDelete: "CASCADE")] #[InverseJoinColumn(onDelete: "RESTRICT")] + #[LoggableProperty] protected Collection $roles; public function __construct() diff --git a/src/Model/Entities/SsoTrait.php b/src/Model/Entities/SsoTrait.php index ada0850..d4916d3 100644 --- a/src/Model/Entities/SsoTrait.php +++ b/src/Model/Entities/SsoTrait.php @@ -4,33 +4,41 @@ namespace ADT\FancyAdmin\Model\Entities; +use ADT\DoctrineLoggable\Attributes\LoggableProperty; use Doctrine\ORM\Mapping as ORM; trait SsoTrait { #[ORM\Column(unique: true, nullable: false)] + #[LoggableProperty] protected string $name; #[ORM\Column(nullable: false)] + #[LoggableProperty] protected string $realm; #[ORM\Column(nullable: false)] + #[LoggableProperty] protected string $baseUrl; #[ORM\Column(nullable: false)] + #[LoggableProperty] protected string $hostUrl; #[ORM\Column(nullable: false)] + #[LoggableProperty] protected string $clientId; #[ORM\Column(nullable: false)] protected string $clientSecret; #[ORM\Column(nullable: false)] + #[LoggableProperty] protected string $frontendClientId; #[ORM\ManyToOne(targetEntity: 'AclRole')] #[ORM\JoinColumn(nullable: true)] + #[LoggableProperty] protected ?AclRole $defaultRole = null; public function getName(): string From 7bd66e967c802f084a7078600e8c97e4bea23b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Konvi=C4=8Dka?= Date: Tue, 28 Jul 2026 14:12:50 +0200 Subject: [PATCH 02/10] feat(firebase): Silent sync of firebase token with backend knownTokens --- src/Model/Services/JsComponents.php | 6 ++++++ src/UI/Presenters/BasePresenterTrait.php | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/Model/Services/JsComponents.php b/src/Model/Services/JsComponents.php index ecae851..3d3fac0 100644 --- a/src/Model/Services/JsComponents.php +++ b/src/Model/Services/JsComponents.php @@ -11,6 +11,12 @@ public function setFirebaseLink(string $key, string $value): static return $this; } + public function setFirebaseKnownTokens(array $tokens): static + { + $this->components['notifications']['knownTokens'] = array_values($tokens); + return $this; + } + public function setFirebaseConfig(array $firebaseConfig): void { $this->components['notifications'] = [ diff --git a/src/UI/Presenters/BasePresenterTrait.php b/src/UI/Presenters/BasePresenterTrait.php index 4c34c1a..782714d 100644 --- a/src/UI/Presenters/BasePresenterTrait.php +++ b/src/UI/Presenters/BasePresenterTrait.php @@ -44,6 +44,11 @@ protected function beforeRender(): void $this->_jsComponents->setFirebaseLink('setFirebaseTokenLink', $this->getPresenter()->link('setFirebaseToken!', ['firebaseToken' => '__firebaseToken__'])); $this->_jsComponents->setFirebaseLink('removeFirebaseTokenLink', $this->getPresenter()->link('removeFirebaseToken!', ['firebaseToken' => '__firebaseToken__'])); $this->_jsComponents->setFirebaseLink('removeAllFirebaseTokensLink', $this->getPresenter()->link('removeAllFirebaseTokens!')); + $this->_jsComponents->setFirebaseLink('syncFirebaseTokenLink', $this->getPresenter()->link('syncFirebaseToken!', ['firebaseToken' => '__firebaseToken__'])); + $identity = $this->getUser()->isLoggedIn() ? $this->getUser()->getIdentity() : null; + $this->_jsComponents->setFirebaseKnownTokens( + $identity !== null && method_exists($identity, 'getFirebaseTokens') ? $identity->getFirebaseTokens() : [] + ); $this->getTemplate()->jsComponentsConfig = $this->_jsComponents->generateConfig(); // Keycloak — dynamický frame-src CSP header pro silent SSO iframe @@ -173,6 +178,14 @@ public function handleSetFirebaseToken(string $firebaseToken): void $this->flashMessageSuccess('fcadmin.firebase.notifications.flashes.success'); } + public function handleSyncFirebaseToken(string $firebaseToken): void + { + $this->getUser()->getIdentity() + ->addFirebaseToken($firebaseToken); + + $this->em->flush(); + } + public function handleRemoveFirebaseToken(string $firebaseToken): void { $this->getUser()->getIdentity() From 15a996a5d55fbfe85798814271991a983f72f643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Konvi=C4=8Dka?= Date: Wed, 29 Jul 2026 22:45:34 +0200 Subject: [PATCH 03/10] fix(security): remove deprecated curl_close() call --- src/Model/Security/BreachedPasswordChecker.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Model/Security/BreachedPasswordChecker.php b/src/Model/Security/BreachedPasswordChecker.php index a6f4da8..91d489c 100644 --- a/src/Model/Security/BreachedPasswordChecker.php +++ b/src/Model/Security/BreachedPasswordChecker.php @@ -50,7 +50,6 @@ private function isInHaveIBeenPwned(string $password): bool CURLOPT_HTTPHEADER => ['User-Agent: FancyAdmin-ASVS-Checker'], ]); $response = curl_exec($ch); - curl_close($ch); if ($response === false) { return false; From de18368dd7ed7a20971e79ab399bc542471bfef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kud=C4=9Blka?= Date: Sun, 2 Aug 2026 14:31:11 +0200 Subject: [PATCH 04/10] RequestLogger --- src/Model/Entities/RequestLog.php | 28 +++ src/Model/Entities/RequestLogBody.php | 27 +++ src/Model/Entities/RequestLogBodyTrait.php | 124 ++++++++++++ src/Model/Entities/RequestLogTrait.php | 124 ++++++++++++ src/Model/RequestLogger.php | 224 +++++++++++++++++++++ 5 files changed, 527 insertions(+) create mode 100644 src/Model/Entities/RequestLog.php create mode 100644 src/Model/Entities/RequestLogBody.php create mode 100644 src/Model/Entities/RequestLogBodyTrait.php create mode 100644 src/Model/Entities/RequestLogTrait.php create mode 100644 src/Model/RequestLogger.php diff --git a/src/Model/Entities/RequestLog.php b/src/Model/Entities/RequestLog.php new file mode 100644 index 0000000..87f9495 --- /dev/null +++ b/src/Model/Entities/RequestLog.php @@ -0,0 +1,28 @@ +requestLog; + } + + public function setRequestLog(RequestLog $requestLog): static + { + $this->requestLog = $requestLog; + return $this; + } + + public function getHeaders(): ?array + { + return $this->headers; + } + + public function setHeaders(?array $headers): static + { + $this->headers = $headers; + return $this; + } + + public function getParams(): ?array + { + return $this->params; + } + + public function setParams(?array $params): static + { + $this->params = $params; + return $this; + } + + public function getPostData(): ?string + { + return $this->postData; + } + + public function setPostData(?string $postData): static + { + $this->postData = $postData; + return $this; + } + + public function getRawDataJson(): ?array + { + return $this->rawDataJson; + } + + public function setRawDataJson(?array $rawDataJson): static + { + $this->rawDataJson = $rawDataJson; + return $this; + } + + public function getRawDataText(): ?string + { + return $this->rawDataText; + } + + public function setRawDataText(?string $rawDataText): static + { + $this->rawDataText = $rawDataText; + return $this; + } + + public function getResponseJson(): ?array + { + return $this->responseJson; + } + + public function setResponseJson(?array $responseJson): static + { + $this->responseJson = $responseJson; + return $this; + } + + public function getResponseText(): ?string + { + return $this->responseText; + } + + public function setResponseText(?string $responseText): static + { + $this->responseText = $responseText; + return $this; + } +} diff --git a/src/Model/Entities/RequestLogTrait.php b/src/Model/Entities/RequestLogTrait.php new file mode 100644 index 0000000..e44088c --- /dev/null +++ b/src/Model/Entities/RequestLogTrait.php @@ -0,0 +1,124 @@ +createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + return $this; + } + + public function getIdentityId(): ?int + { + return $this->identityId; + } + + public function setIdentityId(?int $identityId): static + { + $this->identityId = $identityId; + return $this; + } + + public function getApiKeyId(): ?int + { + return $this->apiKeyId; + } + + public function setApiKeyId(?int $apiKeyId): static + { + $this->apiKeyId = $apiKeyId; + return $this; + } + + public function getUrl(): string + { + return $this->url; + } + + public function setUrl(string $url): static + { + $this->url = $url; + return $this; + } + + public function getMethod(): string + { + return $this->method; + } + + public function setMethod(string $method): static + { + $this->method = $method; + return $this; + } + + public function getCode(): int + { + return $this->code; + } + + public function setCode(int $code): static + { + $this->code = $code; + return $this; + } + + public function getIp(): string + { + return $this->ip; + } + + public function setIp(string $ip): static + { + $this->ip = $ip; + return $this; + } + + public function getResponseTime(): ?float + { + return $this->responseTime !== null ? round((float) $this->responseTime, 2) : null; + } + + public function setResponseTime(?float $responseTime): static + { + $this->responseTime = $responseTime !== null ? (string) round($responseTime, 2) : null; + return $this; + } +} diff --git a/src/Model/RequestLogger.php b/src/Model/RequestLogger.php new file mode 100644 index 0000000..d42cd92 --- /dev/null +++ b/src/Model/RequestLogger.php @@ -0,0 +1,224 @@ + + */ + public static array $sensitiveKeys = [ + 'password', + 'passwd', + 'secret', + 'token', + 'authorization', + 'api_key', + 'apikey', + 'pin', + ]; + + private const string MASK = '***'; + + /** @var array Vlastní projektové sloupce pro tabulku `request_log` */ + private static array $extraLogData = []; + + public function __construct( + private readonly array $dbParams, + private readonly SecurityUser $securityUser, + ) { + } + + /** + * Přidá vlastní sloupec do logu requestu (tabulka `request_log`). + * + * Volej kdykoliv během zpracování requestu (typicky v presenteru), např.: + * RequestLogger::addValue('device_id', $deviceId); + * + * Systémové sloupce (created_at, method, url, ip, code, response_time, + * identity_id, api_key_id) nelze přepsat – slouží pouze k PŘIDÁVÁNÍ. + */ + public static function addValue(string $column, mixed $value): void + { + self::$extraLogData[$column] = $value; + } + + public function logRequest(Presenter $presenter, Response $response): void + { + if (!self::$apiKeyId && !$this->securityUser->isLoggedIn()) { + return; + } + + try { + $this->doLogRequest($presenter, $response); + } catch (Throwable $e) { + Debugger::log('RequestLogger selhal: ' . $e->getMessage(), ILogger::CRITICAL); + } + } + + /** + * @throws JsonException + * @throws Exception + * @throws \Exception + */ + private function doLogRequest(Presenter $presenter, Response $response): void + { + if (json_validate($presenter->getHttpRequest()->getRawBody())) { + $raw_data_text = null; + $raw_data_json = Json::decode($presenter->getHttpRequest()->getRawBody(), forceArrays: true); + } else { + $raw_data_json = null; + $raw_data_text = $presenter->getHttpRequest()->getRawBody(); + } + + if (self::$logResponse) { + if (!$response instanceof FileResponse) { + ob_start(); + $response->send($presenter->getHttpRequest(), $presenter->getHttpResponse()); + $response = ob_get_clean(); + if (json_validate($response)) { + $response_text = null; + $response_json = Json::decode($response, forceArrays: true); + } else { + $response_text = $response; + $response_json = null; + } + } else { + $response_text = null; + $response_json = null; + } + } else { + $response_text = null; + $response_json = null; + } + + $headers = $presenter->getHttpRequest()->getHeaders(); + unset($headers['authorization']); + unset($headers['x-api-key']); + + $connection = DriverManager::getConnection($this->dbParams); + + // Systémové sloupce mají díky `+` vždy přednost – extra data (viz addValue()) + // mohou pouze PŘIDÁVAT vlastní sloupce, ne přepsat defaultní logování. + $connection->insert('request_log', [ + 'created_at' => new DateTimeImmutable()->format('Y-m-d H:i:s.u'), + 'method' => $presenter->getHttpRequest()->getMethod(), + 'url' => $presenter->getHttpRequest()->getUrl()->getBaseUrl() . ltrim($presenter->getHttpRequest()->getUrl()->getPath(), '/'), + 'ip' => $presenter->getHttpRequest()->getRemoteAddress(), + 'code' => $presenter->getHttpResponse()->getCode(), + 'response_time' => (microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']), + 'identity_id' => $this->securityUser->isLoggedIn() ? $this->securityUser->getId() : null, + 'api_key_id' => self::$apiKeyId, + ] + self::$extraLogData); + + $requestLogId = $connection->lastInsertId(); + + $connection->insert('request_log_body', [ + 'request_log_id' => $requestLogId, + 'headers' => $headers ? Json::encode($this->normalizeData($headers)) : null, + 'params' => $_GET ? Json::encode($this->normalizeData($_GET)) : null, + 'post_data' => $_POST ? Json::encode($this->normalizeData($_POST)) : null, + 'raw_data_json' => $raw_data_json ? Json::encode($this->normalizeData($raw_data_json)) : null, + 'raw_data_text' => $this->normalizeData($raw_data_text), + 'response_json' => $response_json ? Json::encode($this->normalizeData($response_json)) : null, + 'response_text' => $this->normalizeData($response_text), + ]); + } + + private function normalizeData($data) + { + if (empty($data)) { + return null; + } + + if (is_array($data)) { + $output = []; + foreach ($data as $key => $value) { + $normalizedKey = is_string($key) ? $this->normalizeString($key) : $key; + $output[$normalizedKey] = $this->isSensitiveKey($normalizedKey) + ? self::MASK + : $this->normalizeData($value); // Rekurze + } + return $output; + } + + if (is_string($data)) { + if (strlen($data) >= 255 && $this->isBase64Encoded($data)) { + return 'md5:' . md5($data); + } + return $this->normalizeString($data); + } + + // Ostatní případy (např. scalar typy) – vracíme beze změny + return $data; + } + + /** + * Je klíč citlivý? (case-insensitive substring match proti self::$sensitiveKeys) + */ + private function isSensitiveKey(int|string $key): bool + { + if (!is_string($key)) { + return false; + } + + $key = mb_strtolower($key); + foreach (self::$sensitiveKeys as $sensitiveKey) { + if (str_contains($key, mb_strtolower($sensitiveKey))) { + return true; + } + } + + return false; + } + + private function normalizeString(string $value): string + { + // Neplatné UTF-8 (např. z útočných requestů) nahradíme náhradním znakem, + // aby šlo hodnotu uložit i serializovat do JSONu bez výjimky + if (!mb_check_encoding($value, 'UTF-8')) { + $value = mb_scrub($value, 'UTF-8'); + } + return $this->removeControlCharacters($value); + } + + private function isBase64Encoded(string $string): bool + { + $decoded = base64_decode($string, true); + + if ($decoded !== false) { + if (base64_encode($decoded) === $string) { + return true; + } + } + + return false; + } + + private function removeControlCharacters(string $input): string + { + // Odebereme všechny znaky s ASCII hodnotou < 32 kromě \n (10), \r (13) a \t (9) + return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/u', '', $input); + } +} From 8f3d54318dc566d22c40e312ed4f005fff5a7794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kud=C4=9Blka?= Date: Sun, 2 Aug 2026 20:22:15 +0200 Subject: [PATCH 05/10] feat(flash): Make flash message auto-closing explicit By default, only `success` flash messages now auto-close. Other types (`warning`, `danger`, `info`) will remain on screen until dismissed by the user, unless an explicit auto-close duration is provided. This clarifies the intended user interaction. --- assets/js/flashes.js | 2 ++ src/UI/Presenters/@layout.latte | 2 +- src/UI/Presenters/BasePresenterTrait.php | 6 ++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/assets/js/flashes.js b/assets/js/flashes.js index 263ee0e..ecbe777 100644 --- a/assets/js/flashes.js +++ b/assets/js/flashes.js @@ -1,6 +1,8 @@ import $ from 'jquery'; const scheduleAutoClose = (root) => { + // Zavírají se pouze zprávy s atributem data-close-duration (řídí se v PHP – viz + // flashMessageCommon). Zprávy bez něj (warning/danger/info) zůstávají do zavření uživatelem. $(root).find('.alert[data-close-duration]').each(function () { const $alert = $(this); if ($alert.data('auto-close-scheduled')) { diff --git a/src/UI/Presenters/@layout.latte b/src/UI/Presenters/@layout.latte index 09dde71..67b9336 100644 --- a/src/UI/Presenters/@layout.latte +++ b/src/UI/Presenters/@layout.latte @@ -147,7 +147,7 @@ {/if}
-
+
closeDuration}>
{$flash->message|noescape}
+
+
+{/define} diff --git a/src/UI/Components/Forms/SignIn/SignInFormTrait.php b/src/UI/Components/Forms/SignIn/SignInFormTrait.php index 6a6d3cf..95e4d4e 100644 --- a/src/UI/Components/Forms/SignIn/SignInFormTrait.php +++ b/src/UI/Components/Forms/SignIn/SignInFormTrait.php @@ -7,12 +7,18 @@ use ADT\FancyAdmin\DI\Injects\AuthenticatorInject; use ADT\FancyAdmin\DI\Injects\FancyAdminInject; use ADT\FancyAdmin\DI\Injects\IdentityQueryFactoryInject; +use ADT\FancyAdmin\DI\Injects\PasskeyServiceInject; use ADT\FancyAdmin\DI\Injects\SecurityUserInject; +use ADT\FancyAdmin\DI\Injects\TranslatorInject; use ADT\FancyAdmin\Model\Entities\Identity; +use ADT\FancyAdmin\Model\Security\Passkey\PasskeyException; +use ADT\FancyAdmin\Model\Security\Passkey\PasskeyService; use ADT\FancyAdmin\UI\Components\ControlTrait; use ADT\FancyAdmin\UI\RedirectAfterLoginTrait; use ADT\Forms\Form; use Nette\Security\AuthenticationException; +use Nette\Utils\Json; +use Nette\Utils\JsonException; trait SignInFormTrait { @@ -22,6 +28,8 @@ trait SignInFormTrait use AuthenticatorInject; use SecurityUserInject; use IdentityQueryFactoryInject; + use PasskeyServiceInject; + use TranslatorInject; private Identity $_identity; @@ -33,6 +41,7 @@ public function initForm(Form $form): void $form->addEmail('email') ->setHtmlAttribute('id', 'login-form-input-email') ->setHtmlAttribute('placeholder', 'fcadmin.forms.signIn.labels.email') + ->setHtmlAttribute('autocomplete', 'username') ->setRequired('fcadmin.forms.signIn.errors.emailRequired'); $form->addPassword('password') @@ -46,6 +55,8 @@ public function initForm(Form $form): void $form->addSubmit('submit', 'fcadmin.forms.signIn.labels.logIn') ->getControlPrototype()->class[] = 'w-100'; + $form->addSection(name: 'passkey'); + $this->getTemplate()->isLostPasswordEnabled = $this->_fancyAdmin->isLostPasswordEnabled(); // Keycloak email check — přidá data atribut pro JS kontrolu @@ -69,6 +80,101 @@ public function handleCheckKeycloak(string $email): void $this->getPresenter()->sendJson(['loginUrl' => $this->getKeycloakLoginUrl($email)]); } + /** + * AJAX signal — vrátí PublicKeyCredentialRequestOptions pro usernameless + * passkey login (binárky base64url). Challenge se drží one-shot v session. + */ + public function handlePasskeyLoginArgs(): void + { + try { + $args = $this->_passkeyService->getLoginArgs(); + } catch (PasskeyException $e) { + $this->getPresenter()->sendJson(['error' => $e->getMessage()]); + } + + $this->getPresenter()->sendJson($args); + } + + /** + * AJAX signal — ověří WebAuthn assertion (JSON tělo requestu ve formátu + * PublicKeyCredential.toJSON()), přihlásí identitu a vrátí JSON s redirect URL + * (přes redirectAfterLogin(), který pod AJAXem pošle payload {redirect: ...}). + */ + public function handlePasskeyLoginVerify(): void + { + $credential = $this->parsePasskeyCredential(); + + try { + if ($credential === null) { + throw new PasskeyException($this->_translator->translate('fcadmin.passkeys.errors.invalidKey')); + } + + $identity = $this->_passkeyService->processLogin( + $credential['credentialId'], + $credential['clientDataJSON'], + $credential['authenticatorData'], + $credential['signature'], + $credential['userHandle'], + ); + + // Stejný ACL check jako AuthenticatorTrait::validateIdentity() + if ( + !$identity->isAllowed($this->_fancyAdmin->getCustomerAclResource()) + && + !$identity->isAllowed($this->_fancyAdmin->getBackofficeAclResource()) + ) { + throw new PasskeyException($this->_translator->translate('fcadmin.appGeneral.exceptions.noPermission')); + } + + $this->_securityUser->login($identity, context: $this->_fancyAdmin->getContext()); + } catch (PasskeyException $e) { + $this->getPresenter()->sendJson(['error' => $e->getMessage()]); + } + + $this->redirectAfterLogin(); + } + + /** + * Načte a dekóduje WebAuthn assertion z JSON těla requestu + * (výstup PublicKeyCredential.toJSON(), binárky base64url). + * + * @return array{credentialId: string, clientDataJSON: string, authenticatorData: string, signature: string, userHandle: ?string}|null + */ + private function parsePasskeyCredential(): ?array + { + try { + $data = Json::decode((string) $this->getPresenter()->getHttpRequest()->getRawBody(), true); + } catch (JsonException) { + return null; + } + + if (!is_array($data)) { + return null; + } + + $response = $data['response'] ?? null; + if (!is_array($response)) { + return null; + } + + $credentialId = PasskeyService::base64UrlDecode($data['rawId'] ?? $data['id'] ?? null); + $clientDataJSON = PasskeyService::base64UrlDecode($response['clientDataJSON'] ?? null); + $authenticatorData = PasskeyService::base64UrlDecode($response['authenticatorData'] ?? null); + $signature = PasskeyService::base64UrlDecode($response['signature'] ?? null); + + if ($credentialId === null || $clientDataJSON === null || $authenticatorData === null || $signature === null) { + return null; + } + + return [ + 'credentialId' => $credentialId, + 'clientDataJSON' => $clientDataJSON, + 'authenticatorData' => $authenticatorData, + 'signature' => $signature, + 'userHandle' => PasskeyService::base64UrlDecode($response['userHandle'] ?? null), + ]; + } + /** * Vrátí Keycloak login URL, pokud se má uživatel s daným emailem přihlašovat přes SSO. * Jinak vrátí null (uživatel neexistuje, nemá SSO instanci nebo Keycloak není zapnutý). diff --git a/src/UI/Components/Forms/SignIn/index.js b/src/UI/Components/Forms/SignIn/index.js new file mode 100644 index 0000000..79bb0be --- /dev/null +++ b/src/UI/Components/Forms/SignIn/index.js @@ -0,0 +1,176 @@ +/** + * SignInForm — přihlášení passkey (WebAuthn). + * + * Ceremony se spouští VÝHRADNĚ kliknutím na tlačítko "Přihlásit se přihlašovacím + * klíčem" (usernameless get, prázdné allowCredentials). Žádná automatika: + * - passkey se nenabízí sám v autofillu email pole (conditional mediation) + * - dokud uživatel neklikne, nejde na server žádný request, takže anonymní + * návštěvník login stránky nedostane ani session cookie + * + * Binárky konvertuje nativní PublicKeyCredential JSON API s base64url fallbackem + * pro starší prohlížeče. + * + * Listener je delegovaný na document, takže funguje i pro formulář vložený přes + * AJAX snippet. Aktivace přes data-adt-fancyadmin-passkey-login. + */ + +const ROOT_SELECTOR = '[data-adt-fancyadmin-passkey-login]'; +const BUTTON_SELECTOR = '[data-passkey-login-button]'; + +let ceremonyRunning = false; + +const base64UrlToBuffer = (base64Url) => { + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const binary = window.atob(base64); + const buffer = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + buffer[i] = binary.charCodeAt(i); + } + return buffer; +}; + +const bufferToBase64Url = (buffer) => { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +// Fallback pro prohlížeče bez PublicKeyCredential.parseRequestOptionsFromJSON() +const parseRequestOptions = (publicKey) => { + if (typeof PublicKeyCredential.parseRequestOptionsFromJSON === 'function') { + return PublicKeyCredential.parseRequestOptionsFromJSON(publicKey); + } + + const options = { ...publicKey }; + options.challenge = base64UrlToBuffer(publicKey.challenge); + if (publicKey.allowCredentials) { + options.allowCredentials = publicKey.allowCredentials.map((cred) => ({ + ...cred, + id: base64UrlToBuffer(cred.id), + })); + } + return options; +}; + +// Fallback pro prohlížeče bez PublicKeyCredential.prototype.toJSON() +const credentialToJson = (credential) => { + if (typeof credential.toJSON === 'function') { + return credential.toJSON(); + } + + return { + id: credential.id, + rawId: bufferToBase64Url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64Url(credential.response.clientDataJSON), + authenticatorData: bufferToBase64Url(credential.response.authenticatorData), + signature: bufferToBase64Url(credential.response.signature), + userHandle: credential.response.userHandle ? bufferToBase64Url(credential.response.userHandle) : null, + }, + }; +}; + +const showError = (root, message) => { + const errorEl = root.querySelector('[data-passkey-error]'); + if (errorEl && message) { + errorEl.textContent = message; + errorEl.classList.remove('d-none'); + } +}; + +const hideError = (root) => { + const errorEl = root.querySelector('[data-passkey-error]'); + if (errorEl) { + errorEl.classList.add('d-none'); + } +}; + +const authenticate = async (root) => { + const argsUrl = root.getAttribute('data-passkey-args-url'); + const verifyUrl = root.getAttribute('data-passkey-verify-url'); + + const argsResponse = await fetch(argsUrl, { + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + const args = await argsResponse.json(); + if (args.error || !args.publicKey) { + throw new Error(args.error || 'invalid args'); + } + + const credential = await navigator.credentials.get({ + publicKey: parseRequestOptions(args.publicKey), + }); + if (!credential) { + throw new Error('no credential'); + } + + const verifyResponse = await fetch(verifyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(credentialToJson(credential)), + }); + + let data = null; + try { + data = await verifyResponse.json(); + } catch (e) { + // non-JSON odpověď (např. ForwardResponse po restoreRequest) — uživatel už je přihlášen + } + + if (data && data.error) { + showError(root, data.error); + return; + } + + if (data && data.redirect) { + window.location.href = data.redirect; + } else { + window.location.reload(); + } +}; + +const onButtonClick = async (event) => { + const button = event.target.closest(BUTTON_SELECTOR); + if (!button) { + return; + } + + const root = button.closest(ROOT_SELECTOR); + if (!root || ceremonyRunning) { + return; + } + + hideError(root); + + if (!window.PublicKeyCredential || !navigator.credentials) { + showError(root, root.getAttribute('data-passkey-error-unsupported')); + return; + } + + ceremonyRunning = true; + button.disabled = true; + try { + await authenticate(root); + } catch (e) { + // NotAllowedError = uživatel dialog zavřel — bez chybové hlášky + if (!(e instanceof DOMException && e.name === 'NotAllowedError')) { + showError(root, root.getAttribute('data-passkey-error-failed')); + } + } finally { + ceremonyRunning = false; + button.disabled = false; + } +}; + +const run = () => { + document.addEventListener('click', onButtonClick); +}; + +export default { run }; diff --git a/src/UI/Components/Grids/Passkey/PasskeyGrid.latte b/src/UI/Components/Grids/Passkey/PasskeyGrid.latte new file mode 100644 index 0000000..a62e5a0 --- /dev/null +++ b/src/UI/Components/Grids/Passkey/PasskeyGrid.latte @@ -0,0 +1 @@ +{varType ADT\FancyAdmin\Model\Entities\Passkey $item} diff --git a/src/UI/Components/Grids/Passkey/PasskeyGrid.php b/src/UI/Components/Grids/Passkey/PasskeyGrid.php new file mode 100644 index 0000000..586fa65 --- /dev/null +++ b/src/UI/Components/Grids/Passkey/PasskeyGrid.php @@ -0,0 +1,7 @@ +withoutIsActiveColumn = true; + $grid->setPagination(false); + + $grid->addColumnText('name', 'fcadmin.passkeys.grid.name'); + + $grid->addColumnText('createdAt', 'fcadmin.passkeys.grid.createdAt') + ->setRenderer(fn(Passkey $passkey) => $passkey->getCreatedAt()->format('d.m.Y H:i')); + + $grid->addColumnText('lastUsedAt', 'fcadmin.passkeys.grid.lastUsedAt') + ->setRenderer(fn(Passkey $passkey) => $passkey->getLastUsedAt()?->format('d.m.Y H:i') ?? ''); + + $grid->addColumnText('backupState', '') + ->setRenderer(fn(Passkey $passkey) => $passkey->getBackupState() + ? Html::el('span') + ->class('badge bg-success') + ->setText($this->getTranslator()->translate('fcadmin.passkeys.grid.synced')) + : ''); + + $grid->addAction('deletePasskey', 'fcadmin.passkeys.grid.delete', 'deletePasskey!') + ->setIcon('trash') + ->setClass('btn btn-danger btn-sm ajax datagrid-delete') + ->setConfirmation(new StringConfirmation('fcadmin.passkeys.confirms.delete')); + } + + public function handleDeletePasskey(int $id): void + { + // Mazat lze jen klíče patřící přihlášené identitě + /** @var Passkey|null $passkey */ + $passkey = $this->_passkeyQueryFactory->create() + ->disableSecurityFilter() + ->disableAccountFilter() + ->byIdentity($this->getSecurityUser()->getIdentity()) + ->byId($id) + ->fetchOneOrNull(); + + if ($passkey === null) { + $this->getPresenter()->error(); + } + + $this->getEntityManager()->remove($passkey); + $this->getEntityManager()->flush(); + + $this->getPresenter()->flashMessageSuccess('fcadmin.passkeys.messages.deleted'); + $this->getPresenter()->redirect('this'); + } + + protected function initQueryObject($queryObject): void + { + $queryObject + ->disableSecurityFilter() + ->disableAccountFilter() + ->byIdentity($this->getSecurityUser()->getIdentity()); + } + + protected function getQueryObjectFactoryClass(): string + { + return PasskeyQueryFactory::class; + } +} diff --git a/src/UI/Presenters/Account/AccountPresenterTrait.php b/src/UI/Presenters/Account/AccountPresenterTrait.php index 928ddbe..dcad63f 100644 --- a/src/UI/Presenters/Account/AccountPresenterTrait.php +++ b/src/UI/Presenters/Account/AccountPresenterTrait.php @@ -7,13 +7,22 @@ use ADT\FancyAdmin\DI\Injects\AuthenticatorInject; use ADT\FancyAdmin\DI\Injects\ChangePasswordFormFactoryInject; use ADT\FancyAdmin\DI\Injects\FancyAdminInject; +use ADT\FancyAdmin\DI\Injects\PasskeyFormFactoryInject; +use ADT\FancyAdmin\DI\Injects\PasskeyServiceInject; use ADT\FancyAdmin\DI\Injects\PersonalDataFormFactoryInject; use ADT\FancyAdmin\DI\Injects\SecurityUserInject; +use ADT\FancyAdmin\DI\Injects\TranslatorInject; +use ADT\FancyAdmin\Model\Security\Passkey\PasskeyException; +use ADT\FancyAdmin\Model\Security\Passkey\PasskeyService; use ADT\FancyAdmin\UI\Components\Controls\SidePanel\SidePanelControl; use ADT\FancyAdmin\UI\Components\Controls\SidePanel\SidePanelControlFactory; +use ADT\FancyAdmin\UI\Components\Grids\Passkey\PasskeyGrid; +use ADT\FancyAdmin\UI\Components\Grids\Passkey\PasskeyGridFactory; use ADT\FancyAdmin\UI\Components\Grids\Session\SessionGrid; use ADT\FancyAdmin\UI\Components\Grids\Session\SessionGridFactory; use ADT\FancyAdmin\UI\Presenters\PresenterTrait; +use Nette\Utils\Json; +use Nette\Utils\JsonException; trait AccountPresenterTrait { @@ -23,6 +32,9 @@ trait AccountPresenterTrait use PersonalDataFormFactoryInject; use ChangePasswordFormFactoryInject; use FancyAdminInject; + use PasskeyFormFactoryInject; + use PasskeyServiceInject; + use TranslatorInject; public function actionDefault(): void { @@ -88,4 +100,80 @@ public function createComponentSessionGrid(SessionGridFactory $factory): Session { return $factory->create(); } + + public function createComponentPasskeyGrid(PasskeyGridFactory $factory): PasskeyGrid + { + return $factory->create(); + } + + public function handleAddPasskey(): void + { + // SSO uživatel klíč registrovat nesmí — panel se ani neotevře + try { + $this->_passkeyService->assertNotSso($this->_securityUser->getIdentity()); + } catch (PasskeyException $e) { + $this->flashMessageError($e->getMessage()); + $this->getPresenter()->redirect('this'); + } + + $this->redrawSidePanel('addPasskey'); + } + + /** + * AJAX signal — vrátí PublicKeyCredentialCreationOptions pro registraci nového + * klíče přihlášené identity (binárky base64url, challenge one-shot v session). + */ + public function handlePasskeyRegisterArgs(): void + { + try { + $args = $this->_passkeyService->getRegistrationArgs($this->_securityUser->getIdentity()); + } catch (PasskeyException $e) { + $this->getPresenter()->sendJson(['error' => $e->getMessage()]); + } + + $this->getPresenter()->sendJson($args); + } + + /** + * AJAX signal — ověří odpověď autentikátoru (JSON tělo: {name, credential}) + * a uloží nový klíč. Vrací {redirect} pro reload stránky, nebo {error}. + */ + public function handlePasskeyRegisterVerify(): void + { + try { + $data = Json::decode((string) $this->getPresenter()->getHttpRequest()->getRawBody(), true); + } catch (JsonException) { + $data = null; + } + + $response = is_array($data) ? ($data['credential']['response'] ?? null) : null; + $clientDataJSON = is_array($response) ? PasskeyService::base64UrlDecode($response['clientDataJSON'] ?? null) : null; + $attestationObject = is_array($response) ? PasskeyService::base64UrlDecode($response['attestationObject'] ?? null) : null; + $transports = is_array($response) && is_array($response['transports'] ?? null) ? $response['transports'] : null; + + try { + if ($clientDataJSON === null || $attestationObject === null) { + throw new PasskeyException($this->_translator->translate('fcadmin.passkeys.errors.invalidKey')); + } + + $this->_passkeyService->processRegistration( + $this->_securityUser->getIdentity(), + $clientDataJSON, + $attestationObject, + is_string($data['name'] ?? null) ? $data['name'] : '', + $transports, + ); + } catch (PasskeyException $e) { + $this->getPresenter()->sendJson(['error' => $e->getMessage()]); + } + + $this->flashMessageSuccess('fcadmin.passkeys.messages.added'); + $this->getPresenter()->redirect('this'); + } + + public function createComponentAddPasskeySidePanel(SidePanelControlFactory $factory): SidePanelControl + { + return $factory->create() + ->setFormFactory(fn() => $this->_passkeyFormFactory->create()); + } } diff --git a/src/UI/Presenters/Account/default.latte b/src/UI/Presenters/Account/default.latte index 0a259db..0d1e760 100644 --- a/src/UI/Presenters/Account/default.latte +++ b/src/UI/Presenters/Account/default.latte @@ -38,6 +38,18 @@
+
+

+ {_fcadmin.passkeys.account.title} +

+ + +
+ +{control passkeyGrid} +

{_fcadmin.presenters.account.sessions} diff --git a/src/lang/fcadmin.cs.yml b/src/lang/fcadmin.cs.yml index 9ed4196..5a622d3 100644 --- a/src/lang/fcadmin.cs.yml +++ b/src/lang/fcadmin.cs.yml @@ -346,6 +346,39 @@ keycloak: noEmail: Účet SSO nemá přiřazený e-mail. loopDetected: Bylo detekováno opakované přesměrování. Zkuste to prosím znovu. +passkeys: + loginButton: Přihlásit se přihlašovacím klíčem + account: + title: Přihlašovací klíče + add: Přidat klíč + form: + name: Název klíče + namePlaceholder: Např. Pracovní notebook + register: Vytvořit přihlašovací klíč + errors: + nameRequired: Název klíče je povinný + grid: + name: Název + createdAt: Vytvořeno + lastUsedAt: Naposledy použito + synced: Synchronizovaný + delete: Smazat + confirms: + delete: Opravdu chcete smazat tento přihlašovací klíč? + messages: + added: Přihlašovací klíč byl přidán. + deleted: Přihlašovací klíč byl smazán. + errors: + unsupportedBrowser: Váš prohlížeč nepodporuje přihlašovací klíče (passkeys). + unknownKey: Neplatný nebo neznámý přihlašovací klíč. + invalidKey: Přihlašovací klíč se nepodařilo ověřit. + alreadyRegistered: Tento přihlašovací klíč je již zaregistrován. + expiredChallenge: Platnost výzvy vypršela. Zkuste to prosím znovu. + ssoAccount: Účet přihlašovaný přes SSO nemůže používat přihlašovací klíče. + unavailable: Přihlašovací klíče nejsou momentálně dostupné. + loginFailed: Přihlášení přihlašovacím klíčem se nezdařilo. Zkuste to prosím znovu. + registrationFailed: Přihlašovací klíč se nepodařilo vytvořit. Zkuste to prosím znovu. + modules: web: navbar: diff --git a/src/lang/fcadmin.sk.yml b/src/lang/fcadmin.sk.yml index e6b4350..9c0553e 100644 --- a/src/lang/fcadmin.sk.yml +++ b/src/lang/fcadmin.sk.yml @@ -344,6 +344,39 @@ keycloak: noEmail: Účet SSO nemá priradený e-mail. loopDetected: Bolo detegované opakované presmerovanie. Skúste to prosím znovu. +passkeys: + loginButton: Prihlásiť sa prihlasovacím kľúčom + account: + title: Prihlasovacie kľúče + add: Pridať kľúč + form: + name: Názov kľúča + namePlaceholder: Napr. Pracovný notebook + register: Vytvoriť prihlasovací kľúč + errors: + nameRequired: Názov kľúča je povinný + grid: + name: Názov + createdAt: Vytvorené + lastUsedAt: Naposledy použité + synced: Synchronizovaný + delete: Zmazať + confirms: + delete: Naozaj chcete zmazať tento prihlasovací kľúč? + messages: + added: Prihlasovací kľúč bol pridaný. + deleted: Prihlasovací kľúč bol zmazaný. + errors: + unsupportedBrowser: Váš prehliadač nepodporuje prihlasovacie kľúče (passkeys). + unknownKey: Neplatný alebo neznámy prihlasovací kľúč. + invalidKey: Prihlasovací kľúč sa nepodarilo overiť. + alreadyRegistered: Tento prihlasovací kľúč je už zaregistrovaný. + expiredChallenge: Platnosť výzvy vypršala. Skúste to prosím znovu. + ssoAccount: Účet prihlasovaný cez SSO nemôže používať prihlasovacie kľúče. + unavailable: Prihlasovacie kľúče nie sú momentálne dostupné. + loginFailed: Prihlásenie prihlasovacím kľúčom sa nepodarilo. Skúste to prosím znovu. + registrationFailed: Prihlasovací kľúč sa nepodarilo vytvoriť. Skúste to prosím znovu. + modules: web: navbar: From dc9d1cba4170e51b5f3a038a1b30f22f45ae27da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxmili=C3=A1n=20Holomek?= Date: Tue, 4 Aug 2026 08:31:33 +0200 Subject: [PATCH 07/10] feat(passkey): add passkeyEnabled flag and conditional passkey UI - Replace implicit passkey availability with explicit `passkeyEnabled` boolean on FancyAdmin config object - Wrap passkey section in account view with `{if $isPasskeyEnabled}` so it only renders when the feature is configured - Inject PasskeyFormFactory directly via use statement instead of through a separate inject trait - Add PasskeyQueryFactory import to DI extension - Add RuntimeException import to AccountPresenterTrait --- README.md | 22 +++++++--- src/DI/FancyAdminExtension.php | 9 ++++ src/Model/Entities/Identity.php | 4 -- src/Model/Entities/IdentityTrait.php | 14 +----- src/Model/FancyAdmin.php | 6 +++ src/Model/Security/Passkey/PasskeyService.php | 44 +++++++++++++++++-- .../Components/Forms/SignIn/SignInForm.latte | 2 + .../Forms/SignIn/SignInFormTrait.php | 5 ++- .../Account/AccountPresenterTrait.php | 24 +++++++--- src/UI/Presenters/Account/default.latte | 18 ++++---- 10 files changed, 108 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index abf7f0d..a05a536 100644 --- a/README.md +++ b/README.md @@ -1240,10 +1240,18 @@ Pro použití vlastní třídy je potřeba rozšířit `KeycloakManager::createI ## 19. Passkeys (WebAuthn) Fancyadmin podporuje přihlašování přes passkeys (WebAuthn) postavené na knihovně -[lbuchs/webauthn](https://github.com/lbuchs/WebAuthn). Passkeys jsou **vždy zapnuté** — -žádný config flag; passkey je vždy jen alternativa k heslu (žádné passkey-only účty). -Identity navázané na Keycloak SSO se přes passkey přihlásit ani registrovat klíč nemohou -(autorita pro SSO účty je Keycloak). +[lbuchs/webauthn](https://github.com/lbuchs/WebAuthn). Passkeys jsou **opt-in** — zapínají +se configem `passkeyEnabled: true` (default `false`, viz 19.2). Při vypnuté featuře se +nevykresluje tlačítko na login stránce ani karta v Můj účet a všechny passkey operace +jsou zablokované i server-side (`PasskeyService::assertEnabled()`). Existující klíče +v DB při vypnutí zůstávají — po opětovném zapnutí zase fungují. Passkey je vždy jen +alternativa k heslu (žádné passkey-only účty). Identity navázané na Keycloak SSO se přes +passkey přihlásit ani registrovat klíč nemohou (autorita pro SSO účty je Keycloak). + +Při `passkeyEnabled: false` (default) projekt **nemusí mít žádné passkey třídy** — +entitu, query, factory, form ani grid (sekce 19.3-19.5). Při `passkeyEnabled: true` +jsou povinné; extension to zvaliduje při kompilaci DI kontejneru a chybějící +infrastrukturu ohlásí srozumitelnou chybou. Co uživatel dostane: @@ -1260,17 +1268,21 @@ Co uživatel dostane: - **rpId = doména admin hostu** — klíče jsou svázané s doménou; změna domény znamená ztrátu registrovaných klíčů. Default se odvozuje z `adminHostPath`. -### 19.2 NEON konfigurace (volitelné) +### 19.2 NEON konfigurace ```neon fancyadmin: # ... ostatní konfigurace ... + # Zapnutí passkeys — bez tohoto flagu je celá featura vypnutá (default: false) + passkeyEnabled: true # Relying Party ID — doména; když není nastaveno, odvodí se host z adminHostPath passkeyRpId: admin.muj-projekt.cz # Relying Party name — zobrazuje se v dialogu autentikátoru; default = projectName passkeyRpName: Můj projekt ``` +Povinné je jen `passkeyEnabled` (pro zapnutí), `passkeyRpId` a `passkeyRpName` jsou volitelné. + ### 19.3 Entita Passkey ```php diff --git a/src/DI/FancyAdminExtension.php b/src/DI/FancyAdminExtension.php index e585d25..a21cf78 100644 --- a/src/DI/FancyAdminExtension.php +++ b/src/DI/FancyAdminExtension.php @@ -17,6 +17,7 @@ use ADT\FancyAdmin\Model\Entities\Profile; use ADT\FancyAdmin\Model\Entities\ProfileTrait; use ADT\FancyAdmin\Model\FancyAdmin; +use ADT\FancyAdmin\Model\Queries\Factories\PasskeyQueryFactory; use ADT\FancyAdmin\Model\Security\Authenticator; use ADT\FancyAdmin\Model\Security\Keycloak\KeycloakManager; use ADT\FancyAdmin\Model\Security\Passkey\PasskeyService; @@ -64,6 +65,7 @@ public function getConfigSchema(): Schema 'keycloakEnabled' => Expect::bool()->default(false), // Vypnutí validace TLS certifikátu Keycloak serveru — POUZE pro lokální vývoj (self-signed cert) 'keycloakVerifySsl' => Expect::bool()->default(true), + 'passkeyEnabled' => Expect::bool()->default(false), // WebAuthn Relying Party ID (doména) — když není nastaveno, odvodí se za běhu host z adminHostPath 'passkeyRpId' => Expect::string()->nullable()->default(null), // WebAuthn Relying Party name — když není nastaveno, použije se projectName @@ -126,6 +128,7 @@ public function loadConfiguration(): void 'context' => $this->config->context, 'colors' => (array) $this->config->colors, 'keycloakEnabled' => $this->config->keycloakEnabled, + 'passkeyEnabled' => $this->config->passkeyEnabled, 'passkeyRpId' => $this->config->passkeyRpId, 'passkeyRpName' => $this->config->passkeyRpName, ]); @@ -179,6 +182,12 @@ public function beforeCompile(): void $fancyAdminDef = $builder->getDefinition($this->prefix('administration')); $fancyAdminDef->addSetup('setKeycloakManager', [$this->prefix('@keycloakManager')]); } + + // passkeyEnabled vyžaduje passkey infrastrukturu v projektu — srozumitelná chyba + // při kompilaci kontejneru místo kryptické autowiring hlášky za běhu + if ($this->config->passkeyEnabled && $builder->getByType(PasskeyQueryFactory::class) === null) { + throw new RuntimeException('fancyadmin: passkeyEnabled je zapnuté, ale v projektu chybí implementace ' . PasskeyQueryFactory::class . '. Vytvořte entitu Passkey, PasskeyQuery, PasskeyQueryFactory, PasskeyForm a PasskeyGrid podle README (sekce 19), nebo passkeys vypněte.'); + } } private function validateTraitInterfaceCompliance(): void diff --git a/src/Model/Entities/Identity.php b/src/Model/Entities/Identity.php index e1dce39..f3a6171 100644 --- a/src/Model/Entities/Identity.php +++ b/src/Model/Entities/Identity.php @@ -57,10 +57,6 @@ public function setSelectedAccount(?Account $selectedAccount): static; public function getSso(): ?Sso; public function setSso(?Sso $sso): static; - /** - * @return Passkey[] - */ - public function getPasskeys(): array; public function getPasskeyUserHandle(): ?string; public function setPasskeyUserHandle(?string $passkeyUserHandle): static; diff --git a/src/Model/Entities/IdentityTrait.php b/src/Model/Entities/IdentityTrait.php index f74cc02..760a280 100644 --- a/src/Model/Entities/IdentityTrait.php +++ b/src/Model/Entities/IdentityTrait.php @@ -77,9 +77,8 @@ abstract public function getId(); #[LoggableProperty] protected Collection $roles; - #[ORM\OneToMany(targetEntity: 'Passkey', mappedBy: 'identity')] - protected Collection $passkeys; - + // Vazba na passkeys je jen jednosměrná (Passkey ManyToOne identity v PasskeyTrait) — + // entita Passkey je v projektu volitelná, Identity na ní nesmí záviset #[ORM\Column(type: 'binary', length: 32, nullable: true, options: ['fixed' => true])] protected mixed $passkeyUserHandle = null; @@ -98,7 +97,6 @@ public function __construct() { $this->profiles = new ArrayCollection(); $this->roles = new ArrayCollection(); - $this->passkeys = new ArrayCollection(); } public function getPassword(): ?string @@ -351,14 +349,6 @@ public function getIdentity(): Identity return $this; } - /** - * @return Passkey[] - */ - public function getPasskeys(): array - { - return $this->passkeys->toArray(); - } - public function getPasskeyUserHandle(): ?string { if ($this->passkeyUserHandle === null) { diff --git a/src/Model/FancyAdmin.php b/src/Model/FancyAdmin.php index 29941fe..5f2d36e 100644 --- a/src/Model/FancyAdmin.php +++ b/src/Model/FancyAdmin.php @@ -29,6 +29,7 @@ public function __construct( protected array $jsComponentsConfig = [], protected array $colors = [], protected bool $keycloakEnabled = false, + protected bool $passkeyEnabled = false, protected ?string $passkeyRpId = null, protected ?string $passkeyRpName = null, ) {} @@ -174,6 +175,11 @@ public function isKeycloakEnabled(): bool return $this->keycloakEnabled; } + public function isPasskeyEnabled(): bool + { + return $this->passkeyEnabled; + } + public function getPasskeyRpId(): ?string { return $this->passkeyRpId; diff --git a/src/Model/Security/Passkey/PasskeyService.php b/src/Model/Security/Passkey/PasskeyService.php index 83886f4..173bdfe 100644 --- a/src/Model/Security/Passkey/PasskeyService.php +++ b/src/Model/Security/Passkey/PasskeyService.php @@ -16,6 +16,7 @@ use Nette\Http\Session; use Nette\Http\SessionSection; use Nette\Localization\Translator; +use RuntimeException; use stdClass; use Throwable; @@ -39,8 +40,10 @@ public function __construct( protected EntityManager $em, protected Session $session, protected FancyAdmin $fancyAdmin, - protected PasskeyQueryFactory $passkeyQueryFactory, protected Translator $translator, + // nullable — passkey infrastruktura (entita, query, factory) je v projektu volitelná, + // služba se ale musí dát vytvořit vždy (injectuje se v traitech přes PasskeyServiceInject) + protected ?PasskeyQueryFactory $passkeyQueryFactory = null, ) {} /** @@ -51,6 +54,7 @@ public function __construct( */ public function getRegistrationArgs(Identity $identity): stdClass { + $this->assertEnabled(); $this->assertNotSso($identity); // Lazy vygenerování opaque user handle — autentikátoru nikdy neposíláme interní ID identity @@ -60,7 +64,8 @@ public function getRegistrationArgs(Identity $identity): stdClass } $excludeCredentialIds = []; - foreach ($identity->getPasskeys() as $passkey) { + /** @var Passkey $passkey */ + foreach ($this->getPasskeyQueryFactory()->create()->disableSecurityFilter()->disableAccountFilter()->byIdentity($identity)->fetch() as $passkey) { $excludeCredentialIds[] = $passkey->getCredentialId(); } @@ -97,6 +102,7 @@ public function processRegistration( ?array $transports = null, ): Passkey { + $this->assertEnabled(); $this->assertNotSso($identity); $name = $this->normalizeName($name); @@ -117,7 +123,7 @@ public function processRegistration( $credentialId = $data->credentialId; - if ($this->passkeyQueryFactory->create()->disableSecurityFilter()->disableAccountFilter()->byCredentialId($credentialId)->count() > 0) { + if ($this->getPasskeyQueryFactory()->create()->disableSecurityFilter()->disableAccountFilter()->byCredentialId($credentialId)->count() > 0) { throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.alreadyRegistered')); } @@ -154,6 +160,8 @@ public function processRegistration( */ public function getLoginArgs(): stdClass { + $this->assertEnabled(); + $webAuthn = $this->createWebAuthn(); $args = $webAuthn->getGetArgs( [], @@ -186,10 +194,12 @@ public function processLogin( ?string $userHandle = null, ): Identity { + $this->assertEnabled(); + $challenge = $this->consumeChallenge(PasskeySessionSection::GET_CHALLENGE); /** @var Passkey|null $passkey */ - $passkey = $this->passkeyQueryFactory->create() + $passkey = $this->getPasskeyQueryFactory()->create() ->disableSecurityFilter() ->disableAccountFilter() ->byCredentialId($credentialId) @@ -239,6 +249,32 @@ public function processLogin( return $identity; } + /** + * Server-side vynucení opt-in configu (fancyadmin: passkeyEnabled) — + * musí fungovat i kdyby UI někde zůstalo viditelné. + * + * @throws PasskeyException pokud passkeys nejsou v configu zapnuté + */ + public function assertEnabled(): void + { + if (!$this->fancyAdmin->isPasskeyEnabled()) { + throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.unavailable')); + } + } + + /** + * @throws RuntimeException pokud projekt nemá zaregistrovanou passkey infrastrukturu — + * chyba konfigurace, ne uživatele (FancyAdminExtension ji při passkeyEnabled hlídá už při kompilaci) + */ + protected function getPasskeyQueryFactory(): PasskeyQueryFactory + { + if ($this->passkeyQueryFactory === null) { + throw new RuntimeException('V projektu chybí implementace ' . PasskeyQueryFactory::class . ' — vytvořte entitu Passkey, query a factory podle README (sekce 19).'); + } + + return $this->passkeyQueryFactory; + } + /** * @throws PasskeyException pokud je identita navázaná na Keycloak SSO */ diff --git a/src/UI/Components/Forms/SignIn/SignInForm.latte b/src/UI/Components/Forms/SignIn/SignInForm.latte index fdc412d..37ccbe1 100644 --- a/src/UI/Components/Forms/SignIn/SignInForm.latte +++ b/src/UI/Components/Forms/SignIn/SignInForm.latte @@ -5,6 +5,7 @@ {/define} {define section-passkey} + {if $isPasskeyEnabled} + {/if} {/define} diff --git a/src/UI/Components/Forms/SignIn/SignInFormTrait.php b/src/UI/Components/Forms/SignIn/SignInFormTrait.php index 95e4d4e..3b4b827 100644 --- a/src/UI/Components/Forms/SignIn/SignInFormTrait.php +++ b/src/UI/Components/Forms/SignIn/SignInFormTrait.php @@ -55,9 +55,12 @@ public function initForm(Form $form): void $form->addSubmit('submit', 'fcadmin.forms.signIn.labels.logIn') ->getControlPrototype()->class[] = 'w-100'; - $form->addSection(name: 'passkey'); + if ($this->_fancyAdmin->isPasskeyEnabled()) { + $form->addSection(name: 'passkey'); + } $this->getTemplate()->isLostPasswordEnabled = $this->_fancyAdmin->isLostPasswordEnabled(); + $this->getTemplate()->isPasskeyEnabled = $this->_fancyAdmin->isPasskeyEnabled(); // Keycloak email check — přidá data atribut pro JS kontrolu if ($this->_fancyAdmin->isKeycloakEnabled()) { diff --git a/src/UI/Presenters/Account/AccountPresenterTrait.php b/src/UI/Presenters/Account/AccountPresenterTrait.php index dcad63f..e207c00 100644 --- a/src/UI/Presenters/Account/AccountPresenterTrait.php +++ b/src/UI/Presenters/Account/AccountPresenterTrait.php @@ -7,13 +7,13 @@ use ADT\FancyAdmin\DI\Injects\AuthenticatorInject; use ADT\FancyAdmin\DI\Injects\ChangePasswordFormFactoryInject; use ADT\FancyAdmin\DI\Injects\FancyAdminInject; -use ADT\FancyAdmin\DI\Injects\PasskeyFormFactoryInject; use ADT\FancyAdmin\DI\Injects\PasskeyServiceInject; use ADT\FancyAdmin\DI\Injects\PersonalDataFormFactoryInject; use ADT\FancyAdmin\DI\Injects\SecurityUserInject; use ADT\FancyAdmin\DI\Injects\TranslatorInject; use ADT\FancyAdmin\Model\Security\Passkey\PasskeyException; use ADT\FancyAdmin\Model\Security\Passkey\PasskeyService; +use ADT\FancyAdmin\UI\Components\Forms\Passkey\PasskeyFormFactory; use ADT\FancyAdmin\UI\Components\Controls\SidePanel\SidePanelControl; use ADT\FancyAdmin\UI\Components\Controls\SidePanel\SidePanelControlFactory; use ADT\FancyAdmin\UI\Components\Grids\Passkey\PasskeyGrid; @@ -23,6 +23,7 @@ use ADT\FancyAdmin\UI\Presenters\PresenterTrait; use Nette\Utils\Json; use Nette\Utils\JsonException; +use RuntimeException; trait AccountPresenterTrait { @@ -32,7 +33,6 @@ trait AccountPresenterTrait use PersonalDataFormFactoryInject; use ChangePasswordFormFactoryInject; use FancyAdminInject; - use PasskeyFormFactoryInject; use PasskeyServiceInject; use TranslatorInject; @@ -46,6 +46,7 @@ public function actionDefault(): void } $this->getTemplate()->identity = $this->_securityUser->getIdentity(); + $this->getTemplate()->isPasskeyEnabled = $this->_fancyAdmin->isPasskeyEnabled(); $this->getTemplate()->setFile(__DIR__ . '/default.latte'); } @@ -101,15 +102,22 @@ public function createComponentSessionGrid(SessionGridFactory $factory): Session return $factory->create(); } - public function createComponentPasskeyGrid(PasskeyGridFactory $factory): PasskeyGrid + // Passkey factories jsou nullable — projekt bez passkey tříd je nemá zaregistrované + // a kdyby/autowired validuje parametry všech createComponent* metod už při attachi presenteru + public function createComponentPasskeyGrid(?PasskeyGridFactory $factory = null): PasskeyGrid { + if ($factory === null) { + throw new RuntimeException('V projektu chybí implementace ' . PasskeyGridFactory::class . ' — vytvořte passkey třídy podle README (sekce 19).'); + } + return $factory->create(); } public function handleAddPasskey(): void { - // SSO uživatel klíč registrovat nesmí — panel se ani neotevře + // Vypnutá featura nebo SSO uživatel — panel se ani neotevře try { + $this->_passkeyService->assertEnabled(); $this->_passkeyService->assertNotSso($this->_securityUser->getIdentity()); } catch (PasskeyException $e) { $this->flashMessageError($e->getMessage()); @@ -171,9 +179,13 @@ public function handlePasskeyRegisterVerify(): void $this->getPresenter()->redirect('this'); } - public function createComponentAddPasskeySidePanel(SidePanelControlFactory $factory): SidePanelControl + public function createComponentAddPasskeySidePanel(SidePanelControlFactory $factory, ?PasskeyFormFactory $passkeyFormFactory = null): SidePanelControl { + if ($passkeyFormFactory === null) { + throw new RuntimeException('V projektu chybí implementace ' . PasskeyFormFactory::class . ' — vytvořte passkey třídy podle README (sekce 19).'); + } + return $factory->create() - ->setFormFactory(fn() => $this->_passkeyFormFactory->create()); + ->setFormFactory(fn() => $passkeyFormFactory->create()); } } diff --git a/src/UI/Presenters/Account/default.latte b/src/UI/Presenters/Account/default.latte index 0d1e760..746a43a 100644 --- a/src/UI/Presenters/Account/default.latte +++ b/src/UI/Presenters/Account/default.latte @@ -38,17 +38,19 @@

-
-

- {_fcadmin.passkeys.account.title} -

+{if $isPasskeyEnabled} +
+

+ {_fcadmin.passkeys.account.title} +

- -
-{control passkeyGrid} + {control passkeyGrid} +{/if}

From 9032a9ec457b765390f5e5487fa98af26c5a36a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxmili=C3=A1n=20Holomek?= Date: Tue, 4 Aug 2026 12:19:29 +0200 Subject: [PATCH 08/10] refactor(passkey): extract passkey logic into dedicated trait and interface - Extract passkey-related ORM fields and methods from IdentityTrait into new IdentityPasskeysTrait - Add HasPasskeys interface under Traits namespace for type-safe passkey contract - Remove passkey methods from Identity interface (getPasskeyUserHandle, setPasskeyUserHandle) - Update FancyAdminExtension imports to reference new IdentityPasskeysTrait and HasPasskeys --- README.md | 25 ++++++++-- src/DI/FancyAdminExtension.php | 3 ++ src/Model/Entities/Identity.php | 3 -- src/Model/Entities/IdentityPasskeysTrait.php | 49 +++++++++++++++++++ src/Model/Entities/IdentityTrait.php | 22 --------- src/Model/Entities/Traits/HasPasskeys.php | 15 ++++++ src/Model/Security/Passkey/PasskeyService.php | 20 ++++++-- 7 files changed, 105 insertions(+), 32 deletions(-) create mode 100644 src/Model/Entities/IdentityPasskeysTrait.php create mode 100644 src/Model/Entities/Traits/HasPasskeys.php diff --git a/README.md b/README.md index a05a536..cb23e05 100644 --- a/README.md +++ b/README.md @@ -1249,7 +1249,8 @@ alternativa k heslu (žádné passkey-only účty). Identity navázané na Keycl passkey přihlásit ani registrovat klíč nemohou (autorita pro SSO účty je Keycloak). Při `passkeyEnabled: false` (default) projekt **nemusí mít žádné passkey třídy** — -entitu, query, factory, form ani grid (sekce 19.3-19.5). Při `passkeyEnabled: true` +entitu, query, factory, form, grid ani passkey trait v Identity (sekce 19.3-19.5); +v tabulce `identity` pak není žádný passkey sloupec. Při `passkeyEnabled: true` jsou povinné; extension to zvaliduje při kompilaci DI kontejneru a chybějící infrastrukturu ohlásí srozumitelnou chybou. @@ -1283,7 +1284,23 @@ fancyadmin: Povinné je jen `passkeyEnabled` (pro zapnutí), `passkeyRpId` a `passkeyRpName` jsou volitelné. -### 19.3 Entita Passkey +### 19.3 Entity — Passkey + rozšíření Identity + +Entita Identity musí použít `IdentityPasskeysTrait` a implementovat `HasPasskeys` +(PasskeyService na ten interface spoléhá): + +```php +// app/Model/Entities/Identity.php — přidat k existující entitě +use ADT\FancyAdmin\Model\Entities\IdentityPasskeysTrait; +use ADT\FancyAdmin\Model\Entities\Traits\HasPasskeys; + +#[ORM\Entity] +class Identity extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\Identity, HasPasskeys /* , ... */ +{ + use IdentityTrait; + use IdentityPasskeysTrait; +} +``` ```php // app/Model/Entities/Passkey.php @@ -1319,9 +1336,9 @@ class Passkey extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\Passk | `createdAt` | DATETIME | Vytvořeno | | `lastUsedAt` | DATETIME, nullable | Poslední přihlášení klíčem | -`IdentityTrait` navíc přidává do tabulky `identity` nullable sloupec `passkey_user_handle` +`IdentityPasskeysTrait` přidává do tabulky `identity` nullable sloupec `passkey_user_handle` (BINARY(32)) — náhodný opaque WebAuthn user handle, generovaný při registraci prvního klíče -(autentikátoru se nikdy neposílá interní ID identity). +(autentikátoru se nikdy neposílá interní ID identity) — a inverzní vazbu `getPasskeys()`. ### 19.4 Query + factory diff --git a/src/DI/FancyAdminExtension.php b/src/DI/FancyAdminExtension.php index a21cf78..82046be 100644 --- a/src/DI/FancyAdminExtension.php +++ b/src/DI/FancyAdminExtension.php @@ -13,7 +13,9 @@ use ADT\FancyAdmin\Model\Entities\AclRole; use ADT\FancyAdmin\Model\Entities\AclRoleTrait; use ADT\FancyAdmin\Model\Entities\Identity; +use ADT\FancyAdmin\Model\Entities\IdentityPasskeysTrait; use ADT\FancyAdmin\Model\Entities\IdentityTrait; +use ADT\FancyAdmin\Model\Entities\Traits\HasPasskeys; use ADT\FancyAdmin\Model\Entities\Profile; use ADT\FancyAdmin\Model\Entities\ProfileTrait; use ADT\FancyAdmin\Model\FancyAdmin; @@ -196,6 +198,7 @@ private function validateTraitInterfaceCompliance(): void AclResourceTrait::class => AclResource::class, AclRoleTrait::class => AclRole::class, IdentityTrait::class => Identity::class, + IdentityPasskeysTrait::class => HasPasskeys::class, ProfileTrait::class => Profile::class, ]; diff --git a/src/Model/Entities/Identity.php b/src/Model/Entities/Identity.php index f3a6171..03011ff 100644 --- a/src/Model/Entities/Identity.php +++ b/src/Model/Entities/Identity.php @@ -57,9 +57,6 @@ public function setSelectedAccount(?Account $selectedAccount): static; public function getSso(): ?Sso; public function setSso(?Sso $sso): static; - public function getPasskeyUserHandle(): ?string; - public function setPasskeyUserHandle(?string $passkeyUserHandle): static; - public function getFullName(): string; public function getGravatar(): string; public function getAccounts(): array; diff --git a/src/Model/Entities/IdentityPasskeysTrait.php b/src/Model/Entities/IdentityPasskeysTrait.php new file mode 100644 index 0000000..81b85d3 --- /dev/null +++ b/src/Model/Entities/IdentityPasskeysTrait.php @@ -0,0 +1,49 @@ + true])] + protected mixed $passkeyUserHandle = null; + + /** + * @return Passkey[] + */ + public function getPasskeys(): array + { + // konstruktor s inicializací kolekcí žije v IdentityTrait — u nové entity + // je property neinicializovaná, ??= ji bezpečně doplní + $this->passkeys ??= new ArrayCollection(); + return $this->passkeys->toArray(); + } + + public function getPasskeyUserHandle(): ?string + { + if ($this->passkeyUserHandle === null) { + return null; + } + if (is_resource($this->passkeyUserHandle)) { + rewind($this->passkeyUserHandle); + return (string) stream_get_contents($this->passkeyUserHandle); + } + return (string) $this->passkeyUserHandle; + } + + public function setPasskeyUserHandle(?string $passkeyUserHandle): static + { + $this->passkeyUserHandle = $passkeyUserHandle; + return $this; + } +} diff --git a/src/Model/Entities/IdentityTrait.php b/src/Model/Entities/IdentityTrait.php index 760a280..e229e43 100644 --- a/src/Model/Entities/IdentityTrait.php +++ b/src/Model/Entities/IdentityTrait.php @@ -77,11 +77,6 @@ abstract public function getId(); #[LoggableProperty] protected Collection $roles; - // Vazba na passkeys je jen jednosměrná (Passkey ManyToOne identity v PasskeyTrait) — - // entita Passkey je v projektu volitelná, Identity na ní nesmí záviset - #[ORM\Column(type: 'binary', length: 32, nullable: true, options: ['fixed' => true])] - protected mixed $passkeyUserHandle = null; - #[ORM\Column(nullable: true)] #[LoggableProperty] protected ?DateTimeImmutable $anonymizedAt = null; @@ -349,21 +344,4 @@ public function getIdentity(): Identity return $this; } - public function getPasskeyUserHandle(): ?string - { - if ($this->passkeyUserHandle === null) { - return null; - } - if (is_resource($this->passkeyUserHandle)) { - rewind($this->passkeyUserHandle); - return (string) stream_get_contents($this->passkeyUserHandle); - } - return (string) $this->passkeyUserHandle; - } - - public function setPasskeyUserHandle(?string $passkeyUserHandle): static - { - $this->passkeyUserHandle = $passkeyUserHandle; - return $this; - } } diff --git a/src/Model/Entities/Traits/HasPasskeys.php b/src/Model/Entities/Traits/HasPasskeys.php new file mode 100644 index 0000000..a8347e3 --- /dev/null +++ b/src/Model/Entities/Traits/HasPasskeys.php @@ -0,0 +1,15 @@ +assertEnabled(); $this->assertNotSso($identity); + $identity = $this->assertHasPasskeys($identity); // Lazy vygenerování opaque user handle — autentikátoru nikdy neposíláme interní ID identity if ($identity->getPasskeyUserHandle() === null) { @@ -64,8 +66,7 @@ public function getRegistrationArgs(Identity $identity): stdClass } $excludeCredentialIds = []; - /** @var Passkey $passkey */ - foreach ($this->getPasskeyQueryFactory()->create()->disableSecurityFilter()->disableAccountFilter()->byIdentity($identity)->fetch() as $passkey) { + foreach ($identity->getPasskeys() as $passkey) { $excludeCredentialIds[] = $passkey->getCredentialId(); } @@ -209,7 +210,7 @@ public function processLogin( throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.unknownKey')); } - $identity = $passkey->getIdentity(); + $identity = $this->assertHasPasskeys($passkey->getIdentity()); if ($userHandle !== null && $userHandle !== '') { $storedHandle = $identity->getPasskeyUserHandle(); @@ -262,6 +263,19 @@ public function assertEnabled(): void } } + /** + * @throws RuntimeException pokud entita Identity nepodporuje passkeys — + * chyba konfigurace, ne uživatele + */ + protected function assertHasPasskeys(Identity $identity): Identity&HasPasskeys + { + if (!$identity instanceof HasPasskeys) { + throw new RuntimeException('Entita ' . $identity::class . ' neimplementuje ' . HasPasskeys::class . ' — přidejte `use IdentityPasskeysTrait` a `implements HasPasskeys` podle README (sekce 19).'); + } + + return $identity; + } + /** * @throws RuntimeException pokud projekt nemá zaregistrovanou passkey infrastrukturu — * chyba konfigurace, ne uživatele (FancyAdminExtension ji při passkeyEnabled hlídá už při kompilaci) From 2191ef596b4d81a87a3f20f514560fb2e0b746b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Konvi=C4=8Dka?= Date: Tue, 4 Aug 2026 18:22:58 +0200 Subject: [PATCH 09/10] Add configurable theme colors for side panels, texts, login page --- assets/scss/_login.scss | 6 +++--- assets/scss/_sidepanel.scss | 4 ++-- src/DI/FancyAdminExtension.php | 6 ++++++ src/UI/Presenters/@layout.latte | 14 ++++++++++++++ src/UI/Presenters/BasePresenterTrait.php | 1 + 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/assets/scss/_login.scss b/assets/scss/_login.scss index 132c6d8..a548c46 100644 --- a/assets/scss/_login.scss +++ b/assets/scss/_login.scss @@ -1,8 +1,8 @@ @use 'variables' as *; .bg-primary-variant { - background-color: $tertiary; - color: $tertiary-text; + background-color: var(--loginPageBackground, #{$tertiary}); + color: var(--loginPageTextColor, #{$tertiary-text}); &::before { content: ''; @@ -137,7 +137,7 @@ } .linkInForm { - color: $tertiary-text; + color: var(--loginPageTextColor, #{$tertiary-text}); } diff --git a/assets/scss/_sidepanel.scss b/assets/scss/_sidepanel.scss index 393da92..7ac4b98 100644 --- a/assets/scss/_sidepanel.scss +++ b/assets/scss/_sidepanel.scss @@ -41,7 +41,7 @@ flex-direction: column; align-items: center; justify-content: center; - color: $black; + color: var(--sidePanelItemColor, #{$black}); transition: width .5s ease; text-decoration: none; @@ -124,7 +124,7 @@ justify-content: flex-start; flex-direction: row; text-decoration: none; - color: $black; + color: var(--sidePanelItemColor, #{$black}); align-items: center; text-align: left; gap: 10px; diff --git a/src/DI/FancyAdminExtension.php b/src/DI/FancyAdminExtension.php index 82046be..ab222b1 100644 --- a/src/DI/FancyAdminExtension.php +++ b/src/DI/FancyAdminExtension.php @@ -90,6 +90,12 @@ public function getConfigSchema(): Schema 'inputBorder' => Expect::string()->required(), 'inputFocusBorder' => Expect::string()->required(), 'inputFocusBackground' => Expect::string()->required(), + // Nepovinne barvy. Pri null si _sidepanel.scss / _login.scss / layout + // drzi puvodni hodnoty, takze existujici projekty se nemeni. + 'sidePanelItemColor' => Expect::string()->nullable()->default(null), + 'textColor' => Expect::string()->nullable()->default(null), + 'loginPageBackground' => Expect::string()->nullable()->default(null), + 'loginPageTextColor' => Expect::string()->nullable()->default(null), ]), ]); } diff --git a/src/UI/Presenters/@layout.latte b/src/UI/Presenters/@layout.latte index 67b9336..70c0a0c 100644 --- a/src/UI/Presenters/@layout.latte +++ b/src/UI/Presenters/@layout.latte @@ -44,6 +44,19 @@ --inputBorder: {$colors['inputBorder']|noescape}; --inputFocusBorder: {$colors['inputFocusBorder']|noescape}; --inputFocusBackground: {$colors['inputFocusBackground']|noescape}; + {if !empty($colors['sidePanelItemColor'])} + --sidePanelItemColor: {$colors['sidePanelItemColor']|noescape}; + {/if} + {if !empty($colors['textColor'])} + --bs-body-color: {$colors['textColor']|noescape}; + --bs-heading-color: {$colors['textColor']|noescape}; + {/if} + {if !empty($colors['loginPageBackground'])} + --loginPageBackground: {$colors['loginPageBackground']|noescape}; + {/if} + {if !empty($colors['loginPageTextColor'])} + --loginPageTextColor: {$colors['loginPageTextColor']|noescape}; + {/if} } {/if} @@ -52,6 +65,7 @@ getTemplate()->loginPageLogoPath = $this->_fancyAdmin->getLoginPageLogoPath(); $this->getTemplate()->hmr = $this->_fancyAdmin->getHmr(); $this->getTemplate()->projectName = $this->_fancyAdmin->getProjectName(); + $this->getTemplate()->project = $this->_fancyAdmin->getProject(); $this->getTemplate()->colors = $this->_fancyAdmin->getColors(); $this->_jsComponents->setComponents($this->_fancyAdmin->getJsComponentsConfig()); $this->_jsComponents->setFirebaseLink('setFirebaseTokenLink', $this->getPresenter()->link('setFirebaseToken!', ['firebaseToken' => '__firebaseToken__'])); From 31d3f4c0760c6970abf359236e701dc8b7355583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Konvi=C4=8Dka?= Date: Mon, 10 Aug 2026 12:40:58 +0200 Subject: [PATCH 10/10] https://trello.com/c/98qWdN41/9510-001x-typeerror-nette-mail-messageaddto-argument-1-email-must-be-of-type-string-null-given-called-in-var-www-html-releases-123-ve --- .../Grids/Traits/ResetPassword/ResetPassword.php | 11 +++++++++++ src/lang/fcadmin.cs.yml | 1 + src/lang/fcadmin.sk.yml | 1 + 3 files changed, 13 insertions(+) diff --git a/src/UI/Components/Grids/Traits/ResetPassword/ResetPassword.php b/src/UI/Components/Grids/Traits/ResetPassword/ResetPassword.php index 8fe6de3..c739ed2 100644 --- a/src/UI/Components/Grids/Traits/ResetPassword/ResetPassword.php +++ b/src/UI/Components/Grids/Traits/ResetPassword/ResetPassword.php @@ -27,10 +27,16 @@ public function injectResetPassword(): void $this['grid']->addAction('newPasswordAgain', 'Nové heslo', 'newPasswordAgain!') ->setIcon('lock') ->setConfirmation(new StringConfirmation('fcadmin.grids.user.confirms.newPassword')) + ->setRenderCondition(fn(HasIdentity $hasIdentity) => $this->getCanResetPassword($hasIdentity)) ->setClass(''); //je potreba, protoze se jinak aplikuje classa btn btn-primary atd. a prida pozadi -> skareda iknka }; } + public function getCanResetPassword(HasIdentity $hasIdentity): bool + { + return (bool) $hasIdentity->getIdentity()->getEmail(); + } + /** * @throws DateMalformedStringException * @throws InvalidArgument @@ -45,6 +51,11 @@ public function handleNewPasswordAgain(int $id): void $this->error(); } + if (!$this->getCanResetPassword($hasIdentity)) { + $this->getPresenter()->flashMessageError('fcadmin.grids.user.messages.mailErrorNoEmail'); + $this->getPresenter()->redirect('this'); + } + $identity = $hasIdentity->getIdentity(); // SSO (Keycloak) uživateli pošleme reset hesla přes Keycloak místo lokálního recovery mailu diff --git a/src/lang/fcadmin.cs.yml b/src/lang/fcadmin.cs.yml index 5a622d3..2d95491 100644 --- a/src/lang/fcadmin.cs.yml +++ b/src/lang/fcadmin.cs.yml @@ -303,6 +303,7 @@ grids: messages: mailSuccess: E-mail pro nastavení hesla byl odeslán mailError: E-mail pro nastavení hesla se nepodařilo odeslat + mailErrorNoEmail: Uživatel nemá vyplněný e-mail, výzvu pro nastavení hesla nelze odeslat. global: isActive: label: Aktivní diff --git a/src/lang/fcadmin.sk.yml b/src/lang/fcadmin.sk.yml index 9c0553e..0cf2af7 100644 --- a/src/lang/fcadmin.sk.yml +++ b/src/lang/fcadmin.sk.yml @@ -302,6 +302,7 @@ grids: messages: mailSuccess: E-mail pre nastavenie hesla bol odoslaný mailError: E-mail pre nastavenie hesla sa nepodarilo odoslať + mailErrorNoEmail: Používateľ nemá vyplnený e-mail, výzvu pre nastavenie hesla nie je možné odoslať. global: isActive: label: Aktívny