' +
+ '
Login
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ // Nostr panel
+ '
' +
+ nostrExtBtn +
+ nostrGuestBtn +
+ nostrSep +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ // Solid panel
+ '
' +
+ solidBtns +
+ '
or enter your identity provider
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
'
+ shadow.appendChild(overlay)
+
+ // --- Wire up tabs ---
+ var tabs = overlay.querySelectorAll('.xl-tab')
+ var panels = overlay.querySelectorAll('.xl-panel')
+ tabs.forEach(function (tab) {
+ tab.onclick = function () {
+ tabs.forEach(function (t) { t.classList.remove('active') })
+ panels.forEach(function (p) { p.classList.remove('active') })
+ tab.classList.add('active')
+ overlay.querySelector('[data-panel="' + tab.dataset.tab + '"]').classList.add('active')
+ }
+ })
+
+ // --- Cancel ---
+ var cancelBtns = overlay.querySelectorAll('.xl-cancel')
+ function cancel() {
+ hideModal()
+ _keyResolvers.forEach(function (r) { r.reject(new Error('User cancelled login')) })
+ _keyResolvers = []
+ }
+ cancelBtns.forEach(function (b) { b.onclick = cancel })
+ overlay.onclick = function (e) { if (e.target === overlay) cancel() }
+
+ // --- Nostr: Extension ---
+ var extBtn = overlay.querySelector('.xl-nostr-ext')
+ var nostrError = overlay.querySelector('.xl-nostr-error')
+ if (extBtn) {
+ extBtn.onclick = async function () {
+ try {
+ var pubkey = await _ext.getPublicKey()
+ hideModal()
+ nostrLoginSuccess(btn, pubkey, 'extension')
+ } catch (e) {
+ nostrError.textContent = 'Extension error: ' + e.message
+ }
+ }
+ }
+
+ // --- Nostr: Guest ---
+ var guestBtn = overlay.querySelector('.xl-nostr-guest')
+ if (guestBtn) {
+ guestBtn.onclick = async function () {
+ await _secpReady
+ _nostrPrivKey = _guestKey
+ var pubkey = bytesToHex(_secp.schnorr.getPublicKey(_guestKey))
+ hideModal()
+ nostrLoginSuccess(btn, pubkey, 'guest')
+ }
+ }
+
+ // --- Nostr: Key ---
+ var nostrKeyInput = overlay.querySelector('.xl-nostr-key')
+ var nostrSubmit = overlay.querySelector('.xl-nostr-submit')
+ nostrSubmit.onclick = async function () {
+ var val = nostrKeyInput.value.trim().toLowerCase()
+ if (!/^[0-9a-f]{64}$/.test(val)) {
+ nostrError.textContent = 'Must be exactly 64 hex characters'
+ return
+ }
+ await _secpReady
+ _nostrPrivKey = val
+ var pubkey = bytesToHex(_secp.schnorr.getPublicKey(val))
+ hideModal()
+ nostrKeyInput.value = ''
+ nostrError.textContent = ''
+ nostrLoginSuccess(btn, pubkey, 'key')
+ }
+ nostrKeyInput.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') nostrSubmit.onclick()
+ })
+ nostrKeyInput.addEventListener('input', function () {
+ if (/^[0-9a-fA-F]{64}$/.test(nostrKeyInput.value.trim())) nostrSubmit.onclick()
+ })
+
+ // --- Solid: Provider buttons ---
+ var solidError = overlay.querySelector('.xl-solid-error')
+ overlay.querySelectorAll('.xl-solid-provider').forEach(function (b) {
+ b.onclick = function () { doSolidLogin(b.dataset.idp, btn, solidError) }
+ })
+
+ // --- Solid: Custom IDP ---
+ var solidIdpInput = overlay.querySelector('.xl-solid-idp')
+ var solidSubmit = overlay.querySelector('.xl-solid-submit')
+ solidSubmit.onclick = function () {
+ var val = solidIdpInput.value.trim()
+ if (!val) {
+ solidError.textContent = 'Enter an identity provider URL'
+ return
+ }
+ if (!/^https?:\/\//.test(val)) val = 'https://' + val
+ doSolidLogin(val, btn, solidError)
+ }
+ solidIdpInput.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') solidSubmit.onclick()
+ })
+
+ return { btn: btn, overlay: overlay }
+ }
+
+ // --- Solid login ---
+ async function doSolidLogin(idp, btn, errorEl) {
+ try {
+ errorEl.textContent = ''
+ await _solidReady
+ _solidSession = new _SolidSession()
+ _solidSession.addEventListener('sessionStateChange', function (e) {
+ if (e.detail.isActive) {
+ var webId = e.detail.webId
+ window.solid = window.solid || {}
+ window.solid.session = _solidSession
+ window.solid.webId = webId
+ onLogin('solid', webId, btn)
+ }
+ })
+ hideModal()
+ await _solidSession.login(idp, window.location.href)
+ } catch (e) {
+ if (errorEl) errorEl.textContent = e.message || 'Login failed'
+ }
+ }
+
+ // --- Solid redirect callback ---
+ async function handleSolidRedirect() {
+ var url = new URL(window.location.href)
+ if (!url.searchParams.get('code')) return false
+ await _solidReady
+ _solidSession = new _SolidSession()
+ var ui = getUI()
+ _solidSession.addEventListener('sessionStateChange', function (e) {
+ if (e.detail.isActive) {
+ var webId = e.detail.webId
+ window.solid = window.solid || {}
+ window.solid.session = _solidSession
+ window.solid.webId = webId
+ if (ui && ui.btn) onLogin('solid', webId, ui.btn)
+ }
+ })
+ await _solidSession.handleRedirectFromLogin()
+ return true
+ }
+
+ // --- Solid session restore ---
+ async function trySolidRestore() {
+ await _solidReady
+ _solidSession = new _SolidSession()
+ var ui = getUI()
+ _solidSession.addEventListener('sessionStateChange', function (e) {
+ if (e.detail.isActive) {
+ var webId = e.detail.webId
+ window.solid = window.solid || {}
+ window.solid.session = _solidSession
+ window.solid.webId = webId
+ if (ui && ui.btn) onLogin('solid', webId, ui.btn)
+ }
+ })
+ await _solidSession.restore()
+ }
+
+ // --- Nostr session restore ---
+ // Returns a Promise so the caller can await the async nip-07
+ // polling tail (#13). All other paths resolve synchronously.
+ function tryNostrRestore() {
+ var restored = loadCurrentAccount()
+ if (!restored) return Promise.resolve()
+ if ((restored.signerType === 'key' || restored.signerType === 'guest') && restored.privkey) {
+ _nostrPrivKey = restored.privkey
+ _nostrPubKey = restored.pubkey
+ _nostrProvider = restored.signerType
+ var ui = getUI()
+ if (ui && ui.btn) onLogin('nostr', restored.pubkey, ui.btn)
+ return Promise.resolve()
+ }
+ if (restored.signerType === 'nip-07') {
+ _nostrPubKey = restored.pubkey
+ return (async function () {
+ for (var i = 0; i < 50; i++) {
+ if (_ext) {
+ _nostrProvider = 'extension'
+ var ui = getUI()
+ if (ui && ui.btn) onLogin('nostr', _nostrPubKey, ui.btn)
+ return
+ }
+ await new Promise(function (r) { setTimeout(r, 100) })
+ }
+ _nostrPubKey = null
+ saveCurrentAccount(null)
+ })()
+ }
+ return Promise.resolve()
+ }
+
+ function getUI() {
+ if (!_ui && document.body) _ui = createWidget()
+ return _ui
+ }
+
+ function showModal() {
+ var ui = getUI()
+ if (ui) ui.overlay.classList.add('active')
+ }
+
+ function hideModal() {
+ var ui = getUI()
+ if (ui) ui.overlay.classList.remove('active')
+ }
+
+ // --- Global API ---
+ window.xlogin = window.xlogin || {}
+ window.xlogin.type = null
+ window.xlogin.id = null
+ window.xlogin.login = function () { showModal() }
+ window.xlogin.logout = function () { onLogout(getUI().btn) }
+ // Resolves when init() has finished restoring (or settled on no
+ // session). Lets consumers `await window.xlogin.ready` instead of
+ // polling `window.xlogin.type` with a timeout. See #13.
+ var _readyResolve
+ window.xlogin.ready = new Promise(function (r) { _readyResolve = r })
+
+ /**
+ * Unified authenticated fetch.
+ * - Nostr login → NIP-98 Authorization header via nip98
+ * - Solid login → DPoP Authorization header via solid-oidc
+ * - Not logged in → plain fetch
+ */
+ window.xlogin.authFetch = async function (url, options) {
+ if (_type === 'nostr') {
+ await _nip98Ready
+ return _nip98AuthFetch(url, options)
+ }
+ if (_type === 'solid' && _solidSession) {
+ return _solidSession.authFetch(url, options)
+ }
+ return fetch(url, options)
+ }
+
+ // --- Init ---
+ async function init() {
+ // Wrap in try/finally so `window.xlogin.ready` always settles —
+ // any unexpected throw inside (beyond the known catches below)
+ // would otherwise leave consumers awaiting it forever (#14).
+ try {
+ getUI()
+
+ // 1. Solid redirect callback
+ var wasRedirect = await handleSolidRedirect().catch(function () { return false })
+
+ if (!wasRedirect) {
+ // 2. Try Nostr restore (await the async nip-07 tail too)
+ await tryNostrRestore()
+
+ // 3. Try Solid restore if not already logged in via Nostr
+ if (!_type) {
+ await trySolidRestore().catch(function () {})
+ }
+
+ // 4. SSO-arrival handling. When a Solid app (e.g. jss.live/sso/)
+ // redirects the user to their pod with a ?webid= hint, xlogin
+ // owns that param — clean it from the URL on sight (phase 2c),
+ // and if no prior session restored AND a signer extension is
+ // present, auto-run the same getPublicKey + nostrLoginSuccess
+ // pair the "Use Browser Extension" button click runs (phase
+ // 2b). Saves the user a click; the signer is still in charge
+ // of approval. The pubkey returned by the signer wins — the
+ // hint is a "should we try this" signal, not a credential.
+ try {
+ var hint = new URLSearchParams(window.location.search).get('webid')
+ if (hint) {
+ // Phase 2c: scrub `?webid=` whether or not we can act on
+ // it (session already restored, no extension, etc.).
+ // Keeps refresh / bookmark / share clean. Other query
+ // params (if any) are preserved.
+ try {
+ var clean = new URL(window.location.href)
+ clean.searchParams.delete('webid')
+ history.replaceState(null, '', clean.href)
+ } catch (_) { /* history API unavailable — harmless, skip */ }
+
+ // Phase 2b auto-trigger keeps its original guards.
+ if (!_type && _ext) {
+ var ui = getUI()
+ if (ui && ui.btn) {
+ // Fire-and-forget — do NOT await the signer prompt.
+ // The signer's getPublicKey() may block indefinitely
+ // while waiting for user approval (especially on
+ // extensions that show a popup), which would in turn
+ // delay window.xlogin.ready (the whole point of #14
+ // was to settle ready promptly regardless of pending
+ // user interaction). Consumers see ready resolve as
+ // "no session yet"; nostrLoginSuccess fires its own
+ // state-change event when/if the signer approves
+ // later, so async consumers still see the eventual
+ // login.
+ _ext.getPublicKey().then(function (pubkey) {
+ // Mirror the manual extBtn.click() path: hide the
+ // modal first so it doesn't stay open in the corner
+ // case where the user opened it during the brief
+ // auto-trigger window.
+ hideModal()
+ nostrLoginSuccess(ui.btn, pubkey, 'extension')
+ }).catch(function () { /* signer declined or unavailable — fall through to manual login */ })
+ }
+ }
+ }
+ } catch (_) { /* missing URLSearchParams or unexpected runtime — no-op */ }
+ }
+ } finally {
+ // Tell consumers (e.g., LOSOS shell) that restore has settled
+ // — success, no-session, or unexpected throw. See #13.
+ _readyResolve()
+ }
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init)
+ } else {
+ init()
+ }
+})()