From 17adc9e9fdf351aa497a736b241f8ec2df8a54e3 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Wed, 15 Jul 2026 11:05:21 +0400 Subject: [PATCH] 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 --- docs/ADMIN.md | 34 ++++++ docs/BACKEND.md | 8 ++ docs/SPRINT-PLAN.md | 8 +- src/app/app.routes.ts | 4 + .../facade/admin-dashboard.facade.ts | 1 + .../admin/users/facade/admin-users.facade.ts | 73 ++++++++++++ .../admin/users/models/admin-user.model.ts | 47 ++++++++ .../pages/admin-users-page.component.html | 106 +++++++++++++++++ .../pages/admin-users-page.component.scss | 8 ++ .../users/pages/admin-users-page.component.ts | 46 ++++++++ .../services/admin-users-gateway.interface.ts | 15 +++ .../services/admin-users-local.gateway.ts | 108 ++++++++++++++++++ 12 files changed, 455 insertions(+), 3 deletions(-) create mode 100644 src/app/features/admin/users/facade/admin-users.facade.ts create mode 100644 src/app/features/admin/users/models/admin-user.model.ts create mode 100644 src/app/features/admin/users/pages/admin-users-page.component.html create mode 100644 src/app/features/admin/users/pages/admin-users-page.component.scss create mode 100644 src/app/features/admin/users/pages/admin-users-page.component.ts create mode 100644 src/app/features/admin/users/services/admin-users-gateway.interface.ts create mode 100644 src/app/features/admin/users/services/admin-users-local.gateway.ts diff --git a/docs/ADMIN.md b/docs/ADMIN.md index e33adbe..c25c1aa 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -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 ` + @for (role of facade.roles(); track role.id) { + + } + + + {{ ('adminUsers.statusValue.' + user.status) | translate }} + {{ user.lastLoginAt ? (user.lastLoginAt | date:'short') : '—' }} + + {{ (user.status === 'suspended' ? 'adminUsers.reactivate' : 'adminUsers.suspend') | translate }} + {{ 'adminUsers.sessions' | translate }} + {{ 'adminTransactions.audit' | translate }} + + + } + + + + +
+

{{ 'adminUsers.invite' | translate }}

+

{{ 'adminUsers.passwordlessHint' | translate }}

+
+ + + + {{ 'adminUsers.sendInvite' | translate }} +
+ + @if (facade.invitations().length > 0) { + + + + {{ 'adminUsers.email' | translate }} + {{ 'adminUsers.role' | translate }} + {{ 'backoffice.status' | translate }} + {{ 'adminProducts.actions' | translate }} + + + + @for (invite of facade.invitations(); track invite.id) { + + {{ invite.email }} + {{ facade.roleName(invite.roleId) }} + {{ ('adminUsers.invitationStatus.' + invite.status) | translate }} + + @if (invite.status === 'pending') { + {{ 'adminUsers.revoke' | translate }} + } + + + } + + + } +
+ +
+

{{ 'adminUsers.roles' | translate }}

+ @for (role of facade.roles(); track role.id) { +

{{ role.name }} — {{ role.permissions.join(', ') }}

+ } +
+ + + @for (session of facade.sessions(); track session.id) { +

{{ session.device }} — {{ session.ip }} — {{ session.lastActiveAt | date:'short' }} + @if (session.current) { {{ 'adminUsers.currentSession' | translate }} } + @else { {{ 'adminUsers.revoke' | translate }} } +

+ } +
+ + + @for (entry of facade.audit(); track $index) { +

{{ entry.timestamp | date:'short' }} — {{ entry.actor }} — {{ entry.action }}

+ } +
+ diff --git a/src/app/features/admin/users/pages/admin-users-page.component.scss b/src/app/features/admin/users/pages/admin-users-page.component.scss new file mode 100644 index 0000000..72ab668 --- /dev/null +++ b/src/app/features/admin/users/pages/admin-users-page.component.scss @@ -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; } } diff --git a/src/app/features/admin/users/pages/admin-users-page.component.ts b/src/app/features/admin/users/pages/admin-users-page.component.ts new file mode 100644 index 0000000..4459f22 --- /dev/null +++ b/src/app/features/admin/users/pages/admin-users-page.component.ts @@ -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('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); + } +} diff --git a/src/app/features/admin/users/services/admin-users-gateway.interface.ts b/src/app/features/admin/users/services/admin-users-gateway.interface.ts new file mode 100644 index 0000000..bfac666 --- /dev/null +++ b/src/app/features/admin/users/services/admin-users-gateway.interface.ts @@ -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; + loadRoles(): Observable; + loadInvitations(): Observable; + loadSessions(userId: string): Observable; + loadAudit(userId: string): Observable; + setUserRole(userId: string, roleId: string): Observable; + setUserStatus(userId: string, status: AdminUserStatus): Observable; + inviteUser(email: string, roleId: string, scope: AdminUserScope): Observable; + revokeInvitation(id: string): Observable; + revokeSession(sessionId: string): Observable; +} diff --git a/src/app/features/admin/users/services/admin-users-local.gateway.ts b/src/app/features/admin/users/services/admin-users-local.gateway.ts new file mode 100644 index 0000000..4163723 --- /dev/null +++ b/src/app/features/admin/users/services/admin-users-local.gateway.ts @@ -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 = {}; + private audit: Record = {}; + + loadUsers(): Observable { + return of(this.ensureUsers()).pipe(delay(50)); + } + + loadRoles(): Observable { + return of(this.roles).pipe(delay(50)); + } + + loadInvitations(): Observable { + return of(this.invitations).pipe(delay(50)); + } + + loadSessions(userId: string): Observable { + 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 { + return of(this.audit[userId] ?? []).pipe(delay(50)); + } + + setUserRole(userId: string, roleId: string): Observable { + return this.mutateUser(userId, user => ({ ...user }), roleId, undefined); + } + + setUserStatus(userId: string, status: AdminUserStatus): Observable { + return this.mutateUser(userId, user => ({ ...user }), undefined, status); + } + + inviteUser(email: string, roleId: string, scope: AdminUserScope): Observable { + 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 { + this.invitations = this.invitations.map(invite => invite.id === id ? { ...invite, status: 'revoked' } : invite); + return of(void 0).pipe(delay(50)); + } + + revokeSession(sessionId: string): Observable { + 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 { + 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; + } +}