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>
2026-07-15 11:05:21 +04:00
|
|
|
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] ?? []),
|
fix(backoffice): wording quality pass across orders, moderation, transactions, users, customers
- Replaced hardcoded English audit/timeline text (order status changes,
review moderation events, user role/status changes) with proper
adminXxx.timelineEvent.*/adminUsers.audit.* i18n keys, so Recent
Activity/Timeline/Audit panels no longer mix English into ru/hy UI.
- Translated raw internal codes rendered directly to users: transaction
payment method ('card'/'qr'/'cash_on_delivery' -> adminTransactions.methodValue.*)
and user roles/permissions ('products.manage' etc -> adminUsers.roleValue.*/
adminUsers.permission.*), replacing developer-facing enum leakage with
real copy.
- Fixed wrong-noun list-footer counts: Orders/Transactions/Moderation
list pages all reused adminProducts.items ("N товаров"/"N products")
regardless of what was actually listed; each now has its own itemsCount
key ("N заказов", "N транзакций", "N отзывов").
- Fixed customer detail page's "Back" button reusing adminOrders.back
("Back to orders") instead of a customers-specific label.
- Added translation keys to en/ru/hy + translations.ts interface for all
of the above.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:20:37 +04:00
|
|
|
roleId
|
|
|
|
|
? { eventKey: 'roleChanged', roleId, actor: 'admin', timestamp: new Date().toISOString() }
|
|
|
|
|
: { eventKey: 'statusChanged', status, actor: 'admin', timestamp: new Date().toISOString() },
|
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>
2026-07-15 11:05:21 +04:00
|
|
|
];
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|