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

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