feat(sprint18): editor autosave/reset, admin auth, QR reuse, Ed25519 prep
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
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:
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 />
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/app/core/admin-auth/admin-auth-headers.interceptor.ts
Normal file
29
src/app/core/admin-auth/admin-auth-headers.interceptor.ts
Normal 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 }));
|
||||||
|
};
|
||||||
15
src/app/core/admin-auth/admin-auth.guard.ts
Normal file
15
src/app/core/admin-auth/admin-auth.guard.ts
Normal 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;
|
||||||
|
};
|
||||||
286
src/app/core/admin-auth/admin-auth.service.ts
Normal file
286
src/app/core/admin-auth/admin-auth.service.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/app/core/admin-auth/admin-login.component.html
Normal file
72
src/app/core/admin-auth/admin-login.component.html
Normal 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>
|
||||||
|
}
|
||||||
254
src/app/core/admin-auth/admin-login.component.scss
Normal file
254
src/app/core/admin-auth/admin-login.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/core/admin-auth/admin-login.component.ts
Normal file
54
src/app/core/admin-auth/admin-login.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/core/admin-auth/ed25519-verification.model.ts
Normal file
31
src/app/core/admin-auth/ed25519-verification.model.ts
Normal 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>;
|
||||||
|
}
|
||||||
20
src/app/core/admin-auth/noop-ed25519-verification.service.ts
Normal file
20
src/app/core/admin-auth/noop-ed25519-verification.service.ts
Normal 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.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 /> }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 'Ընտրյալներ',
|
||||||
|
|||||||
@@ -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: 'Избранное',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
16
src/app/models/admin-auth.model.ts
Normal file
16
src/app/models/admin-auth.model.ts
Normal 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';
|
||||||
@@ -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;
|
||||||
|
|||||||
155
src/app/shared/qr-login/qr-login.engine.ts
Normal file
155
src/app/shared/qr-login/qr-login.engine.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/shared/qr-login/qr-login.model.ts
Normal file
21
src/app/shared/qr-login/qr-login.model.ts
Normal 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;
|
||||||
|
}
|
||||||
21
src/app/shared/util/guid.util.ts
Normal file
21
src/app/shared/util/guid.util.ts
Normal 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('')}`;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user