diff --git a/BACKEND-API-REFERENCE.md b/BACKEND-API-REFERENCE.md index 0dcb822..696b9ab 100644 --- a/BACKEND-API-REFERENCE.md +++ b/BACKEND-API-REFERENCE.md @@ -454,3 +454,81 @@ 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. + +### 12.5 Back-in-stock ("Notify Me") subscription + +**Gap:** the "Notify Me" button on out-of-stock products had no real subscription mechanism at all - it just toggled wishlist. Client fix already shipped: `notifyMe()` now calls `POST /items/{id}/notify-me` and, if that fails (today it always will - the endpoint doesn't exist), falls back to a local-only record in `localStorage['restockSubscriptions']` so the request isn't silently dropped while waiting on the backend. The shopper sees the same confirmation either way. + +**Ask:** implement `POST /items/{id}/notify-me`, plus whatever mechanism actually sends the notification once the item restocks (Telegram message, most likely, given the rest of the auth stack). Request body sent today: +```json +{ "telegramUserId": "8823771" } +``` +`telegramUserId` may be `null` for a non-Telegram web session - decide whether to also accept an email address as an alternative identifier (the frontend has no email capture on this flow today, so that would need a small frontend addition too). Once this ships, the frontend's localStorage fallback becomes purely a resilience path rather than the common case, and could optionally sync any locally-queued subscriptions on next successful call. + +### 12.6 Trending search terms + +**Gap:** `SearchTrendingService.loadTrending()` is a stub returning `of(null)` - no trending-searches endpoint exists. It already degrades gracefully (UI hides the trending section rather than showing an error), so this is purely a missing-feature gap, not a bug. + +**Ask:** an endpoint returning the top N search queries over some recent window, e.g.: +```json +{ "trending": [{ "query": "wireless earbuds", "count": 214 }, { "query": "winter jacket", "count": 187 }] } +``` +Once it exists, wire `loadTrending()` to it and map `query` -> `SearchSuggestion.title/text`. diff --git a/nginx.conf b/nginx.conf index b6d2d38..4e2f8bb 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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; } diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 1d5ba92..cc229b7 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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', diff --git a/src/app/core/admin-auth/admin-auth.guard.ts b/src/app/core/admin-auth/admin-auth.guard.ts index d4ec66a..e7a8185 100644 --- a/src/app/core/admin-auth/admin-auth.guard.ts +++ b/src/app/core/admin-auth/admin-auth.guard.ts @@ -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); + }; +} diff --git a/src/app/core/admin-auth/admin-permissions.service.ts b/src/app/core/admin-auth/admin-permissions.service.ts new file mode 100644 index 0000000..975916a --- /dev/null +++ b/src/app/core/admin-auth/admin-permissions.service.ts @@ -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(() => { + 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); + } +} diff --git a/src/app/core/auth/models/auth-error.model.ts b/src/app/core/auth/models/auth-error.model.ts index f2e6b03..74a7ce0 100644 --- a/src/app/core/auth/models/auth-error.model.ts +++ b/src/app/core/auth/models/auth-error.model.ts @@ -17,6 +17,24 @@ export interface AuthError { status?: number; } +/** + * Maps the backend error envelope's `error.code` (see + * BACKEND-API-REFERENCE.md §5) to the client's AuthErrorCode screens. + * Only codes with a dedicated screen are mapped; anything else falls back + * to the HTTP-status-derived code via authErrorCodeFromStatus. + */ +const BACKEND_ERROR_CODE_MAP: Record = { + TOKEN_EXPIRED: 'session-expired', + INVALID_SIGNATURE: 'invalid-signature', + UNAUTHENTICATED: 'unauthorized', + FORBIDDEN: 'forbidden', + SERVICE_UNAVAILABLE: 'backend-unavailable', +}; + +export function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined { + return typeof code === 'string' ? BACKEND_ERROR_CODE_MAP[code] : undefined; +} + /** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */ export function authErrorCodeFromStatus(status: number): AuthErrorCode { switch (status) { diff --git a/src/app/core/auth/services/auth.service.ts b/src/app/core/auth/services/auth.service.ts index 55fd221..ef5547f 100644 --- a/src/app/core/auth/services/auth.service.ts +++ b/src/app/core/auth/services/auth.service.ts @@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { catchError, switchMap, tap, throwError } from 'rxjs'; import { Observable } from 'rxjs'; import { AuthTokenPair } from '../models/auth-api.model'; -import { AuthError, authErrorCodeFromStatus } from '../models/auth-error.model'; +import { AuthError, authErrorCodeFromBackendCode, authErrorCodeFromStatus } from '../models/auth-error.model'; import { AuthApiService } from './auth-api.service'; import { Ed25519KeypairService } from './ed25519-keypair.service'; import { SessionService } from './session.service'; @@ -109,7 +109,9 @@ export class AuthService { private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError { if (error instanceof HttpErrorResponse) { - return { code: authErrorCodeFromStatus(error.status), message: error.message, status: error.status }; + const bodyCode = (error.error as { error?: { code?: unknown } } | null)?.error?.code; + const code = authErrorCodeFromBackendCode(bodyCode) ?? authErrorCodeFromStatus(error.status); + return { code, message: error.message, status: error.status }; } if (error instanceof Error) { return { code: fallbackCode, message: error.message }; diff --git a/src/app/core/categories/category-repository.token.ts b/src/app/core/categories/category-repository.token.ts index f73a084..7ecd098 100644 --- a/src/app/core/categories/category-repository.token.ts +++ b/src/app/core/categories/category-repository.token.ts @@ -1,20 +1,13 @@ import { InjectionToken, inject } from '@angular/core'; -import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service'; import { ApiCategoryRepository } from './repositories/api-category.repository'; import { CategoryRepository } from './repositories/category.repository'; +/** + * No mock CategoryRepository implementation exists - same dead branch as + * PRODUCT_DATA_PROVIDER. Always resolved to the real API repository + * regardless of getCategoryProviderMode(); removed the dead switch. + */ export const CATEGORY_REPOSITORY = new InjectionToken('CATEGORY_REPOSITORY', { providedIn: 'root', - factory: () => { - const strategy = inject(RuntimeProviderStrategyService); - const apiRepository = inject(ApiCategoryRepository); - - switch (strategy.getCategoryProviderMode()) { - case 'mock': - case 'remote-config': - case 'api': - default: - return apiRepository; - } - } + factory: () => inject(ApiCategoryRepository) }); \ No newline at end of file diff --git a/src/app/core/products/product-data-provider.token.ts b/src/app/core/products/product-data-provider.token.ts index 7178288..50f3ff3 100644 --- a/src/app/core/products/product-data-provider.token.ts +++ b/src/app/core/products/product-data-provider.token.ts @@ -1,20 +1,14 @@ import { InjectionToken, inject } from '@angular/core'; -import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service'; import { ApiProductDataProvider } from './providers/api-product-data.provider'; import { ProductDataProvider } from './providers/product-data-provider.interface'; +/** + * No mock ProductDataProvider implementation exists - RuntimeProviderStrategyService. + * getProductProviderMode() can report 'mock', but there was never a branch that acted + * on it, so this always resolved to the real API provider regardless. Removed the dead + * switch instead of leaving code that implies a mock mode which doesn't exist. + */ export const PRODUCT_DATA_PROVIDER = new InjectionToken('PRODUCT_DATA_PROVIDER', { providedIn: 'root', - factory: () => { - const strategy = inject(RuntimeProviderStrategyService); - const apiProvider = inject(ApiProductDataProvider); - - switch (strategy.getProductProviderMode()) { - case 'mock': - case 'remote-config': - case 'api': - default: - return apiProvider; - } - } + factory: () => inject(ApiProductDataProvider) }); diff --git a/src/app/dynamic-renderer/section-engine/section-engine.service.ts b/src/app/dynamic-renderer/section-engine/section-engine.service.ts index 889c163..6fddddf 100644 --- a/src/app/dynamic-renderer/section-engine/section-engine.service.ts +++ b/src/app/dynamic-renderer/section-engine/section-engine.service.ts @@ -1,12 +1,15 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { PageConfig } from '../../shared/models/config'; import { PageRenderModel } from '../page-renderer/page-renderer.model'; import { SectionRendererService } from '../section-renderer/section-renderer.service'; import { PlatformLayoutConfig, PlatformLayoutType } from '../../shared/models/config'; import { SectionConfig } from '../../shared/models/config'; +import { ConfigService } from '../../core/config/config.service'; @Injectable({ providedIn: 'root' }) export class SectionEngineService { + private readonly configService = inject(ConfigService); + constructor(private readonly sectionRenderer: SectionRendererService) {} toPageRenderModel(page: PageConfig): PageRenderModel { @@ -27,12 +30,23 @@ export class SectionEngineService { }; } + /** + * Falls back to the site-wide builder setting (bootstrap.layout.type, + * "Site Layout" in the theme editor) when a page has no layout of its + * own - previously that global setting was saved but never read by + * rendering at all, so it had no visible effect. + */ private resolveLayoutType(layout: PageConfig['layout']): string { if (typeof layout === 'string') { return layout; } - return (layout as PlatformLayoutConfig)?.type ?? 'default'; + const pageLayoutType = (layout as PlatformLayoutConfig)?.type; + if (pageLayoutType) { + return pageLayoutType; + } + + return this.configService.getBootstrapSnapshot()?.layout?.type ?? 'default'; } private normalizeSectionsByLayout(sections: PageConfig['sections'], layoutType: string): PageConfig['sections'] { diff --git a/src/app/features/admin/analytics/facade/admin-analytics.facade.ts b/src/app/features/admin/analytics/facade/admin-analytics.facade.ts index 6daae5c..466e83d 100644 --- a/src/app/features/admin/analytics/facade/admin-analytics.facade.ts +++ b/src/app/features/admin/analytics/facade/admin-analytics.facade.ts @@ -1,5 +1,6 @@ import { Injectable, computed, inject, signal } from '@angular/core'; -import { take } from 'rxjs/operators'; +import { forkJoin, Subject } from 'rxjs'; +import { take, takeUntil } from 'rxjs/operators'; import { AdminAnalyticsDateRange, AdminAnalyticsSeriesPoint, @@ -62,7 +63,13 @@ export class AdminAnalyticsFacade { readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info')); + private readonly cancelPreviousLoad$ = new Subject(); + load(): void { + // Cancel any still-in-flight previous load so a rapid setDateRange() double-call + // can't have a stale response overwrite a newer one. + this.cancelPreviousLoad$.next(); + this.loading.set(true); this.error.set(false); this.dashboardFacade.ensureLoaded(); @@ -74,10 +81,13 @@ export class AdminAnalyticsFacade { })), ); - const fail = (): void => { this.loading.set(false); this.error.set(true); }; - - this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ - next: orderResult => { + forkJoin({ + orderResult: this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }), + productResult: this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }), + categories: this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }), + reviewResult: this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }), + }).pipe(take(1), takeUntil(this.cancelPreviousLoad$)).subscribe({ + next: ({ orderResult, productResult, categories, reviewResult }) => { const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000; const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff); @@ -89,43 +99,28 @@ export class AdminAnalyticsFacade { const ordersCount = inRange.length; const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size; - this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ - next: productResult => { - this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({ - next: categories => { - this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ - next: reviewResult => { - const products = productResult.items; - const reviews = reviewResult.items; + const products = productResult.items; + const reviews = reviewResult.items; - this.summary.set({ - revenueTotal, - currency: inRange[0]?.currency ?? 'RUB', - ordersCount, - avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0, - productsCount: products.length, - categoriesCount: categories.length, - customersCount: uniqueCustomers, - conversionRate: null, - }); - - this.lowStockProducts.set(this.buildLowStock(products)); - this.productAnalytics.set(this.buildProductAnalytics(products)); - this.marketplaceHealth.set(this.buildMarketplaceHealth(products, categories, reviews, orderResult.items)); - this.recommendations.set(this.buildRecommendations(products, categories)); - - this.loading.set(false); - }, - error: fail - }); - }, - error: fail - }); - }, - error: fail + this.summary.set({ + revenueTotal, + currency: inRange[0]?.currency ?? 'RUB', + ordersCount, + avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0, + productsCount: products.length, + categoriesCount: categories.length, + customersCount: uniqueCustomers, + conversionRate: null, }); + + this.lowStockProducts.set(this.buildLowStock(products)); + this.productAnalytics.set(this.buildProductAnalytics(products)); + this.marketplaceHealth.set(this.buildMarketplaceHealth(products, categories, reviews, orderResult.items)); + this.recommendations.set(this.buildRecommendations(products, categories)); + + this.loading.set(false); }, - error: fail + error: () => { this.loading.set(false); this.error.set(true); } }); } diff --git a/src/app/features/admin/categories/components/admin-categories-list.component.html b/src/app/features/admin/categories/components/admin-categories-list.component.html index 082b62b..d765ab1 100644 --- a/src/app/features/admin/categories/components/admin-categories-list.component.html +++ b/src/app/features/admin/categories/components/admin-categories-list.component.html @@ -64,6 +64,8 @@ @for (i of [1,2,3,4]; track i) { } {{ 'common.loading' | translate }} + } @else if (error) { + } @else if (treeRows.length === 0) { diff --git a/src/app/features/admin/categories/components/admin-categories-list.component.ts b/src/app/features/admin/categories/components/admin-categories-list.component.ts index 5398b98..7cad1ba 100644 --- a/src/app/features/admin/categories/components/admin-categories-list.component.ts +++ b/src/app/features/admin/categories/components/admin-categories-list.component.ts @@ -51,6 +51,7 @@ export class AdminCategoriesListComponent { @Input() flatCategories: AdminCategory[] = []; @Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean }; @Input() loading = false; + @Input() error: string | null = null; @Input() viewMode: AdminCategoriesViewMode = 'tree'; @Input() density: AdminCategoriesDensity = 'comfortable'; @Input() visibleColumns: AdminCategoryColumn[] = [...ALL_CATEGORY_COLUMNS]; diff --git a/src/app/features/admin/categories/components/admin-category-form.component.html b/src/app/features/admin/categories/components/admin-category-form.component.html index 05f6c59..75f189c 100644 --- a/src/app/features/admin/categories/components/admin-category-form.component.html +++ b/src/app/features/admin/categories/components/admin-category-form.component.html @@ -29,7 +29,7 @@ - +