Files
marketplaces/docs/ADMIN.md
sdarbinyan 17adc9e9fd feat(admin): users and permissions
Sprint 25.

New features/admin/users/ module, net-new /:lang/backoffice/users route +
Dashboard Quick Action.

- users: name, Telegram username, scope (marketplace vs office admin),
  role (inline change), status (active/invited/suspended), last login
- 4 built-in roles (owner/admin/editor/viewer) with flat permission lists
- invitations: email + role + scope form, pending list + revoke (no email
  actually sends - local record only)
- passwordless login confirmed already real (AdminAuthService Telegram QR,
  docs/BACKEND.md item 1) - linked, not reimplemented
- per-user mock session list (device/IP/last-active, revoke) - flagged as
  mock since the real AdminAuthService only ever tracks the current
  browser's session
- per-user audit log dialog (role/status changes), same pattern as
  Sprint 24's per-transaction audit, intentionally separate from the
  system-wide log planned for Sprint 26

docs/ADMIN.md + docs/BACKEND.md (new item 14) updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:05:21 +04:00

22 KiB
Raw Blame History

Marketplace Admin Dashboard - Sprint 19

Scope

Sprint 19 adds the production Admin Dashboard and makes it the default landing page for the admin area. It also wires the previously-unrouted admin/products feature and adds route placeholders for backoffice sections that don't have a feature built yet.

Routing

All admin routes live under /:lang/backoffice/** (app.routes.ts), guarded by the existing adminAuthGuard (core/admin-auth/admin-auth.guard.ts):

/:lang/backoffice            -> redirects to dashboard
/:lang/backoffice/dashboard  -> AdminDashboardPageComponent
/:lang/backoffice/products            -> AdminProductsListPageComponent
/:lang/backoffice/products/create     -> AdminProductEditorPageComponent
/:lang/backoffice/products/:id/edit   -> AdminProductEditorPageComponent
/:lang/backoffice/products/:id/duplicate -> AdminProductEditorPageComponent
/:lang/backoffice/categories             -> AdminCategoriesListPageComponent
/:lang/backoffice/categories/create      -> AdminCategoryEditorPageComponent
/:lang/backoffice/categories/:id/edit    -> AdminCategoryEditorPageComponent
/:lang/backoffice/static-pages  -> BackofficeComingSoonPageComponent
/:lang/backoffice/transactions  -> BackofficeComingSoonPageComponent
/:lang/backoffice/orders        -> BackofficeComingSoonPageComponent
/:lang/backoffice/media         -> BackofficeComingSoonPageComponent

admin/products (features/admin/products/) was already fully implemented in an earlier sprint but was never wired into app.routes.ts and its internal navigation hardcoded the ru locale segment. Both are fixed in this sprint: routes are wired, and admin-products-list-page.component.ts / admin-product-editor-page.component.ts now build the locale segment from LanguageService.currentLanguage().

Dashboard as default admin page: on successful admin Telegram QR login, TelegramLoginComponent (mode="admin") navigates to /:lang/backoffice/dashboard (components/telegram-login/telegram-login.component.ts). The backoffice route's empty path also redirects to dashboard, so any bare /:lang/backoffice link lands there too.

Architecture

src/app/features/admin/dashboard/
  models/    admin-dashboard.model.ts
  services/  admin-dashboard-metrics.gateway.interface.ts
             admin-dashboard-metrics.local.gateway.ts
             admin-dashboard-metrics-gateway.token.ts
             admin-dashboard-history.service.ts
  facade/    admin-dashboard.facade.ts
  components/ admin-dashboard-card.component.*
              admin-dashboard-quick-actions.component.*
              admin-dashboard-activity.component.*
              admin-dashboard-health.component.*
  pages/     admin-dashboard-page.component.*

src/app/features/backoffice/shared/
  backoffice-coming-soon-page.component.*

Follows the existing container/facade/service split (ADR-006, ADR-007): AdminDashboardPageComponent is the container, AdminDashboardFacade owns orchestration, presentational card/quick-actions/activity/health components take only @Input()s and have no HttpClient/localStorage/route access.

Data sources (future-ready)

Cards never read ConfigService, localStorage, or an HTTP client directly - everything routes through AdminDashboardFacade, which composes:

  • ProjectEditorFacade (already existed) - bootstrap, status, lastSavedAt, lastPublishedAt (new, see below), validationIssues, homepageWidgets. Backs Marketplace Status, Project Name, Current Theme, Languages, Last Publish, Last Draft Save, Bootstrap Version, Active Layout, Enabled Widgets, and the System Health checks.
  • ADMIN_DASHBOARD_METRICS_GATEWAY (new InjectionToken, same swap pattern as BACKOFFICE_DATA_PROVIDER) - defaults to AdminDashboardMetricsLocalGateway, which composes BackofficeDataService.loadCategories()/loadProducts() (already used by AdminProductsLocalGateway) into counts. Backs Categories Count and Products Count. Swapping to a real dashboard-metrics endpoint later means implementing AdminDashboardMetricsGateway and rebinding the token - the facade and cards don't change.
  • AdminDashboardHistoryService (new) - localStorage-backed activity log, scoped per tenant, same pattern as ProjectEditorDraftStorageService. The facade appends an entry whenever lastSavedAt/lastPublishedAt change (detected via an effect(), primed on first read so the initial bootstrap load doesn't get logged as an activity event). Backs Recent Activity.

Orders / Revenue

No backend or local data model exists for orders or revenue anywhere in the codebase (features/backoffice/orders is an empty placeholder folder). These two cards render an honest pending-backend card state ("Awaiting backend integration") rather than fabricated numbers - not a "no data" empty state, since the gap is structural, not a temporarily-empty dataset.

Card states

AdminDashboardCardComponent (components/admin-dashboard-card.component.ts) renders one of: loading (skeleton), empty, error, pending-backend, or the ready value + optional subtitle. The container computes each card's status per data source (bootstrap not yet loaded -> loading; metrics gateway error -> error; no supported locales -> empty; Orders/Revenue -> always pending-backend).

System Health

ProjectValidator (features/project-editor/services/project-validator.service.ts) already covered 5 of the 6 required checks. This sprint added two more:

  • translationIssues() - flags a supported non-default locale missing a header nav label translation or a static-page translations entry.
  • layoutIssues() - flags bootstrap.layout.type or any section's layout.strategy that isn't one of the known enum values (PlatformLayoutType / SectionLayoutStrategy). Runtime validation matters here because bootstrap JSON isn't type-checked at load time.

Dashboard mapping (AdminDashboardFacade.healthChecks):

Dashboard label Validator code
Bootstrap valid structural: bootstrap !== null && schemaVersion set
Configuration valid no validation issues at all
Missing translations missing-translations (new)
Invalid colors invalid-colors (existing)
Invalid widget references missing-widget (existing - a homepage widget with no type)
Invalid layouts invalid-layouts (new)

Quick Actions

Static list in AdminDashboardFacade (route arrays relative to the lang root); the page component prefixes the current locale (LanguageService.currentLanguage()) before binding routerLink. Categories, Static Pages, Transactions, Orders, and Media Library currently land on BackofficeComingSoonPageComponent since those features aren't built yet - this is a routing placeholder, not a dashboard card placeholder.

lastPublishedAt (ProjectEditorFacade change)

Before this sprint, publish() only updated lastSavedAt, so "last draft save" and "last publish" were indistinguishable after a publish. Added lastPublishedAt: number | null to ProjectEditorState / ProjectEditorFacade, set only inside publish(). lastSavedAt behavior is unchanged (still updated by both save() and publish()).

Sprint 20 - Category Management

features/admin/categories/ (model/gateway/facade/pages/components), same container/facade/service split as admin/products and admin/dashboard:

src/app/features/admin/categories/
  models/    admin-category.model.ts
  services/  admin-categories-gateway.interface.ts
             admin-categories-local.gateway.ts
             admin-categories-form.factory.ts
  facade/    admin-categories.facade.ts
  guards/    admin-category-dirty.guard.ts
  components/ admin-categories-list.component.*
              admin-category-form.component.*
  pages/     admin-categories-list-page.component.ts
             admin-category-editor-page.component.ts
  • Hierarchy: AdminCategory.parentId (nullable). List page renders a flattened, indented tree (AdminCategoriesFacade.rootCategories() / childrenOf(id)); the editor's parent <select> excludes the category itself and its descendants to prevent cycles.
  • Reordering: native HTML5 drag-and-drop in admin-categories-list.component.ts (draggable, dragstart/drop), persists via AdminCategoriesFacade.reorder() which just rewrites order.
  • Delete/restore: soft delete (deletedAt timestamp). Blocked client-side (facade.canDelete()) if the category has children or itemsCount > 0; list has an "include deleted" filter with a Restore action for soft-deleted rows.
  • Draft/publish: status: 'draft' | 'published', set by the editor's "Save Draft" vs "Publish" buttons (AdminCategoriesFacade.saveDraft(publish)).
  • Local draft recovery + unsaved-changes guard: every updateDraft() call persists the in-progress category to localStorage under admin-category-draft:<id> (via the existing LocalStorageService, same pattern as Project Editor autosave); the editor reloads that draft ahead of the saved value if present, and is cleared on save. adminCategoryDirtyGuard (mirrors projectEditorDirtyGuard) blocks navigation away from an unsaved edit with window.confirm.
  • Image: reuses the existing MediaPickerComponent (same one used by Media Manager) rather than a free-text URL field.
  • Seed data: AdminCategoriesLocalGateway seeds its in-memory cache from BackofficeDataService.loadCategories() (CategoryCardConfig, currently flat/no hierarchy) - same swappable-provider pattern as AdminProductsLocalGateway.
  • Not yet wired: admin/products' category <select> still uses AdminProductsGateway.loadCategories() (its own AdminProductCategoryOption seed), not AdminCategoriesGateway - unifying them is Sprint 21 scope (docs/SPRINT-PLAN.md).

Sprint 21 - Product Management completion

  • Categories now real: AdminProductsLocalGateway seeds its category dropdown from AdminCategoriesLocalGateway.loadCategories() (Sprint 20) instead of raw BackofficeDataService.loadCategories() - product categoryId now points at real admin-managed categories.
  • Archive/restore: AdminProduct.archived (soft, distinct from visible). List has an "include archived" filter + per-row Archive/Restore action; archived products excluded by default (mirrors categories' deletedAt/restore pattern).
  • Barcode: added alongside sku.
  • Variants: lightweight AdminProductVariant[] (name/price/quantity), edited as name|price|quantity lines (same textarea-parse convention as specifications/attributes). Not a full options-matrix variant system - scoped to what the model/backend contract actually needs today.
  • Related products: relatedProductIds: string[], checkbox picker in the editor sourced from AdminProductFormComponent's allProducts input - which is AdminProductsFacade.products(), i.e. whatever page is currently loaded in the facade (usually primed by navigating from the list). Not a full catalog search; fine for the current mock-data scale, worth revisiting if AdminProductsLocalGateway is ever swapped for a real API with more than a page of products.
  • Gallery: media.gallery now built via the shared MediaPickerComponent (add/remove thumbnails) instead of a raw URL textarea; media.images/media.videos unchanged (still textarea, out of this ticket's scope).
  • Preview: simple read-only line in the editor showing computed discounted price.
  • Infinite scroll: AdminProductsFacade.infiniteScroll toggle - when on, loadMore() appends the next page to products() instead of replacing it; pagination UI swaps for a "Load more" button. Off by default (existing paginated behavior unchanged).

Sprint 22 - Media System hardening

core/media/ (MediaRepository abstraction, MockMediaRepository IndexedDB implementation) + features/backoffice/media/ + the shared shared/media/media-picker/:

  • Folders: flat folder?: string tag on MediaAsset (no nesting) - "New folder" just sets the active filter to a name typed via window.prompt (mirrors the window.confirm pattern already used for destructive actions elsewhere); the folder is created implicitly the next time something uploads into it. MediaRepository.listFolders() derives the folder list from existing records rather than a separate folder entity - intentionally light, matches the flat-storage reality of an IndexedDB mock.
  • Tags: already existed on MediaAsset; added an edit affordance (window.prompt, comma-separated) and MediaLibraryFacade.updateTags().
  • Validation: MockMediaRepository.validateFile() rejects anything over 10MB or outside the allow-list (jpeg/png/webp/gif/svg+xml/pdf); errors now propagate as real messages through MediaLibraryFacade.error (both media-library-page and media-picker display it - previously upload failures were swallowed into a generic string).
  • SVG sanitization: sanitizeSvg() strips <script> tags and on*="..." attributes from uploaded SVG markup before storing it, since SVG is the one accepted format that can carry inline script.
  • Compression/resize: raster images (not SVG/GIF) are downscaled to a 2000px max dimension and re-encoded (JPEG/PNG, quality 0.85) via <canvas> before being stored - client-side only, no crop UI. A full interactive cropper was out of scope for this ticket; revisit if a real design need for manual cropping shows up.
  • Reuse confirmed: MediaPickerComponent is now wired into Category images (Sprint 20), Product gallery (Sprint 21), and Project Editor branding (logo / compact logo / favicon, this sprint) - one media library for the whole platform, per the sprint goal. Static Pages editor has no image fields to wire (confirmed, not a gap). Hero image: no dedicated hero-image field exists in BootstrapConfig today - nothing to wire.
  • Storage abstraction: already existed via MediaRepository (abstract class + DI token providedIn: 'root' on MockMediaRepository) - swapping to a real CDN/backend means implementing MediaRepository against a real API and rebinding the provider; no consumer (MediaLibraryFacade, MediaPickerComponent, or any of the pickers above) changes.

Sprint 22 - Media System hardening

core/media/ (MediaRepository abstraction, MockMediaRepository IndexedDB implementation) + features/backoffice/media/ + the shared shared/media/media-picker/:

  • Folders: flat folder?: string tag on MediaAsset (no nesting) - "New folder" just sets the active filter to a name typed via window.prompt (mirrors the window.confirm pattern already used for destructive actions elsewhere); the folder is created implicitly the next time something uploads into it. MediaRepository.listFolders() derives the folder list from existing records rather than a separate folder entity - intentionally light, matches the flat-storage reality of an IndexedDB mock.
  • Tags: already existed on MediaAsset; added an edit affordance (window.prompt, comma-separated) and MediaLibraryFacade.updateTags().
  • Validation: MockMediaRepository.validateFile() rejects anything over 10MB or outside the allow-list (jpeg/png/webp/gif/svg+xml/pdf); errors now propagate as real messages through MediaLibraryFacade.error (both media-library-page and media-picker display it - previously upload failures were swallowed into a generic string).
  • SVG sanitization: sanitizeSvg() strips <script> tags and on*="..." attributes from uploaded SVG markup before storing it, since SVG is the one accepted format that can carry inline script.
  • Compression/resize: raster images (not SVG/GIF) are downscaled to a 2000px max dimension and re-encoded (JPEG/PNG, quality 0.85) via <canvas> before being stored - client-side only, no crop UI. A full interactive cropper was out of scope for this ticket; revisit if a real design need for manual cropping shows up.
  • Reuse confirmed: MediaPickerComponent is now wired into Category images (Sprint 20), Product gallery (Sprint 21), and Project Editor branding (logo / compact logo / favicon, this sprint) - one media library for the whole platform, per the sprint goal. Static Pages editor has no image fields to wire (confirmed, not a gap). Hero image: no dedicated hero-image field exists in BootstrapConfig today ('hero' only appears as a SectionLayoutStrategy enum value) - nothing to wire.
  • Storage abstraction: already existed via MediaRepository (abstract class + DI token providedIn: 'root' on MockMediaRepository) - swapping to a real CDN/backend means implementing MediaRepository against a real API and rebinding the provider; no consumer (MediaLibraryFacade, MediaPickerComponent, or any of the pickers above) changes.

Sprint 23 - Orders (mock/local)

features/admin/orders/ (model/gateway/facade/pages), same container/facade/service split as the rest of admin/*:

  • No real data source exists for orders anywhere in this repo (already called out in Sprint 19's dashboard gap and docs/BACKEND.md item 7) - AdminOrdersLocalGateway seeds 24 deterministic synthetic orders in memory (cycling through all statuses/customers) rather than reading from BackofficeDataService, since there is nothing there to read. This is explicitly a placeholder to unblock the admin UI, not a real mock of production order volume.
  • List: search (order number/customer/email), status filter, pagination, CSV export (client-side Blob download, no server round-trip).
  • Detail: customer/payment/shipping info, itemized line items + total, status timeline, change-status dropdown, refund request and cancel (both window.confirm-gated), customer-visible notes vs internal-only notes (two separate free-text logs), print invoice via window.print() with a @media print rule hiding all non-invoice chrome (.no-print) - no PDF generation library, deliberately minimal.
  • Wired into /:lang/backoffice/orders and /:lang/backoffice/orders/:id, replacing the coming-soon placeholder.

Sprint 24 - Transactions (mock/local)

features/admin/transactions/. AdminTransactionsLocalGateway derives its mock data from AdminOrdersLocalGateway's 24 seeded orders (one transaction per order, deterministic type/status/method assignment) rather than a separate synthetic dataset - keeps order numbers/totals consistent between the two mock feature areas.

  • List: search, status filter, type filter (payment/refund/qr_payment), pagination, CSV export.
  • Retry failed transactions (status: 'failed' -> 'retried', appends an audit entry).
  • Fraud flag toggle per transaction.
  • Audit log: each transaction carries its own audit: AdminTransactionAuditEntry[] (creation, retries, fraud-flag changes), viewed via a dialog - this is a per-transaction audit trail, not the system-wide audit/security log planned for Sprint 26 (Monitoring); the two are intentionally separate scopes.
  • Wired into /:lang/backoffice/transactions, replacing the coming-soon placeholder.

Sprint 25 - Users & Roles (mock/local)

features/admin/users/. Single consolidated page (admin-users-page) at /:lang/backoffice/users - not previously in the Quick Actions list or routes at all, this is a net-new admin section.

  • Users: name, Telegram username, scope (marketplace vs office admin - distinguishes tenant-level owners/admins from internal staff), role, status (active/invited/suspended), last login. Role change is an inline <select>; suspend/reactivate is confirm-gated for suspend only.
  • Roles/permissions: 4 built-in roles (owner/admin/editor/viewer) with a flat permission-string list (products.manage, * for owner, etc.) - a real permission catalog and custom-role creation don't exist, intentionally scoped down to what's needed to demonstrate the model.
  • Invitations: email + role + scope form, pending list with revoke. No email actually sends - AdminUsersLocalGateway.inviteUser() only creates the local record.
  • Passwordless login: already existed before this sprint - AdminAuthService's Telegram QR flow (docs/ADMIN.md's existing admin login section, docs/BACKEND.md item 1). This sprint's Users page links to it via a hint, doesn't reimplement it.
  • Session manager / device manager: per-user session list (device, IP, last active, current-session badge) with per-session revoke, mocked (AdminUsersLocalGateway.loadSessions() fabricates 2 sessions per user on first view) - the real AdminAuthService/session-cookie flow only ever tracks the current browser's session, so multi-device session listing has no real backend counterpart yet (see docs/BACKEND.md item 1).
  • Audit: per-user audit log (role/status changes), same dialog pattern as Sprint 24's per-transaction audit - not the system-wide security/audit log planned for Sprint 26.
  • Wired into AdminDashboardFacade's Quick Actions list (dashboard.actionUsers -> /:lang/backoffice/users).

Known gaps / backend needs

  • Dashboard metrics endpoint. Categories/Products counts are computed client-side from BackofficeDataService (itself mock/API-switchable via BACKOFFICE_DATA_PROVIDER). A dedicated /builder/dashboard/summary-style endpoint would let AdminDashboardMetricsGateway return richer data (real-time counts, trend deltas) without touching the facade or cards.
  • Orders/Revenue have no backend at all (see above) - needs an order domain and revenue aggregation before these cards can show real data.
  • Recent Activity is local-only, scoped to the browser/tenant via localStorage (adminDashboard.activityHistory.v1), same limitation as the existing draft-save local storage. It will not show another editor's activity until a real audit-log endpoint exists.
  • Admin authorization is still not enforced server-side (see Project-Editor.md - "Admin Authentication" section); this sprint does not change that. Nothing new here beyond routing/dashboard.