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

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

import { verifyDevicePassword, loginWithSavedPassword } from '../services/device-login-service.js';
import { checkPasskeySupport, forgetPasskeyFailure, unavailablePasskey } from '../services/passkey-support-service.js';
import QuickLoginService from '../services/quick-login-service.js';

/** Guide personal-device setup and show explicit protection and offline-readiness results. */
class OfflineAccessController extends Controller {
    static targets = ['status', 'link', 'choice', 'forget', 'setup', 'password', 'passwordFields', 'method', 'pinFields', 'pin', 'confirm', 'continue', 'unlock', 'unlockPin', 'unlockButton', 'wizard', 'stepLabel', 'stepHeading', 'protectionFields', 'methodHelp', 'next', 'back', 'success', 'successHeading', 'successMethod', 'readiness', 'retry', 'setupProgress', 'setupError', 'setupErrorMessage', 'pinFallback', 'methodChoice', 'availability', 'recheck', 'passwordSwitch', 'migration', 'migrationMessage', 'trustButton'];

    connect() {
        this.connected = true;
        QuickLoginService.beginPinMigration();
        this.update = () => { this.updatePasswordSwitch(); this.render(); };
        this.updatePasswordSwitch();
        window.addEventListener('kmp:offline-state', this.update);
        window.addEventListener('kmp:offline-progress', this.update);
        window.addEventListener('online', this.update);
        window.addEventListener('offline', this.update);
        this.render();
    }

    disconnect() {
        this.connected = false;
        window.removeEventListener('kmp:offline-state', this.update);
        window.removeEventListener('kmp:offline-progress', this.update);
        window.removeEventListener('online', this.update);
        window.removeEventListener('offline', this.update);
        this.cancelStatus?.();
        this.cancelSetup();
    }

    async shellReady() {
        const registration = await navigator.serviceWorker?.getRegistration?.('/');
        if (!registration?.active || !this.connected) return false;
        return new Promise(resolve => {
            const channel = new MessageChannel();
            const finish = ready => {
                clearTimeout(timer);
                channel.port1.close();
                this.cancelStatus = null;
                resolve(ready);
            };
            const timer = setTimeout(() => finish(false), 3000);
            this.cancelStatus = () => finish(false);
            channel.port1.onmessage = event => finish(event.data?.ready === true);
            registration.active.postMessage({ type: 'OFFLINE_STATUS' }, [channel.port2]);
        });
    }

    async render() {
        const id = Symbol();
        this.renderId = id;
        this.cancelStatus?.();
        let record;
        let ready = false;
        try {
            record = await vault.metadata();
            if (!this.connected || this.renderId !== id) return;
            this.publishDeviceAvailability(record);
            if (record?.snapshotSaved) ready = await this.shellReady();
        } catch { this.publishDeviceAvailability(null); }
        if (!this.connected || this.renderId !== id) return;
        let context;
        try { context = JSON.parse(document.querySelector('meta[name="kmp-offline-session"]')?.content || 'null'); } catch { /* Remain hidden. */ }
        const eligible = context?.owner && !context.impersonating;
        const trusted = record?.wrapper.method === 'trusted';
        const protectedDevice = !!record?.wrapper.unlockMethod;
        const locked = protectedDevice && !vault.key;
        const settings = !!this.element.closest('turbo-frame');
        // A locked local copy is not a reason to ask an online, signed-in user to log in again.
        const signedInPage = !!context?.owner && navigator.onLine && !this.isLoginPage && !settings;
        const showUnlock = locked && !signedInPage;
        if (protectedDevice) QuickLoginService.completePinMigration();
        const dismissed = sessionStorage.getItem('kmp.offline.declined') === '1';
        const migrating = !protectedDevice && QuickLoginService.needsPinMigration();
        const showMigration = migrating && (!eligible || !dismissed || !!this.element.closest('turbo-frame')) && !this.setupStep && !this.completed
            && !this.element.closest('[data-controller~="login-device-auth"]');
        if (this.hasMigrationTarget) {
            this.migrationTarget.hidden = !showMigration;
            this.migrationMessageTarget.textContent = eligible
                ? 'You’re signed in. Your old PIN no longer works with the updated device unlock. Set up a new PIN or passkey on this personal device. We’ll guide you through it, and it will work online and offline.'
                : 'Your old PIN no longer works with the updated device unlock. Connect to the internet and sign in with your email and password once. Then we’ll help you set up a new PIN or passkey for online and offline access.';
        }
        if (this.hasTrustButtonTarget) this.trustButtonTarget.textContent = migrating ? 'Set up new PIN or passkey' : 'Trust this personal device';
        if ((this.setupStep || this.completed) && (!eligible || (this.completed && (!protectedDevice || !vault.key)))) this.resetSetup();
        this.element.hidden = (signedInPage && locked)
            || (!showMigration && !record && (!eligible || (dismissed && !settings)));
        if (this.hasChoiceTarget) this.choiceTarget.hidden = !eligible || protectedDevice || !!this.setupStep || this.completed;
        const compact = !this.completed && !this.setupStep && protectedDevice && !locked && eligible && !settings;
        this.element.classList.toggle('card', !compact);
        this.element.classList.toggle('my-3', !compact);
        this.element.classList.toggle('my-2', compact);
        this.element.querySelector('h2')?.toggleAttribute('hidden', compact);
        this.element.firstElementChild?.classList.toggle('card-body', !compact);
        if (this.hasForgetTarget) this.forgetTarget.hidden = !trusted || (!settings && !locked);
        this.linkTarget.hidden = !record || protectedDevice || compact || locked || !!this.setupStep || this.completed;
        if (this.completed && this.hasForgetTarget) this.forgetTarget.hidden = true;
        this.statusTarget.textContent = trusted
            ? (compact && ready && record.expiresAt > Date.now() ? 'Available offline on this device.' : offlineStatus.message || (ready && record.expiresAt > Date.now() ? 'Saved on this device. Ready offline.'
                : `Your device is trusted. ${shortSiteTitle()} will update your saved information when you’re signed in and connected.`))
            : record ? 'Open your previously saved information to switch to automatic access.'
                : 'Keep your card, RSVPs, and events ready without internet. Protect this personal device with a PIN or passkey.';
        if (protectedDevice && locked) this.statusTarget.textContent = `Unlock ${shortSiteTitle()} with your PIN or passkey. It works online and offline.`;
        if (trusted && !protectedDevice && eligible) this.statusTarget.textContent = 'Add a PIN or passkey to keep this device trusted after you log out.';
        if (trusted && !protectedDevice && !eligible && localStorage.getItem('kmp.offline.signedOut') === '1') {
            this.statusTarget.textContent = 'Sign in online once to add a PIN or passkey to this trusted device.';
            this.linkTarget.hidden = true;
        }
        if (this.hasUnlockTarget) {
            this.unlockTarget.hidden = !showUnlock;
            this.unlockPinTarget.hidden = record?.wrapper.unlockMethod !== 'pin';
            this.unlockPinTarget.required = showUnlock && record.wrapper.unlockMethod === 'pin';
            this.unlockPinTarget.disabled = !this.unlockPinTarget.required;
            this.unlockTarget.querySelector('label').hidden = this.unlockPinTarget.hidden;
            this.unlockButtonTarget.textContent = record?.wrapper.unlockMethod === 'device' ? 'Unlock with passkey' : `Unlock ${shortSiteTitle()}`;
        }
        this.linkTarget.textContent = trusted ? 'Open my card' : 'Open previously saved information';
        if (showMigration) this.statusTarget.textContent = '';
        if (this.setupStep) {
            this.statusTarget.textContent = '';
            this.setupProgressTarget.textContent = this.setupMessage || '';
            this.setupProgressTarget.hidden = !this.setupMessage || !!this.errorMessage;
        }
        else if (this.completed) {
            this.statusTarget.textContent = '';
            this.renderCompletion(ready && record?.snapshotSaved && record.expiresAt > Date.now());
        } else if (this.errorMessage) this.statusTarget.textContent = this.errorMessage;
        if (this.navigating) {
            this.statusTarget.textContent = `Opening ${shortSiteTitle()}…`;
            if (this.hasUnlockTarget) this.unlockTarget.hidden = true;
            if (this.hasForgetTarget) this.forgetTarget.hidden = true;
        }
        if (!this.busy && !this.navigating && protectedDevice && vault.key && this.isLoginPage && !this.usingPasswordLogin) await this.enterApp();
    }

    get isLoginPage() { return /^\/members\/login\/?$/i.test(location.pathname); }

    navigate(destination) { location.replace(destination); }

    /** Login owns routing; offline-data refresh continues on the destination page. */
    async enterApp() {
        if (this.navigating) return;
        this.navigating = true;
        const generation = vault.generation;
        this.linkTarget.hidden = true;
        this.statusTarget.textContent = `Opening ${shortSiteTitle()}…`;
        let destination = null;
        try {
            if (navigator.onLine) destination = await loginWithSavedPassword(true);
        } catch { /* A failed connection still permits the unlocked mobile copy. */ }
        if (!this.connected || !vault.key || generation !== vault.generation) {
            this.navigating = false;
            return;
        }
        this.navigate(destination || '/offline');
    }

    trust() {
        this.resetSetup();
        this.showStep(1);
    }

    showStep(step) {
        this.setupStep = step;
        this.setWizardActive(true);
        this.wizardTarget.hidden = false;
        this.choiceTarget.hidden = true;
        this.passwordFieldsTarget.hidden = this.passwordFieldsTarget.disabled = step !== 1;
        this.protectionFieldsTarget.hidden = this.protectionFieldsTarget.disabled = step !== 2;
        this.passwordTarget.required = step === 1;
        this.backTarget.hidden = step !== 2;
        this.stepLabelTarget.textContent = `Step ${step} of 3`;
        this.stepHeadingTarget.textContent = step === 1 ? 'Is this your personal device?' : `Choose how to unlock ${shortSiteTitle()}`;
        this.chooseMethod();
        this.stepHeadingTarget.focus();
        this.render();
    }

    chooseMethod() {
        this.errorMessage = this.setupMessage = '';
        this.setupErrorTarget.hidden = true;
        const pin = this.methodTarget.value === 'pin';
        if (pin && this.ownsEnrollment) { vault.pendingDevice = null; this.ownsEnrollment = false; }
        this.pinFieldsTarget.hidden = !pin;
        this.pinTarget.disabled = this.confirmTarget.disabled = !pin || this.setupStep !== 2;
        this.pinTarget.required = this.confirmTarget.required = pin && this.setupStep === 2;
        this.methodHelpTarget.textContent = pin
            ? `Choose a PIN you can remember. This PIN is just for ${shortSiteTitle()} on this device. Avoid easy guesses like 123456.`
            : `Your device will guide you through using Face ID, a fingerprint, or its screen lock. This creates a passkey. If it isn’t supported, choose ${shortSiteTitle()} PIN instead.`;
        this.nextTarget.hidden = this.setupStep === 2 && !pin;
        this.nextTarget.textContent = this.setupStep === 1 ? 'Continue' : 'Save PIN and trust device';
        this.continueTarget.hidden = this.setupStep !== 2 || pin;
        this.continueTarget.textContent = vault.pendingDevice && this.ownsEnrollment ? 'Finish passkey setup' : 'Set up passkey';
        this.stepHeadingTarget.textContent = this.setupStep === 1 ? 'Is this your personal device?'
            : this.passkeySupport?.available === false ? `Choose your ${shortSiteTitle()} PIN` : `Choose how to unlock ${shortSiteTitle()}`;
        this.render();
    }

    applyPasskeySupport(support) {
        this.passkeySupport = support;
        const unavailable = !support.available;
        const option = this.methodTarget.querySelector('[value=device]');
        option.hidden = option.disabled = unavailable;
        this.methodChoiceTarget.hidden = unavailable;
        this.availabilityTarget.hidden = !unavailable;
        this.availabilityTarget.textContent = support.reason === 'provider'
            ? `Passkeys couldn’t protect offline information in this browser. Use a ${shortSiteTitle()} PIN to unlock ${shortSiteTitle()} online and offline. Your password check carries over.`
            : `Use a ${shortSiteTitle()} PIN on this device. It will unlock ${shortSiteTitle()} online and offline.`;
        this.recheckTarget.hidden = support.reason !== 'provider';
        if (unavailable) this.methodTarget.value = 'pin';
        this.chooseMethod();
    }

    async recheckPasskeys() {
        await this.run(async () => {
            forgetPasskeyFailure();
            const support = await checkPasskeySupport();
            if (!this.connected) return;
            this.applyPasskeySupport(support);
            this.focusAfterRun = support.available ? this.methodTarget : this.pinTarget;
        });
    }

    usePin() {
        this.methodTarget.value = 'pin';
        this.chooseMethod();
        this.pinTarget.focus();
    }

    setWizardActive(active) {
        this.element.dispatchEvent(new CustomEvent('kmp:device-setup', { bubbles: true, detail: { active } }));
    }

    resetSetup() {
        this.setWizardActive(false);
        this.setupStep = null;
        this.completed = false;
        this.pendingSetup = null;
        this.errorMessage = this.setupMessage = '';
        if (this.ownsEnrollment) vault.pendingDevice = null;
        this.ownsEnrollment = false;
        for (const name of ['password', 'pin', 'confirm']) {
            if (!this[`has${name[0].toUpperCase() + name.slice(1)}Target`]) continue;
            this[`${name}Target`].value = '';
            this[`${name}Target`].removeAttribute('aria-invalid');
        }
        if (this.hasWizardTarget) this.wizardTarget.hidden = true;
        if (this.hasSetupErrorTarget) this.setupErrorTarget.hidden = true;
        if (this.hasSetupProgressTarget) this.setupProgressTarget.hidden = true;
        if (this.hasSuccessTarget) this.successTarget.hidden = true;
    }

    cancelSetup() {
        this.resetSetup();
        if (this.connected) {
            this.choiceTarget.hidden = false;
            this.choiceTarget.querySelector('button')?.focus();
            this.render();
        }
    }

    back() {
        this.resetSetup();
        this.showStep(1);
    }

    async run(operation) {
        if (this.busy) return;
        this.busy = true;
        this.errorMessage = '';
        if (this.hasSetupErrorTarget) this.setupErrorTarget.hidden = true;
        this.element.setAttribute('aria-busy', 'true');
        const controls = [...this.element.querySelectorAll('[data-trust-control], [data-offline-access-target="method"]')];
        controls.forEach(control => { control.disabled = true; });
        try { await operation(); }
        catch (error) {
            this.errorMessage = error.name === 'NotAllowedError'
                ? `Passkey setup wasn’t completed. Try again, or choose ${shortSiteTitle()} PIN instead.` : error.message;
            if (this.setupStep === 2 && (error.code === 'PASSKEY_UNAVAILABLE' || error.name === 'OperationError')) {
                if (error.name === 'OperationError') unavailablePasskey('Passkey verification failed.');
                this.applyPasskeySupport({ available: false, reason: 'provider' });
                this.pinTarget.focus();
            } else if (this.setupStep) {
                if (error.name === 'OperationError') this.errorMessage = `This passkey could not reopen your protected information. Try again, or use a ${shortSiteTitle()} PIN on this device.`;
                this.setupErrorTarget.hidden = false;
                this.setupErrorMessageTarget.textContent = this.errorMessage;
                this.setupProgressTarget.hidden = true;
                this.pinFallbackTarget.hidden = this.setupStep !== 2 || this.methodTarget.value !== 'device';
                this.setupErrorMessageTarget.focus();
                console.info('[KMP device setup]', { stage: this.element.dataset.deviceSetupState || 'setup',
                    outcome: 'failed', error: ['NotAllowedError', 'OperationError', 'AbortError', 'SecurityError'].includes(error.name) ? error.name : 'SetupError' });
            } else {
                this.statusTarget.textContent = this.errorMessage;
                this.statusTarget.focus();
            }
        }
        finally {
            this.busy = false;
            controls.forEach(control => { control.disabled = false; });
            this.element.removeAttribute('aria-busy');
            if (this.errorMessage && this.setupStep === 2 && this.methodTarget.value === 'device') {
                this.continueTarget.textContent = vault.pendingDevice ? 'Finish passkey setup' : 'Try passkey setup again';
            }
            this.focusAfterRun?.focus();
            this.focusAfterRun = null;
        }
    }

    async setupDevice(event) {
        event.preventDefault();
        await this.run(async () => {
            if (this.setupStep === 1) {
                this.setupMessage = this.statusTarget.textContent = 'Checking your password and preparing this device…';
                const verified = await verifyDevicePassword(this.passwordTarget.value);
                await prepareOfflineShell();
                if (!this.connected) return;
                if (verified.generation !== vault.generation) throw new Error('Your sign-in changed. Start device setup again.');
                this.pendingSetup = verified;
                this.passwordTarget.value = '';
                const support = await checkPasskeySupport();
                if (!this.connected) return;
                this.showStep(2);
                this.applyPasskeySupport(support);
                return;
            }
            if (this.setupStep !== 2 || this.methodTarget.value !== 'pin') return;
            if (!/^\d{6,12}$/.test(this.pinTarget.value) || this.pinTarget.value !== this.confirmTarget.value) {
                this.confirmTarget.setAttribute('aria-invalid', 'true');
                throw new Error('Enter the same 6–12 digit PIN in both boxes.');
            }
            this.confirmTarget.removeAttribute('aria-invalid');
            const verified = this.pendingSetup;
            if (!verified || verified.generation !== vault.generation) throw new Error('Your sign-in changed. Start device setup again.');
            this.setupMessage = this.statusTarget.textContent = 'Saving your PIN…';
            await vault.enroll(verified.context, 'pin', this.pinTarget.value, verified.login);
            await this.completeSetup('pin');
        });
    }

    async continueSetup() {
        await this.run(async () => {
            if (!this.pendingSetup) throw new Error('Device setup was interrupted. Choose Back to confirm your password and try again.');
            if (this.pendingSetup.generation !== vault.generation) throw new Error('Your sign-in changed. Start device setup again.');
            this.ownsEnrollment = true;
            this.element.dataset.deviceSetupState = vault.pendingDevice ? 'verifying-passkey' : 'creating-passkey';
            this.setupMessage = vault.pendingDevice ? 'Checking your passkey. Unlock when your device asks.'
                : 'Creating your passkey. Complete all prompts from your browser, including any password-manager setup.';
            this.setupProgressTarget.hidden = false;
            this.setupProgressTarget.textContent = this.setupMessage;
            this.continueTarget.textContent = vault.pendingDevice ? 'Checking passkey…' : 'Waiting for your browser…';
            const step = vault.pendingDevice ? await vault.continueDeviceEnrollment()
                : await vault.enroll(this.pendingSetup.context, 'device', '', this.pendingSetup.login);
            if (!this.connected) { vault.pendingDevice = null; return; }
            if (step === 'complete') await this.completeSetup('device');
            else {
                this.element.dataset.deviceSetupState = step === 'wrap' ? 'passkey-created' : 'passkey-ready-to-check';
                this.stepHeadingTarget.textContent = 'Your passkey was created. Let’s check it.';
                this.continueTarget.textContent = 'Finish passkey setup';
                this.setupMessage = step === 'wrap'
                    ? `Your passkey is saved in your browser. Tap Finish passkey setup to connect it to ${shortSiteTitle()}. Your device may ask you to unlock twice to check offline access.`
                    : `Final check: tap Finish passkey setup and unlock once more. ${shortSiteTitle()} will then finish trusting this device.`;
                this.setupProgressTarget.textContent = this.setupMessage;
                this.focusAfterRun = this.continueTarget;
            }
        });
    }

    async completeSetup(method) {
        QuickLoginService.completePinMigration();
        this.resetSetup();
        this.completed = true;
        this.element.dataset.deviceSetupState = 'complete';
        this.setWizardActive(true);
        this.saving = true;
        this.successTarget.hidden = false;
        this.successMethodTarget.textContent = method === 'device'
            ? 'Your passkey is set up and ready to use.' : `Your ${shortSiteTitle()} PIN is set up and ready to use.`;
        this.renderCompletion(false);
        this.successHeadingTarget.focus();
        navigator.storage?.persist?.().catch(() => false);
        await this.render();
        await this.retrySave();
    }

    renderCompletion(ready) {
        this.readinessTarget.textContent = ready ? 'Ready offline. Your card, RSVPs, and events are saved on this device.'
            : this.saving ? `Saving your offline information… Keep ${shortSiteTitle()} open for a moment.`
                : `Your unlock is set up, but your offline information isn’t ready yet. Stay connected and try saving again. ${shortSiteTitle()} will also retry automatically.`;
        this.retryTarget.hidden = ready || this.saving;
    }

    async retrySave() {
        this.saving = true;
        this.retryTarget.disabled = true;
        this.renderCompletion(false);
        try { await updateTrustedDevice(true); }
        catch { /* Protection is saved; the completion panel explains how to retry data saving. */ }
        finally {
            this.saving = false;
            this.retryTarget.disabled = false;
            if (this.connected) await this.render();
        }
    }

    async done() {
        this.resetSetup();
        this.statusTarget.textContent = 'This device is trusted.';
        this.statusTarget.focus();
        await this.render();
    }

    async unlockDevice(event) {
        event.preventDefault();
        await this.run(async () => {
            await vault.unlock(this.unlockPinTarget.value);
            this.unlockPinTarget.value = '';
            if (this.isLoginPage) { await this.enterApp(); return; }
            try {
                if (navigator.onLine) await loginWithSavedPassword(true);
                await updateTrustedDevice(true);
            } catch { this.statusTarget.textContent = 'Your saved information is unlocked. Sign in with your password if your online password has changed.'; }
            await this.render();
        });
        this.unlockPinTarget.value = '';
    }

    publishDeviceAvailability(record) {
        if (!this.connected) return;
        this.element.dispatchEvent(new CustomEvent('kmp:device-login-availability', {
            bubbles: true, detail: { available: !!record?.wrapper.unlockMethod }
        }));
    }

    updatePasswordSwitch() {
        if (this.hasPasswordSwitchTarget) this.passwordSwitchTarget.hidden = !navigator.onLine;
    }

    get usingPasswordLogin() {
        return this.element.closest('[data-controller~="login-device-auth"]')?.dataset.loginMethod === 'password';
    }

    usePassword() {
        if (!navigator.onLine) return;
        if (this.element.closest('[data-controller~="login-device-auth"]')) {
            this.unlockPinTarget.value = '';
            this.element.dispatchEvent(new CustomEvent('kmp:password-login', { bubbles: true, cancelable: true }));
        } else location.assign('/members/login?method=password');
    }

    decline() {
        sessionStorage.setItem('kmp.offline.declined', '1');
        this.choiceTarget.hidden = true;
        this.statusTarget.textContent = `This browser will use ${shortSiteTitle()} online. You can trust it later in Security.`;
        this.statusTarget.focus();
    }

    async forget() {
        if (!await window.KMP_accessibility.confirm('Stop trusting this device? Saved information and any RSVPs waiting to send will be removed.')) return;
        await vault.clear();
        await this.render();
        this.statusTarget.textContent = 'This device is no longer trusted. Saved information has been removed.';
        this.statusTarget.focus();
    }
}
window.Controllers ||= {};
window.Controllers['offline-access'] = OfflineAccessController;
export default OfflineAccessController;