feat(admin): users and permissions
Sprint 25. New features/admin/users/ module, net-new /:lang/backoffice/users route + Dashboard Quick Action. - users: name, Telegram username, scope (marketplace vs office admin), role (inline change), status (active/invited/suspended), last login - 4 built-in roles (owner/admin/editor/viewer) with flat permission lists - invitations: email + role + scope form, pending list + revoke (no email actually sends - local record only) - passwordless login confirmed already real (AdminAuthService Telegram QR, docs/BACKEND.md item 1) - linked, not reimplemented - per-user mock session list (device/IP/last-active, revoke) - flagged as mock since the real AdminAuthService only ever tracks the current browser's session - per-user audit log dialog (role/status changes), same pattern as Sprint 24's per-transaction audit, intentionally separate from the system-wide log planned for Sprint 26 docs/ADMIN.md + docs/BACKEND.md (new item 14) updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
<section class="admin-users-page">
|
||||
<div class="card">
|
||||
<h2>{{ 'adminUsers.title' | translate }}</h2>
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'adminUsers.name' | translate }}</th>
|
||||
<th>{{ 'adminUsers.scope' | translate }}</th>
|
||||
<th>{{ 'adminUsers.role' | translate }}</th>
|
||||
<th>{{ 'backoffice.status' | translate }}</th>
|
||||
<th>{{ 'adminUsers.lastLogin' | translate }}</th>
|
||||
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (user of facade.users(); track user.id) {
|
||||
<tr>
|
||||
<td>{{ user.name }}<br /><small>{{ user.telegramUsername }}</small></td>
|
||||
<td>{{ ('adminUsers.scopeValue.' + user.scope) | translate }}</td>
|
||||
<td>
|
||||
<select [ngModel]="user.roleId" (ngModelChange)="facade.setRole(user.id, $event)">
|
||||
@for (role of facade.roles(); track role.id) {
|
||||
<option [value]="role.id">{{ role.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td><app-badge [variant]="user.status === 'active' ? 'success' : user.status === 'suspended' ? 'danger' : 'neutral'">{{ ('adminUsers.statusValue.' + user.status) | translate }}</app-badge></td>
|
||||
<td>{{ user.lastLoginAt ? (user.lastLoginAt | date:'short') : '—' }}</td>
|
||||
<td class="actions">
|
||||
<app-button variant="secondary" size="sm" (click)="toggleStatus(user.id, user.status)">{{ (user.status === 'suspended' ? 'adminUsers.reactivate' : 'adminUsers.suspend') | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="facade.openSessions(user)">{{ 'adminUsers.sessions' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="facade.openAudit(user)">{{ 'adminTransactions.audit' | translate }}</app-button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>{{ 'adminUsers.invite' | translate }}</h2>
|
||||
<p class="hint">{{ 'adminUsers.passwordlessHint' | translate }}</p>
|
||||
<div class="invite-form">
|
||||
<app-input type="email" [ngModel]="inviteEmail()" (ngModelChange)="inviteEmail.set($event)" [placeholder]="'adminUsers.email' | translate" />
|
||||
<select [ngModel]="inviteRoleId()" (ngModelChange)="inviteRoleId.set($event)">
|
||||
@for (role of facade.roles(); track role.id) {
|
||||
<option [value]="role.id">{{ role.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<select [ngModel]="inviteScope()" (ngModelChange)="inviteScope.set($event)">
|
||||
<option value="marketplace">{{ 'adminUsers.scopeValue.marketplace' | translate }}</option>
|
||||
<option value="office">{{ 'adminUsers.scopeValue.office' | translate }}</option>
|
||||
</select>
|
||||
<app-button variant="primary" (click)="sendInvite()">{{ 'adminUsers.sendInvite' | translate }}</app-button>
|
||||
</div>
|
||||
|
||||
@if (facade.invitations().length > 0) {
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'adminUsers.email' | translate }}</th>
|
||||
<th>{{ 'adminUsers.role' | translate }}</th>
|
||||
<th>{{ 'backoffice.status' | translate }}</th>
|
||||
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (invite of facade.invitations(); track invite.id) {
|
||||
<tr>
|
||||
<td>{{ invite.email }}</td>
|
||||
<td>{{ facade.roleName(invite.roleId) }}</td>
|
||||
<td><app-badge variant="neutral">{{ ('adminUsers.invitationStatus.' + invite.status) | translate }}</app-badge></td>
|
||||
<td>
|
||||
@if (invite.status === 'pending') {
|
||||
<app-button variant="danger" size="sm" (click)="facade.revokeInvitation(invite.id)">{{ 'adminUsers.revoke' | translate }}</app-button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>{{ 'adminUsers.roles' | translate }}</h2>
|
||||
@for (role of facade.roles(); track role.id) {
|
||||
<p><strong>{{ role.name }}</strong> — {{ role.permissions.join(', ') }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-dialog [open]="!!facade.sessionsTarget()" [titleText]="'adminUsers.sessions' | translate" size="sm" (closed)="facade.closeSessions()">
|
||||
@for (session of facade.sessions(); track session.id) {
|
||||
<p>{{ session.device }} — {{ session.ip }} — {{ session.lastActiveAt | date:'short' }}
|
||||
@if (session.current) { <app-badge variant="success">{{ 'adminUsers.currentSession' | translate }}</app-badge> }
|
||||
@else { <app-button variant="danger" size="sm" (click)="facade.revokeSession(session.id)">{{ 'adminUsers.revoke' | translate }}</app-button> }
|
||||
</p>
|
||||
}
|
||||
</app-dialog>
|
||||
|
||||
<app-dialog [open]="!!facade.auditTarget()" [titleText]="'adminTransactions.audit' | translate" size="sm" (closed)="facade.closeAudit()">
|
||||
@for (entry of facade.audit(); track $index) {
|
||||
<p>{{ entry.timestamp | date:'short' }} — {{ entry.actor }} — {{ entry.action }}</p>
|
||||
}
|
||||
</app-dialog>
|
||||
</section>
|
||||
@@ -0,0 +1,8 @@
|
||||
.admin-users-page { display: grid; gap: 16px; padding: 16px; max-width: 1100px; margin: 0 auto; }
|
||||
.card { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
|
||||
.card h2 { margin: 0; font-size: 1.1rem; }
|
||||
.hint { margin: 0; color: var(--text-secondary, #6b7280); font-size: 0.85rem; }
|
||||
.invite-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
@media (max-width: 640px) { .invite-form { flex-direction: column; align-items: stretch; } }
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AdminUsersFacade } from '../facade/admin-users.facade';
|
||||
import { AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { InputComponent } from '../../../../shared/ui/input/input.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-users-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, DialogComponent],
|
||||
templateUrl: './admin-users-page.component.html',
|
||||
styleUrls: ['./admin-users-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminUsersPageComponent {
|
||||
readonly facade = inject(AdminUsersFacade);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly inviteEmail = signal('');
|
||||
readonly inviteRoleId = signal('viewer');
|
||||
readonly inviteScope = signal<AdminUserScope>('office');
|
||||
|
||||
constructor() {
|
||||
this.facade.loadAll();
|
||||
}
|
||||
|
||||
sendInvite(): void {
|
||||
this.facade.invite(this.inviteEmail(), this.inviteRoleId(), this.inviteScope());
|
||||
this.inviteEmail.set('');
|
||||
}
|
||||
|
||||
toggleStatus(userId: string, current: AdminUserStatus): void {
|
||||
const next: AdminUserStatus = current === 'suspended' ? 'active' : 'suspended';
|
||||
if (next === 'suspended' && !window.confirm(this.translate.t('adminUsers.confirmSuspend'))) {
|
||||
return;
|
||||
}
|
||||
this.facade.setStatus(userId, next);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user