(function (root, factory) { if (typeof module === 'object' && module.exports) module.exports = factory; else root.createLoginController = factory; }(typeof self !== 'undefined' ? self : this, function createLoginController(options) { function validShareContinuation(value) { if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './'; const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200, preview: 200 }; const params = new URLSearchParams(value.slice(3)); const entries = Array.from(params.entries()); if (!entries.length) return './'; const invalid = entries.some(([name, content]) => ( !Object.prototype.hasOwnProperty.call(limits, name) || !content || content.length > limits[name] )); if (invalid) return './'; if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './'; const search = params.get('search'); const preview = params.get('preview'); if (search !== null || preview !== null) { if (entries.some(([name]) => !['search', 'preview'].includes(name))) return './'; if (search === null || preview === null) return './'; if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './'; return value; } const launch = params.get('launch'); const shared = params.get('shared'); if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './'; if (shared !== null && (shared !== 'image' || launch !== 'new')) return './'; return value; } const form = options.form; const status = options.status; const button = options.button; const passkeyButton = options.passkeyButton; const credentials = options.credentials; const fetchImpl = options.fetchImpl; const location = options.location; const clearPrivateDeviceData = options.clearPrivateDeviceData; const continuation = validShareContinuation(options.continuation); const setIntervalImpl = options.setIntervalImpl || setInterval; const clearIntervalImpl = options.clearIntervalImpl || clearInterval; const setTimeoutImpl = options.setTimeoutImpl || setTimeout; const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout; const requestTimeoutMs = options.requestTimeoutMs || 15000; let timer = null; let activeAttempt = false; function setAttemptActive(active) { activeAttempt = active; button.disabled = active; if (passkeyButton) passkeyButton.disabled = active || !credentials?.get; } async function requestWithDeadline(url, init) { const controller = new AbortController(); const timeout = setTimeoutImpl(() => { const error = new Error('Sign-in request timed out'); error.name = 'TimeoutError'; controller.abort(error); }, requestTimeoutMs); try { return await fetchImpl(url, { ...init, signal: controller.signal }); } finally { clearTimeoutImpl(timeout); } } function decodeBase64Url(value) { const padded = String(value).replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - String(value).length % 4) % 4); return Uint8Array.from(atob(padded), character => character.charCodeAt(0)); } function encodeBase64Url(value) { if (value === null || value === undefined) return null; const bytes = new Uint8Array(value); let binary = ''; bytes.forEach(byte => { binary += String.fromCharCode(byte); }); return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); } function credentialJSON(credential) { return { id: credential.id, type: credential.type, rawId: encodeBase64Url(credential.rawId), response: { authenticatorData: encodeBase64Url(credential.response.authenticatorData), clientDataJSON: encodeBase64Url(credential.response.clientDataJSON), signature: encodeBase64Url(credential.response.signature), userHandle: encodeBase64Url(credential.response.userHandle), }, }; } if (continuation !== './') { const continuationParams = new URLSearchParams(continuation.slice(3)); status.textContent = continuationParams.has('preview') ? 'Sign in to open the shared Search result.' : 'Sign in to continue your shared capture.'; } async function showReason(reason) { if (reason === 'session-expired') { status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.'; return; } if (reason === 'session-idle') { status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.'; return; } if (reason !== 'session-revoked') return; button.disabled = true; status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…'; try { if (typeof clearPrivateDeviceData !== 'function') throw new Error('Private data purger unavailable.'); await clearPrivateDeviceData(); status.textContent = 'This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.'; button.disabled = false; } catch (_error) { status.textContent = 'This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.'; } } function showRetryCountdown(seconds) { let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1); button.disabled = true; status.textContent = `Too many attempts. Try again in ${remaining} seconds.`; timer = setIntervalImpl(() => { remaining -= 1; if (remaining <= 0) { clearIntervalImpl(timer); timer = null; button.disabled = false; status.textContent = 'You can try signing in again.'; return; } status.textContent = `Too many attempts. Try again in ${remaining} seconds.`; }, 1000); } async function submit(accessToken, deviceLabel = 'This device') { if (activeAttempt) return false; setAttemptActive(true); status.textContent = 'Signing in…'; let response; try { response = await requestWithDeadline('api/v1/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }), }); } catch (error) { form.reset(); status.textContent = error?.name === 'TimeoutError' ? 'Sign-in timed out. Check your connection and try again.' : 'Sign-in failed. Check your connection and try again.'; return false; } finally { setAttemptActive(false); } form.reset(); if (response.ok) { location.replace(continuation); return; } if (response.status === 429) { showRetryCountdown(response.headers.get('Retry-After')); return; } status.textContent = 'Sign-in failed. Check the token and try again.'; } async function signInWithPasskey(deviceLabel = 'This device') { if (!credentials?.get) { status.textContent = 'Passkeys are not supported in this browser. Use the access token.'; return false; } if (activeAttempt) return false; setAttemptActive(true); status.textContent = 'Waiting for your passkey…'; try { const optionsResponse = await requestWithDeadline('api/v1/passkeys/authentication/options', { method: 'POST', headers: { Accept: 'application/json' }, }); if (!optionsResponse.ok) throw new Error('No enrolled passkey'); const publicKey = await optionsResponse.json(); const challenge = publicKey.challenge; publicKey.challenge = decodeBase64Url(publicKey.challenge); publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({ ...item, id: decodeBase64Url(item.id), })); const credential = await credentials.get({ publicKey }); const response = await requestWithDeadline('api/v1/passkeys/authentication/verify', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge, credential: credentialJSON(credential), device_label: deviceLabel, action: 'sign_in', target: 'dashboard', }), }); if (!response.ok) throw new Error('Passkey verification failed'); location.replace(continuation); return true; } catch (error) { status.textContent = error?.name === 'TimeoutError' ? 'Passkey sign-in timed out. Check your connection and try again.' : 'Passkey sign-in was not completed. Try again or use the access token.'; return false; } finally { setAttemptActive(false); } } if (passkeyButton) passkeyButton.disabled = !credentials?.get; return { submit, signInWithPasskey, showReason }; })); if (typeof document !== 'undefined') { const form = document.getElementById('sign-in'); const status = document.getElementById('status'); const button = document.getElementById('submit-sign-in'); const passkeyButton = document.getElementById('passkey-sign-in'); const loginParams = new URLSearchParams(window.location.search); const controller = createLoginController({ form, status, button, passkeyButton, credentials: window.navigator?.credentials, fetchImpl: fetch.bind(window), location: window.location, continuation: loginParams.get('continue'), clearPrivateDeviceData: window.stackchainPrivateDeviceData, }); controller.showReason(loginParams.get('reason')); form.addEventListener('submit', event => { event.preventDefault(); const data = new FormData(form); const accessToken = data.get('access_token'); const deviceLabel = data.get('device_label'); controller.submit(accessToken, deviceLabel); }); passkeyButton?.addEventListener('click', () => { const data = new FormData(form); controller.signInWithPasskey(data.get('device_label')); }); }