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:
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('')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user