Compare commits
7 Commits
e6d64abd56
...
ebca66dd4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebca66dd4c | ||
|
|
c7d8ef1295 | ||
|
|
f336420415 | ||
|
|
9fa3321322 | ||
|
|
bac415d003 | ||
|
|
0646d587eb | ||
|
|
c3b5820ac9 |
@@ -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.
|
- **`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).
|
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.
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" 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.
|
# Template for onboarding a new marketplace tenant.
|
||||||
@@ -178,4 +180,6 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { languageGuard } from './guards/language.guard';
|
import { languageGuard } from './guards/language.guard';
|
||||||
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.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 { authRoutes } from './core/auth/auth.routes';
|
||||||
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
|
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
|
||||||
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
|
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
|
||||||
@@ -229,6 +229,7 @@ const coreRoutes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'users',
|
path: 'users',
|
||||||
|
canActivate: [requireAdminPermission('users.manage')],
|
||||||
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent),
|
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent),
|
||||||
data: {
|
data: {
|
||||||
titleKey: 'adminShell.pages.users.title',
|
titleKey: 'adminShell.pages.users.title',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { CanActivateFn } from '@angular/router';
|
import { CanActivateFn } from '@angular/router';
|
||||||
import { AdminAuthService } from './admin-auth.service';
|
import { AdminAuthService } from './admin-auth.service';
|
||||||
|
import { AdminPermissionsService } from './admin-permissions.service';
|
||||||
|
|
||||||
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
|
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
|
||||||
export const adminAuthGuard: CanActivateFn = () => {
|
export const adminAuthGuard: CanActivateFn = () => {
|
||||||
@@ -13,3 +14,22 @@ export const adminAuthGuard: CanActivateFn = () => {
|
|||||||
adminAuth.requestLogin();
|
adminAuth.requestLogin();
|
||||||
return false;
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
41
src/app/core/admin-auth/admin-permissions.service.ts
Normal file
41
src/app/core/admin-auth/admin-permissions.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
|
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
|
||||||
<app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" />
|
<app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" />
|
||||||
</app-form-field>
|
</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-input [ngModel]="category.slug" (ngModelChange)="updateField('slug', $event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
<label><span>{{ 'adminCategories.parent' | translate }}</span>
|
<label><span>{{ 'adminCategories.parent' | translate }}</span>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export class AdminCategoryFormComponent {
|
|||||||
@Input() breadcrumb: string[] = [];
|
@Input() breadcrumb: string[] = [];
|
||||||
@Input() children: AdminCategory[] = [];
|
@Input() children: AdminCategory[] = [];
|
||||||
@Input() slugTaken = false;
|
@Input() slugTaken = false;
|
||||||
|
@Input() slugCheckError = false;
|
||||||
@Input() locales: string[] = ['en'];
|
@Input() locales: string[] = ['en'];
|
||||||
@Input() mode: 'create' | 'edit' = 'create';
|
@Input() mode: 'create' | 'edit' = 'create';
|
||||||
@Input() health!: AdminCategoryHealth;
|
@Input() health!: AdminCategoryHealth;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
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 { AdminCategory, AdminCategoryEditorMode, AdminCategoryListFilters } from '../models/admin-category.model';
|
||||||
import { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory';
|
import { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory';
|
||||||
import { ADMIN_CATEGORIES_GATEWAY } from '../services/admin-categories-gateway.token';
|
import { ADMIN_CATEGORIES_GATEWAY } from '../services/admin-categories-gateway.token';
|
||||||
@@ -135,6 +135,7 @@ export class AdminCategoriesFacade {
|
|||||||
readonly editorMode = signal<AdminCategoryEditorMode>('create');
|
readonly editorMode = signal<AdminCategoryEditorMode>('create');
|
||||||
readonly dirty = signal(false);
|
readonly dirty = signal(false);
|
||||||
readonly slugTaken = signal(false);
|
readonly slugTaken = signal(false);
|
||||||
|
readonly slugCheckError = signal(false);
|
||||||
private savedSnapshot: string | null = null;
|
private savedSnapshot: string | null = null;
|
||||||
private draftStorageKey: string | null = null;
|
private draftStorageKey: string | null = null;
|
||||||
|
|
||||||
@@ -305,15 +306,26 @@ export class AdminCategoriesFacade {
|
|||||||
const draft = this.draft();
|
const draft = this.draft();
|
||||||
if (!draft || !draft.slug) {
|
if (!draft || !draft.slug) {
|
||||||
this.slugTaken.set(false);
|
this.slugTaken.set(false);
|
||||||
|
this.slugCheckError.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(take(1))
|
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(
|
||||||
.subscribe(taken => this.slugTaken.set(taken));
|
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 {
|
saveDraft(publish: boolean): void {
|
||||||
const draft = this.draft();
|
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 toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() };
|
||||||
const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave);
|
const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { LanguageService } from '../../../../services/language.service';
|
|||||||
selector: 'app-admin-category-editor-page',
|
selector: 'app-admin-category-editor-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [AdminCategoryFormComponent, TranslatePipe],
|
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; }`],
|
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
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ export class AdminCategoriesApiGateway implements AdminCategoriesGateway {
|
|||||||
params = params.set('excludingId', excludingId);
|
params = params.set('excludingId', excludingId);
|
||||||
}
|
}
|
||||||
return this.http.get<{ taken: boolean }>(`${this.baseUrl}/slug-taken`, { params }).pipe(
|
return this.http.get<{ taken: boolean }>(`${this.baseUrl}/slug-taken`, { params }).pipe(
|
||||||
map(response => response.taken),
|
map(response => response.taken)
|
||||||
catchError(() => of(false))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="order-timeline__note">{{ entry.note }}</p>
|
<p class="order-timeline__note">{{ entry.note }}</p>
|
||||||
|
@if (entry.actor) {
|
||||||
|
<span class="order-timeline__actor">{{ entry.actor }}</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,3 +55,10 @@
|
|||||||
font-size: var(--font-size-sm, 0.8125rem);
|
font-size: var(--font-size-sm, 0.8125rem);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-timeline__actor {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface OrderTimelineEntry {
|
|||||||
/** Pre-resolved, already-translated display text for this event. */
|
/** Pre-resolved, already-translated display text for this event. */
|
||||||
note: string;
|
note: string;
|
||||||
orderNumber?: string;
|
orderNumber?: string;
|
||||||
|
actor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reusable vertical event timeline - used on both the order detail page and the customer activity tab. */
|
/** Reusable vertical event timeline - used on both the order detail page and the customer activity tab. */
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface AdminOrderTimelineEntry {
|
|||||||
status: AdminOrderStatus;
|
status: AdminOrderStatus;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
eventKey: AdminOrderTimelineEventKey;
|
eventKey: AdminOrderTimelineEventKey;
|
||||||
|
actor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminOrder {
|
export interface AdminOrder {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export class AdminOrderDetailPageComponent {
|
|||||||
note: this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, {
|
note: this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, {
|
||||||
status: this.translate.t('adminOrders.status.' + entry.status),
|
status: this.translate.t('adminOrders.status.' + entry.status),
|
||||||
}),
|
}),
|
||||||
|
actor: entry.actor,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { delay } from 'rxjs/operators';
|
import { delay } from 'rxjs/operators';
|
||||||
import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model';
|
import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model';
|
||||||
import { AdminOrdersGateway } from './admin-orders-gateway.interface';
|
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 STATUSES: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
|
||||||
const CUSTOMER_NAMES = ['Anna Petrova', 'Karen Sargsyan', 'Ivan Ivanov', 'Mariam Grigoryan', 'Sergey Volkov', 'Lilit Hakobyan'];
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
||||||
|
private readonly adminAuth = inject(AdminAuthService);
|
||||||
private cache: AdminOrder[] | null = null;
|
private cache: AdminOrder[] | null = null;
|
||||||
|
|
||||||
|
private get currentActor(): string {
|
||||||
|
return this.adminAuth.displayName() ?? 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult> {
|
loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult> {
|
||||||
const all = this.ensureData();
|
const all = this.ensureData();
|
||||||
const filtered = all
|
const filtered = all
|
||||||
@@ -36,7 +42,7 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
|||||||
...order,
|
...order,
|
||||||
status,
|
status,
|
||||||
updatedAt: new Date().toISOString(),
|
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,
|
...order,
|
||||||
payment: { ...order.payment, status: 'refund_requested' },
|
payment: { ...order.payment, status: 'refund_requested' },
|
||||||
updatedAt: new Date().toISOString(),
|
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: '',
|
notes: '',
|
||||||
internalNotes: '',
|
internalNotes: '',
|
||||||
timeline: [
|
timeline: [
|
||||||
{ status: 'pending', timestamp: createdAt, eventKey: 'created' },
|
{ status: 'pending', timestamp: createdAt, eventKey: 'created', actor: 'system' },
|
||||||
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const }] : []),
|
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const, actor: 'system' }] : []),
|
||||||
],
|
],
|
||||||
archived: false,
|
archived: false,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { delay } from 'rxjs/operators';
|
import { delay } from 'rxjs/operators';
|
||||||
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
|
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
|
||||||
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
|
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
|
||||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
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'];
|
const METHODS = ['card', 'qr', 'cash_on_delivery'];
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
|
export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
|
||||||
|
private readonly adminAuth = inject(AdminAuthService);
|
||||||
private cache: AdminTransaction[] | null = null;
|
private cache: AdminTransaction[] | null = null;
|
||||||
|
|
||||||
constructor(private readonly ordersGateway: AdminOrdersLocalGateway) {}
|
constructor(private readonly ordersGateway: AdminOrdersLocalGateway) {}
|
||||||
|
|
||||||
|
private get currentActor(): string {
|
||||||
|
return this.adminAuth.displayName() ?? 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult> {
|
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult> {
|
||||||
return new Observable<AdminTransactionsListResult>(subscriber => {
|
return new Observable<AdminTransactionsListResult>(subscriber => {
|
||||||
this.ensureData().then(() => {
|
this.ensureData().then(() => {
|
||||||
@@ -38,7 +44,7 @@ export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
|
|||||||
...tx,
|
...tx,
|
||||||
status: 'retried',
|
status: 'retried',
|
||||||
updatedAt: new Date().toISOString(),
|
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,
|
...tx,
|
||||||
fraudFlag: flagged,
|
fraudFlag: flagged,
|
||||||
updatedAt: new Date().toISOString(),
|
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() }],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { delay } from 'rxjs/operators';
|
import { delay } from 'rxjs/operators';
|
||||||
import { AdminInvitation, AdminRole, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
|
import { AdminInvitation, AdminRole, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
|
||||||
import { AdminUsersGateway } from './admin-users-gateway.interface';
|
import { AdminUsersGateway } from './admin-users-gateway.interface';
|
||||||
|
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||||
|
|
||||||
const BUILT_IN_ROLES: AdminRole[] = [
|
const BUILT_IN_ROLES: AdminRole[] = [
|
||||||
{ id: 'owner', name: 'Owner', permissions: ['*'], builtIn: true },
|
{ 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: 'editor', name: 'Editor', permissions: ['products.manage', 'categories.manage', 'media.manage'], builtIn: true },
|
||||||
{ id: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true },
|
{ id: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminUsersLocalGateway implements AdminUsersGateway {
|
export class AdminUsersLocalGateway implements AdminUsersGateway {
|
||||||
|
private readonly adminAuth = inject(AdminAuthService);
|
||||||
private users: AdminUser[] | null = null;
|
private users: AdminUser[] | null = null;
|
||||||
private roles: AdminRole[] = [...BUILT_IN_ROLES];
|
private roles: AdminRole[] = [...BUILT_IN_ROLES];
|
||||||
private invitations: AdminInvitation[] = [];
|
private invitations: AdminInvitation[] = [];
|
||||||
@@ -90,12 +92,16 @@ export class AdminUsersLocalGateway implements AdminUsersGateway {
|
|||||||
this.audit[userId] = [
|
this.audit[userId] = [
|
||||||
...(this.audit[userId] ?? []),
|
...(this.audit[userId] ?? []),
|
||||||
roleId
|
roleId
|
||||||
? { eventKey: 'roleChanged', roleId, actor: 'admin', timestamp: new Date().toISOString() }
|
? { eventKey: 'roleChanged', roleId, actor: this.currentActor, timestamp: new Date().toISOString() }
|
||||||
: { eventKey: 'statusChanged', status, actor: 'admin', timestamp: new Date().toISOString() },
|
: { eventKey: 'statusChanged', status, actor: this.currentActor, timestamp: new Date().toISOString() },
|
||||||
];
|
];
|
||||||
return of(updated).pipe(delay(50));
|
return of(updated).pipe(delay(50));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get currentActor(): string {
|
||||||
|
return this.adminAuth.displayName() ?? 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
private ensureUsers(): AdminUser[] {
|
private ensureUsers(): AdminUser[] {
|
||||||
if (!this.users) {
|
if (!this.users) {
|
||||||
this.users = [
|
this.users = [
|
||||||
|
|||||||
@@ -1533,6 +1533,7 @@ export const en: Translations = {
|
|||||||
title: 'Title',
|
title: 'Title',
|
||||||
slug: 'URL slug',
|
slug: 'URL slug',
|
||||||
slugTaken: 'This slug is already used by another category.',
|
slugTaken: 'This slug is already used by another category.',
|
||||||
|
slugCheckError: 'Could not verify this slug is unique. Try again before saving.',
|
||||||
parent: 'Parent category',
|
parent: 'Parent category',
|
||||||
noParent: 'No parent (top level)',
|
noParent: 'No parent (top level)',
|
||||||
icon: 'Icon',
|
icon: 'Icon',
|
||||||
|
|||||||
@@ -1528,6 +1528,7 @@ export const hy: Translations = {
|
|||||||
title: 'Անուն',
|
title: 'Անուն',
|
||||||
slug: 'URL հասցե',
|
slug: 'URL հասցե',
|
||||||
slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։',
|
slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։',
|
||||||
|
slugCheckError: 'Չհաջողվեց ստուգել հասցեի եզակիությունը։ Փորձեք կրկին՝ նախքան պահպանելը։',
|
||||||
parent: 'Ծնող կատեգորիա',
|
parent: 'Ծնող կատեգորիա',
|
||||||
noParent: 'Առանց ծնողի (վերին մակարդակ)',
|
noParent: 'Առանց ծնողի (վերին մակարդակ)',
|
||||||
icon: 'Պատկերակ',
|
icon: 'Պատկերակ',
|
||||||
|
|||||||
@@ -1528,6 +1528,7 @@ export const ru: Translations = {
|
|||||||
title: 'Название',
|
title: 'Название',
|
||||||
slug: 'URL-адрес',
|
slug: 'URL-адрес',
|
||||||
slugTaken: 'Этот адрес уже используется другой категорией.',
|
slugTaken: 'Этот адрес уже используется другой категорией.',
|
||||||
|
slugCheckError: 'Не удалось проверить уникальность адреса. Повторите попытку перед сохранением.',
|
||||||
parent: 'Родительская категория',
|
parent: 'Родительская категория',
|
||||||
noParent: 'Без родителя (верхний уровень)',
|
noParent: 'Без родителя (верхний уровень)',
|
||||||
icon: 'Иконка',
|
icon: 'Иконка',
|
||||||
|
|||||||
@@ -1541,6 +1541,7 @@ export interface Translations {
|
|||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
slugTaken: string;
|
slugTaken: string;
|
||||||
|
slugCheckError: string;
|
||||||
parent: string;
|
parent: string;
|
||||||
noParent: string;
|
noParent: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
|||||||
@@ -424,7 +424,6 @@ export class CartComponent implements OnDestroy {
|
|||||||
productId: String(item.itemID),
|
productId: String(item.itemID),
|
||||||
name: item.name,
|
name: item.name,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
price: item.discount > 0 ? item.price * (1 - item.discount / 100) : item.price,
|
|
||||||
})),
|
})),
|
||||||
customer: {
|
customer: {
|
||||||
name: this.getTelegramUsername() || this.i18n.t('common.guest'),
|
name: this.getTelegramUsername() || this.i18n.t('common.guest'),
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ export interface CartPaymentRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOrderRequest {
|
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 };
|
customer: { name: string; email: string; phone: string };
|
||||||
payment?: { method: string; currency: string };
|
payment?: { method: string; currency: string };
|
||||||
shipping?: { address: string; method: string; trackingNumber: string };
|
shipping?: { address: string; method: string; trackingNumber: string };
|
||||||
|
|||||||
Reference in New Issue
Block a user