7 Commits

Author SHA1 Message Date
sdarbinyan
ebca66dd4c docs: backend TODOs for the four Phase 0 items needing backend work
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Adds §12 to the living backend reference: admin role claim, HttpOnly
session cookie, server-side order pricing, and a real order audit
trail, each with the proposed API/JSON shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:28:47 +04:00
sdarbinyan
c7d8ef1295 fix: add CSP/Permissions-Policy to lovero.store and tenant template
Both server blocks were missing Content-Security-Policy and
Permissions-Policy entirely (dexarmarket.ru already had them). This is
defense-in-depth against XSS, not a fix for the underlying issue: the
customer session cookie is still non-HttpOnly and JS-readable, which
only a backend Set-Cookie change can close (BACKEND-API-REFERENCE.md
§12).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:28:10 +04:00
sdarbinyan
f336420415 feat: add actor to order timeline audit trail
AdminOrderTimelineEntry had no actor field at all - order status
changes and refund requests were unattributed. Added actor: string,
populated from the signed-in admin's displayName (same pattern as
Users/Transactions), surfaced in the order detail timeline UI.

Real backend-issued orders still need a server-side audit trail;
this only covers the local mock gateway pending backend work
(BACKEND-API-REFERENCE.md §12).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:27:10 +04:00
sdarbinyan
9fa3321322 fix: stop sending client-computed price on order creation
createOrder() sent a discount-applied price per line item that the
client computed itself, with no server revalidation. Items now only
carry productId/name/quantity - the backend must price from its own
catalog. createPayment()'s amount (required to actually charge the
payment gateway) is unchanged; backend must revalidate it instead,
tracked in BACKEND-API-REFERENCE.md §12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:24:08 +04:00
sdarbinyan
bac415d003 feat: UI-only permission gate for admin routes (cosmetic pending backend)
adminAuthGuard only checked isAuthenticated() - any signed-in admin
could reach any route. The live Telegram/QR auth (Mechanism A) carries
no role claim, so a real gate needs a backend change (tracked in
BACKEND-API-REFERENCE.md).

Added AdminPermissionsService + requireAdminPermission() guard factory
that derive a permission set locally by matching the Telegram username
against the mock Users domain's roleId - the same local-only stand-in
already used for the rest of that domain. Wired onto /backoffice/users
requiring 'users.manage'. Explicitly cosmetic: backend must
independently authorize every mutation regardless of what this guard
decides.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:22:01 +04:00
sdarbinyan
0646d587eb fix: record real admin identity in Users/Transactions audit trail
audit entries hardcoded actor: 'admin' regardless of who performed the
action. Both local gateways now pull the signed-in admin's displayName
from AdminAuthService, falling back to 'admin' only when unavailable.

Moderation's actor field is a role classifier ('admin' | 'customer'),
not an identity string, and is left unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:15:59 +04:00
sdarbinyan
c3b5820ac9 fix: category slug-uniqueness check fails closed on API error
isSlugTaken previously caught network/API errors and returned false,
letting the save proceed as if the slug were free. Now the error
propagates and blocks save via a distinct slugCheckError state,
surfaced in the category form with its own hint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:15:50 +04:00
24 changed files with 198 additions and 23 deletions

View File

@@ -454,3 +454,61 @@ Per-domain migration pattern for the six no-seam admin domains (Orders, Products
- **`ADMIN_DASHBOARD_METRICS_GATEWAY` and `USER_EXPERIENCE_REPOSITORY` token factories return the mock/local class in every mode** — a real implementation must be written *and* explicitly bound; the seam existing does not mean a real backend is one line away.
For open product/business decisions this document deliberately does not resolve (rate limiting posture, refresh-token reuse detection, tenant-scoped auth, API versioning scheme, etc.), see [GAPS-AND-IMPROVEMENTS.md](GAPS-AND-IMPROVEMENTS.md).
---
## 12. Frontend-blocked TODOs — needs backend
Raised during the Phase 0 security hardening pass (see the sprint plan). Each of these has a client-side mitigation already in place where one exists, but none of them close the actual gap without a backend change.
### 12.1 Admin role claim on the session
**Gap:** `adminAuthGuard` (Mechanism A, Telegram/QR) only checks "is there an active session" — the session API has no concept of admin role at all, so the frontend cannot enforce permissions server-authoritatively. Client mitigation: `AdminPermissionsService` derives a cosmetic permission set by matching the Telegram username against the mock Users domain locally — this is UI-only and trivially bypassed by calling the API directly.
**Ask:** either (a) add a `role` field to the existing `GET /users/sessions/{id}` response when the session belongs to a registered admin, or (b) finish Mechanism B (Ed25519 challenge/response, already wired client-side, `/challenge` and `/verify` currently 404) so the JWT `role` claim becomes real. Whichever is chosen, every admin-mutating endpoint must independently authorize the request — a role claim on the session is necessary but not sufficient.
Proposed minimal shape for option (a), added to the existing poll response (§2a):
```json
{
"webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11",
"status": "active",
"user": { "id": 8823771, "username": "buyer_ivan", "firstName": "Ivan", "lastName": "P" },
"expiresAt": "2026-07-26T05:00:00Z",
"adminRole": "admin"
}
```
`adminRole` absent/null → treat as non-admin regardless of what `/backoffice/**` UI is reachable client-side.
### 12.2 HttpOnly session cookie
**Gap:** the customer session cookie (`webSessionID`, `services/auth.service.ts`) is set via `document.cookie` from the frontend, which means it cannot be `HttpOnly` — only a `Set-Cookie` response header from the backend can set that flag, and JS-set cookies are readable by any injected script. Client mitigation: CSP hardened on all three nginx tenant blocks (was missing entirely on two of three) as defense-in-depth, but this does not close the gap.
**Ask:** `POST /users/sessions` and `GET /users/sessions/{id}` issue the session id via `Set-Cookie: webSessionID=…; HttpOnly; Secure; SameSite=Lax; Max-Age=…` instead of (or in addition to, during migration) returning it in the JSON body. Once that ships, the frontend stops writing `document.cookie` itself and relies on the browser sending the cookie automatically; `credentials: 'include'` needs enabling on the relevant HTTP calls.
### 12.3 Server-side order pricing
**Gap:** `POST` order creation (§7) let the client send a computed, discount-applied `price` per line item with no server-side revalidation. Client fix already shipped: `CreateOrderRequest.items` no longer sends `price` — only `{ productId, name, quantity }`.
**Ask:** the order-creation endpoint must price every line item itself by looking up `productId` in its own catalog (applying whatever discount/promo logic is authoritative server-side), and reject/[400] if the resulting total doesn't reconcile with what the client displayed (or just recompute and use the server total as-of-record, ignoring any client total entirely). Example of the request shape now sent:
```json
{
"items": [{ "productId": "prod_1042", "name": "Sample Product", "quantity": 2 }],
"customer": { "name": "Ivan P", "email": "ivan@example.com", "phone": "79991234567" },
"payment": { "method": "card", "currency": "RUB" }
}
```
Separately, `createCartPayment()` (payment-gateway charge creation) still sends a client-computed `amount` — that field can't simply be dropped, since it's what tells the payment provider how much to charge. That endpoint must independently revalidate `amount` against its own pricing before creating the charge, and reject on mismatch.
### 12.4 Real order audit trail
**Gap:** `AdminOrder` had no actor/audit field at all. Client fix already shipped: `AdminOrderTimelineEntry.actor` now exists and is populated from the signed-in admin's display name in the local mock gateway — but that's client-only bookkeeping with no server-side record.
**Ask:** when admin Orders CRUD gets a real backend (§10, step 6), every mutating endpoint (`updateStatus`, `requestRefund`, `addNote`, etc.) should record who performed the action server-side (from the authenticated session/JWT, not a client-supplied field) and return it in the order/timeline response:
```json
{
"timeline": [
{ "status": "processing", "timestamp": "2026-08-13T10:15:00Z", "eventKey": "statusChanged", "actor": "anna@dexar.market" }
]
}
```
`actor` must be derived server-side from the authenticated caller, never trusted from the request body.

View File

@@ -93,6 +93,8 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
}
# Template for onboarding a new marketplace tenant.
@@ -178,4 +180,6 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
}

View File

@@ -1,7 +1,7 @@
import { Routes } from '@angular/router';
import { languageGuard } from './guards/language.guard';
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
import { adminAuthGuard } from './core/admin-auth/admin-auth.guard';
import { adminAuthGuard, requireAdminPermission } from './core/admin-auth/admin-auth.guard';
import { authRoutes } from './core/auth/auth.routes';
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
@@ -229,6 +229,7 @@ const coreRoutes: Routes = [
},
{
path: 'users',
canActivate: [requireAdminPermission('users.manage')],
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent),
data: {
titleKey: 'adminShell.pages.users.title',

View File

@@ -1,6 +1,7 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { AdminAuthService } from './admin-auth.service';
import { AdminPermissionsService } from './admin-permissions.service';
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
export const adminAuthGuard: CanActivateFn = () => {
@@ -13,3 +14,22 @@ export const adminAuthGuard: CanActivateFn = () => {
adminAuth.requestLogin();
return false;
};
/**
* UI-only gate for a specific permission, on top of adminAuthGuard's
* authentication check. See AdminPermissionsService for why this is
* cosmetic until the backend ships real admin-role enforcement.
*/
export function requireAdminPermission(permission: string): CanActivateFn {
return () => {
const adminAuth = inject(AdminAuthService);
const permissions = inject(AdminPermissionsService);
if (!adminAuth.isAuthenticated()) {
adminAuth.requestLogin();
return false;
}
return permissions.has(permission);
};
}

View File

@@ -0,0 +1,41 @@
import { Injectable, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { AdminAuthService } from './admin-auth.service';
import { AdminUsersLocalGateway } from '../../features/admin/users/services/admin-users-local.gateway';
/**
* UI-only permission gate for the live Telegram/QR admin auth (Mechanism A),
* which carries no role claim of its own (see admin-auth.service.ts). This
* derives a permission set by matching the signed-in Telegram username
* against the mock Users domain's roleId - the same local-only stand-in the
* rest of the Users admin domain already uses (see BACKEND-API-REFERENCE.md
* §8 "Users - MOCK-ONLY, no seam"). It is cosmetic until a real backend
* ships either an admin-role claim on the session, or Mechanism B
* (Ed25519 JWT + PermissionService) goes live.
*/
@Injectable({ providedIn: 'root' })
export class AdminPermissionsService {
private readonly adminAuth = inject(AdminAuthService);
private readonly usersGateway = inject(AdminUsersLocalGateway);
private readonly users = toSignal(this.usersGateway.loadUsers(), { initialValue: [] });
private readonly roles = toSignal(this.usersGateway.loadRoles(), { initialValue: [] });
readonly permissions = computed<readonly string[]>(() => {
const session = this.adminAuth.session();
if (!session) {
return [];
}
const username = session.username?.replace(/^@/, '');
const matchedUser = this.users().find(user => user.telegramUsername.replace(/^@/, '') === username);
if (!matchedUser) {
return [];
}
return this.roles().find(role => role.id === matchedUser.roleId)?.permissions ?? [];
});
has(permission: string): boolean {
const permissions = this.permissions();
return permissions.includes('*') || permissions.includes(permission);
}
}

View File

@@ -29,7 +29,7 @@
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
<app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" />
</app-form-field>
<app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [hint]="'adminProducts.slugHint' | translate" [error]="slugTaken ? ('adminCategories.slugTaken' | translate) : null">
<app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [hint]="'adminProducts.slugHint' | translate" [error]="slugCheckError ? ('adminCategories.slugCheckError' | translate) : (slugTaken ? ('adminCategories.slugTaken' | translate) : null)">
<app-input [ngModel]="category.slug" (ngModelChange)="updateField('slug', $event)" />
</app-form-field>
<label><span>{{ 'adminCategories.parent' | translate }}</span>

View File

@@ -41,6 +41,7 @@ export class AdminCategoryFormComponent {
@Input() breadcrumb: string[] = [];
@Input() children: AdminCategory[] = [];
@Input() slugTaken = false;
@Input() slugCheckError = false;
@Input() locales: string[] = ['en'];
@Input() mode: 'create' | 'edit' = 'create';
@Input() health!: AdminCategoryHealth;

View File

@@ -1,5 +1,5 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { catchError, of, take } from 'rxjs';
import { AdminCategory, AdminCategoryEditorMode, AdminCategoryListFilters } from '../models/admin-category.model';
import { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory';
import { ADMIN_CATEGORIES_GATEWAY } from '../services/admin-categories-gateway.token';
@@ -135,6 +135,7 @@ export class AdminCategoriesFacade {
readonly editorMode = signal<AdminCategoryEditorMode>('create');
readonly dirty = signal(false);
readonly slugTaken = signal(false);
readonly slugCheckError = signal(false);
private savedSnapshot: string | null = null;
private draftStorageKey: string | null = null;
@@ -305,15 +306,26 @@ export class AdminCategoriesFacade {
const draft = this.draft();
if (!draft || !draft.slug) {
this.slugTaken.set(false);
this.slugCheckError.set(false);
return;
}
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(take(1))
.subscribe(taken => this.slugTaken.set(taken));
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(
take(1),
catchError(() => {
this.slugCheckError.set(true);
return of(true);
})
).subscribe(taken => {
this.slugTaken.set(taken);
if (!taken) {
this.slugCheckError.set(false);
}
});
}
saveDraft(publish: boolean): void {
const draft = this.draft();
if (!draft || this.slugTaken()) return;
if (!draft || this.slugTaken() || this.slugCheckError()) return;
const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() };
const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave);

View File

@@ -9,7 +9,7 @@ import { LanguageService } from '../../../../services/language.service';
selector: 'app-admin-category-editor-page',
standalone: true,
imports: [AdminCategoryFormComponent, TranslatePipe],
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [slugCheckError]="facade.slugCheckError()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
changeDetection: ChangeDetectionStrategy.OnPush
})

View File

@@ -59,8 +59,7 @@ export class AdminCategoriesApiGateway implements AdminCategoriesGateway {
params = params.set('excludingId', excludingId);
}
return this.http.get<{ taken: boolean }>(`${this.baseUrl}/slug-taken`, { params }).pipe(
map(response => response.taken),
catchError(() => of(false))
map(response => response.taken)
);
}
}

View File

@@ -13,6 +13,9 @@
}
</div>
<p class="order-timeline__note">{{ entry.note }}</p>
@if (entry.actor) {
<span class="order-timeline__actor">{{ entry.actor }}</span>
}
</div>
</li>
}

View File

@@ -55,3 +55,10 @@
font-size: var(--font-size-sm, 0.8125rem);
color: var(--text-secondary);
}
.order-timeline__actor {
display: block;
margin-top: 2px;
font-size: var(--font-size-xs, 0.75rem);
color: var(--text-light);
}

View File

@@ -8,6 +8,7 @@ export interface OrderTimelineEntry {
/** Pre-resolved, already-translated display text for this event. */
note: string;
orderNumber?: string;
actor?: string;
}
/** Reusable vertical event timeline - used on both the order detail page and the customer activity tab. */

View File

@@ -33,6 +33,7 @@ export interface AdminOrderTimelineEntry {
status: AdminOrderStatus;
timestamp: string;
eventKey: AdminOrderTimelineEventKey;
actor: string;
}
export interface AdminOrder {

View File

@@ -54,6 +54,7 @@ export class AdminOrderDetailPageComponent {
note: this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, {
status: this.translate.t('adminOrders.status.' + entry.status),
}),
actor: entry.actor,
}));
});

View File

@@ -1,8 +1,9 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model';
import { AdminOrdersGateway } from './admin-orders-gateway.interface';
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
const STATUSES: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
const CUSTOMER_NAMES = ['Anna Petrova', 'Karen Sargsyan', 'Ivan Ivanov', 'Mariam Grigoryan', 'Sergey Volkov', 'Lilit Hakobyan'];
@@ -10,8 +11,13 @@ const SEED_COUNT = 24;
@Injectable({ providedIn: 'root' })
export class AdminOrdersLocalGateway implements AdminOrdersGateway {
private readonly adminAuth = inject(AdminAuthService);
private cache: AdminOrder[] | null = null;
private get currentActor(): string {
return this.adminAuth.displayName() ?? 'admin';
}
loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult> {
const all = this.ensureData();
const filtered = all
@@ -36,7 +42,7 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
...order,
status,
updatedAt: new Date().toISOString(),
timeline: [...order.timeline, { status, timestamp: new Date().toISOString(), eventKey: 'statusChanged' as const }],
timeline: [...order.timeline, { status, timestamp: new Date().toISOString(), eventKey: 'statusChanged' as const, actor: this.currentActor }],
}));
}
@@ -45,7 +51,7 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
...order,
payment: { ...order.payment, status: 'refund_requested' },
updatedAt: new Date().toISOString(),
timeline: [...order.timeline, { status: order.status, timestamp: new Date().toISOString(), eventKey: 'refundRequested' as const }],
timeline: [...order.timeline, { status: order.status, timestamp: new Date().toISOString(), eventKey: 'refundRequested' as const, actor: this.currentActor }],
}));
}
@@ -122,8 +128,8 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
notes: '',
internalNotes: '',
timeline: [
{ status: 'pending', timestamp: createdAt, eventKey: 'created' },
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const }] : []),
{ status: 'pending', timestamp: createdAt, eventKey: 'created', actor: 'system' },
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const, actor: 'system' }] : []),
],
archived: false,
createdAt,

View File

@@ -1,18 +1,24 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
const METHODS = ['card', 'qr', 'cash_on_delivery'];
@Injectable({ providedIn: 'root' })
export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
private readonly adminAuth = inject(AdminAuthService);
private cache: AdminTransaction[] | null = null;
constructor(private readonly ordersGateway: AdminOrdersLocalGateway) {}
private get currentActor(): string {
return this.adminAuth.displayName() ?? 'admin';
}
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult> {
return new Observable<AdminTransactionsListResult>(subscriber => {
this.ensureData().then(() => {
@@ -38,7 +44,7 @@ export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
...tx,
status: 'retried',
updatedAt: new Date().toISOString(),
audit: [...tx.audit, { action: 'Retried failed transaction', actor: 'admin', timestamp: new Date().toISOString() }],
audit: [...tx.audit, { action: 'Retried failed transaction', actor: this.currentActor, timestamp: new Date().toISOString() }],
}));
}
@@ -47,7 +53,7 @@ export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
...tx,
fraudFlag: flagged,
updatedAt: new Date().toISOString(),
audit: [...tx.audit, { action: flagged ? 'Flagged as fraud' : 'Fraud flag cleared', actor: 'admin', timestamp: new Date().toISOString() }],
audit: [...tx.audit, { action: flagged ? 'Flagged as fraud' : 'Fraud flag cleared', actor: this.currentActor, timestamp: new Date().toISOString() }],
}));
}

View File

@@ -1,18 +1,20 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } 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';
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
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: 'admin', name: 'Admin', permissions: ['products.manage', 'categories.manage', 'orders.manage', 'media.manage', 'users.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 readonly adminAuth = inject(AdminAuthService);
private users: AdminUser[] | null = null;
private roles: AdminRole[] = [...BUILT_IN_ROLES];
private invitations: AdminInvitation[] = [];
@@ -90,12 +92,16 @@ export class AdminUsersLocalGateway implements AdminUsersGateway {
this.audit[userId] = [
...(this.audit[userId] ?? []),
roleId
? { eventKey: 'roleChanged', roleId, actor: 'admin', timestamp: new Date().toISOString() }
: { eventKey: 'statusChanged', status, actor: 'admin', timestamp: new Date().toISOString() },
? { eventKey: 'roleChanged', roleId, actor: this.currentActor, timestamp: new Date().toISOString() }
: { eventKey: 'statusChanged', status, actor: this.currentActor, timestamp: new Date().toISOString() },
];
return of(updated).pipe(delay(50));
}
private get currentActor(): string {
return this.adminAuth.displayName() ?? 'admin';
}
private ensureUsers(): AdminUser[] {
if (!this.users) {
this.users = [

View File

@@ -1533,6 +1533,7 @@ export const en: Translations = {
title: 'Title',
slug: 'URL slug',
slugTaken: 'This slug is already used by another category.',
slugCheckError: 'Could not verify this slug is unique. Try again before saving.',
parent: 'Parent category',
noParent: 'No parent (top level)',
icon: 'Icon',

View File

@@ -1528,6 +1528,7 @@ export const hy: Translations = {
title: 'Անուն',
slug: 'URL հասցե',
slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։',
slugCheckError: 'Չհաջողվեց ստուգել հասցեի եզակիությունը։ Փորձեք կրկին՝ նախքան պահպանելը։',
parent: 'Ծնող կատեգորիա',
noParent: 'Առանց ծնողի (վերին մակարդակ)',
icon: 'Պատկերակ',

View File

@@ -1528,6 +1528,7 @@ export const ru: Translations = {
title: 'Название',
slug: 'URL-адрес',
slugTaken: 'Этот адрес уже используется другой категорией.',
slugCheckError: 'Не удалось проверить уникальность адреса. Повторите попытку перед сохранением.',
parent: 'Родительская категория',
noParent: 'Без родителя (верхний уровень)',
icon: 'Иконка',

View File

@@ -1541,6 +1541,7 @@ export interface Translations {
title: string;
slug: string;
slugTaken: string;
slugCheckError: string;
parent: string;
noParent: string;
icon: string;

View File

@@ -424,7 +424,6 @@ export class CartComponent implements OnDestroy {
productId: String(item.itemID),
name: item.name,
quantity: item.quantity,
price: item.discount > 0 ? item.price * (1 - item.discount / 100) : item.price,
})),
customer: {
name: this.getTelegramUsername() || this.i18n.t('common.guest'),

View File

@@ -54,7 +54,12 @@ export interface CartPaymentRequest {
}
export interface CreateOrderRequest {
items: Array<{ productId: string; name: string; quantity: number; price: number }>;
/**
* No `price` field: the backend must price each line item from its own
* catalog by `productId`, never trust a client-supplied amount.
* See BACKEND-API-REFERENCE.md §12.
*/
items: Array<{ productId: string; name: string; quantity: number }>;
customer: { name: string; email: string; phone: string };
payment?: { method: string; currency: string };
shipping?: { address: string; method: string; trackingNumber: string };