From 3877b70fdf2fdb3175ea4db3d7fb7ac9c9cce0bd Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Tue, 14 Jul 2026 09:50:03 +0400 Subject: [PATCH] feat(sprint18): editor autosave/reset, admin auth, QR reuse, Ed25519 prep - Project editor: persist draft to localStorage, restore on reload, last-saved/draft-restored status indicators, section/whole-draft reset with confirmation. - Extract shared QR/polling/expiry engine from TelegramLoginComponent (shared/qr-login) and reuse it for a new admin login flow. - Admin authentication kept fully separate from customer session: own cookie/localStorage keys, signals, guard, and header interceptor (core/admin-auth). - ?login=true / ?adminLogin=true open the respective login dialog for manual testing. - Ed25519 challenge/verify interfaces (fail-closed no-op binding) ready for backend delivery. - Document autosave/reset/admin-auth/QR-reuse/Ed25519 model and the remaining full-field-coverage gap in docs/Project-Editor.md. --- docs/Project-Editor.md | 124 +++++++- src/app/app.config.ts | 6 +- src/app/app.html | 1 + src/app/app.ts | 22 +- .../telegram-login.component.ts | 155 ++-------- .../admin-auth-headers.interceptor.ts | 29 ++ src/app/core/admin-auth/admin-auth.guard.ts | 15 + src/app/core/admin-auth/admin-auth.service.ts | 286 ++++++++++++++++++ .../admin-auth/admin-login.component.html | 72 +++++ .../admin-auth/admin-login.component.scss | 254 ++++++++++++++++ .../core/admin-auth/admin-login.component.ts | 54 ++++ .../admin-auth/ed25519-verification.model.ts | 31 ++ .../noop-ed25519-verification.service.ts | 20 ++ .../project-editor-save-bar.component.html | 9 + .../project-editor-save-bar.component.scss | 24 ++ .../project-editor-save-bar.component.ts | 18 ++ .../facade/project-editor.facade.ts | 66 +++- .../models/project-editor.model.ts | 16 + .../pages/project-editor-page.component.html | 5 + .../pages/project-editor-page.component.scss | 14 + .../pages/project-editor-page.component.ts | 11 +- .../project-editor-draft-storage.service.ts | 51 ++++ src/app/i18n/en.ts | 17 ++ src/app/i18n/hy.ts | 17 ++ src/app/i18n/ru.ts | 17 ++ src/app/i18n/translations.ts | 17 ++ src/app/models/admin-auth.model.ts | 16 + src/app/services/auth.service.ts | 26 +- src/app/shared/qr-login/qr-login.engine.ts | 155 ++++++++++ src/app/shared/qr-login/qr-login.model.ts | 21 ++ src/app/shared/util/guid.util.ts | 21 ++ src/environments/environment.production.ts | 2 + src/environments/environment.ts | 2 + 33 files changed, 1423 insertions(+), 171 deletions(-) create mode 100644 src/app/core/admin-auth/admin-auth-headers.interceptor.ts create mode 100644 src/app/core/admin-auth/admin-auth.guard.ts create mode 100644 src/app/core/admin-auth/admin-auth.service.ts create mode 100644 src/app/core/admin-auth/admin-login.component.html create mode 100644 src/app/core/admin-auth/admin-login.component.scss create mode 100644 src/app/core/admin-auth/admin-login.component.ts create mode 100644 src/app/core/admin-auth/ed25519-verification.model.ts create mode 100644 src/app/core/admin-auth/noop-ed25519-verification.service.ts create mode 100644 src/app/features/project-editor/services/project-editor-draft-storage.service.ts create mode 100644 src/app/models/admin-auth.model.ts create mode 100644 src/app/shared/qr-login/qr-login.engine.ts create mode 100644 src/app/shared/qr-login/qr-login.model.ts create mode 100644 src/app/shared/util/guid.util.ts diff --git a/docs/Project-Editor.md b/docs/Project-Editor.md index d8513fc..824f85e 100644 --- a/docs/Project-Editor.md +++ b/docs/Project-Editor.md @@ -1,4 +1,4 @@ -# Marketplace Project Editor - Sprint 13 +# Marketplace Project Editor - Sprint 13 (updated Sprint 18) ## Scope @@ -128,22 +128,57 @@ Current widget editor supports explicit fields for: Other widgets use JSON props fallback until dedicated editors are added. -## Draft / Publish (Sprint 16) +## Draft / Publish (Sprint 16, autosave added Sprint 18) There is still no backend draft/publish API. This sprint models it client-side in `ProjectEditorFacade`: - `status: 'draft' | 'published'` and `dirty` (diffed against the last-saved snapshot) live in facade state. -- `save()` snapshots the current in-memory bootstrap as "last saved" (no - network call yet). +- `save()` snapshots the current in-memory bootstrap as "last saved" and + timestamps it (`lastSavedAt`). - `publish()` runs `ProjectValidator`, and if there are no issues, applies - the bootstrap via `PlatformRuntimeService.reloadFromBootstrap` and marks - status `published`. + the bootstrap via `PlatformRuntimeService.reloadFromBootstrap`, marks + status `published`, and becomes the new `originalBootstrap` snapshot used + by reset. **Backend gap, not yet implemented:** real persistence needs `PUT /builder/bootstrap/draft` and `POST /builder/bootstrap/publish` endpoints so drafts/publishes survive a reload and are shared across editors. +### Autosave (Sprint 18) + +`ProjectEditorDraftStorageService` (`services/project-editor-draft-storage.service.ts`) +persists the full bootstrap draft to `localStorage` (key +`projectEditor.draftBootstrap.v1`, scoped by `tenant.id`) on every +`updateBootstrap()` call, `save()`, and `publish()`. On `loadBootstrap()`, if a +stored draft exists for the same tenant it is loaded instead of the +freshly-fetched bootstrap and `draftRestored` is set true (surfaced in the +save bar as a dismissible notice). The published/loaded bootstrap is never +overwritten automatically — only explicit `publish()` calls change what the +runtime actually serves; the localStorage draft is a separate, purely local +concern that survives refreshes and browser restarts. + +Status indicators in `ProjectEditorSaveBarComponent`: +- **Unsaved changes** - shown while `dirty()` is true. +- **Last saved: HH:MM:SS** - shown once not dirty and `lastSavedAt` is set. +- **Draft restored** banner - shown once after a local draft is loaded from + a previous session, dismissible. + +### Reset (Sprint 18) + +- **Reset section** - button above the active section (shown only for + sections with a bootstrap-key mapping in `EDITOR_SECTION_BOOTSTRAP_KEYS`, + `models/project-editor.model.ts`). Reverts that section's bootstrap keys + to `originalBootstrap` (the last loaded/published snapshot). Confirmation + required. +- **Reset draft** - button in the save bar. Reverts the entire bootstrap to + `originalBootstrap` and clears the persisted local draft. Confirmation + required. +- Per-field reset is **not implemented** - the bootstrap schema has no + registry of per-field defaults, so only section- and project-level reset + exist. Adding field-level reset would require either a default-value + registry per field or storing per-field undo history; deferred. + ## Validation `ProjectValidator` (`services/project-validator.service.ts`) runs on every @@ -159,6 +194,83 @@ Static page HTML is edited via `MarketplaceHtmlEditorComponent` external dependency. It emits raw HTML on every change and never sanitizes — sanitization remains a storefront-render concern. +## QR Login Reuse (Sprint 18) + +The Telegram QR-login flow (QR image, polling, expiry, "return from app" +recovery via visibilitychange/focus/pageshow) was extracted from +`TelegramLoginComponent` into `shared/qr-login/qr-login.engine.ts` +(`QrLoginEngine`) plus an adapter interface +(`shared/qr-login/qr-login.model.ts`, `QrLoginAdapter`). The engine +is not a DI singleton - each login surface instantiates its own +`new QrLoginEngine(adapter)` and drives it from an `effect()` watching its own +dialog-visibility signal. `TelegramLoginComponent` was refactored onto this +engine with no behavior change. `AdminLoginComponent` +(`core/admin-auth/admin-login.component.ts`) reuses the same engine against a +separate adapter backed by `AdminAuthService`, so QR/polling/timeout logic is +not duplicated between customer and admin login. + +## Admin Authentication (Sprint 18) + +Admin authentication is completely separate from the customer/storefront +session (`AuthService`), by design - one must never authenticate the other: + +| | Customer (`AuthService`) | Admin (`AdminAuthService`, `core/admin-auth/`) | +|---|---|---| +| Cookie | `webSessionID` | `adminSessionID` (`SameSite=Strict`) | +| Anonymous/local id | `web_session_id` (localStorage, API attribution only) | `adminToken` / `adminRefreshToken` (localStorage, reserved for future JWT pair) | +| Signals | `session`, `status`, `showLoginDialog` on `AuthService` | `session`, `status`, `showLoginDialog`, `role` on `AdminAuthService` | +| Guard | none yet for customer routes | `adminAuthGuard` (`core/admin-auth/admin-auth.guard.ts`) | +| Interceptor | `apiHeadersInterceptor` | `adminAuthHeadersInterceptor` (`core/admin-auth/admin-auth-headers.interceptor.ts`), self-guards on `/admin/` in the URL, sets `AdminWebSessionID` + `Authorization: Bearer ` when present | +| Login UI | `TelegramLoginComponent` (mounted per-page, e.g. cart) | `AdminLoginComponent` (mounted once, globally, in `app.html`) | + +**Backend gap:** `environment.adminAuthApiUrl` (`https://users.vitanova.network:456/admin`) +is a placeholder path under the existing auth host - there is no real admin +session/login backend yet. `AdminAuthService.createWebSession()` / +`checkSessionOnce()` / `logout()` call `POST|GET|DELETE {adminAuthApiUrl}/sessions...` +following the same shape as the customer session API; confirm/repoint this +once the backend ships dedicated admin endpoints. + +### Login test mode + +`?login=true` and `?adminLogin=true` query params (handled once in +`App.ngOnInit` via `openLoginDialogsFromTestModeQueryParams()`, `app.ts`) call +`AuthService.requestLogin()` / `AdminAuthService.requestLogin()` respectively, +for manual testing. This only sets the same signal a normal "please log in" +action would set - it does not bypass authentication or change any other +behavior, so it is safe in all environments. Note `TelegramLoginComponent` is +currently mounted only on the cart page, so `?login=true` only shows a dialog +there; `AdminLoginComponent` is mounted globally so `?adminLogin=true` works +from any route. + +### Ed25519 prep + +`core/admin-auth/ed25519-verification.model.ts` defines +`Ed25519VerificationService` (abstract, injectable) with +`requestChallenge()` / `verify(signedResponse)` and the +`Ed25519Challenge` / `Ed25519SignedResponse` / `Ed25519VerificationResult` +shapes (nonce, timestamp, payload, public key, signature). No crypto is +implemented. The current DI binding, +`NoopEd25519VerificationService` (registered in `app.config.ts`), fails +closed (throws) rather than silently accepting anything, so it's safe to wire +into a real login path today - it will error loudly instead of pretending to +verify a signature. Swap the DI binding for a real implementation once the +backend ships challenge/verify endpoints; nothing else needs to change. + +## Known gaps / deferred (Sprint 18) + +Full field-by-field coverage of every supported bootstrap property (with +bilingual EN/RU labels, description, and validation state per field) was not +completed in this pass - the bootstrap schema is large (theme typography/ +spacing/shadows, full company address, per-locale footer copyright, grouped +footer navigation editing, sidebar navigation, per-page SEO map, catalog/ +product-page/user-experience sub-fields, permissions, API endpoints) and +several concepts named in the sprint brief (payments, delivery/shipping, +checkout, unified search config) have **no corresponding model in +`shared/models/config` at all** - they would need new bootstrap schema before +an editor could expose them. See the section-by-section gap list gathered +during Sprint 18 investigation for the full inventory; treat as a follow-up +sprint rather than something silently skipped. + ## Constraints - runtime bootstrap engine not replaced diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 7d95137..0e14baa 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -7,6 +7,9 @@ import { cacheInterceptor } from './interceptors/cache.interceptor'; import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor'; import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor'; import { mockDataInterceptor } from './interceptors/mock-data.interceptor'; +import { adminAuthHeadersInterceptor } from './core/admin-auth/admin-auth-headers.interceptor'; +import { Ed25519VerificationService } from './core/admin-auth/ed25519-verification.model'; +import { NoopEd25519VerificationService } from './core/admin-auth/noop-ed25519-verification.service'; import { provideServiceWorker } from '@angular/service-worker'; export const appConfig: ApplicationConfig = { @@ -18,8 +21,9 @@ export const appConfig: ApplicationConfig = { withInMemoryScrolling({ scrollPositionRestoration: 'top' }) ), provideHttpClient( - withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, cacheInterceptor]) + withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor]) ), + { provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService }, provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000' diff --git a/src/app/app.html b/src/app/app.html index d620679..91031eb 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -25,4 +25,5 @@ } + } \ No newline at end of file diff --git a/src/app/app.ts b/src/app/app.ts index 5918df3..79b8845 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -15,10 +15,13 @@ import { PlatformRuntimeService } from './core/runtime/platform-runtime.service' import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade'; import { ApiHealthService } from './services/api-health.service'; import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component'; +import { AdminLoginComponent } from './core/admin-auth/admin-login.component'; +import { AdminAuthService } from './core/admin-auth/admin-auth.service'; +import { AuthService } from './services/auth.service'; @Component({ selector: 'app-root', - imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent], + imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent, AdminLoginComponent], templateUrl: './app.html', styleUrl: './app.scss' }) @@ -37,6 +40,8 @@ export class App implements OnInit { private platformRuntime = inject(PlatformRuntimeService); private uiRuntime = inject(UiRuntimeFacade); private apiHealth = inject(ApiHealthService); + private authService = inject(AuthService); + private adminAuthService = inject(AdminAuthService); ngOnInit(): void { this.platformRuntime.initialize(); @@ -44,6 +49,7 @@ export class App implements OnInit { this.titleService.setTitle(`${this.uiRuntime.marketplaceDisplayName()} - ${this.i18n.t('app.pageTitle')}`); this.checkServerHealth(); this.setupAutoUpdates(); + this.openLoginDialogsFromTestModeQueryParams(); // Track route changes to show/hide back button this.router.events @@ -79,6 +85,20 @@ export class App implements OnInit { this.checkServerHealth(); } + /** ?login=true / ?adminLogin=true open the respective login dialog for manual testing. No effect when absent. */ + private openLoginDialogsFromTestModeQueryParams(): void { + if (typeof window === 'undefined') { + return; + } + const params = new URLSearchParams(window.location.search); + if (params.get('login') === 'true') { + this.authService.requestLogin(); + } + if (params.get('adminLogin') === 'true') { + this.adminAuthService.requestLogin(); + } + } + private setupAutoUpdates(): void { if (!this.swUpdate.isEnabled) { return; diff --git a/src/app/components/telegram-login/telegram-login.component.ts b/src/app/components/telegram-login/telegram-login.component.ts index f1ee79e..c4528f0 100644 --- a/src/app/components/telegram-login/telegram-login.component.ts +++ b/src/app/components/telegram-login/telegram-login.component.ts @@ -1,6 +1,9 @@ -import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnDestroy } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, effect, OnDestroy } from '@angular/core'; import { AuthService } from '../../services/auth.service'; import { TranslatePipe } from '../../i18n/translate.pipe'; +import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine'; +import { QrLoginAdapter } from '../../shared/qr-login/qr-login.model'; +import { AuthSession } from '../../models/auth.model'; @Component({ selector: 'app-telegram-login', @@ -15,153 +18,39 @@ export class TelegramLoginComponent implements OnDestroy { showDialog = this.authService.showLoginDialog; status = this.authService.status; - loginUrl = signal(''); - webSessionID = signal(''); - qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading'); - encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl())); - awaitingTelegramReturn = signal(false); + private readonly adapter: QrLoginAdapter = { + createSession: () => this.authService.createWebSession(), + checkSessionOnce: webSessionID => this.authService.checkSessionOnce(webSessionID), + isSessionActive: session => !!session?.active, + getAppLoginUrl: webSessionID => this.authService.getTelegramAppLoginUrl(webSessionID), + onLoginComplete: () => this.authService.onTelegramLoginComplete(), + }; - private readonly pollIntervalMs = 5000; - private pollTimer?: ReturnType; - private readonly handleVisibilityChange = () => { - if (typeof document !== 'undefined' && document.visibilityState === 'visible') { - this.checkLoginAfterReturn(); - } - }; - private readonly handleWindowFocus = () => { - this.checkLoginAfterReturn(); - }; - private readonly handlePageShow = () => { - this.checkLoginAfterReturn(); - }; + private readonly engine = new QrLoginEngine(this.adapter); + readonly loginUrl = this.engine.loginUrl; + readonly webSessionID = this.engine.webSessionID; + readonly qrStatus = this.engine.qrStatus; + readonly encodedQrUrl = this.engine.encodedQrUrl; + readonly awaitingTelegramReturn = this.engine.awaitingAppReturn; constructor() { - effect(() => { - if (this.showDialog()) { - this.initQrLogin(); - } else { - this.awaitingTelegramReturn.set(false); - this.stopPolling(); - } - }); - - if (typeof window !== 'undefined') { - document.addEventListener('visibilitychange', this.handleVisibilityChange); - window.addEventListener('focus', this.handleWindowFocus); - window.addEventListener('pageshow', this.handlePageShow); - } + effect(() => this.engine.setActive(this.showDialog())); } ngOnDestroy(): void { - this.awaitingTelegramReturn.set(false); - this.stopPolling(); - - if (typeof window !== 'undefined') { - document.removeEventListener('visibilitychange', this.handleVisibilityChange); - window.removeEventListener('focus', this.handleWindowFocus); - window.removeEventListener('pageshow', this.handlePageShow); - } + this.engine.destroy(); } close(): void { - this.awaitingTelegramReturn.set(false); + this.engine.setActive(false); this.authService.hideLogin(); - this.stopPolling(); } openTelegramLogin(): void { - const webSessionID = this.webSessionID(); - if (!webSessionID || typeof window === 'undefined') return; - - if (!this.pollTimer) { - this.startPolling(webSessionID); - } - - this.awaitingTelegramReturn.set(true); - window.location.href = this.authService.getTelegramAppLoginUrl(webSessionID); + this.engine.openAppLogin(); } refreshQr(): void { - this.awaitingTelegramReturn.set(false); - this.stopPolling(); - this.initQrLogin(); - } - - private initQrLogin(): void { - this.awaitingTelegramReturn.set(false); - this.qrStatus.set('loading'); - this.loginUrl.set(''); - this.webSessionID.set(''); - - this.authService.createWebSession().subscribe({ - next: (res) => { - this.loginUrl.set(res.url); - this.webSessionID.set(res.webSessionID); - this.qrStatus.set('ready'); - this.startPolling(res.webSessionID); - }, - error: () => { - this.qrStatus.set('error'); - } - }); - } - - private startPolling(webSessionID: string): void { - this.stopPolling(); - if (!webSessionID) return; - - let checks = 0; - this.pollTimer = setInterval(() => { - checks++; - if (checks > 100) { - this.stopPolling(); - this.qrStatus.set('expired'); - return; - } - - this.authService.checkSessionOnce(webSessionID).subscribe({ - next: (session) => { - if (session?.active) { - this.awaitingTelegramReturn.set(false); - this.stopPolling(); - this.authService.onTelegramLoginComplete(); - } - }, - error: () => { - // Network error — keep polling - } - }); - }, this.pollIntervalMs); - } - - private stopPolling(): void { - if (this.pollTimer) { - clearInterval(this.pollTimer); - this.pollTimer = undefined; - } - } - - private checkLoginAfterReturn(): void { - if (!this.showDialog() || !this.awaitingTelegramReturn()) { - return; - } - - const webSessionID = this.webSessionID(); - if (!webSessionID) { - this.awaitingTelegramReturn.set(false); - return; - } - - if (!this.pollTimer) { - this.startPolling(webSessionID); - } - - this.authService.checkSessionOnce(webSessionID).subscribe(session => { - if (session?.active) { - this.awaitingTelegramReturn.set(false); - this.stopPolling(); - this.authService.onTelegramLoginComplete(); - } - }); + this.engine.refresh(); } } diff --git a/src/app/core/admin-auth/admin-auth-headers.interceptor.ts b/src/app/core/admin-auth/admin-auth-headers.interceptor.ts new file mode 100644 index 0000000..8bdaed7 --- /dev/null +++ b/src/app/core/admin-auth/admin-auth-headers.interceptor.ts @@ -0,0 +1,29 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { AdminAuthService } from './admin-auth.service'; + +/** + * Attaches admin session/token headers only to admin API requests. Mirrors + * apiHeadersInterceptor's self-guarding pattern but scoped to `/admin` so it + * never touches customer requests and never reads AuthService's session. + */ +export const adminAuthHeadersInterceptor: HttpInterceptorFn = (req, next) => { + const isAdminRequest = req.url.includes('/admin/'); + if (!isAdminRequest) { + return next(req); + } + + const adminAuth = inject(AdminAuthService); + const session = adminAuth.session(); + const token = adminAuth.getAdminToken(); + + let headers = req.headers; + if (session?.sessionId) { + headers = headers.set('AdminWebSessionID', session.sessionId); + } + if (token) { + headers = headers.set('Authorization', `Bearer ${token}`); + } + + return next(req.clone({ headers })); +}; diff --git a/src/app/core/admin-auth/admin-auth.guard.ts b/src/app/core/admin-auth/admin-auth.guard.ts new file mode 100644 index 0000000..d4ec66a --- /dev/null +++ b/src/app/core/admin-auth/admin-auth.guard.ts @@ -0,0 +1,15 @@ +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; +import { AdminAuthService } from './admin-auth.service'; + +/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */ +export const adminAuthGuard: CanActivateFn = () => { + const adminAuth = inject(AdminAuthService); + + if (adminAuth.isAuthenticated()) { + return true; + } + + adminAuth.requestLogin(); + return false; +}; diff --git a/src/app/core/admin-auth/admin-auth.service.ts b/src/app/core/admin-auth/admin-auth.service.ts new file mode 100644 index 0000000..e6a9207 --- /dev/null +++ b/src/app/core/admin-auth/admin-auth.service.ts @@ -0,0 +1,286 @@ +import { Injectable, signal, computed } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, of, catchError, map, tap } from 'rxjs'; +import { AdminAuthStatus, AdminSession, AdminWebSessionStart } from '../../models/admin-auth.model'; +import { environment } from '../../../environments/environment'; +import { generateGuid } from '../../shared/util/guid.util'; + +/** + * Admin session storage is completely separate from customer session storage + * (AuthService uses cookie `webSessionID` + localStorage `web_session_id`). + * Distinct cookie/localStorage names here are intentional: an admin login must + * never authenticate the customer session and vice versa. + */ +const ADMIN_SESSION_COOKIE = 'adminSessionID'; +const ADMIN_TOKEN_STORAGE_KEY = 'adminToken'; +const ADMIN_REFRESH_STORAGE_KEY = 'adminRefreshToken'; +const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60; + +@Injectable({ providedIn: 'root' }) +export class AdminAuthService { + private readonly sessionSignal = signal(null); + private readonly statusSignal = signal('unknown'); + private readonly showLoginSignal = signal(false); + + readonly session = this.sessionSignal.asReadonly(); + readonly status = this.statusSignal.asReadonly(); + readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated'); + readonly showLoginDialog = this.showLoginSignal.asReadonly(); + readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null); + readonly role = computed(() => this.sessionSignal()?.role ?? null); + + private readonly adminAuthApiUrl = (environment as Record)['adminAuthApiUrl'] as string + ?? `${environment.authApiUrl}/admin`; + private sessionCheckTimer?: ReturnType; + + constructor(private readonly http: HttpClient) { + this.checkSession(); + } + + checkSession(): void { + const webSessionID = this.getStoredAdminSessionID(); + if (!webSessionID) { + this.clearAuthState('unauthenticated'); + return; + } + + this.statusSignal.set('checking'); + this.checkSessionOnce(webSessionID).subscribe(session => { + if (!session?.active) { + this.clearAuthState('unauthenticated'); + } + }); + } + + /** Check session without mutating internal state (used for polling). */ + checkSessionOnce(webSessionID = this.getStoredAdminSessionID()): Observable { + if (!webSessionID) { + return of(null); + } + + return this.http.get>( + `${this.adminAuthApiUrl}/sessions/${encodeURIComponent(webSessionID)}` + ).pipe( + map(response => this.normalizeSession(response, webSessionID)), + tap(session => { + if (session?.active) { + this.activateSession(session); + } + }), + catchError(() => of(null)) + ); + } + + /** Create a backend admin web session, to be scanned/opened the same way customer QR login works. */ + createWebSession(): Observable { + const webSessionID = generateGuid(); + + return this.http.post>( + `${this.adminAuthApiUrl}/sessions`, + { webSessionID }, + { headers: { AdminWebSessionID: webSessionID } } + ).pipe( + map(response => { + const responseWebSessionID = this.extractSessionId(response, webSessionID); + return { + webSessionID: responseWebSessionID, + url: this.getAdminLoginUrl(responseWebSessionID), + }; + }) + ); + } + + getAdminLoginUrl(webSessionID: string): string { + const botUsername = this.getAdminBotUsername(); + return `https://t.me/${botUsername}?start=admin_${encodeURIComponent(webSessionID)}`; + } + + getAdminAppLoginUrl(webSessionID: string): string { + const botUsername = this.getAdminBotUsername(); + return `tg://resolve?domain=${encodeURIComponent(botUsername)}&start=admin_${encodeURIComponent(webSessionID)}`; + } + + onLoginComplete(): void { + this.hideLogin(); + if (!this.isAuthenticated()) { + this.checkSession(); + } + } + + requestLogin(): void { + this.showLoginSignal.set(true); + } + + hideLogin(): void { + this.showLoginSignal.set(false); + } + + logout(): void { + const webSessionID = this.sessionSignal()?.sessionId || this.getStoredAdminSessionID(); + if (!webSessionID) { + this.clearAuthState('unauthenticated'); + return; + } + + this.http.delete(`${this.adminAuthApiUrl}/sessions/${encodeURIComponent(webSessionID)}`, { + headers: { AdminWebSessionID: webSessionID } + }).pipe(catchError(() => of(null))).subscribe(() => this.clearAuthState('unauthenticated')); + } + + /** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */ + getAdminToken(): string | null { + return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY); + } + + setAdminTokens(token: string, refreshToken: string): void { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token); + localStorage.setItem(ADMIN_REFRESH_STORAGE_KEY, refreshToken); + } + + clearAdminTokens(): void { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); + localStorage.removeItem(ADMIN_REFRESH_STORAGE_KEY); + } + + private activateSession(session: AdminSession): void { + this.sessionSignal.set(session); + this.statusSignal.set('authenticated'); + this.setStoredAdminSessionID(session.sessionId); + this.scheduleSessionRefresh(session.expires); + } + + private clearAuthState(status: AdminAuthStatus): void { + this.sessionSignal.set(null); + this.statusSignal.set(status); + this.clearStoredAdminSessionID(); + this.clearAdminTokens(); + this.clearSessionRefresh(); + } + + private scheduleSessionRefresh(expiresAt: string): void { + this.clearSessionRefresh(); + const expiresMs = new Date(expiresAt).getTime(); + const nowMs = Date.now(); + const refreshIn = Number.isFinite(expiresMs) + ? Math.max(expiresMs - nowMs - 60_000, 30_000) + : ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS * 1000; + + this.sessionCheckTimer = setTimeout(() => this.checkSession(), refreshIn); + } + + private clearSessionRefresh(): void { + if (this.sessionCheckTimer) { + clearTimeout(this.sessionCheckTimer); + this.sessionCheckTimer = undefined; + } + } + + private normalizeSession(response: Record | null, fallbackSessionId: string): AdminSession | null { + if (!response) { + return null; + } + + const status = this.readFirst(response, ['status', 'Status', 'active', 'Active', 'authenticated', 'Authenticated']); + const active = this.isActiveStatus(status); + const sessionId = this.extractSessionId(response, fallbackSessionId); + const username = this.readString(this.readFirst(response, ['username', 'Username'])); + const displayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name'])) ?? username ?? 'Admin'; + const role = this.readString(this.readFirst(response, ['role', 'Role'])); + const adminId = this.readNumber(this.readFirst(response, ['adminId', 'AdminId', 'userId', 'UserId', 'id', 'ID'])); + const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires'])) + ?? new Date(Date.now() + ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS * 1000).toISOString(); + + return { sessionId, adminId, username, displayName, role, active, expires: expiresAt }; + } + + private extractSessionId(response: Record | null, fallbackSessionId: string): string { + if (!response) { + return fallbackSessionId; + } + return this.readString(this.readFirst(response, [ + 'webSessionID', 'WebSessionID', 'sessionID', 'SessionID', 'sessionId', 'id', 'ID' + ])) ?? fallbackSessionId; + } + + private readFirst(source: Record, keys: string[]): unknown { + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + return source[key]; + } + } + return undefined; + } + + private readString(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) { + return value; + } + if (typeof value === 'number' || typeof value === 'bigint') { + return value.toString(); + } + return null; + } + + private readNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + } + + private isActiveStatus(status: unknown): boolean { + if (status === true || status === 1) { + return true; + } + if (typeof status !== 'string') { + return false; + } + return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase()); + } + + private getStoredAdminSessionID(): string | null { + if (typeof document === 'undefined') { + return null; + } + const cookie = document.cookie.split('; ').find(row => row.startsWith(`${ADMIN_SESSION_COOKIE}=`)); + if (!cookie) { + return null; + } + try { + return decodeURIComponent(cookie.substring(ADMIN_SESSION_COOKIE.length + 1)); + } catch { + return null; + } + } + + private setStoredAdminSessionID(webSessionID: string): void { + if (typeof document === 'undefined') { + return; + } + const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : ''; + document.cookie = `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Strict${secure}`; + } + + private clearStoredAdminSessionID(): void { + if (typeof document === 'undefined') { + return; + } + document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`; + } + + private getAdminBotUsername(): string { + return (environment as Record)['adminTelegramBot'] as string + ?? (environment as Record)['telegramBot'] as string + ?? 'DexarSupport_bot'; + } +} diff --git a/src/app/core/admin-auth/admin-login.component.html b/src/app/core/admin-auth/admin-login.component.html new file mode 100644 index 0000000..2650ed8 --- /dev/null +++ b/src/app/core/admin-auth/admin-login.component.html @@ -0,0 +1,72 @@ +@if (showDialog()) { + +} diff --git a/src/app/core/admin-auth/admin-login.component.scss b/src/app/core/admin-auth/admin-login.component.scss new file mode 100644 index 0000000..a1302ad --- /dev/null +++ b/src/app/core/admin-auth/admin-login.component.scss @@ -0,0 +1,254 @@ +.login-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.2s ease; + padding: 16px; +} + +.login-dialog { + position: relative; + background: var(--bg-card, #fff); + border-radius: 20px; + padding: 32px 28px; + max-width: 400px; + width: 100%; + text-align: center; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + animation: scaleIn 0.25s ease; +} + +.close-btn { + position: absolute; + top: 12px; + right: 12px; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: var(--bg-hover, #f0f0f0); + color: var(--text-secondary, #666); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + background: #e0e0e0; + color: #333; + } +} + +.login-icon { + margin: 0 auto 16px; + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--accent-light, rgba(73, 118, 113, 0.1)); + color: var(--accent-color, #497671); + display: flex; + align-items: center; + justify-content: center; +} + +h2 { + margin: 0 0 8px; + font-size: 20px; + font-weight: 700; + color: var(--text-primary, #1a1a1a); +} + +.login-desc { + margin: 0 0 24px; + font-size: 14px; + color: var(--text-secondary, #666); + line-height: 1.5; +} + +.telegram-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 14px 24px; + border: none; + border-radius: 12px; + background: #2AABEE; + color: #fff; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: #229ED9; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3); + } + + &:active { + transform: translateY(0); + } + + .tg-icon { + flex-shrink: 0; + } +} + +.bot-link { + display: block; + margin-top: 10px; + color: var(--accent-color, #497671); + font-size: 12px; + line-height: 1.35; + overflow-wrap: anywhere; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.qr-section { + margin-top: 20px; + + .qr-hint { + margin: 0 0 12px; + font-size: 13px; + color: var(--text-secondary, #999); + } + + .qr-container { + display: inline-flex; + padding: 12px; + background: #fff; + border-radius: 12px; + border: 1px solid #e8e8e8; + + img { + display: block; + border-radius: 4px; + } + + &.qr-loading { + align-items: center; + justify-content: center; + width: 204px; + height: 204px; + + .spinner { + width: 32px; + height: 32px; + border: 3px solid #e0e0e0; + border-top-color: var(--accent-color, #497671); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + } + + &.qr-expired { + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + width: 204px; + height: 204px; + cursor: pointer; + color: var(--text-secondary, #999); + transition: color 0.2s ease; + + &:hover { + color: var(--accent-color, #497671); + } + + span { + font-size: 13px; + } + } + + &.qr-error { + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + width: 204px; + height: 204px; + cursor: pointer; + color: var(--text-secondary, #999); + transition: color 0.2s ease; + + &:hover { + color: var(--accent-color, #497671); + } + + span { + font-size: 13px; + } + } + } +} + +.login-note { + margin: 16px 0 0; + font-size: 12px; + color: var(--text-secondary, #999); + line-height: 1.4; +} + +.login-status { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 16px; + color: var(--text-secondary, #666); + font-size: 14px; + + .spinner { + width: 20px; + height: 20px; + border: 2px solid #e0e0e0; + border-top-color: var(--accent-color, #497671); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 480px) { + .login-dialog { + padding: 24px 20px; + border-radius: 16px; + } + + .qr-section .qr-container img { + width: 140px; + height: 140px; + } +} diff --git a/src/app/core/admin-auth/admin-login.component.ts b/src/app/core/admin-auth/admin-login.component.ts new file mode 100644 index 0000000..f49518a --- /dev/null +++ b/src/app/core/admin-auth/admin-login.component.ts @@ -0,0 +1,54 @@ +import { Component, ChangeDetectionStrategy, inject, effect, OnDestroy } from '@angular/core'; +import { AdminAuthService } from './admin-auth.service'; +import { TranslatePipe } from '../../i18n/translate.pipe'; +import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine'; +import { QrLoginAdapter } from '../../shared/qr-login/qr-login.model'; +import { AdminSession } from '../../models/admin-auth.model'; + +@Component({ + selector: 'app-admin-login', + standalone: true, + imports: [TranslatePipe], + templateUrl: './admin-login.component.html', + styleUrls: ['./admin-login.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AdminLoginComponent implements OnDestroy { + private readonly adminAuth = inject(AdminAuthService); + + readonly showDialog = this.adminAuth.showLoginDialog; + readonly status = this.adminAuth.status; + + private readonly adapter: QrLoginAdapter = { + createSession: () => this.adminAuth.createWebSession(), + checkSessionOnce: webSessionID => this.adminAuth.checkSessionOnce(webSessionID), + isSessionActive: session => !!session?.active, + getAppLoginUrl: webSessionID => this.adminAuth.getAdminAppLoginUrl(webSessionID), + onLoginComplete: () => this.adminAuth.onLoginComplete(), + }; + + private readonly engine = new QrLoginEngine(this.adapter); + readonly qrStatus = this.engine.qrStatus; + readonly encodedQrUrl = this.engine.encodedQrUrl; + + constructor() { + effect(() => this.engine.setActive(this.showDialog())); + } + + ngOnDestroy(): void { + this.engine.destroy(); + } + + close(): void { + this.engine.setActive(false); + this.adminAuth.hideLogin(); + } + + openAppLogin(): void { + this.engine.openAppLogin(); + } + + refreshQr(): void { + this.engine.refresh(); + } +} diff --git a/src/app/core/admin-auth/ed25519-verification.model.ts b/src/app/core/admin-auth/ed25519-verification.model.ts new file mode 100644 index 0000000..989636c --- /dev/null +++ b/src/app/core/admin-auth/ed25519-verification.model.ts @@ -0,0 +1,31 @@ +import { Observable } from 'rxjs'; + +/** + * Prep interfaces for a future Ed25519 challenge/response admin auth flow. + * No crypto is implemented here - verification is delegated to an injectable + * service so the real implementation (native WebCrypto Ed25519 support, or a + * backend verification call) can be swapped in once the backend API exists, + * without touching AdminAuthService or components. + */ +export interface Ed25519Challenge { + nonce: string; + timestamp: string; + /** Opaque challenge payload the client must sign with its private key. */ + payload: string; +} + +export interface Ed25519SignedResponse { + challenge: Ed25519Challenge; + publicKey: string; + signature: string; +} + +export interface Ed25519VerificationResult { + valid: boolean; + reason?: string; +} + +export abstract class Ed25519VerificationService { + abstract requestChallenge(): Observable; + abstract verify(response: Ed25519SignedResponse): Observable; +} diff --git a/src/app/core/admin-auth/noop-ed25519-verification.service.ts b/src/app/core/admin-auth/noop-ed25519-verification.service.ts new file mode 100644 index 0000000..f57a84f --- /dev/null +++ b/src/app/core/admin-auth/noop-ed25519-verification.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Observable, throwError } from 'rxjs'; +import { Ed25519Challenge, Ed25519SignedResponse, Ed25519VerificationResult, Ed25519VerificationService } from './ed25519-verification.model'; + +/** + * Default DI binding for Ed25519VerificationService until the backend ships + * the real challenge/verify endpoints. Intentionally fails closed (throws) + * rather than pretending to verify anything, so accidental use in a login + * path is loud instead of silently accepting unsigned sessions. + */ +@Injectable({ providedIn: 'root' }) +export class NoopEd25519VerificationService implements Ed25519VerificationService { + requestChallenge(): Observable { + return throwError(() => new Error('Ed25519 challenge endpoint is not yet available from the backend.')); + } + + verify(_response: Ed25519SignedResponse): Observable { + return throwError(() => new Error('Ed25519 verification endpoint is not yet available from the backend.')); + } +} diff --git a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html index 175bf9d..e323b01 100644 --- a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html +++ b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html @@ -1,8 +1,16 @@
+ @if (draftRestored()) { +
+ {{ 'builder.draftRestored' | translate }} + +
+ }
{{ (status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }} @if (dirty()) { {{ 'builder.unsavedChanges' | translate }} + } @else if (lastSavedAt()) { + {{ 'builder.lastSaved' | translate }}: {{ formatSavedAt(lastSavedAt()!) }} } @if (issues().length > 0) {
    @@ -13,6 +21,7 @@ }
+
diff --git a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss index 4a213a4..dca6f03 100644 --- a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss +++ b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss @@ -25,3 +25,27 @@ .project-editor-save-bar-actions button + button { margin-left: 0.5rem; } + +.project-editor-save-bar-saved-at { + color: var(--muted-foreground, #6b7280); + margin-left: 0.5rem; + font-size: 0.85em; +} + +.project-editor-save-bar-notice { + position: absolute; + top: -2.5rem; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + background: var(--info-bg, #eff6ff); + color: var(--info, #1d4ed8); + border-bottom: 1px solid var(--border, #ddd); +} + +.project-editor-save-bar-reset { + color: var(--danger, #b91c1c); +} diff --git a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts index 3b858ed..e4ed5f8 100644 --- a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts +++ b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { TranslateService } from '../../../../i18n/translate.service'; import { ProjectEditorFacade } from '../../facade/project-editor.facade'; @Component({ @@ -12,9 +13,12 @@ import { ProjectEditorFacade } from '../../facade/project-editor.facade'; }) export class ProjectEditorSaveBarComponent { private readonly facade = inject(ProjectEditorFacade); + private readonly translate = inject(TranslateService); readonly dirty = this.facade.dirty; readonly status = this.facade.status; readonly issues = this.facade.validationIssues; + readonly lastSavedAt = this.facade.lastSavedAt; + readonly draftRestored = this.facade.draftRestored; save(): void { this.facade.save(); @@ -23,4 +27,18 @@ export class ProjectEditorSaveBarComponent { publish(): void { this.facade.publish(); } + + resetDraft(): void { + if (confirm(this.translate.t('builder.confirmResetDraft'))) { + this.facade.resetDraft(); + } + } + + formatSavedAt(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString(); + } + + dismissDraftRestoredNotice(): void { + this.facade.dismissDraftRestoredNotice(); + } } diff --git a/src/app/features/project-editor/facade/project-editor.facade.ts b/src/app/features/project-editor/facade/project-editor.facade.ts index 07e56bb..71418c2 100644 --- a/src/app/features/project-editor/facade/project-editor.facade.ts +++ b/src/app/features/project-editor/facade/project-editor.facade.ts @@ -9,6 +9,8 @@ import { LocaleSyncService } from '../services/locale-sync.service'; import { ProjectEditorState } from '../models/project-editor.model'; import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service'; import { ProjectValidator } from '../services/project-validator.service'; +import { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service'; +import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model'; @Injectable({ providedIn: 'root' }) export class ProjectEditorFacade { @@ -18,13 +20,17 @@ export class ProjectEditorFacade { private readonly localeSync = inject(LocaleSyncService); private readonly runtime = inject(PlatformRuntimeService); private readonly validator = inject(ProjectValidator); + private readonly draftStorage = inject(ProjectEditorDraftStorageService); private readonly state = signal({ bootstrap: null, + originalBootstrap: null, importError: null, activeSection: 'general', status: 'draft', lastSavedBootstrap: null, + lastSavedAt: null, + draftRestored: false, }); readonly bootstrap = computed(() => this.state().bootstrap); @@ -33,6 +39,8 @@ export class ProjectEditorFacade { readonly homepagePage = computed(() => this.bootstrap()?.pages.find(page => page.key === 'home' || page.route.path === '/') ?? null); readonly homepageWidgets = computed(() => this.homepagePage()?.sections.flatMap(section => section.widgets.map(widget => ({ sectionId: section.id, sectionType: section.type, widget }))) ?? []); readonly status = computed(() => this.state().status); + readonly lastSavedAt = computed(() => this.state().lastSavedAt); + readonly draftRestored = computed(() => this.state().draftRestored); readonly validationIssues = computed(() => { const current = this.bootstrap(); return current ? this.validator.validate(current) : []; @@ -49,7 +57,19 @@ export class ProjectEditorFacade { this.configService.loadBootstrap(true).pipe(take(1)).subscribe({ next: config => { const normalized = this.normalize(JSON.parse(JSON.stringify(config)) as BootstrapConfig); - this.state.update(current => ({ ...current, bootstrap: normalized, importError: null, lastSavedBootstrap: normalized, status: 'draft' })); + const storedDraft = this.draftStorage.load(normalized.tenant.id); + const restoredFromDraft = storedDraft !== null; + const bootstrap = restoredFromDraft ? this.normalize(storedDraft!.bootstrap) : normalized; + this.state.update(current => ({ + ...current, + bootstrap, + originalBootstrap: normalized, + importError: null, + lastSavedBootstrap: normalized, + lastSavedAt: restoredFromDraft ? storedDraft!.savedAt : null, + status: 'draft', + draftRestored: restoredFromDraft, + })); }, error: () => this.state.update(current => ({ ...current, bootstrap: null, importError: 'builder.importError' })), }); @@ -61,10 +81,13 @@ export class ProjectEditorFacade { return; } - this.state.update(state => ({ - ...state, - bootstrap: this.normalize(updater(JSON.parse(JSON.stringify(current)) as BootstrapConfig)), - })); + const next = this.normalize(updater(JSON.parse(JSON.stringify(current)) as BootstrapConfig)); + this.state.update(state => ({ ...state, bootstrap: next, draftRestored: false })); + this.draftStorage.save(next); + } + + dismissDraftRestoredNotice(): void { + this.state.update(current => ({ ...current, draftRestored: false })); } exportBootstrap(): string { @@ -115,7 +138,35 @@ export class ProjectEditorFacade { if (!current) { return; } - this.state.update(state => ({ ...state, lastSavedBootstrap: JSON.parse(JSON.stringify(current)) })); + const savedAt = this.draftStorage.save(current); + this.state.update(state => ({ ...state, lastSavedBootstrap: JSON.parse(JSON.stringify(current)), lastSavedAt: savedAt })); + } + + /** Reverts one section's bootstrap keys to the originally loaded/published snapshot. Caller is responsible for confirmation UX. */ + resetSection(sectionId: ProjectEditorState['activeSection']): void { + const original = this.state().originalBootstrap; + const keys = EDITOR_SECTION_BOOTSTRAP_KEYS[sectionId]; + if (!original || !keys) { + return; + } + this.updateBootstrap(current => { + const patch: Partial = {}; + for (const key of keys) { + (patch as Record)[key] = JSON.parse(JSON.stringify(original[key])); + } + return { ...current, ...patch }; + }); + } + + /** Discards the entire draft, reverting to the originally loaded/published bootstrap. Caller is responsible for confirmation UX. */ + resetDraft(): void { + const original = this.state().originalBootstrap; + if (!original) { + return; + } + const reset = this.normalize(JSON.parse(JSON.stringify(original)) as BootstrapConfig); + this.draftStorage.clear(); + this.state.update(state => ({ ...state, bootstrap: reset, lastSavedAt: null, draftRestored: false })); } publish(): boolean { @@ -124,10 +175,13 @@ export class ProjectEditorFacade { return false; } this.runtime.reloadFromBootstrap(current); + const savedAt = this.draftStorage.save(current); this.state.update(state => ({ ...state, status: 'published', lastSavedBootstrap: JSON.parse(JSON.stringify(current)), + originalBootstrap: JSON.parse(JSON.stringify(current)), + lastSavedAt: savedAt, })); return true; } diff --git a/src/app/features/project-editor/models/project-editor.model.ts b/src/app/features/project-editor/models/project-editor.model.ts index 339ec17..971dd07 100644 --- a/src/app/features/project-editor/models/project-editor.model.ts +++ b/src/app/features/project-editor/models/project-editor.model.ts @@ -16,12 +16,28 @@ export type ProjectEditorSectionId = export interface ProjectEditorState { bootstrap: BootstrapConfig | null; + originalBootstrap: BootstrapConfig | null; importError: string | null; activeSection: ProjectEditorSectionId; status: 'draft' | 'published'; lastSavedBootstrap: BootstrapConfig | null; + lastSavedAt: number | null; + draftRestored: boolean; } +export const EDITOR_SECTION_BOOTSTRAP_KEYS: Partial> = { + general: ['tenant', 'seo'], + branding: ['branding'], + theme: ['theme'], + header: ['header'], + footer: ['footer', 'company'], + homepage: ['pages'], + widgets: ['pages'], + features: ['featureFlags', 'userExperience', 'catalog', 'productPage'], + languages: ['localization'], + navigation: ['navigation'], +}; + export interface ProjectEditorWidgetPreset { id: string; type: string; diff --git a/src/app/features/project-editor/pages/project-editor-page.component.html b/src/app/features/project-editor/pages/project-editor-page.component.html index fa0d546..46b770c 100644 --- a/src/app/features/project-editor/pages/project-editor-page.component.html +++ b/src/app/features/project-editor/pages/project-editor-page.component.html @@ -13,6 +13,11 @@ } @else {
+ @if (canResetActiveSection()) { +
+ +
+ } @switch (activeSection()) { @case ('general') { } @case ('branding') { } diff --git a/src/app/features/project-editor/pages/project-editor-page.component.scss b/src/app/features/project-editor/pages/project-editor-page.component.scss index bec0e1e..9d713c7 100644 --- a/src/app/features/project-editor/pages/project-editor-page.component.scss +++ b/src/app/features/project-editor/pages/project-editor-page.component.scss @@ -25,6 +25,20 @@ gap: 16px; } +.project-editor-section-actions { + display: flex; + justify-content: flex-end; +} + +.project-editor-section-actions button { + color: #b91c1c; + background: transparent; + border: 1px solid #d3dad9; + border-radius: 8px; + padding: 6px 12px; + cursor: pointer; +} + .project-editor-empty { min-height: 240px; display: grid; diff --git a/src/app/features/project-editor/pages/project-editor-page.component.ts b/src/app/features/project-editor/pages/project-editor-page.component.ts index 31e54a7..ae8232c 100644 --- a/src/app/features/project-editor/pages/project-editor-page.component.ts +++ b/src/app/features/project-editor/pages/project-editor-page.component.ts @@ -16,9 +16,10 @@ import { ProjectEditorLanguagesSectionComponent } from '../sections/languages-se import { ProjectEditorNavigationSectionComponent } from '../sections/navigation-section.component'; import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component'; import { TranslatePipe } from '../../../i18n/translate.pipe'; +import { TranslateService } from '../../../i18n/translate.service'; import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component'; import { ProjectEditorSaveBarComponent } from '../components/save-bar/project-editor-save-bar.component'; -import { ProjectEditorSectionId } from '../models/project-editor.model'; +import { EDITOR_SECTION_BOOTSTRAP_KEYS, ProjectEditorSectionId } from '../models/project-editor.model'; const KNOWN_SECTIONS: ProjectEditorSectionId[] = [ 'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'navigation', 'preview' @@ -51,8 +52,10 @@ const KNOWN_SECTIONS: ProjectEditorSectionId[] = [ export class ProjectEditorPageComponent { readonly facade = inject(ProjectEditorFacade); private readonly route = inject(ActivatedRoute); + private readonly translate = inject(TranslateService); readonly bootstrap = this.facade.bootstrap; readonly activeSection = this.facade.activeSection; + readonly canResetActiveSection = () => !!EDITOR_SECTION_BOOTSTRAP_KEYS[this.activeSection()]; private readonly routeSection = toSignal( this.route.paramMap.pipe(map(params => params.get('section') as ProjectEditorSectionId | null)), @@ -69,6 +72,12 @@ export class ProjectEditorPageComponent { }); } + resetActiveSection(): void { + if (confirm(this.translate.t('builder.confirmResetSection'))) { + this.facade.resetSection(this.activeSection()); + } + } + @HostListener('window:beforeunload', ['$event']) warnBeforeUnload(event: BeforeUnloadEvent): void { if (this.facade.dirty()) { diff --git a/src/app/features/project-editor/services/project-editor-draft-storage.service.ts b/src/app/features/project-editor/services/project-editor-draft-storage.service.ts new file mode 100644 index 0000000..2f2c0a2 --- /dev/null +++ b/src/app/features/project-editor/services/project-editor-draft-storage.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; +import { BootstrapConfig } from '../../../shared/models/config'; + +const DRAFT_STORAGE_KEY = 'projectEditor.draftBootstrap.v1'; +const DRAFT_SAVED_AT_KEY = 'projectEditor.draftSavedAt.v1'; + +interface StoredDraft { + tenantId: string; + bootstrap: BootstrapConfig; + savedAt: number; +} + +@Injectable({ providedIn: 'root' }) +export class ProjectEditorDraftStorageService { + save(bootstrap: BootstrapConfig): number { + const savedAt = Date.now(); + const payload: StoredDraft = { tenantId: bootstrap.tenant.id, bootstrap, savedAt }; + try { + localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(payload)); + localStorage.setItem(DRAFT_SAVED_AT_KEY, String(savedAt)); + } catch { + // storage unavailable (private mode / quota) - draft simply won't persist + } + return savedAt; + } + + load(tenantId: string): { bootstrap: BootstrapConfig; savedAt: number } | null { + try { + const raw = localStorage.getItem(DRAFT_STORAGE_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as StoredDraft; + if (parsed.tenantId !== tenantId) { + return null; + } + return { bootstrap: parsed.bootstrap, savedAt: parsed.savedAt }; + } catch { + return null; + } + } + + clear(): void { + try { + localStorage.removeItem(DRAFT_STORAGE_KEY); + localStorage.removeItem(DRAFT_SAVED_AT_KEY); + } catch { + // ignore + } + } +} diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 2a62ba6..32d14bb 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -522,6 +522,13 @@ export const en: Translations = { promptImageUrl: 'Image URL', htmlEditorCode: 'Code', htmlEditorPreview: 'Preview', + lastSaved: 'Last saved', + draftRestored: 'A local draft was restored from your last session.', + dismiss: 'Dismiss', + resetDraft: 'Reset draft', + resetSection: 'Reset section', + confirmResetDraft: 'This discards all unpublished changes and restores the last published configuration. Continue?', + confirmResetSection: 'This discards unpublished changes in this section only. Continue?', }, staticPages: { notFound: 'Page not found', @@ -555,6 +562,16 @@ export const en: Translations = { qrExpired: 'QR code expired. Click to refresh', qrError: 'Could not create login session. Click to retry', }, + adminAuth: { + loginRequired: 'Admin login required', + loginDescription: 'Log in with your admin account to continue. This is a separate session from the storefront login.', + checking: 'Checking...', + loginWithApp: 'Log in with app', + orScanQr: 'Or scan the QR code', + loginNote: 'You will be redirected back after login', + qrExpired: 'QR code expired. Click to refresh', + qrError: 'Could not create login session. Click to retry', + }, ux: { items: 'items', wishlistTitle: 'Wishlist', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index c3586e1..cf362b3 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -522,6 +522,13 @@ export const hy: Translations = { promptImageUrl: 'Նկարի URL', htmlEditorCode: 'Կոդ', htmlEditorPreview: 'Նախադիտում', + lastSaved: 'Վերջին պահպանումը', + draftRestored: 'Տեղական սևագիրը վերականգնվել է նախորդ սեսիայից։', + dismiss: 'Փակել', + resetDraft: 'Զրոյացնել սևագիրը', + resetSection: 'Զրոյացնել սեկցիան', + confirmResetDraft: 'Սա կչեղարկի բոլոր չհրապարակված փոփոխությունները և կվերականգնի վերջին հրապարակված կոնֆիգուրացիան։ Շարունակե՞լ։', + confirmResetSection: 'Սա կչեղարկի չհրապարակված փոփոխությունները միայն այս սեկցիայում։ Շարունակե՞լ։', }, staticPages: { notFound: 'Էջը չի գտնվել', @@ -555,6 +562,16 @@ export const hy: Translations = { qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար', qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար', }, + adminAuth: { + loginRequired: 'Անհրաժեշտ է ադմինի մուտք', + loginDescription: 'Մուտք գործեք ադմինի հաշվով։ Սա առանձին սեսիա է՝ խանութի մուտքից անկախ։', + checking: 'Ստուգում...', + loginWithApp: 'Մուտք հավելվածով', + orScanQr: 'Կամ սքանավորեք QR կոդը', + loginNote: 'Մուտքից հետո դուք կվերաուղղվեք', + qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար', + qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար', + }, ux: { items: 'ապրանք', wishlistTitle: 'Ընտրյալներ', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index ff24232..1d063ef 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -522,6 +522,13 @@ export const ru: Translations = { promptImageUrl: 'URL изображения', htmlEditorCode: 'Код', htmlEditorPreview: 'Предпросмотр', + lastSaved: 'Последнее сохранение', + draftRestored: 'Локальный черновик восстановлен из предыдущей сессии.', + dismiss: 'Скрыть', + resetDraft: 'Сбросить черновик', + resetSection: 'Сбросить секцию', + confirmResetDraft: 'Это отменит все неопубликованные изменения и восстановит последнюю опубликованную конфигурацию. Продолжить?', + confirmResetSection: 'Это отменит неопубликованные изменения только в этой секции. Продолжить?', }, staticPages: { notFound: 'Страница не найдена', @@ -555,6 +562,16 @@ export const ru: Translations = { qrExpired: 'QR-код устарел. Нажмите, чтобы обновить', qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить', }, + adminAuth: { + loginRequired: 'Требуется вход администратора', + loginDescription: 'Войдите под учётной записью администратора. Это отдельная сессия от входа покупателя.', + checking: 'Проверка...', + loginWithApp: 'Войти через приложение', + orScanQr: 'Или отсканируйте QR-код', + loginNote: 'После входа вы будете перенаправлены обратно', + qrExpired: 'QR-код устарел. Нажмите, чтобы обновить', + qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить', + }, ux: { items: 'товаров', wishlistTitle: 'Избранное', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 51e7416..e577428 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -520,6 +520,13 @@ export interface Translations { promptImageUrl: string; htmlEditorCode: string; htmlEditorPreview: string; + lastSaved: string; + draftRestored: string; + dismiss: string; + resetDraft: string; + resetSection: string; + confirmResetDraft: string; + confirmResetSection: string; }; staticPages: { notFound: string; @@ -553,6 +560,16 @@ export interface Translations { qrExpired: string; qrError: string; }; + adminAuth: { + loginRequired: string; + loginDescription: string; + checking: string; + loginWithApp: string; + orScanQr: string; + loginNote: string; + qrExpired: string; + qrError: string; + }; ux: { items: string; wishlistTitle: string; diff --git a/src/app/models/admin-auth.model.ts b/src/app/models/admin-auth.model.ts new file mode 100644 index 0000000..bc18439 --- /dev/null +++ b/src/app/models/admin-auth.model.ts @@ -0,0 +1,16 @@ +export interface AdminSession { + sessionId: string; + adminId: number | null; + username: string | null; + displayName: string; + role: string | null; + active: boolean; + expires: string; +} + +export interface AdminWebSessionStart { + webSessionID: string; + url: string; +} + +export type AdminAuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated'; diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index b3b767d..56df48f 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable, of, catchError, map, tap } from 'rxjs'; import { AuthSession, AuthStatus, WebSessionStart } from '../models/auth.model'; import { environment } from '../../environments/environment'; +import { generateGuid } from '../shared/util/guid.util'; const WEB_SESSION_COOKIE = 'webSessionID'; const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60; @@ -83,7 +84,7 @@ export class AuthService { } /** Generate the Telegram login URL for bot-based auth */ - getTelegramLoginUrl(webSessionID = this.generateGuid()): string { + getTelegramLoginUrl(webSessionID = generateGuid()): string { const botUsername = this.getTelegramBotUsername(); return `https://t.me/${botUsername}?start=${encodeURIComponent(webSessionID)}`; } @@ -101,7 +102,7 @@ export class AuthService { /** Create a backend web session and return the Telegram start link for it. */ createWebSession(): Observable { - const webSessionID = this.generateGuid(); + const webSessionID = generateGuid(); return this.http.post>( `${this.authApiUrl}/users/sessions`, @@ -297,27 +298,6 @@ export class AuthService { return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase()); } - private generateGuid(): string { - if (globalThis.crypto?.randomUUID) { - return globalThis.crypto.randomUUID(); - } - - const bytes = new Uint8Array(16); - if (globalThis.crypto?.getRandomValues) { - globalThis.crypto.getRandomValues(bytes); - } else { - for (let index = 0; index < bytes.length; index++) { - bytes[index] = Math.floor(Math.random() * 256); - } - } - - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - - const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')); - return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; - } - private getStoredWebSessionID(): string | null { if (typeof document === 'undefined') { return null; diff --git a/src/app/shared/qr-login/qr-login.engine.ts b/src/app/shared/qr-login/qr-login.engine.ts new file mode 100644 index 0000000..96ba112 --- /dev/null +++ b/src/app/shared/qr-login/qr-login.engine.ts @@ -0,0 +1,155 @@ +import { signal, computed } from '@angular/core'; +import { QrLoginAdapter, QrLoginStatus } from './qr-login.model'; + +const POLL_INTERVAL_MS = 5000; +const MAX_POLLS = 100; + +/** + * Shared QR-login state machine: session creation, polling, expiry, and the + * "returned from the messenger app" recovery flow (visibilitychange/focus/pageshow). + * Extracted from the original TelegramLoginComponent so admin login (and any + * future QR login surface) can reuse it instead of duplicating timers/listeners. + * Not a DI singleton — instantiate one per component instance. + */ +export class QrLoginEngine { + readonly loginUrl = signal(''); + readonly webSessionID = signal(''); + readonly qrStatus = signal('loading'); + readonly encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl())); + readonly awaitingAppReturn = signal(false); + + private pollTimer?: ReturnType; + private active = false; + + private readonly handleVisibilityChange = () => { + if (typeof document !== 'undefined' && document.visibilityState === 'visible') { + this.checkAfterReturn(); + } + }; + private readonly handleWindowFocus = () => this.checkAfterReturn(); + private readonly handlePageShow = () => this.checkAfterReturn(); + + constructor(private readonly adapter: QrLoginAdapter) { + if (typeof window !== 'undefined') { + document.addEventListener('visibilitychange', this.handleVisibilityChange); + window.addEventListener('focus', this.handleWindowFocus); + window.addEventListener('pageshow', this.handlePageShow); + } + } + + /** Call from an effect() watching the dialog-visibility signal. */ + setActive(active: boolean): void { + this.active = active; + if (active) { + this.init(); + } else { + this.awaitingAppReturn.set(false); + this.stopPolling(); + } + } + + refresh(): void { + this.awaitingAppReturn.set(false); + this.stopPolling(); + this.init(); + } + + openAppLogin(): void { + const webSessionID = this.webSessionID(); + if (!webSessionID || typeof window === 'undefined') return; + + if (!this.pollTimer) { + this.startPolling(webSessionID); + } + + this.awaitingAppReturn.set(true); + window.location.href = this.adapter.getAppLoginUrl(webSessionID); + } + + destroy(): void { + this.awaitingAppReturn.set(false); + this.stopPolling(); + + if (typeof window !== 'undefined') { + document.removeEventListener('visibilitychange', this.handleVisibilityChange); + window.removeEventListener('focus', this.handleWindowFocus); + window.removeEventListener('pageshow', this.handlePageShow); + } + } + + private init(): void { + this.awaitingAppReturn.set(false); + this.qrStatus.set('loading'); + this.loginUrl.set(''); + this.webSessionID.set(''); + + this.adapter.createSession().subscribe({ + next: res => { + this.loginUrl.set(res.url); + this.webSessionID.set(res.webSessionID); + this.qrStatus.set('ready'); + this.startPolling(res.webSessionID); + }, + error: () => this.qrStatus.set('error'), + }); + } + + private startPolling(webSessionID: string): void { + this.stopPolling(); + if (!webSessionID) return; + + let checks = 0; + this.pollTimer = setInterval(() => { + checks++; + if (checks > MAX_POLLS) { + this.stopPolling(); + this.qrStatus.set('expired'); + return; + } + + this.adapter.checkSessionOnce(webSessionID).subscribe({ + next: session => { + if (this.adapter.isSessionActive(session)) { + this.awaitingAppReturn.set(false); + this.stopPolling(); + this.adapter.onLoginComplete(); + } + }, + error: () => { + // network error - keep polling + }, + }); + }, POLL_INTERVAL_MS); + } + + private stopPolling(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = undefined; + } + } + + private checkAfterReturn(): void { + if (!this.active || !this.awaitingAppReturn()) { + return; + } + + const webSessionID = this.webSessionID(); + if (!webSessionID) { + this.awaitingAppReturn.set(false); + return; + } + + if (!this.pollTimer) { + this.startPolling(webSessionID); + } + + this.adapter.checkSessionOnce(webSessionID).subscribe(session => { + if (this.adapter.isSessionActive(session)) { + this.awaitingAppReturn.set(false); + this.stopPolling(); + this.adapter.onLoginComplete(); + } + }); + } +} diff --git a/src/app/shared/qr-login/qr-login.model.ts b/src/app/shared/qr-login/qr-login.model.ts new file mode 100644 index 0000000..a83bb3f --- /dev/null +++ b/src/app/shared/qr-login/qr-login.model.ts @@ -0,0 +1,21 @@ +import { Observable } from 'rxjs'; + +export interface QrLoginSessionStart { + webSessionID: string; + url: string; +} + +export type QrLoginStatus = 'loading' | 'ready' | 'expired' | 'error'; + +/** + * Adapter every QR-login surface (customer Telegram login, admin login, ...) + * implements so QrLoginEngine can drive session creation/polling without + * knowing which auth service or storage backs it. + */ +export interface QrLoginAdapter { + createSession(): Observable; + checkSessionOnce(webSessionID: string): Observable; + isSessionActive(session: TSession | null): boolean; + getAppLoginUrl(webSessionID: string): string; + onLoginComplete(): void; +} diff --git a/src/app/shared/util/guid.util.ts b/src/app/shared/util/guid.util.ts new file mode 100644 index 0000000..88ec139 --- /dev/null +++ b/src/app/shared/util/guid.util.ts @@ -0,0 +1,21 @@ +/** RFC4122 v4-ish GUID, using crypto when available. Shared by customer and admin session creation. */ +export function generateGuid(): string { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + + const bytes = new Uint8Array(16); + if (globalThis.crypto?.getRandomValues) { + globalThis.crypto.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index++) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')); + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; +} diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts index 37c62b0..ff6c902 100644 --- a/src/environments/environment.production.ts +++ b/src/environments/environment.production.ts @@ -15,6 +15,8 @@ export const environment = { theme: 'dexar', apiUrl: 'https://api.dexarmarket.ru:445', authApiUrl: 'https://users.vitanova.network:456', + // Placeholder until backend delivers dedicated admin auth endpoints - keep path-scoped under authApiUrl so it's easy to repoint. + adminAuthApiUrl: 'https://users.vitanova.network:456/admin', qrApiUrl: 'https://qr.vitanova.network/api', logo: '/icons/icon-192x192.png', contactEmail: 'info@dexarmarket.ru', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 72c0611..4024fd3 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -16,6 +16,8 @@ export const environment = { theme: 'dexar', apiUrl: '/api', authApiUrl: 'https://users.vitanova.network:456', + // Placeholder until backend delivers dedicated admin auth endpoints - keep path-scoped under authApiUrl so it's easy to repoint. + adminAuthApiUrl: 'https://users.vitanova.network:456/admin', qrApiUrl: 'https://qr.vitanova.network/api', logo: '/icons/icon-192x192.png', contactEmail: 'info@dexarmarket.ru',