ui: refine admin workspace

This commit is contained in:
Lucio Lelii 2026-09-29 15:00:22 +02:00
parent f4a162a485
commit d3f8ea3006
8 changed files with 364 additions and 116 deletions

View File

@ -18,43 +18,89 @@
min-height: 0;
/* unico contenitore scrollabile della console admin:
header + sidebar + contenuto scorrono insieme */
gap: 14px;
padding: 16px clamp(14px, 2.5vw, 32px) 24px;
background: #f8fafc;
overflow-y: auto;
overflow-x: hidden;
}
.admin-shell__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 6px 4px 10px;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #e2e8f0;
background: #fff;
}
.admin-shell__brand-mark {
display: grid;
flex: 0 0 auto;
width: 36px;
height: 36px;
place-items: center;
border-radius: 10px;
background: #eff6ff;
color: #2563eb;
}
.admin-shell__brand-mark mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
.admin-shell__header-copy {
min-width: 0;
flex: 1;
}
.admin-shell__eyebrow {
color: #2563eb;
font-size: 12px;
color: #64748b;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.admin-shell__title {
margin: 4px 0 0;
margin: 2px 0 0;
color: #0f172a;
font-size: 26px;
font-weight: 800;
font-size: 22px;
font-weight: 700;
}
.admin-shell__subtitle {
margin: 6px 0 0;
margin: 3px 0 0;
color: #64748b;
font-size: 14px;
font-size: 13px;
}
.admin-shell__header-status {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
padding: 6px 9px;
border: 1px solid #dbeafe;
border-radius: 999px;
background: rgba(255, 255, 255, 0.76);
color: #1d4ed8;
font-size: 12px;
font-weight: 700;
}
.admin-shell__header-status mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.admin-shell__body {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 18px;
grid-template-columns: 224px minmax(0, 1fr);
gap: 14px;
flex: 1 0 auto;
min-height: 0;
}
@ -62,23 +108,26 @@
.admin-shell__sidebar {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 14px;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 22px;
background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
gap: 12px;
padding: 12px 10px;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #fff;
position: sticky;
top: 0;
align-self: start;
}
.admin-shell__menu-group {
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
}
.admin-shell__sidebar-title {
color: #0f172a;
font-size: 12px;
padding: 0 8px;
color: #64748b;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
@ -94,14 +143,14 @@
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.24);
padding: 9px 10px;
border-radius: 9px;
border: 1px solid #e2e8f0;
color: #0f172a;
font-size: 14px;
font-weight: 700;
text-decoration: none;
background: rgba(255, 255, 255, 0.78);
background: #fff;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}
@ -115,24 +164,34 @@
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 16px;
padding: 9px 10px;
border-radius: 9px;
color: #334155;
font-size: 14px;
font-weight: 700;
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: background-color 120ms ease, color 120ms ease, transform 120ms ease;
position: relative;
transition: background-color 120ms ease, color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
}
.admin-shell__nav-link:hover {
background: rgba(37, 99, 235, 0.08);
background: #eff6ff;
color: #1d4ed8;
}
.admin-shell__nav-link--active {
background: #eff6ff;
color: #1d4ed8;
}
.admin-shell__nav-link--active::before {
position: absolute;
left: 0;
width: 3px;
height: 20px;
border-radius: 999px;
background: #2563eb;
color: #fff;
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.2);
content: '';
}
.admin-shell__content {
@ -147,10 +206,15 @@
.admin-shell__sidebar {
padding: 14px;
position: static;
}
.admin-shell__nav {
flex-direction: row;
flex-wrap: wrap;
}
.admin-shell__header-status {
display: none;
}
}

View File

@ -6,10 +6,17 @@
<div class="admin-shell">
<div class="admin-shell__header">
<div>
<div class="admin-shell__eyebrow">Administration</div>
<div class="admin-shell__brand-mark" aria-hidden="true">
<mat-icon fontIcon="admin_panel_settings"></mat-icon>
</div>
<div class="admin-shell__header-copy">
<div class="admin-shell__eyebrow">Administration workspace</div>
<h1 class="admin-shell__title">Admin Console</h1>
<p class="admin-shell__subtitle">Manage users and admin-only operations from a dedicated workspace.</p>
<p class="admin-shell__subtitle">Users, access and service health in one focused workspace.</p>
</div>
<div class="admin-shell__header-status">
<mat-icon fontIcon="verified_user"></mat-icon>
Restricted access
</div>
</div>

View File

@ -7,34 +7,37 @@
.admin-activity-page {
display: flex;
flex-direction: column;
gap: 20px;
padding-block: 8px;
gap: 14px;
padding: 8px 4px 16px;
}
.admin-activity-page__header {
display: flex;
flex-wrap: wrap;
gap: 16px;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
}
.admin-activity-page__eyebrow {
font-size: 12px;
color: #2563eb;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.6;
}
.admin-activity-page__title {
margin: 4px 0 6px;
font-size: 22px;
margin: 2px 0 4px;
color: #0f172a;
font-size: 24px;
font-weight: 700;
}
.admin-activity-page__subtitle {
margin: 0;
max-width: 62ch;
opacity: 0.75;
color: #64748b;
}
.admin-activity-page__actions {
@ -53,10 +56,11 @@
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 10px;
padding: 9px 11px;
border-radius: 12px;
border: 1px solid transparent;
font-weight: 500;
box-shadow: none;
font-weight: 600;
}
.admin-activity-verdict--clear {
@ -77,11 +81,14 @@
.admin-activity-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px;
gap: 12px;
}
.admin-activity-card {
padding: 16px;
padding: 14px;
border: 1px solid rgba(203, 213, 225, 0.84);
border-radius: 14px;
box-shadow: none;
}
.admin-activity-card__title {
@ -94,10 +101,18 @@
}
.admin-activity-card__count {
font-size: 24px;
color: #1d4ed8;
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
:host ::ng-deep .mat-mdc-button-base {
min-height: 34px;
padding-inline: 10px;
font-size: 12px;
}
.admin-activity-card__hint {
margin: 6px 0 12px;
font-size: 12px;

View File

@ -7,37 +7,36 @@
.admin-create-user-page {
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
min-height: 100%;
padding: 8px 4px 16px;
background: linear-gradient(180deg, #f8fafc 0%, #eef6ff 100%);
}
.admin-create-user-page__eyebrow {
color: #2563eb;
font-size: 12px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.admin-create-user-page__title {
margin: 4px 0 0;
margin: 2px 0 0;
color: #0f172a;
font-size: 28px;
font-weight: 800;
font-size: 24px;
font-weight: 700;
}
.admin-create-user-page__subtitle {
margin: 6px 0 0;
margin: 3px 0 0;
color: #64748b;
font-size: 14px;
font-size: 13px;
}
.admin-create-user-page__success,
.admin-create-user-card__error {
border-radius: 14px;
padding: 12px 14px;
border-radius: 10px;
padding: 9px 11px;
font-size: 13px;
font-weight: 600;
}
@ -49,15 +48,17 @@
}
.admin-create-user-card {
border-radius: 22px;
padding: 20px;
border-radius: 14px;
padding: 14px;
max-width: 980px;
border: 1px solid #e2e8f0;
box-shadow: none;
}
.admin-create-user-card__title {
margin-bottom: 14px;
color: #0f172a;
font-size: 18px;
font-size: 16px;
font-weight: 700;
}
@ -87,6 +88,16 @@
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
:host ::ng-deep .mat-mdc-button-base {
min-height: 34px;
padding-inline: 10px;
font-size: 12px;
}
.admin-create-user-check {
@ -103,7 +114,9 @@
.admin-create-user-form__actions {
display: flex;
align-items: center;
justify-content: flex-end;
padding-top: 4px;
}
@media (max-width: 1100px) {

View File

@ -7,42 +7,43 @@
.admin-stats-page {
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
min-height: 100%;
padding: 8px 4px 16px;
background: linear-gradient(180deg, #f8fafc 0%, #eef6ff 100%);
}
.admin-stats-page__eyebrow {
color: #2563eb;
font-size: 12px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.admin-stats-page__title {
margin: 4px 0 0;
margin: 2px 0 0;
color: #0f172a;
font-size: 28px;
font-weight: 800;
font-size: 24px;
font-weight: 700;
}
.admin-stats-page__subtitle {
margin: 6px 0 0;
margin: 3px 0 0;
color: #64748b;
font-size: 14px;
font-size: 13px;
}
.admin-stats-card {
border-radius: 22px;
padding: 20px;
border-radius: 14px;
padding: 14px;
border: 1px solid #e2e8f0;
box-shadow: none;
}
.admin-stats-card__title {
margin-bottom: 14px;
color: #0f172a;
font-size: 18px;
font-size: 16px;
font-weight: 700;
}
@ -69,7 +70,7 @@
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
gap: 10px;
}
.admin-stats-users {
@ -84,20 +85,19 @@
flex-direction: column;
gap: 3px;
min-width: 180px;
padding: 12px 14px;
padding: 9px 11px;
border: 1px solid #dbe7f5;
border-radius: 16px;
background: #f8fbff;
border-radius: 12px;
background: #fff;
color: #334155;
text-align: left;
cursor: pointer;
transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease;
transition: border-color 120ms ease, background-color 120ms ease;
}
.admin-stats-user-chip:hover {
border-color: #93c5fd;
background: #eff6ff;
transform: translateY(-1px);
}
.admin-stats-user-chip--active {
@ -117,8 +117,11 @@
}
.admin-stats-metric {
border-radius: 20px;
padding: 18px;
border-radius: 14px;
padding: 14px;
border: 1px solid #e2e8f0;
background: #fff;
box-shadow: none;
}
.admin-stats-metric__label {
@ -132,8 +135,14 @@
.admin-stats-metric__value {
margin-top: 10px;
color: #0f172a;
font-size: 34px;
font-weight: 800;
font-size: 28px;
font-weight: 700;
}
:host ::ng-deep .mat-mdc-button-base {
min-height: 34px;
padding-inline: 10px;
font-size: 12px;
}
.admin-stats-metric__meta {

View File

@ -7,10 +7,9 @@
.admin-users-list-page {
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
min-height: 100%;
padding: 8px 4px 16px;
background: linear-gradient(180deg, #f8fafc 0%, #eef6ff 100%);
}
.admin-users-list-page__header {
@ -22,30 +21,30 @@
.admin-users-list-page__eyebrow {
color: #2563eb;
font-size: 12px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.admin-users-list-page__title {
margin: 4px 0 0;
margin: 2px 0 0;
color: #0f172a;
font-size: 28px;
font-weight: 800;
font-size: 24px;
font-weight: 700;
}
.admin-users-list-page__subtitle {
margin: 6px 0 0;
margin: 3px 0 0;
color: #64748b;
font-size: 14px;
font-size: 13px;
}
.admin-users-list-page__success,
.admin-users-list-card__error,
.admin-users-list-row__error {
border-radius: 14px;
padding: 12px 14px;
border-radius: 10px;
padding: 9px 11px;
font-size: 13px;
font-weight: 600;
}
@ -57,17 +56,38 @@
}
.admin-users-list-card {
border-radius: 22px;
padding: 20px;
border-radius: 14px;
padding: 14px;
border: 1px solid #e2e8f0;
box-shadow: none;
}
.admin-users-list-card__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.admin-users-list-card__title {
margin-bottom: 14px;
color: #0f172a;
font-size: 18px;
font-size: 16px;
font-weight: 700;
}
.admin-users-list-card__summary {
margin-top: 4px;
color: #64748b;
font-size: 12px;
font-weight: 600;
}
.admin-users-list-card__search {
width: min(100%, 340px);
margin: -10px 0;
}
.admin-users-list-card__error,
.admin-users-list-row__error {
border: 1px solid #fecaca;
@ -81,10 +101,21 @@
padding: 12px 4px;
}
.admin-users-list-state--empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 90px;
border: 1px dashed #cbd5e1;
border-radius: 16px;
background: #f8fafc;
}
.admin-users-list {
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
}
.admin-users-list-row {
@ -93,32 +124,58 @@
gap: 16px;
align-items: center;
border: 1px solid #e2e8f0;
border-radius: 18px;
border-radius: 12px;
background: #fff;
padding: 12px;
transition: border-color 140ms ease, background-color 140ms ease;
}
.admin-users-list-row:hover {
border-color: #bfdbfe;
background: #f8fbff;
padding: 16px;
}
.admin-users-list-row__identity {
display: flex;
align-items: center;
min-width: 0;
gap: 11px;
}
.admin-users-list-row__avatar {
display: grid;
flex: 0 0 auto;
width: 32px;
height: 32px;
place-items: center;
border-radius: 9px;
background: #eff6ff;
color: #1d4ed8;
font-size: 13px;
font-weight: 800;
}
.admin-users-list-row__identity-copy {
min-width: 0;
}
.admin-users-list-row__username {
color: #0f172a;
font-size: 15px;
font-size: 14px;
font-weight: 700;
}
.admin-users-list-row__email {
color: #64748b;
font-size: 13px;
margin-top: 4px;
margin-top: 2px;
word-break: break-word;
}
.admin-users-list-row__role {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
}
.admin-users-list-row__role-field {
@ -133,6 +190,35 @@
flex-wrap: wrap;
}
.admin-users-list-row__role-badge {
display: inline-flex;
align-items: center;
padding: 4px 7px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #f8fafc;
color: #475569;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
}
:host ::ng-deep .mat-mdc-button-base {
min-height: 34px;
padding-inline: 10px;
font-size: 12px;
}
:host ::ng-deep .mat-mdc-button-base .mat-icon {
margin-right: 4px;
}
.admin-users-list-row__role-badge--admin {
border-color: #c4b5fd;
background: #f5f3ff;
color: #6d28d9;
}
.admin-users-list-row__error {
grid-column: 1 / -1;
}
@ -145,4 +231,14 @@
.admin-users-list-row__actions {
justify-content: flex-start;
}
.admin-users-list-card__toolbar {
align-items: stretch;
flex-direction: column;
}
.admin-users-list-card__search {
width: 100%;
margin: 0;
}
}

View File

@ -11,9 +11,9 @@
<h2 class="admin-users-list-page__title">Users</h2>
<p class="admin-users-list-page__subtitle">Review existing users, change roles, reset passwords and remove accounts.</p>
</div>
<button type="button" mat-stroked-button (click)="loadUsers()">
<button type="button" mat-stroked-button [disabled]="loading()" (click)="loadUsers()">
<mat-icon fontIcon="refresh"></mat-icon>
Refresh
{{ loading() ? 'Refreshing…' : 'Refresh' }}
</button>
</div>
@ -22,7 +22,24 @@
}
<mat-card class="admin-users-list-card">
<div class="admin-users-list-card__title">Users</div>
<div class="admin-users-list-card__toolbar">
<div>
<div class="admin-users-list-card__title">Users</div>
<div class="admin-users-list-card__summary">
{{ filteredUsers().length }} of {{ users().length }} account{{ users().length === 1 ? '' : 's' }} shown
</div>
</div>
<mat-form-field appearance="outline" class="admin-users-list-card__search">
<mat-label>Find a user</mat-label>
<mat-icon matPrefix fontIcon="search"></mat-icon>
<input matInput [ngModel]="userSearch()" (ngModelChange)="userSearch.set($event)" placeholder="Username, email or role" />
@if (userSearch()) {
<button type="button" mat-icon-button matSuffix aria-label="Clear user search" (click)="userSearch.set('')">
<mat-icon fontIcon="close"></mat-icon>
</button>
}
</mat-form-field>
</div>
@if (loading()) {
<div class="admin-users-list-state">Loading users...</div>
@ -30,13 +47,22 @@
<div class="admin-users-list-card__error">{{ pageError() }}</div>
} @else if (!users().length) {
<div class="admin-users-list-state">No users found.</div>
} @else if (!filteredUsers().length) {
<div class="admin-users-list-state admin-users-list-state--empty">
<mat-icon fontIcon="person_search"></mat-icon>
No users match “{{ userSearch() }}”.
<button type="button" mat-button (click)="userSearch.set('')">Clear search</button>
</div>
} @else {
<div class="admin-users-list">
@for (user of users(); track user.username) {
@for (user of filteredUsers(); track user.username) {
<div class="admin-users-list-row">
<div class="admin-users-list-row__identity">
<div class="admin-users-list-row__username">{{ user.username }}</div>
<div class="admin-users-list-row__email">{{ user.email || 'No email' }}</div>
<div class="admin-users-list-row__avatar" aria-hidden="true">{{ user.username.slice(0, 1).toUpperCase() }}</div>
<div class="admin-users-list-row__identity-copy">
<div class="admin-users-list-row__username">{{ user.username }}</div>
<div class="admin-users-list-row__email">{{ user.email || 'No email provided' }}</div>
</div>
</div>
<div class="admin-users-list-row__role">
@ -47,15 +73,23 @@
<mat-option value="ADMIN">ADMIN</mat-option>
</mat-select>
</mat-form-field>
<button type="button" mat-stroked-button [disabled]="roleSavingByUser()[user.username] || roleDraft(user.username) === user.role" (click)="saveRole(user)">
Save role
<button type="button" mat-flat-button [disabled]="roleSavingByUser()[user.username] || roleDraft(user.username) === user.role" (click)="saveRole(user)">
<mat-icon fontIcon="save"></mat-icon>
{{ roleSavingByUser()[user.username] ? 'Saving…' : 'Save role' }}
</button>
</div>
<div class="admin-users-list-row__actions">
<button type="button" mat-stroked-button (click)="openResetPasswordDialog(user)">Reset password</button>
<span class="admin-users-list-row__role-badge" [class.admin-users-list-row__role-badge--admin]="user.role === 'ADMIN'">
{{ user.role }}
</span>
<button type="button" mat-stroked-button (click)="openResetPasswordDialog(user)">
<mat-icon fontIcon="key"></mat-icon>
Reset password
</button>
<button type="button" mat-stroked-button color="warn" [disabled]="deleteBusyByUser()[user.username]" (click)="deleteUser(user)">
Delete
<mat-icon fontIcon="delete_outline"></mat-icon>
{{ deleteBusyByUser()[user.username] ? 'Deleting…' : 'Delete' }}
</button>
</div>

View File

@ -3,12 +3,13 @@
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { AdminUser, UserRole } from '@models/user';
import { Router } from '@angular/router';
@ -27,6 +28,7 @@ import { scheduleSignalClear } from '@utilities/temporary-signal';
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
AdminResetPasswordDialogComponent
],
@ -40,6 +42,14 @@ export class AdminUsersListPage {
private router = inject(Router);
readonly users = signal<AdminUser[]>([]);
readonly userSearch = signal('');
readonly filteredUsers = computed(() => {
const query = this.userSearch().trim().toLowerCase();
if (!query) return this.users();
return this.users().filter((user) =>
`${user.username} ${user.email ?? ''} ${user.role}`.toLowerCase().includes(query)
);
});
readonly loading = signal(true);
readonly pageError = signal<string | null>(null);
readonly roleSavingByUser = signal<Record<string, boolean>>({});