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

@@ -0,0 +1,155 @@
import { signal, computed } from '@angular/core';
import { QrLoginAdapter, QrLoginStatus } from './qr-login.model';
const POLL_INTERVAL_MS = 5000;
const MAX_POLLS = 100;
/**
* Shared QR-login state machine: session creation, polling, expiry, and the
* "returned from the messenger app" recovery flow (visibilitychange/focus/pageshow).
* Extracted from the original TelegramLoginComponent so admin login (and any
* future QR login surface) can reuse it instead of duplicating timers/listeners.
* Not a DI singleton — instantiate one per component instance.
*/
export class QrLoginEngine<TSession> {
readonly loginUrl = signal('');
readonly webSessionID = signal('');
readonly qrStatus = signal<QrLoginStatus>('loading');
readonly encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
readonly awaitingAppReturn = signal(false);
private pollTimer?: ReturnType<typeof setInterval>;
private active = false;
private readonly handleVisibilityChange = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
this.checkAfterReturn();
}
};
private readonly handleWindowFocus = () => this.checkAfterReturn();
private readonly handlePageShow = () => this.checkAfterReturn();
constructor(private readonly adapter: QrLoginAdapter<TSession>) {
if (typeof window !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange);
window.addEventListener('focus', this.handleWindowFocus);
window.addEventListener('pageshow', this.handlePageShow);
}
}
/** Call from an effect() watching the dialog-visibility signal. */
setActive(active: boolean): void {
this.active = active;
if (active) {
this.init();
} else {
this.awaitingAppReturn.set(false);
this.stopPolling();
}
}
refresh(): void {
this.awaitingAppReturn.set(false);
this.stopPolling();
this.init();
}
openAppLogin(): void {
const webSessionID = this.webSessionID();
if (!webSessionID || typeof window === 'undefined') return;
if (!this.pollTimer) {
this.startPolling(webSessionID);
}
this.awaitingAppReturn.set(true);
window.location.href = this.adapter.getAppLoginUrl(webSessionID);
}
destroy(): void {
this.awaitingAppReturn.set(false);
this.stopPolling();
if (typeof window !== 'undefined') {
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
window.removeEventListener('focus', this.handleWindowFocus);
window.removeEventListener('pageshow', this.handlePageShow);
}
}
private init(): void {
this.awaitingAppReturn.set(false);
this.qrStatus.set('loading');
this.loginUrl.set('');
this.webSessionID.set('');
this.adapter.createSession().subscribe({
next: res => {
this.loginUrl.set(res.url);
this.webSessionID.set(res.webSessionID);
this.qrStatus.set('ready');
this.startPolling(res.webSessionID);
},
error: () => this.qrStatus.set('error'),
});
}
private startPolling(webSessionID: string): void {
this.stopPolling();
if (!webSessionID) return;
let checks = 0;
this.pollTimer = setInterval(() => {
checks++;
if (checks > MAX_POLLS) {
this.stopPolling();
this.qrStatus.set('expired');
return;
}
this.adapter.checkSessionOnce(webSessionID).subscribe({
next: session => {
if (this.adapter.isSessionActive(session)) {
this.awaitingAppReturn.set(false);
this.stopPolling();
this.adapter.onLoginComplete();
}
},
error: () => {
// network error - keep polling
},
});
}, POLL_INTERVAL_MS);
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = undefined;
}
}
private checkAfterReturn(): void {
if (!this.active || !this.awaitingAppReturn()) {
return;
}
const webSessionID = this.webSessionID();
if (!webSessionID) {
this.awaitingAppReturn.set(false);
return;
}
if (!this.pollTimer) {
this.startPolling(webSessionID);
}
this.adapter.checkSessionOnce(webSessionID).subscribe(session => {
if (this.adapter.isSessionActive(session)) {
this.awaitingAppReturn.set(false);
this.stopPolling();
this.adapter.onLoginComplete();
}
});
}
}

View File

@@ -0,0 +1,21 @@
import { Observable } from 'rxjs';
export interface QrLoginSessionStart {
webSessionID: string;
url: string;
}
export type QrLoginStatus = 'loading' | 'ready' | 'expired' | 'error';
/**
* Adapter every QR-login surface (customer Telegram login, admin login, ...)
* implements so QrLoginEngine can drive session creation/polling without
* knowing which auth service or storage backs it.
*/
export interface QrLoginAdapter<TSession> {
createSession(): Observable<QrLoginSessionStart>;
checkSessionOnce(webSessionID: string): Observable<TSession | null>;
isSessionActive(session: TSession | null): boolean;
getAppLoginUrl(webSessionID: string): string;
onLoginComplete(): void;
}

View File

@@ -0,0 +1,21 @@
/** RFC4122 v4-ish GUID, using crypto when available. Shared by customer and admin session creation. */
export function generateGuid(): string {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index++) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0'));
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`;
}