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:
sdarbinyan
2026-07-15 11:05:21 +04:00
parent 7d65913245
commit 17adc9e9fd
12 changed files with 455 additions and 3 deletions

View File

@@ -335,6 +335,40 @@ between the two mock feature areas.
- Wired into `/:lang/backoffice/transactions`, replacing the coming-soon
placeholder.
## Sprint 25 - Users & Roles (mock/local)
`features/admin/users/`. Single consolidated page (`admin-users-page`) at
`/:lang/backoffice/users` - not previously in the Quick Actions list or
routes at all, this is a net-new admin section.
- **Users**: name, Telegram username, `scope` (`marketplace` vs `office`
admin - distinguishes tenant-level owners/admins from internal staff),
role, status (`active`/`invited`/`suspended`), last login. Role change is
an inline `<select>`; suspend/reactivate is confirm-gated for suspend
only.
- **Roles/permissions**: 4 built-in roles (`owner`/`admin`/`editor`/`viewer`)
with a flat permission-string list (`products.manage`, `*` for owner,
etc.) - a real permission catalog and custom-role creation don't exist,
intentionally scoped down to what's needed to demonstrate the model.
- **Invitations**: email + role + scope form, pending list with revoke.
No email actually sends - `AdminUsersLocalGateway.inviteUser()` only
creates the local record.
- **Passwordless login**: already existed before this sprint -
`AdminAuthService`'s Telegram QR flow (`docs/ADMIN.md`'s existing admin
login section, `docs/BACKEND.md` item 1). This sprint's Users page links
to it via a hint, doesn't reimplement it.
- **Session manager / device manager**: per-user session list (device, IP,
last active, current-session badge) with per-session revoke, mocked
(`AdminUsersLocalGateway.loadSessions()` fabricates 2 sessions per user
on first view) - the real `AdminAuthService`/session-cookie flow only
ever tracks the *current* browser's session, so multi-device session
listing has no real backend counterpart yet (see `docs/BACKEND.md` item 1).
- **Audit**: per-user audit log (role/status changes), same dialog pattern
as Sprint 24's per-transaction audit - not the system-wide security/audit
log planned for Sprint 26.
- Wired into `AdminDashboardFacade`'s Quick Actions list (`dashboard.actionUsers`
-> `/:lang/backoffice/users`).
## Known gaps / backend needs
- **Dashboard metrics endpoint.** Categories/Products counts are computed

View File

@@ -152,6 +152,14 @@ Plus, if authenticated history/wishlist/compare/saved-searches sync is wanted: `
**Frontend files:** implement `AdminTransactionsApiGateway` against `AdminTransactionsGateway` (`services/admin-transactions-gateway.interface.ts`) and rebind via an injection token.
## 14. Users, roles & permissions (Sprint 25, mock/local)
**Current behavior:** `features/admin/users/` (users, built-in roles, invitations, per-user mock sessions, per-user audit log) against `AdminUsersLocalGateway` — fully synthetic, no backend. Passwordless login itself is real (`AdminAuthService`, Telegram QR, `docs/BACKEND.md` item 1) — only the roles/permissions/invitations/multi-session-listing layer on top is mocked.
**Needed:** a real user/role domain tied to the eventual server-side admin-authorization enforcement in item 1 — role assignment, a real permission catalog, invitation emails, and genuine multi-device session listing (the current `AdminAuthService` only ever knows about the current browser's session).
**Frontend files:** implement `AdminUsersApiGateway` against `AdminUsersGateway` (`services/admin-users-gateway.interface.ts`) and rebind via an injection token.
## Known reliability issues
### Production 502/504 Bad Gateway on refresh / back-navigation

View File

@@ -52,9 +52,11 @@ Notify user: **from Sprint 20 (Categories) once product↔category link + admin
- [x] `docs/ADMIN.md` (new Sprint 24 section), `docs/BACKEND.md` item 13 added
- Commit: `feat(admin): transaction management`
## Sprint 25 — Users & Roles
- [ ] Users/roles/permissions models (local), marketplace vs office admin distinction
- [ ] Invitations, passwordless login (reuse existing Telegram QR pattern), session/device manager, audit trail
## Sprint 25 — Users & Roles ✅ done
- [x] Users/roles/permissions models (local, 4 built-in roles), marketplace vs office admin distinction
- [x] Invitations, passwordless login confirmed already real (Telegram QR, AdminAuthService — linked not reimplemented), session/device manager (mocked), per-user audit trail
- [x] Wired into Dashboard Quick Actions + new `/:lang/backoffice/users` route
- [x] `docs/ADMIN.md` (new Sprint 25 section), `docs/BACKEND.md` item 14 added
- Commit: `feat(admin): users and permissions`
## Sprint 26 — Monitoring

View File

@@ -108,6 +108,10 @@ const coreRoutes: Routes = [
loadComponent: () => import('./features/backoffice/media/media-library-page.component').then(m => m.MediaLibraryPageComponent),
data: { titleKey: 'dashboard.actionMediaLibrary' }
},
{
path: 'users',
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent)
},
{ path: '**', redirectTo: 'dashboard' }
]
},

View File

@@ -18,6 +18,7 @@ const QUICK_ACTIONS: AdminDashboardQuickAction[] = [
{ id: 'transactions', labelKey: 'dashboard.actionTransactions', route: ['backoffice', 'transactions'] },
{ id: 'orders', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] },
{ id: 'media-library', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] },
{ id: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] },
{ id: 'preview-marketplace', labelKey: 'dashboard.actionPreviewMarketplace', route: [''] },
];

View File

@@ -0,0 +1,73 @@
import { Injectable, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { AdminInvitation, AdminRole, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
import { AdminUsersLocalGateway } from '../services/admin-users-local.gateway';
@Injectable({ providedIn: 'root' })
export class AdminUsersFacade {
private readonly gateway = inject(AdminUsersLocalGateway);
readonly users = signal<AdminUser[]>([]);
readonly roles = signal<AdminRole[]>([]);
readonly invitations = signal<AdminInvitation[]>([]);
readonly loading = signal(false);
readonly sessionsTarget = signal<AdminUser | null>(null);
readonly sessions = signal<AdminSession[]>([]);
readonly auditTarget = signal<AdminUser | null>(null);
readonly audit = signal<AdminUserAuditEntry[]>([]);
loadAll(): void {
this.loading.set(true);
this.gateway.loadUsers().pipe(take(1)).subscribe(users => { this.users.set(users); this.loading.set(false); });
this.gateway.loadRoles().pipe(take(1)).subscribe(roles => this.roles.set(roles));
this.gateway.loadInvitations().pipe(take(1)).subscribe(invitations => this.invitations.set(invitations));
}
roleName(roleId: string): string {
return this.roles().find(role => role.id === roleId)?.name ?? roleId;
}
setRole(userId: string, roleId: string): void {
this.gateway.setUserRole(userId, roleId).pipe(take(1)).subscribe({ next: () => this.loadAll() });
}
setStatus(userId: string, status: AdminUserStatus): void {
this.gateway.setUserStatus(userId, status).pipe(take(1)).subscribe({ next: () => this.loadAll() });
}
invite(email: string, roleId: string, scope: AdminUserScope): void {
if (!email.trim()) return;
this.gateway.inviteUser(email.trim(), roleId, scope).pipe(take(1)).subscribe({ next: () => this.loadAll() });
}
revokeInvitation(id: string): void {
this.gateway.revokeInvitation(id).pipe(take(1)).subscribe({ next: () => this.loadAll() });
}
openSessions(user: AdminUser): void {
this.sessionsTarget.set(user);
this.gateway.loadSessions(user.id).pipe(take(1)).subscribe(sessions => this.sessions.set(sessions));
}
closeSessions(): void {
this.sessionsTarget.set(null);
}
revokeSession(sessionId: string): void {
this.gateway.revokeSession(sessionId).pipe(take(1)).subscribe({
next: () => {
const user = this.sessionsTarget();
if (user) this.openSessions(user);
}
});
}
openAudit(user: AdminUser): void {
this.auditTarget.set(user);
this.gateway.loadAudit(user.id).pipe(take(1)).subscribe(entries => this.audit.set(entries));
}
closeAudit(): void {
this.auditTarget.set(null);
}
}

View File

@@ -0,0 +1,47 @@
export type AdminUserScope = 'marketplace' | 'office';
export type AdminUserStatus = 'active' | 'invited' | 'suspended';
export type AdminInvitationStatus = 'pending' | 'accepted' | 'expired' | 'revoked';
export interface AdminRole {
id: string;
name: string;
permissions: string[];
builtIn: boolean;
}
export interface AdminUser {
id: string;
name: string;
telegramUsername: string;
email: string;
scope: AdminUserScope;
roleId: string;
status: AdminUserStatus;
lastLoginAt: string | null;
createdAt: string;
}
export interface AdminInvitation {
id: string;
email: string;
roleId: string;
scope: AdminUserScope;
status: AdminInvitationStatus;
invitedAt: string;
expiresAt: string;
}
export interface AdminSession {
id: string;
userId: string;
device: string;
ip: string;
lastActiveAt: string;
current: boolean;
}
export interface AdminUserAuditEntry {
action: string;
actor: string;
timestamp: string;
}

View File

@@ -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>

View File

@@ -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; } }

View File

@@ -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);
}
}

View File

@@ -0,0 +1,15 @@
import { Observable } from 'rxjs';
import { AdminInvitation, AdminRole, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
export interface AdminUsersGateway {
loadUsers(): Observable<AdminUser[]>;
loadRoles(): Observable<AdminRole[]>;
loadInvitations(): Observable<AdminInvitation[]>;
loadSessions(userId: string): Observable<AdminSession[]>;
loadAudit(userId: string): Observable<AdminUserAuditEntry[]>;
setUserRole(userId: string, roleId: string): Observable<AdminUser | null>;
setUserStatus(userId: string, status: AdminUserStatus): Observable<AdminUser | null>;
inviteUser(email: string, roleId: string, scope: AdminUserScope): Observable<AdminInvitation>;
revokeInvitation(id: string): Observable<void>;
revokeSession(sessionId: string): Observable<void>;
}

View File

@@ -0,0 +1,108 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { AdminInvitation, AdminRole, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
import { AdminUsersGateway } from './admin-users-gateway.interface';
const BUILT_IN_ROLES: AdminRole[] = [
{ id: 'owner', name: 'Owner', permissions: ['*'], builtIn: true },
{ id: 'admin', name: 'Admin', permissions: ['products.manage', 'categories.manage', 'orders.manage', 'media.manage'], builtIn: true },
{ id: 'editor', name: 'Editor', permissions: ['products.manage', 'categories.manage', 'media.manage'], builtIn: true },
{ id: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true },
];
@Injectable({ providedIn: 'root' })
export class AdminUsersLocalGateway implements AdminUsersGateway {
private users: AdminUser[] | null = null;
private roles: AdminRole[] = [...BUILT_IN_ROLES];
private invitations: AdminInvitation[] = [];
private sessions: Record<string, AdminSession[]> = {};
private audit: Record<string, AdminUserAuditEntry[]> = {};
loadUsers(): Observable<AdminUser[]> {
return of(this.ensureUsers()).pipe(delay(50));
}
loadRoles(): Observable<AdminRole[]> {
return of(this.roles).pipe(delay(50));
}
loadInvitations(): Observable<AdminInvitation[]> {
return of(this.invitations).pipe(delay(50));
}
loadSessions(userId: string): Observable<AdminSession[]> {
if (!this.sessions[userId]) {
this.sessions[userId] = [
{ id: `${userId}-s1`, userId, device: 'Chrome on Windows', ip: '10.0.0.1', lastActiveAt: new Date().toISOString(), current: true },
{ id: `${userId}-s2`, userId, device: 'Telegram App on Android', ip: '10.0.0.2', lastActiveAt: new Date(Date.now() - 86400000).toISOString(), current: false },
];
}
return of(this.sessions[userId]).pipe(delay(50));
}
loadAudit(userId: string): Observable<AdminUserAuditEntry[]> {
return of(this.audit[userId] ?? []).pipe(delay(50));
}
setUserRole(userId: string, roleId: string): Observable<AdminUser | null> {
return this.mutateUser(userId, user => ({ ...user }), roleId, undefined);
}
setUserStatus(userId: string, status: AdminUserStatus): Observable<AdminUser | null> {
return this.mutateUser(userId, user => ({ ...user }), undefined, status);
}
inviteUser(email: string, roleId: string, scope: AdminUserScope): Observable<AdminInvitation> {
const invitation: AdminInvitation = {
id: `invite-${Date.now()}`,
email,
roleId,
scope,
status: 'pending',
invitedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
};
this.invitations = [invitation, ...this.invitations];
return of(invitation).pipe(delay(50));
}
revokeInvitation(id: string): Observable<void> {
this.invitations = this.invitations.map(invite => invite.id === id ? { ...invite, status: 'revoked' } : invite);
return of(void 0).pipe(delay(50));
}
revokeSession(sessionId: string): Observable<void> {
for (const userId of Object.keys(this.sessions)) {
this.sessions[userId] = this.sessions[userId].filter(session => session.id !== sessionId);
}
return of(void 0).pipe(delay(50));
}
private mutateUser(userId: string, transform: (user: AdminUser) => AdminUser, roleId: string | undefined, status: AdminUserStatus | undefined): Observable<AdminUser | null> {
const users = this.ensureUsers();
const existing = users.find(user => user.id === userId);
if (!existing) {
return of(null);
}
const updated = transform({ ...existing, roleId: roleId ?? existing.roleId, status: status ?? existing.status });
this.users = users.map(user => user.id === userId ? updated : user);
this.audit[userId] = [
...(this.audit[userId] ?? []),
{ action: roleId ? `Role changed to ${roleId}` : `Status changed to ${status}`, actor: 'admin', timestamp: new Date().toISOString() },
];
return of(updated).pipe(delay(50));
}
private ensureUsers(): AdminUser[] {
if (!this.users) {
this.users = [
{ id: 'user-1', name: 'Karen Sargsyan', telegramUsername: '@karen', email: 'karen@dexar.market', scope: 'marketplace', roleId: 'owner', status: 'active', lastLoginAt: new Date().toISOString(), createdAt: new Date(Date.now() - 90 * 86400000).toISOString() },
{ id: 'user-2', name: 'Anna Petrova', telegramUsername: '@anna', email: 'anna@dexar.market', scope: 'marketplace', roleId: 'admin', status: 'active', lastLoginAt: new Date(Date.now() - 2 * 86400000).toISOString(), createdAt: new Date(Date.now() - 60 * 86400000).toISOString() },
{ id: 'user-3', name: 'Ivan Ivanov', telegramUsername: '@ivan', email: 'ivan@dexar.market', scope: 'office', roleId: 'editor', status: 'active', lastLoginAt: new Date(Date.now() - 5 * 86400000).toISOString(), createdAt: new Date(Date.now() - 30 * 86400000).toISOString() },
{ id: 'user-4', name: 'Mariam Grigoryan', telegramUsername: '@mariam', email: 'mariam@dexar.market', scope: 'office', roleId: 'viewer', status: 'suspended', lastLoginAt: null, createdAt: new Date(Date.now() - 10 * 86400000).toISOString() },
];
}
return this.users;
}
}