assets/js/controllers/offline-vault-controller.js

import { Controller } from '@hotwired/stimulus';
import vault from '../services/offline-vault-service.js';
import { trustThisDevice, updateTrustedDevice, offlineStatus } from '../services/offline-runtime-service.js';

/** Device recovery only. Normal mobile templates and controllers own all application rendering. */
class OfflineVaultController extends Controller {
    static targets = ['status', 'content', 'enroll', 'locked', 'deviceUnlock', 'passphraseForm', 'passphrase', 'trustLegacy', 'device', 'signIn'];
    connect() {
        this.connected = true;
        this.onState = () => this.render().catch(() => this.message('Unable to open saved information.'));
        this.onProgress = () => this.resumeOnline();
        this.onOnline = () => { this.onState(); updateTrustedDevice(true).catch(() => {}); };
        window.addEventListener('kmp:offline-state', this.onState);
        window.addEventListener('kmp:offline-progress', this.onProgress);
        window.addEventListener('online', this.onOnline);
        window.addEventListener('offline', this.onState);
        this.timer = setInterval(this.onState, 15000);
        this.onState();
    }
    disconnect() {
        this.connected = false;
        clearInterval(this.timer);
        window.removeEventListener('kmp:offline-state', this.onState);
        window.removeEventListener('kmp:offline-progress', this.onProgress);
        window.removeEventListener('online', this.onOnline);
        window.removeEventListener('offline', this.onState);
        if (!vault.trusted) vault.lock();
    }
    message(text) {
        if (!this.connected) return;
        this.statusTarget.textContent = text;
        this.statusTarget.hidden = !text;
    }
    async render() {
        const record = await vault.metadata();
        if (!this.connected) return;
        const open = !!record && !!vault.key && record.expiresAt > Date.now();
        this.contentTarget.hidden = !open;
        if (!open && this.wasOpen) window.dispatchEvent(new CustomEvent('kmp:offline-revoked'));
        this.enrollTarget.hidden = !!record;
        this.lockedTarget.hidden = !record || !!vault.key || record.wrapper.method === 'trusted';
        this.deviceUnlockTarget.hidden = record?.wrapper.method !== 'device';
        this.passphraseFormTarget.hidden = record?.wrapper.method !== 'passphrase';
        this.trustLegacyTarget.hidden = !navigator.onLine || !open || vault.trusted;
        if (this.hasSignInTarget) this.signInTarget.hidden = !navigator.onLine;
        this.deviceTarget.hidden = !record;
        // The shared device panel owns connection/progress and modern unlock instructions.
        this.message(record && record.expiresAt <= Date.now()
            ? 'Connect and sign in to update your saved information. Waiting RSVPs are kept.'
            : record && !open && !record.wrapper.unlockMethod ? 'Opening your saved information…' : '');
        if (open && !this.wasOpen) window.dispatchEvent(new CustomEvent('kmp:offline-unlocked'));
        this.wasOpen = open;
    }
    resumeOnline() {
        if (!this.connected || this.busy || !vault.trusted || !navigator.onLine || !offlineStatus.ready) return;
        // The login controller follows the server's device-routing rules after local unlock.
        if (/^\/members\/login\/?$/i.test(location.pathname)) return;
        const destinations = { 'auth-card': '/members/view-mobile-card', rsvps: '/gathering-attendances/my-rsvps', events: '/gatherings/mobile-calendar' };
        const path = ['/offline', '/members/login'].includes(location.pathname)
            ? destinations[document.body.dataset.section] : location.pathname + location.search;
        if (Date.now() - Number(sessionStorage.getItem('kmp.offline.resume')) < 60000) return;
        sessionStorage.setItem('kmp.offline.resume', String(Date.now()));
        location.replace(path);
    }
    async run(operation) {
        if (this.busy) return;
        this.busy = true;
        try { await operation(); await this.render(); }
        catch (error) { this.message(error.message); }
        finally { this.busy = false; this.passphraseTarget.value = ''; }
    }
    async trust() { await this.run(() => trustThisDevice()); this.resumeOnline(); }
    async unlockDevice() { await this.run(() => vault.unlock()); }
    async unlockPassphrase(event) { event.preventDefault(); await this.run(() => vault.unlock(this.passphraseTarget.value)); }
    lock() { vault.signOut(); }
    async forget() {
        if (!await window.KMP_accessibility.confirm('Remove saved information and any unsent RSVPs from this device?')) return;
        await this.run(() => vault.clear());
        this.enrollTarget.querySelector('a')?.focus();
    }
}
window.Controllers ||= {};
window.Controllers['offline-vault'] = OfflineVaultController;
export default OfflineVaultController;