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

- Project editor: persist draft to localStorage, restore on reload,
  last-saved/draft-restored status indicators, section/whole-draft reset
  with confirmation.
- Extract shared QR/polling/expiry engine from TelegramLoginComponent
  (shared/qr-login) and reuse it for a new admin login flow.
- Admin authentication kept fully separate from customer session:
  own cookie/localStorage keys, signals, guard, and header interceptor
  (core/admin-auth).
- ?login=true / ?adminLogin=true open the respective login dialog for
  manual testing.
- Ed25519 challenge/verify interfaces (fail-closed no-op binding) ready
  for backend delivery.
- Document autosave/reset/admin-auth/QR-reuse/Ed25519 model and the
  remaining full-field-coverage gap in docs/Project-Editor.md.
This commit is contained in:
sdarbinyan
2026-07-14 09:50:03 +04:00
parent c6482f0037
commit 3877b70fdf
33 changed files with 1423 additions and 171 deletions

View File

@@ -1,6 +1,9 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnDestroy } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, effect, OnDestroy } from '@angular/core';
import { AuthService } from '../../services/auth.service';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine';
import { QrLoginAdapter } from '../../shared/qr-login/qr-login.model';
import { AuthSession } from '../../models/auth.model';
@Component({
selector: 'app-telegram-login',
@@ -15,153 +18,39 @@ export class TelegramLoginComponent implements OnDestroy {
showDialog = this.authService.showLoginDialog;
status = this.authService.status;
loginUrl = signal('');
webSessionID = signal('');
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
awaitingTelegramReturn = signal(false);
private readonly adapter: QrLoginAdapter<AuthSession> = {
createSession: () => this.authService.createWebSession(),
checkSessionOnce: webSessionID => this.authService.checkSessionOnce(webSessionID),
isSessionActive: session => !!session?.active,
getAppLoginUrl: webSessionID => this.authService.getTelegramAppLoginUrl(webSessionID),
onLoginComplete: () => this.authService.onTelegramLoginComplete(),
};
private readonly pollIntervalMs = 5000;
private pollTimer?: ReturnType<typeof setInterval>;
private readonly handleVisibilityChange = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
this.checkLoginAfterReturn();
}
};
private readonly handleWindowFocus = () => {
this.checkLoginAfterReturn();
};
private readonly handlePageShow = () => {
this.checkLoginAfterReturn();
};
private readonly engine = new QrLoginEngine<AuthSession>(this.adapter);
readonly loginUrl = this.engine.loginUrl;
readonly webSessionID = this.engine.webSessionID;
readonly qrStatus = this.engine.qrStatus;
readonly encodedQrUrl = this.engine.encodedQrUrl;
readonly awaitingTelegramReturn = this.engine.awaitingAppReturn;
constructor() {
effect(() => {
if (this.showDialog()) {
this.initQrLogin();
} else {
this.awaitingTelegramReturn.set(false);
this.stopPolling();
}
});
if (typeof window !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange);
window.addEventListener('focus', this.handleWindowFocus);
window.addEventListener('pageshow', this.handlePageShow);
}
effect(() => this.engine.setActive(this.showDialog()));
}
ngOnDestroy(): void {
this.awaitingTelegramReturn.set(false);
this.stopPolling();
if (typeof window !== 'undefined') {
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
window.removeEventListener('focus', this.handleWindowFocus);
window.removeEventListener('pageshow', this.handlePageShow);
}
this.engine.destroy();
}
close(): void {
this.awaitingTelegramReturn.set(false);
this.engine.setActive(false);
this.authService.hideLogin();
this.stopPolling();
}
openTelegramLogin(): void {
const webSessionID = this.webSessionID();
if (!webSessionID || typeof window === 'undefined') return;
if (!this.pollTimer) {
this.startPolling(webSessionID);
}
this.awaitingTelegramReturn.set(true);
window.location.href = this.authService.getTelegramAppLoginUrl(webSessionID);
this.engine.openAppLogin();
}
refreshQr(): void {
this.awaitingTelegramReturn.set(false);
this.stopPolling();
this.initQrLogin();
}
private initQrLogin(): void {
this.awaitingTelegramReturn.set(false);
this.qrStatus.set('loading');
this.loginUrl.set('');
this.webSessionID.set('');
this.authService.createWebSession().subscribe({
next: (res) => {
this.loginUrl.set(res.url);
this.webSessionID.set(res.webSessionID);
this.qrStatus.set('ready');
this.startPolling(res.webSessionID);
},
error: () => {
this.qrStatus.set('error');
}
});
}
private startPolling(webSessionID: string): void {
this.stopPolling();
if (!webSessionID) return;
let checks = 0;
this.pollTimer = setInterval(() => {
checks++;
if (checks > 100) {
this.stopPolling();
this.qrStatus.set('expired');
return;
}
this.authService.checkSessionOnce(webSessionID).subscribe({
next: (session) => {
if (session?.active) {
this.awaitingTelegramReturn.set(false);
this.stopPolling();
this.authService.onTelegramLoginComplete();
}
},
error: () => {
// Network error — keep polling
}
});
}, this.pollIntervalMs);
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = undefined;
}
}
private checkLoginAfterReturn(): void {
if (!this.showDialog() || !this.awaitingTelegramReturn()) {
return;
}
const webSessionID = this.webSessionID();
if (!webSessionID) {
this.awaitingTelegramReturn.set(false);
return;
}
if (!this.pollTimer) {
this.startPolling(webSessionID);
}
this.authService.checkSessionOnce(webSessionID).subscribe(session => {
if (session?.active) {
this.awaitingTelegramReturn.set(false);
this.stopPolling();
this.authService.onTelegramLoginComplete();
}
});
this.engine.refresh();
}
}