1
0

feat(admin-users): dedicated admin profile drawer with own tabs

The first pass had admin/users sharing the address book profile partial,
which surfaced Contact/Address/Organization/About tabs that ignored the
admin-specific context. Separates the admin quick-view so it mirrors the
edit-page tab structure while staying read-only.

- `UserProfileViewService::buildAdminProfile()` extends the base profile
  with `admin_extras` (active, email_verified_at, last_login_at,
  last_login_provider, created, modified).
- `templates/partials/app-admin-user-profile.phtml` is the admin-only
  render path: Master data · Organization · Security · About. Status
  badge under the name; timestamps locale-formatted.
- `pages/admin/users/view-fragment($id).php` / `(none).phtml` switched
  to the admin service method + partial.
- Drawer CSS aligns the aside with the main section's inline padding so
  the content strip does not visually step in/out.

Address book is unchanged — still uses `buildProfile()` + the base
partial. Two i18n keys added (`Login provider`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 16:30:03 +02:00
parent c1cf9f94bb
commit 9a78a81af8
7 changed files with 302 additions and 3 deletions

View File

@@ -124,6 +124,37 @@ class UserProfileViewService
];
}
/**
* Extended profile for admin contexts. Returns the same shape as
* buildProfile() plus `$user['admin_extras']` with operational fields
* that are inappropriate for non-admin views (email verification state,
* last login, active status, created/modified timestamps).
*
* @api Called from pages/admin/users/view-fragment($id).php via app(...).
*
* @return array{status: string, user?: array<string, mixed>}
*/
public function buildAdminProfile(int $currentUserId, string $uuid): array
{
$profile = $this->buildProfile($currentUserId, $uuid);
if ($profile['status'] !== 'ok' || !isset($profile['user'])) {
return $profile;
}
$user = $profile['user'];
$user['admin_extras'] = [
'active' => (bool) ($user['active'] ?? false),
'email_verified_at' => (string) ($user['email_verified_at'] ?? ''),
'last_login_at' => (string) ($user['last_login_at'] ?? ''),
'last_login_provider' => (string) ($user['last_login_provider'] ?? ''),
'created' => (string) ($user['created'] ?? ''),
'modified' => (string) ($user['modified'] ?? ''),
];
$profile['user'] = $user;
return $profile;
}
/**
* @param array<int, int|string> $values
* @return list<int>

View File

@@ -1,6 +1,7 @@
{
"Login": "Login",
"Login credentials": "Mit Zugangsdaten oder SSO anmelden",
"Login provider": "Login-Anbieter",
"Register": "Registrieren",
"Password": "Passwort",
"Or": "Oder",

View File

@@ -1,6 +1,7 @@
{
"Login": "Login",
"Login credentials": "Login credentials",
"Login provider": "Login provider",
"Register": "Register",
"Password": "Password",
"Or": "Or",

View File

@@ -25,7 +25,7 @@ if (!$decision->isAllowed()) {
return;
}
$profile = app(UserProfileViewService::class)->buildProfile($currentUserId, $uuid);
$profile = app(UserProfileViewService::class)->buildAdminProfile($currentUserId, $uuid);
$status = (string) ($profile['status'] ?? '');
if ($status === 'not_found' || $status === 'invalid_uuid') {
http_response_code(404);

View File

@@ -3,4 +3,4 @@ if (!isset($user) || !is_array($user)) {
return;
}
$profileKey = 'admin-users-profile';
require templatePath('partials/app-user-profile.phtml');
require templatePath('partials/app-admin-user-profile.phtml');

View File

@@ -0,0 +1,264 @@
<?php
use MintyPHP\I18n;
$values = $user ?? [];
$profileKey = trim((string) ($profileKey ?? 'admin-user-profile'));
if ($profileKey === '') {
$profileKey = 'admin-user-profile';
}
$name = trim(($values['first_name'] ?? '') . ' ' . ($values['last_name'] ?? ''));
$displayName = $name !== '' ? $name : ($values['email'] ?? t('User'));
$email = trim((string) ($values['email'] ?? ''));
$phone = trim((string) ($values['phone'] ?? ''));
$mobile = trim((string) ($values['mobile'] ?? ''));
$shortDial = trim((string) ($values['short_dial'] ?? ''));
$address = trim((string) ($values['address'] ?? ''));
$postalCode = trim((string) ($values['postal_code'] ?? ''));
$city = trim((string) ($values['city'] ?? ''));
$region = trim((string) ($values['region'] ?? ''));
$country = trim((string) ($values['country'] ?? ''));
$jobTitle = trim((string) ($values['job_title'] ?? ''));
$hireDate = trim((string) ($values['hire_date'] ?? ''));
$profileDescription = trim((string) ($values['profile_description'] ?? ''));
$avatarUuid = (string) ($values['uuid'] ?? '');
$hasAvatar = !empty($values['has_avatar']) && $avatarUuid !== '';
$tenantGroups = $values['tenant_groups'] ?? [];
$roleLabels = $values['role_labels'] ?? [];
$adminExtras = is_array($values['admin_extras'] ?? null) ? $values['admin_extras'] : [];
$localeFormat = (strpos((string) (I18n::$locale ?? ''), 'de') === 0) ? 'd.m.Y' : 'Y-m-d';
$localeFormatTime = (strpos((string) (I18n::$locale ?? ''), 'de') === 0) ? 'd.m.Y H:i' : 'Y-m-d H:i';
$formatDate = static function (string $input, string $format) {
$input = trim($input);
if ($input === '') {
return '';
}
try {
return (new \DateTimeImmutable($input))->format($format);
} catch (\Exception $e) {
return $input;
}
};
$hireDateLabel = $formatDate($hireDate, $localeFormat);
$lastLoginLabel = $formatDate((string) ($adminExtras['last_login_at'] ?? ''), $localeFormatTime);
$emailVerifiedLabel = $formatDate((string) ($adminExtras['email_verified_at'] ?? ''), $localeFormatTime);
$createdLabel = $formatDate((string) ($adminExtras['created'] ?? ''), $localeFormatTime);
$modifiedLabel = $formatDate((string) ($adminExtras['modified'] ?? ''), $localeFormatTime);
$lastLoginProvider = trim((string) ($adminExtras['last_login_provider'] ?? ''));
$isActive = (bool) ($adminExtras['active'] ?? false);
$initials = '';
if ($displayName !== '') {
$parts = array_filter(array_map('trim', preg_split('/\s+/', $displayName) ?: []));
$chars = '';
foreach ($parts as $part) {
if (function_exists('mb_substr')) {
$chars .= mb_substr($part, 0, 1);
} else {
$chars .= substr($part, 0, 1);
}
}
$initials = strtoupper($chars !== '' ? $chars : '?');
}
$hasContact = ($email !== '' || $phone !== '' || $mobile !== '' || $shortDial !== '');
$hasAddress = ($address !== '' || $postalCode !== '' || $city !== '' || $region !== '' || $country !== '');
$hasOrganization = (!empty($tenantGroups) || !empty($roleLabels));
?>
<div class="app-details-container">
<section>
<div class="app-profile-card-container">
<div class="app-profile-card">
<div class="app-profile-banner"></div>
<div class="app-profile-header">
<div class="user-avatar-block avatar-round app-profile-avatar">
<?php if ($hasAvatar): ?>
<a data-fslightbox="<?php e($profileKey); ?>-avatar"
href="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=256">
<img class="user-avatar-image" src="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=160" alt="">
</a>
<?php else: ?>
<span class="user-avatar-placeholder"><?php e($initials ?: '?'); ?></span>
<?php endif; ?>
</div>
<div class="app-profile-meta">
<hgroup>
<h2><?php e($displayName); ?></h2>
<p>
<?php if ($jobTitle !== ''): ?>
<small><?php e($jobTitle); ?></small>
<?php elseif ($email !== ''): ?>
<a href="mailto:<?php e($email); ?>"><small><?php e($email); ?></small></a>
<?php endif; ?>
<small class="badge" data-variant="<?php e($isActive ? 'success' : 'danger'); ?>">
<?php e($isActive ? t('Active') : t('Inactive')); ?>
</small>
</p>
</hgroup>
</div>
</div>
</div>
</div>
<div class="app-profile-body-container">
<div class="app-profile-body">
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="<?php e($profileKey); ?>">
<div class="app-tabs-nav">
<button type="button" data-tab="master" data-tab-default><small><?php e(t('Master data')); ?></small></button>
<?php if ($hasOrganization): ?>
<button type="button" data-tab="organization"><small><?php e(t('Organization')); ?></small></button>
<?php endif; ?>
<button type="button" data-tab="security"><small><?php e(t('Security')); ?></small></button>
<button type="button" data-tab="about"><small><?php e(t('About')); ?></small></button>
</div>
<div data-tab-panel="master">
<?php if ($hasContact): ?>
<div class="grid">
<?php if ($email !== ''): ?>
<div>
<small><?php e(t('Email')); ?></small>
<p><a href="mailto:<?php e($email); ?>"><?php e($email); ?></a></p>
</div>
<?php endif; ?>
<?php if ($phone !== ''): ?>
<div>
<small><?php e(t('Phone')); ?></small>
<p><a href="tel:<?php e($phone); ?>"><?php e($phone); ?></a></p>
</div>
<?php endif; ?>
</div>
<div class="grid">
<?php if ($mobile !== ''): ?>
<div>
<small><?php e(t('Mobile')); ?></small>
<p><a href="tel:<?php e($mobile); ?>"><?php e($mobile); ?></a></p>
</div>
<?php endif; ?>
<?php if ($shortDial !== ''): ?>
<div>
<small><?php e(t('Short dial')); ?></small>
<p><?php e($shortDial); ?></p>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($hasAddress): ?>
<div>
<small><?php e(t('Address')); ?></small>
<?php if ($address !== ''): ?><p><?php e($address); ?></p><?php endif; ?>
<?php $line2 = trim($postalCode . ' ' . $city); ?>
<?php if ($line2 !== ''): ?><p><?php e($line2); ?></p><?php endif; ?>
<?php if ($region !== ''): ?><p><?php e($region); ?></p><?php endif; ?>
<?php if ($country !== ''): ?><p><?php e($country); ?></p><?php endif; ?>
</div>
<?php endif; ?>
<?php if (!$hasContact && !$hasAddress): ?>
<p>-</p>
<?php endif; ?>
</div>
<?php if ($hasOrganization): ?>
<div data-tab-panel="organization">
<?php if (!empty($tenantGroups)): ?>
<table class="app-profile-table">
<thead>
<tr>
<th><?php e(t('Tenant')); ?></th>
<th><?php e(t('Departments')); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($tenantGroups as $group): ?>
<?php $departments = is_array($group['departments'] ?? null) ? $group['departments'] : []; ?>
<tr>
<td>
<?php e($group['label'] ?? ''); ?>
<?php if (!empty($group['is_primary'])): ?>
<small>(<?php e(t('Primary tenant')); ?>)</small>
<?php endif; ?>
</td>
<td><?php e(!empty($departments) ? implode(', ', $departments) : '-'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if (!empty($roleLabels)): ?>
<div>
<small><?php e(t('Roles')); ?></small>
<p><?php e(implode(', ', $roleLabels)); ?></p>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<div data-tab-panel="security">
<div class="grid">
<div>
<small><?php e(t('State')); ?></small>
<p>
<span class="badge" data-variant="<?php e($isActive ? 'success' : 'danger'); ?>">
<?php e($isActive ? t('Active') : t('Inactive')); ?>
</span>
</p>
</div>
<div>
<small><?php e(t('Email verified')); ?></small>
<p><?php e($emailVerifiedLabel !== '' ? $emailVerifiedLabel : t('Not verified')); ?></p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Last login')); ?></small>
<p><?php e($lastLoginLabel !== '' ? $lastLoginLabel : t('Never')); ?></p>
</div>
<?php if ($lastLoginProvider !== ''): ?>
<div>
<small><?php e(t('Login provider')); ?></small>
<p><?php e($lastLoginProvider); ?></p>
</div>
<?php endif; ?>
</div>
<div class="grid">
<?php if ($createdLabel !== ''): ?>
<div>
<small><?php e(t('Created')); ?></small>
<p><?php e($createdLabel); ?></p>
</div>
<?php endif; ?>
<?php if ($modifiedLabel !== ''): ?>
<div>
<small><?php e(t('Modified')); ?></small>
<p><?php e($modifiedLabel); ?></p>
</div>
<?php endif; ?>
</div>
</div>
<div data-tab-panel="about">
<?php if ($hireDateLabel !== ''): ?>
<div>
<small><?php e(t('Hire date')); ?></small>
<p><?php e($hireDateLabel); ?></p>
</div>
<?php endif; ?>
<?php if ($profileDescription !== ''): ?>
<?php foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line): ?>
<?php if (trim($line) !== ''): ?>
<p><?php e($line); ?></p>
<?php endif; ?>
<?php endforeach; ?>
<?php elseif ($hireDateLabel === ''): ?>
<p>-</p>
<?php endif; ?>
</div>
</div>
</div>
</div>
</section>
</div>

View File

@@ -126,7 +126,9 @@
border-left: none;
border-top: 1px solid var(--app-border);
min-height: 0;
padding: calc(var(--app-spacing) * 1.5) 0 0;
/* Match the inline padding of `section > *` (from app-details.css) so the
aside block aligns visually with the main content above it. */
padding: calc(var(--app-spacing) * 1.5) calc(var(--app-spacing) * 2) 0;
margin-top: calc(var(--app-spacing) * 1.5);
}
}