Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.01% covered (warning)
81.01%
64 / 79
57.14% covered (warning)
57.14%
8 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
TwigUserExtension
80.77% covered (warning)
80.77%
63 / 78
57.14% covered (warning)
57.14%
8 / 14
57.77
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setImpersonationService
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFunctions
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentUser
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 getCurrentUserName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getCurrentUserEmail
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentUserInitial
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getCurrentUserProfile
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 isImpersonating
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
4.59
 getImpersonatorUser
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 canImpersonate
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 getImpersonatableUsers
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 getUserImpersonationTargets
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 getUserImpersonationSources
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Security\Twig;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\User\Domain\Repository\UserRepositoryInterface;
12use Twig\Extension\AbstractExtension;
13use Twig\TwigFunction;
14use Yiisoft\Session\SessionInterface;
15
16/**
17 * Twig Extension for Authenticated User Session Access in Layouts and Navigation.
18 *
19 * Exposes current_user(), current_user_name(), and helper functions to Twig templates.
20 *
21 * @package App\Core\Security\Twig
22 */
23final class TwigUserExtension extends AbstractExtension
24{
25    /**
26     * TwigUserExtension constructor.
27     *
28     * @param SessionInterface|null $session Yii3 session service.
29     * @param UserRepositoryInterface|null $userRepository User repository contract.
30     * @param \App\Modules\User\Application\Service\UserImpersonationService|null $impersonationService Impersonation.
31     * @param string $appProfile Current application deployment profile.
32     */
33    public function __construct(
34        private readonly ?SessionInterface $session = null,
35        private readonly ?UserRepositoryInterface $userRepository = null,
36        private ?\App\Modules\User\Application\Service\UserImpersonationService $impersonationService = null
37    ) {
38    }
39
40    /**
41     * Sets or replaces the user impersonation service dynamically.
42     *
43     * @param \App\Modules\User\Application\Service\UserImpersonationService $impersonationService Service instance.
44     * @return void
45     */
46    public function setImpersonationService(
47        \App\Modules\User\Application\Service\UserImpersonationService $impersonationService
48    ): void {
49        $this->impersonationService = $impersonationService;
50    }
51
52    /**
53     * {@inheritdoc}
54     */
55    public function getFunctions(): array
56    {
57        return [
58            new TwigFunction('current_user', [$this, 'getCurrentUser']),
59            new TwigFunction('current_user_name', [$this, 'getCurrentUserName']),
60            new TwigFunction('current_user_email', [$this, 'getCurrentUserEmail']),
61            new TwigFunction('current_user_initial', [$this, 'getCurrentUserInitial']),
62            new TwigFunction('current_user_profile', [$this, 'getCurrentUserProfile']),
63            new TwigFunction('is_impersonating', [$this, 'isImpersonating']),
64            new TwigFunction('impersonator_user', [$this, 'getImpersonatorUser']),
65            new TwigFunction('can_impersonate', [$this, 'canImpersonate']),
66            new TwigFunction('impersonatable_users', [$this, 'getImpersonatableUsers']),
67            new TwigFunction('user_impersonation_targets', [$this, 'getUserImpersonationTargets']),
68            new TwigFunction('user_impersonation_sources', [$this, 'getUserImpersonationSources']),
69        ];
70    }
71
72    /**
73     * Returns the currently authenticated user session array or empty array if guest.
74     *
75     * @return array<string, mixed> User session data array.
76     */
77    public function getCurrentUser(): array
78    {
79        if ($this->session !== null && $this->session->has('user')) {
80            $user = $this->session->get('user');
81            if (is_array($user)) {
82                return $user;
83            }
84        }
85
86        if (isset($_SESSION['user']) && is_array($_SESSION['user'])) {
87            return $_SESSION['user'];
88        }
89
90        return [];
91    }
92
93    /**
94     * Returns display name for currently authenticated user.
95     *
96     * @param string $fallback Fallback string if not authenticated.
97     * @return string Display name.
98     */
99    public function getCurrentUserName(string $fallback = 'Guest'): string
100    {
101        $user = $this->getCurrentUser();
102        $name = (string)($user['username'] ?? $user['name'] ?? '');
103
104        return $name !== '' ? $name : $fallback;
105    }
106
107    /**
108     * Returns email for currently authenticated user.
109     *
110     * @param string $fallback Fallback email if not authenticated.
111     * @return string User email.
112     */
113    public function getCurrentUserEmail(string $fallback = ''): string
114    {
115        $user = $this->getCurrentUser();
116
117        return (string)($user['email'] ?? $fallback);
118    }
119
120    /**
121     * Returns single uppercase initial for avatar icon.
122     *
123     * @return string Uppercase single character.
124     */
125    public function getCurrentUserInitial(): string
126    {
127        $name = $this->getCurrentUserName('');
128        if ($name === '') {
129            return 'A';
130        }
131
132        return mb_strtoupper(mb_substr($name, 0, 1));
133    }
134
135    /**
136     * Returns full profile details for currently authenticated user.
137     *
138     * @return array<string, mixed> User profile data dictionary.
139     */
140    public function getCurrentUserProfile(): array
141    {
142        $user = $this->getCurrentUser();
143        $userId = (int)($user['id'] ?? 0);
144        if ($userId > 0 && $this->userRepository !== null) {
145            $profile = $this->userRepository->getUserProfile($userId);
146            if ($profile !== null) {
147                $firstName = (string)($profile['first_name'] ?? '');
148                $lastName = (string)($profile['last_name'] ?? '');
149                $fullName = trim($firstName . ' ' . $lastName);
150                if ($fullName === '') {
151                    $fullName = (string)($profile['c_cn'] ?? $profile['username'] ?? 'User');
152                }
153                $profile['full_name'] = $fullName;
154                $profile['initial'] = mb_strtoupper(mb_substr($fullName, 0, 1, 'UTF-8'), 'UTF-8');
155                return $profile;
156            }
157        }
158
159        return $user;
160    }
161
162    /**
163     * Checks whether the current session is running in impersonation mode.
164     *
165     * @return bool True if impersonating another user.
166     */
167    public function isImpersonating(): bool
168    {
169        if ($this->session !== null && $this->session->has('impersonator_user')) {
170            return true;
171        }
172
173        return isset($_SESSION['impersonator_user']) && is_array($_SESSION['impersonator_user']);
174    }
175
176    /**
177     * Returns the original impersonator user session array.
178     *
179     * @return array<string, mixed> Original user data or empty array.
180     */
181    public function getImpersonatorUser(): array
182    {
183        if ($this->session !== null && $this->session->has('impersonator_user')) {
184            $imp = $this->session->get('impersonator_user');
185            if (is_array($imp)) {
186                return $imp;
187            }
188        }
189
190        if (isset($_SESSION['impersonator_user']) && is_array($_SESSION['impersonator_user'])) {
191            return $_SESSION['impersonator_user'];
192        }
193
194        return [];
195    }
196
197    /**
198     * Checks whether the user is eligible to use the impersonation switcher.
199     *
200     * In accordance with anti-looping rules, this returns false when already impersonating.
201     *
202     * @return bool True if switcher should be displayed.
203     */
204    public function canImpersonate(): bool
205    {
206        $user = $this->getCurrentUser();
207        $userId = (int) ($user['id'] ?? 0);
208        if ($this->isImpersonating() || $userId <= 0) {
209            return false;
210        }
211
212        if ($this->impersonationService !== null) {
213            return $this->impersonationService->hasAnyImpersonationRights($userId);
214        }
215
216        return false;
217    }
218
219    /**
220     * Returns list of active users that the current user is permitted to impersonate.
221     *
222     * @return array<int, array{id: int, username: string, email: string, is_superuser: bool, label: string}>
223     */
224    public function getImpersonatableUsers(): array
225    {
226        if ($this->isImpersonating()) {
227            return [];
228        }
229
230        $user = $this->getCurrentUser();
231        $userId = (int) ($user['id'] ?? 0);
232
233        if ($userId <= 0 || $this->impersonationService === null) {
234            return [];
235        }
236
237        return $this->impersonationService->getSwitchableUsersFor($userId);
238    }
239
240    /**
241     * Returns target users that the given user is authorized to impersonate.
242     *
243     * @param int $userId Actor user ID.
244     * @return array<int, array{id: int, username: string, email: string, is_superuser: bool, label: string}>
245     */
246    public function getUserImpersonationTargets(int $userId): array
247    {
248        if ($userId <= 0 || $this->impersonationService === null) {
249            return [];
250        }
251
252        $targetIds = $this->impersonationService->getGrantsForUser($userId);
253        if (empty($targetIds)) {
254            return [];
255        }
256
257        return $this->impersonationService->findUsersByIds($targetIds);
258    }
259
260    /**
261     * Returns actor users authorized to impersonate the given target user.
262     *
263     * @param int $targetUserId Target user ID.
264     * @return array<int, array{id: int, username: string, email: string, is_superuser: bool, label: string}>
265     */
266    public function getUserImpersonationSources(int $targetUserId): array
267    {
268        if ($targetUserId <= 0 || $this->impersonationService === null) {
269            return [];
270        }
271
272        return $this->impersonationService->getActorsForTargetUser($targetUserId);
273    }
274}