feat(sprint18): editor autosave/reset, admin auth, QR reuse, Ed25519 prep
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

- 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.
This commit is contained in:
sdarbinyan
2026-07-14 09:50:03 +04:00
parent c6482f0037
commit 3877b70fdf
33 changed files with 1423 additions and 171 deletions

View File

@@ -1,4 +1,4 @@
# Marketplace Project Editor - Sprint 13 # Marketplace Project Editor - Sprint 13 (updated Sprint 18)
## Scope ## Scope
@@ -128,22 +128,57 @@ Current widget editor supports explicit fields for:
Other widgets use JSON props fallback until dedicated editors are added. 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 There is still no backend draft/publish API. This sprint models it client-side
in `ProjectEditorFacade`: in `ProjectEditorFacade`:
- `status: 'draft' | 'published'` and `dirty` (diffed against the - `status: 'draft' | 'published'` and `dirty` (diffed against the
last-saved snapshot) live in facade state. last-saved snapshot) live in facade state.
- `save()` snapshots the current in-memory bootstrap as "last saved" (no - `save()` snapshots the current in-memory bootstrap as "last saved" and
network call yet). timestamps it (`lastSavedAt`).
- `publish()` runs `ProjectValidator`, and if there are no issues, applies - `publish()` runs `ProjectValidator`, and if there are no issues, applies
the bootstrap via `PlatformRuntimeService.reloadFromBootstrap` and marks the bootstrap via `PlatformRuntimeService.reloadFromBootstrap`, marks
status `published`. status `published`, and becomes the new `originalBootstrap` snapshot used
by reset.
**Backend gap, not yet implemented:** real persistence needs **Backend gap, not yet implemented:** real persistence needs
`PUT /builder/bootstrap/draft` and `POST /builder/bootstrap/publish` `PUT /builder/bootstrap/draft` and `POST /builder/bootstrap/publish`
endpoints so drafts/publishes survive a reload and are shared across editors. 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 ## Validation
`ProjectValidator` (`services/project-validator.service.ts`) runs on every `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 — external dependency. It emits raw HTML on every change and never sanitizes —
sanitization remains a storefront-render concern. 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<TSession>`) plus an adapter interface
(`shared/qr-login/qr-login.model.ts`, `QrLoginAdapter<TSession>`). 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 <adminToken>` 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 ## Constraints
- runtime bootstrap engine not replaced - runtime bootstrap engine not replaced

View File

@@ -7,6 +7,9 @@ import { cacheInterceptor } from './interceptors/cache.interceptor';
import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor'; import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor';
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor'; import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
import { mockDataInterceptor } from './interceptors/mock-data.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'; import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
@@ -18,8 +21,9 @@ export const appConfig: ApplicationConfig = {
withInMemoryScrolling({ scrollPositionRestoration: 'top' }) withInMemoryScrolling({ scrollPositionRestoration: 'top' })
), ),
provideHttpClient( provideHttpClient(
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, cacheInterceptor]) withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor])
), ),
{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService },
provideServiceWorker('ngsw-worker.js', { provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(), enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000' registrationStrategy: 'registerWhenStable:30000'

View File

@@ -25,4 +25,5 @@
<div class="footer-placeholder" aria-hidden="true"></div> <div class="footer-placeholder" aria-hidden="true"></div>
} }
<!-- <app-telegram-login /> --> <!-- <app-telegram-login /> -->
<app-admin-login />
} }

View File

@@ -15,10 +15,13 @@ import { PlatformRuntimeService } from './core/runtime/platform-runtime.service'
import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade'; import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
import { ApiHealthService } from './services/api-health.service'; import { ApiHealthService } from './services/api-health.service';
import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component'; 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({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent], imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent, AdminLoginComponent],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss'
}) })
@@ -37,6 +40,8 @@ export class App implements OnInit {
private platformRuntime = inject(PlatformRuntimeService); private platformRuntime = inject(PlatformRuntimeService);
private uiRuntime = inject(UiRuntimeFacade); private uiRuntime = inject(UiRuntimeFacade);
private apiHealth = inject(ApiHealthService); private apiHealth = inject(ApiHealthService);
private authService = inject(AuthService);
private adminAuthService = inject(AdminAuthService);
ngOnInit(): void { ngOnInit(): void {
this.platformRuntime.initialize(); this.platformRuntime.initialize();
@@ -44,6 +49,7 @@ export class App implements OnInit {
this.titleService.setTitle(`${this.uiRuntime.marketplaceDisplayName()} - ${this.i18n.t('app.pageTitle')}`); this.titleService.setTitle(`${this.uiRuntime.marketplaceDisplayName()} - ${this.i18n.t('app.pageTitle')}`);
this.checkServerHealth(); this.checkServerHealth();
this.setupAutoUpdates(); this.setupAutoUpdates();
this.openLoginDialogsFromTestModeQueryParams();
// Track route changes to show/hide back button // Track route changes to show/hide back button
this.router.events this.router.events
@@ -79,6 +85,20 @@ export class App implements OnInit {
this.checkServerHealth(); 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 { private setupAutoUpdates(): void {
if (!this.swUpdate.isEnabled) { if (!this.swUpdate.isEnabled) {
return; return;

View File

@@ -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 { AuthService } from '../../services/auth.service';
import { TranslatePipe } from '../../i18n/translate.pipe'; 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({ @Component({
selector: 'app-telegram-login', selector: 'app-telegram-login',
@@ -15,153 +18,39 @@ export class TelegramLoginComponent implements OnDestroy {
showDialog = this.authService.showLoginDialog; showDialog = this.authService.showLoginDialog;
status = this.authService.status; status = this.authService.status;
loginUrl = signal(''); private readonly adapter: QrLoginAdapter<AuthSession> = {
webSessionID = signal(''); createSession: () => this.authService.createWebSession(),
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading'); checkSessionOnce: webSessionID => this.authService.checkSessionOnce(webSessionID),
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl())); isSessionActive: session => !!session?.active,
awaitingTelegramReturn = signal(false); getAppLoginUrl: webSessionID => this.authService.getTelegramAppLoginUrl(webSessionID),
onLoginComplete: () => this.authService.onTelegramLoginComplete(),
};
private readonly pollIntervalMs = 5000; private readonly engine = new QrLoginEngine<AuthSession>(this.adapter);
private pollTimer?: ReturnType<typeof setInterval>; readonly loginUrl = this.engine.loginUrl;
private readonly handleVisibilityChange = () => { readonly webSessionID = this.engine.webSessionID;
if (typeof document !== 'undefined' && document.visibilityState === 'visible') { readonly qrStatus = this.engine.qrStatus;
this.checkLoginAfterReturn(); readonly encodedQrUrl = this.engine.encodedQrUrl;
} readonly awaitingTelegramReturn = this.engine.awaitingAppReturn;
};
private readonly handleWindowFocus = () => {
this.checkLoginAfterReturn();
};
private readonly handlePageShow = () => {
this.checkLoginAfterReturn();
};
constructor() { constructor() {
effect(() => { effect(() => this.engine.setActive(this.showDialog()));
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);
}
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.awaitingTelegramReturn.set(false); this.engine.destroy();
this.stopPolling();
if (typeof window !== 'undefined') {
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
window.removeEventListener('focus', this.handleWindowFocus);
window.removeEventListener('pageshow', this.handlePageShow);
}
} }
close(): void { close(): void {
this.awaitingTelegramReturn.set(false); this.engine.setActive(false);
this.authService.hideLogin(); this.authService.hideLogin();
this.stopPolling();
} }
openTelegramLogin(): void { openTelegramLogin(): void {
const webSessionID = this.webSessionID(); this.engine.openAppLogin();
if (!webSessionID || typeof window === 'undefined') return;
if (!this.pollTimer) {
this.startPolling(webSessionID);
}
this.awaitingTelegramReturn.set(true);
window.location.href = this.authService.getTelegramAppLoginUrl(webSessionID);
} }
refreshQr(): void { refreshQr(): void {
this.awaitingTelegramReturn.set(false); this.engine.refresh();
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();
}
});
} }
} }

View File

@@ -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 }));
};

View File

@@ -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;
};

View File

@@ -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<AdminSession | null>(null);
private readonly statusSignal = signal<AdminAuthStatus>('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<string, unknown>)['adminAuthApiUrl'] as string
?? `${environment.authApiUrl}/admin`;
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
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<AdminSession | null> {
if (!webSessionID) {
return of(null);
}
return this.http.get<Record<string, unknown>>(
`${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<AdminWebSessionStart> {
const webSessionID = generateGuid();
return this.http.post<Record<string, unknown>>(
`${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<string, unknown> | 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<string, unknown> | 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<string, unknown>, 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<string, unknown>)['adminTelegramBot'] as string
?? (environment as Record<string, unknown>)['telegramBot'] as string
?? 'DexarSupport_bot';
}
}

View File

@@ -0,0 +1,72 @@
@if (showDialog()) {
<div class="login-overlay" (click)="close()">
<div class="login-dialog" (click)="$event.stopPropagation()">
<button class="close-btn" (click)="close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
<div class="login-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2 3 6v6c0 5 4 8.7 9 10 5-1.3 9-5 9-10V6l-9-4z"/>
</svg>
</div>
<h2>{{ 'adminAuth.loginRequired' | translate }}</h2>
<p class="login-desc">{{ 'adminAuth.loginDescription' | translate }}</p>
@if (status() === 'checking') {
<div class="login-status checking">
<div class="spinner"></div>
<span>{{ 'adminAuth.checking' | translate }}</span>
</div>
} @else {
<button class="telegram-btn" (click)="openAppLogin()">
{{ 'adminAuth.loginWithApp' | translate }}
</button>
<div class="qr-section">
<p class="qr-hint">{{ 'adminAuth.orScanQr' | translate }}</p>
@switch (qrStatus()) {
@case ('loading') {
<div class="qr-container qr-loading">
<div class="spinner"></div>
</div>
}
@case ('ready') {
<div class="qr-container">
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
alt="QR Code"
width="180"
height="180"
loading="eager" />
</div>
}
@case ('expired') {
<div class="qr-container qr-expired" (click)="refreshQr()">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
<span>{{ 'adminAuth.qrExpired' | translate }}</span>
</div>
}
@case ('error') {
<div class="qr-container qr-error" (click)="refreshQr()">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
<span>{{ 'adminAuth.qrError' | translate }}</span>
</div>
}
}
</div>
<p class="login-note">{{ 'adminAuth.loginNote' | translate }}</p>
}
</div>
</div>
}

View File

@@ -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;
}
}

View File

@@ -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<AdminSession> = {
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<AdminSession>(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();
}
}

View File

@@ -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<Ed25519Challenge>;
abstract verify(response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
}

View File

@@ -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<Ed25519Challenge> {
return throwError(() => new Error('Ed25519 challenge endpoint is not yet available from the backend.'));
}
verify(_response: Ed25519SignedResponse): Observable<Ed25519VerificationResult> {
return throwError(() => new Error('Ed25519 verification endpoint is not yet available from the backend.'));
}
}

View File

@@ -1,8 +1,16 @@
<div class="project-editor-save-bar"> <div class="project-editor-save-bar">
@if (draftRestored()) {
<div class="project-editor-save-bar-notice">
<span>{{ 'builder.draftRestored' | translate }}</span>
<button type="button" (click)="dismissDraftRestoredNotice()">{{ 'builder.dismiss' | translate }}</button>
</div>
}
<div class="project-editor-save-bar-status"> <div class="project-editor-save-bar-status">
<span>{{ (status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }}</span> <span>{{ (status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }}</span>
@if (dirty()) { @if (dirty()) {
<span class="project-editor-save-bar-dirty">{{ 'builder.unsavedChanges' | translate }}</span> <span class="project-editor-save-bar-dirty">{{ 'builder.unsavedChanges' | translate }}</span>
} @else if (lastSavedAt()) {
<span class="project-editor-save-bar-saved-at">{{ 'builder.lastSaved' | translate }}: {{ formatSavedAt(lastSavedAt()!) }}</span>
} }
@if (issues().length > 0) { @if (issues().length > 0) {
<ul class="project-editor-save-bar-issues"> <ul class="project-editor-save-bar-issues">
@@ -13,6 +21,7 @@
} }
</div> </div>
<div class="project-editor-save-bar-actions"> <div class="project-editor-save-bar-actions">
<button type="button" class="project-editor-save-bar-reset" (click)="resetDraft()">{{ 'builder.resetDraft' | translate }}</button>
<button type="button" (click)="save()">{{ 'builder.save' | translate }}</button> <button type="button" (click)="save()">{{ 'builder.save' | translate }}</button>
<button type="button" [disabled]="issues().length > 0" (click)="publish()">{{ 'builder.publish' | translate }}</button> <button type="button" [disabled]="issues().length > 0" (click)="publish()">{{ 'builder.publish' | translate }}</button>
</div> </div>

View File

@@ -25,3 +25,27 @@
.project-editor-save-bar-actions button + button { .project-editor-save-bar-actions button + button {
margin-left: 0.5rem; 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);
}

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { TranslateService } from '../../../../i18n/translate.service';
import { ProjectEditorFacade } from '../../facade/project-editor.facade'; import { ProjectEditorFacade } from '../../facade/project-editor.facade';
@Component({ @Component({
@@ -12,9 +13,12 @@ import { ProjectEditorFacade } from '../../facade/project-editor.facade';
}) })
export class ProjectEditorSaveBarComponent { export class ProjectEditorSaveBarComponent {
private readonly facade = inject(ProjectEditorFacade); private readonly facade = inject(ProjectEditorFacade);
private readonly translate = inject(TranslateService);
readonly dirty = this.facade.dirty; readonly dirty = this.facade.dirty;
readonly status = this.facade.status; readonly status = this.facade.status;
readonly issues = this.facade.validationIssues; readonly issues = this.facade.validationIssues;
readonly lastSavedAt = this.facade.lastSavedAt;
readonly draftRestored = this.facade.draftRestored;
save(): void { save(): void {
this.facade.save(); this.facade.save();
@@ -23,4 +27,18 @@ export class ProjectEditorSaveBarComponent {
publish(): void { publish(): void {
this.facade.publish(); 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();
}
} }

View File

@@ -9,6 +9,8 @@ import { LocaleSyncService } from '../services/locale-sync.service';
import { ProjectEditorState } from '../models/project-editor.model'; import { ProjectEditorState } from '../models/project-editor.model';
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service'; import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
import { ProjectValidator } from '../services/project-validator.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' }) @Injectable({ providedIn: 'root' })
export class ProjectEditorFacade { export class ProjectEditorFacade {
@@ -18,13 +20,17 @@ export class ProjectEditorFacade {
private readonly localeSync = inject(LocaleSyncService); private readonly localeSync = inject(LocaleSyncService);
private readonly runtime = inject(PlatformRuntimeService); private readonly runtime = inject(PlatformRuntimeService);
private readonly validator = inject(ProjectValidator); private readonly validator = inject(ProjectValidator);
private readonly draftStorage = inject(ProjectEditorDraftStorageService);
private readonly state = signal<ProjectEditorState>({ private readonly state = signal<ProjectEditorState>({
bootstrap: null, bootstrap: null,
originalBootstrap: null,
importError: null, importError: null,
activeSection: 'general', activeSection: 'general',
status: 'draft', status: 'draft',
lastSavedBootstrap: null, lastSavedBootstrap: null,
lastSavedAt: null,
draftRestored: false,
}); });
readonly bootstrap = computed(() => this.state().bootstrap); 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 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 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 status = computed(() => this.state().status);
readonly lastSavedAt = computed(() => this.state().lastSavedAt);
readonly draftRestored = computed(() => this.state().draftRestored);
readonly validationIssues = computed(() => { readonly validationIssues = computed(() => {
const current = this.bootstrap(); const current = this.bootstrap();
return current ? this.validator.validate(current) : []; return current ? this.validator.validate(current) : [];
@@ -49,7 +57,19 @@ export class ProjectEditorFacade {
this.configService.loadBootstrap(true).pipe(take(1)).subscribe({ this.configService.loadBootstrap(true).pipe(take(1)).subscribe({
next: config => { next: config => {
const normalized = this.normalize(JSON.parse(JSON.stringify(config)) as BootstrapConfig); 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' })), error: () => this.state.update(current => ({ ...current, bootstrap: null, importError: 'builder.importError' })),
}); });
@@ -61,10 +81,13 @@ export class ProjectEditorFacade {
return; return;
} }
this.state.update(state => ({ const next = this.normalize(updater(JSON.parse(JSON.stringify(current)) as BootstrapConfig));
...state, this.state.update(state => ({ ...state, bootstrap: next, draftRestored: false }));
bootstrap: this.normalize(updater(JSON.parse(JSON.stringify(current)) as BootstrapConfig)), this.draftStorage.save(next);
})); }
dismissDraftRestoredNotice(): void {
this.state.update(current => ({ ...current, draftRestored: false }));
} }
exportBootstrap(): string { exportBootstrap(): string {
@@ -115,7 +138,35 @@ export class ProjectEditorFacade {
if (!current) { if (!current) {
return; 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<BootstrapConfig> = {};
for (const key of keys) {
(patch as Record<string, unknown>)[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 { publish(): boolean {
@@ -124,10 +175,13 @@ export class ProjectEditorFacade {
return false; return false;
} }
this.runtime.reloadFromBootstrap(current); this.runtime.reloadFromBootstrap(current);
const savedAt = this.draftStorage.save(current);
this.state.update(state => ({ this.state.update(state => ({
...state, ...state,
status: 'published', status: 'published',
lastSavedBootstrap: JSON.parse(JSON.stringify(current)), lastSavedBootstrap: JSON.parse(JSON.stringify(current)),
originalBootstrap: JSON.parse(JSON.stringify(current)),
lastSavedAt: savedAt,
})); }));
return true; return true;
} }

View File

@@ -16,12 +16,28 @@ export type ProjectEditorSectionId =
export interface ProjectEditorState { export interface ProjectEditorState {
bootstrap: BootstrapConfig | null; bootstrap: BootstrapConfig | null;
originalBootstrap: BootstrapConfig | null;
importError: string | null; importError: string | null;
activeSection: ProjectEditorSectionId; activeSection: ProjectEditorSectionId;
status: 'draft' | 'published'; status: 'draft' | 'published';
lastSavedBootstrap: BootstrapConfig | null; lastSavedBootstrap: BootstrapConfig | null;
lastSavedAt: number | null;
draftRestored: boolean;
} }
export const EDITOR_SECTION_BOOTSTRAP_KEYS: Partial<Record<ProjectEditorSectionId, (keyof BootstrapConfig)[]>> = {
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 { export interface ProjectEditorWidgetPreset {
id: string; id: string;
type: string; type: string;

View File

@@ -13,6 +13,11 @@
</section> </section>
} @else { } @else {
<section class="project-editor-stack"> <section class="project-editor-stack">
@if (canResetActiveSection()) {
<div class="project-editor-section-actions">
<button type="button" (click)="resetActiveSection()">{{ 'builder.resetSection' | translate }}</button>
</div>
}
@switch (activeSection()) { @switch (activeSection()) {
@case ('general') { <app-project-editor-general-section /> } @case ('general') { <app-project-editor-general-section /> }
@case ('branding') { <app-project-editor-branding-section /> } @case ('branding') { <app-project-editor-branding-section /> }

View File

@@ -25,6 +25,20 @@
gap: 16px; 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 { .project-editor-empty {
min-height: 240px; min-height: 240px;
display: grid; display: grid;

View File

@@ -16,9 +16,10 @@ import { ProjectEditorLanguagesSectionComponent } from '../sections/languages-se
import { ProjectEditorNavigationSectionComponent } from '../sections/navigation-section.component'; import { ProjectEditorNavigationSectionComponent } from '../sections/navigation-section.component';
import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component'; import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component';
import { TranslatePipe } from '../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../i18n/translate.pipe';
import { TranslateService } from '../../../i18n/translate.service';
import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component'; import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component';
import { ProjectEditorSaveBarComponent } from '../components/save-bar/project-editor-save-bar.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[] = [ const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'navigation', 'preview' 'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'navigation', 'preview'
@@ -51,8 +52,10 @@ const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
export class ProjectEditorPageComponent { export class ProjectEditorPageComponent {
readonly facade = inject(ProjectEditorFacade); readonly facade = inject(ProjectEditorFacade);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly translate = inject(TranslateService);
readonly bootstrap = this.facade.bootstrap; readonly bootstrap = this.facade.bootstrap;
readonly activeSection = this.facade.activeSection; readonly activeSection = this.facade.activeSection;
readonly canResetActiveSection = () => !!EDITOR_SECTION_BOOTSTRAP_KEYS[this.activeSection()];
private readonly routeSection = toSignal( private readonly routeSection = toSignal(
this.route.paramMap.pipe(map(params => params.get('section') as ProjectEditorSectionId | null)), 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']) @HostListener('window:beforeunload', ['$event'])
warnBeforeUnload(event: BeforeUnloadEvent): void { warnBeforeUnload(event: BeforeUnloadEvent): void {
if (this.facade.dirty()) { if (this.facade.dirty()) {

View File

@@ -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
}
}
}

View File

@@ -522,6 +522,13 @@ export const en: Translations = {
promptImageUrl: 'Image URL', promptImageUrl: 'Image URL',
htmlEditorCode: 'Code', htmlEditorCode: 'Code',
htmlEditorPreview: 'Preview', 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: { staticPages: {
notFound: 'Page not found', notFound: 'Page not found',
@@ -555,6 +562,16 @@ export const en: Translations = {
qrExpired: 'QR code expired. Click to refresh', qrExpired: 'QR code expired. Click to refresh',
qrError: 'Could not create login session. Click to retry', 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: { ux: {
items: 'items', items: 'items',
wishlistTitle: 'Wishlist', wishlistTitle: 'Wishlist',

View File

@@ -522,6 +522,13 @@ export const hy: Translations = {
promptImageUrl: 'Նկարի URL', promptImageUrl: 'Նկարի URL',
htmlEditorCode: 'Կոդ', htmlEditorCode: 'Կոդ',
htmlEditorPreview: 'Նախադիտում', htmlEditorPreview: 'Նախադիտում',
lastSaved: 'Վերջին պահպանումը',
draftRestored: 'Տեղական սևագիրը վերականգնվել է նախորդ սեսիայից։',
dismiss: 'Փակել',
resetDraft: 'Զրոյացնել սևագիրը',
resetSection: 'Զրոյացնել սեկցիան',
confirmResetDraft: 'Սա կչեղարկի բոլոր չհրապարակված փոփոխությունները և կվերականգնի վերջին հրապարակված կոնֆիգուրացիան։ Շարունակե՞լ։',
confirmResetSection: 'Սա կչեղարկի չհրապարակված փոփոխությունները միայն այս սեկցիայում։ Շարունակե՞լ։',
}, },
staticPages: { staticPages: {
notFound: 'Էջը չի գտնվել', notFound: 'Էջը չի գտնվել',
@@ -555,6 +562,16 @@ export const hy: Translations = {
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար', qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար', qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար',
}, },
adminAuth: {
loginRequired: 'Անհրաժեշտ է ադմինի մուտք',
loginDescription: 'Մուտք գործեք ադմինի հաշվով։ Սա առանձին սեսիա է՝ խանութի մուտքից անկախ։',
checking: 'Ստուգում...',
loginWithApp: 'Մուտք հավելվածով',
orScanQr: 'Կամ սքանավորեք QR կոդը',
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար',
},
ux: { ux: {
items: 'ապրանք', items: 'ապրանք',
wishlistTitle: 'Ընտրյալներ', wishlistTitle: 'Ընտրյալներ',

View File

@@ -522,6 +522,13 @@ export const ru: Translations = {
promptImageUrl: 'URL изображения', promptImageUrl: 'URL изображения',
htmlEditorCode: 'Код', htmlEditorCode: 'Код',
htmlEditorPreview: 'Предпросмотр', htmlEditorPreview: 'Предпросмотр',
lastSaved: 'Последнее сохранение',
draftRestored: 'Локальный черновик восстановлен из предыдущей сессии.',
dismiss: 'Скрыть',
resetDraft: 'Сбросить черновик',
resetSection: 'Сбросить секцию',
confirmResetDraft: 'Это отменит все неопубликованные изменения и восстановит последнюю опубликованную конфигурацию. Продолжить?',
confirmResetSection: 'Это отменит неопубликованные изменения только в этой секции. Продолжить?',
}, },
staticPages: { staticPages: {
notFound: 'Страница не найдена', notFound: 'Страница не найдена',
@@ -555,6 +562,16 @@ export const ru: Translations = {
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить', qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить', qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить',
}, },
adminAuth: {
loginRequired: 'Требуется вход администратора',
loginDescription: 'Войдите под учётной записью администратора. Это отдельная сессия от входа покупателя.',
checking: 'Проверка...',
loginWithApp: 'Войти через приложение',
orScanQr: 'Или отсканируйте QR-код',
loginNote: 'После входа вы будете перенаправлены обратно',
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить',
},
ux: { ux: {
items: 'товаров', items: 'товаров',
wishlistTitle: 'Избранное', wishlistTitle: 'Избранное',

View File

@@ -520,6 +520,13 @@ export interface Translations {
promptImageUrl: string; promptImageUrl: string;
htmlEditorCode: string; htmlEditorCode: string;
htmlEditorPreview: string; htmlEditorPreview: string;
lastSaved: string;
draftRestored: string;
dismiss: string;
resetDraft: string;
resetSection: string;
confirmResetDraft: string;
confirmResetSection: string;
}; };
staticPages: { staticPages: {
notFound: string; notFound: string;
@@ -553,6 +560,16 @@ export interface Translations {
qrExpired: string; qrExpired: string;
qrError: string; qrError: string;
}; };
adminAuth: {
loginRequired: string;
loginDescription: string;
checking: string;
loginWithApp: string;
orScanQr: string;
loginNote: string;
qrExpired: string;
qrError: string;
};
ux: { ux: {
items: string; items: string;
wishlistTitle: string; wishlistTitle: string;

View File

@@ -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';

View File

@@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
import { Observable, of, catchError, map, tap } from 'rxjs'; import { Observable, of, catchError, map, tap } from 'rxjs';
import { AuthSession, AuthStatus, WebSessionStart } from '../models/auth.model'; import { AuthSession, AuthStatus, WebSessionStart } from '../models/auth.model';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { generateGuid } from '../shared/util/guid.util';
const WEB_SESSION_COOKIE = 'webSessionID'; const WEB_SESSION_COOKIE = 'webSessionID';
const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60; 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 */ /** Generate the Telegram login URL for bot-based auth */
getTelegramLoginUrl(webSessionID = this.generateGuid()): string { getTelegramLoginUrl(webSessionID = generateGuid()): string {
const botUsername = this.getTelegramBotUsername(); const botUsername = this.getTelegramBotUsername();
return `https://t.me/${botUsername}?start=${encodeURIComponent(webSessionID)}`; 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. */ /** Create a backend web session and return the Telegram start link for it. */
createWebSession(): Observable<WebSessionStart> { createWebSession(): Observable<WebSessionStart> {
const webSessionID = this.generateGuid(); const webSessionID = generateGuid();
return this.http.post<Record<string, unknown>>( return this.http.post<Record<string, unknown>>(
`${this.authApiUrl}/users/sessions`, `${this.authApiUrl}/users/sessions`,
@@ -297,27 +298,6 @@ export class AuthService {
return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase()); 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 { private getStoredWebSessionID(): string | null {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
return null; return null;

View File

@@ -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<TSession> {
readonly loginUrl = signal('');
readonly webSessionID = signal('');
readonly qrStatus = signal<QrLoginStatus>('loading');
readonly encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
readonly awaitingAppReturn = signal(false);
private pollTimer?: ReturnType<typeof setInterval>;
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<TSession>) {
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();
}
});
}
}

View File

@@ -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<TSession> {
createSession(): Observable<QrLoginSessionStart>;
checkSessionOnce(webSessionID: string): Observable<TSession | null>;
isSessionActive(session: TSession | null): boolean;
getAppLoginUrl(webSessionID: string): string;
onLoginComplete(): void;
}

View File

@@ -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('')}`;
}

View File

@@ -15,6 +15,8 @@ export const environment = {
theme: 'dexar', theme: 'dexar',
apiUrl: 'https://api.dexarmarket.ru:445', apiUrl: 'https://api.dexarmarket.ru:445',
authApiUrl: 'https://users.vitanova.network:456', 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', qrApiUrl: 'https://qr.vitanova.network/api',
logo: '/icons/icon-192x192.png', logo: '/icons/icon-192x192.png',
contactEmail: 'info@dexarmarket.ru', contactEmail: 'info@dexarmarket.ru',

View File

@@ -16,6 +16,8 @@ export const environment = {
theme: 'dexar', theme: 'dexar',
apiUrl: '/api', apiUrl: '/api',
authApiUrl: 'https://users.vitanova.network:456', 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', qrApiUrl: 'https://qr.vitanova.network/api',
logo: '/icons/icon-192x192.png', logo: '/icons/icon-192x192.png',
contactEmail: 'info@dexarmarket.ru', contactEmail: 'info@dexarmarket.ru',