// SPDX-License-Identifier: Apache-2.0 // Copyright © 2022 Roberto Hidalgo import * as webauthn from "./webauthn.js" const host = document.location.protocol + "//" + document.location.host // const host = "http://localhost:8081" function localDate(src) { const exp = new Date(src) return new Date(exp - exp.getTimezoneOffset() * 60000).toISOString().replace("Z", "").replace(/\.\d+$/, '') } class UserInfoPanel extends HTMLElement { constructor(user) { super() } connectedCallback() { let template = document.getElementById("user-info-panel") const shadowRoot = this.attachShadow({ mode: "open" }) const panel = template.content.cloneNode(true) let handle = this.getAttribute("handle") panel.querySelector('h3').innerHTML = this.getAttribute("name") panel.querySelector('input[name=name]').value = this.getAttribute("name") const form = panel.querySelector('form') form.action = panel.querySelector('form').action.replace(":id", handle) panel.querySelector('code').textContent = handle panel.querySelector('input[name=greeting]').value = this.getAttribute("greeting") if (this.hasAttribute('schedule')){ panel.querySelector('input[name=schedule]').value = this.getAttribute("schedule") } if (this.hasAttribute('expires')){ panel.querySelector('input[name=expires]').value = localDate(this.getAttribute("expires")) } if (this.hasAttribute("is_admin")) { const adminSpan = document.createElement("span") adminSpan.innerText = "🔑" panel.querySelector(".user-info-meta").prepend(adminSpan) } panel.querySelector('input[name=max_ttl]').value = this.getAttribute("max_ttl") panel.querySelector('input[name=is_admin]').checked = this.hasAttribute("is_admin") panel.querySelector('input[name=second_factor]').checked = this.hasAttribute("second_factor") panel.querySelector('input[name=receives_notifications]').checked = this.hasAttribute("receives_notifications") panel.querySelector("button.user-edit").addEventListener('click', evt => { form.classList.toggle("hidden") this.classList.toggle("editing") }) form.addEventListener("submit", async (evt) => { evt.preventDefault() await UpdateUser(form) }) panel.querySelector("button.user-delete").addEventListener('click', async evt => { evt.preventDefault() if (confirm(`Seguro que borramos a ${handle}?`)) { let response = await webauthn.withAuth(`${host}/api/user/${handle}`, { credentials: "include", method: "DELETE" }) if (!response.ok) { throw new Error("Could not delete user:", response) } window.location.reload() } }) shadowRoot.appendChild(panel) } } customElements.define("user-info-panel", UserInfoPanel) class REXRow extends HTMLElement { constructor(rex) { super() let template = document.getElementById("rex-record") const shadowRoot = this.attachShadow({ mode: "open" }) const row = template.content.cloneNode(true) row.querySelector('.log-record-timestamp').innerText = localDate(rex.timestamp) row.querySelector('.log-record-user').innerText = rex.user row.querySelector('.log-record-status').innerHTML = !rex.error ? "ok" : `${rex.error} ${rex.failure}` row.querySelector('.log-record-second_factor').innerText = rex.second_factor ? "✓" : "" row.querySelector('.log-record-ip_address').innerText = rex.ip_address row.querySelector('.log-record-user_agent').innerText = rex.user_agent shadowRoot.appendChild(row) } } customElements.define("rex-record", REXRow, {extends: "tr"}) async function fetchUsers() { console.debug("fetching users") let response = await window.fetch(`${host}/api/user`, {credentials: "include"}) if (!response.ok) { alert("Could not load users") return } let json = {} try { json = await response.json() } catch (err) { alert(err) return } document.querySelector("#user-list").replaceChildren(...json.map(u => { const ul = new UserInfoPanel() Object.keys(u).forEach(k => { let val = u[k] if (!val) { return } if(typeof(val) == "boolean") { val = k; } ul.setAttribute(k, u[k]) }) return ul })) } async function fetchLog() { console.debug("fetching log") let response = await window.fetch(`${host}/api/log?last=20`, {credentials: "include"}) if (!response.ok) { alert("Could not load log") return } let json = {} try { json = await response.json() } catch (err) { alert(err) return } document.querySelector("#rex-records").replaceChildren(...json.map(rex => { const tr = document.createElement("tr") tr.classList.add("rex-staus-" + (!rex.error ? "ok" : "failure")) tr.classList.add("rex-record") const status = !rex.error ? "ok" : `${rex.error} ${rex.failure}` tr.innerHTML = `${localDate(rex.timestamp)} ${rex.user} ${status} ${rex.second_factor ? "✓" : ""} ${rex.ip_address} ${rex.user_agent}` // table rows and shadow dom don't really play along // also, the `is` attribute is not supported by safari :/ // tr.appendChild(new REXRow(record)) return tr })) } function userFromForm(form) { const user = Object.fromEntries(new FormData(form)) delete(user.id) if (user.expires != "") { user.expires = (new Date(user.expires)).toISOString() } else { delete(user.expires) } if (user.max_ttl == "") { delete(user.max_ttl) } if (user.schedule == "") { delete(user.schedule) } user.is_admin = user.is_admin == "on" user.second_factor = user.second_factor == "on" user.receives_notifications = user.receives_notifications == "on" return user } async function UpdateUser(form) { const user = userFromForm(form) let response = await webauthn.withAuth(form.getAttribute("action"), { credentials: "include", method: "POST", body: JSON.stringify(user), headers: { 'Content-Type': 'application/json' } }) if (!response.ok) { throw new Error("Could not update user:", response) } window.location.reload() } async function CreateUser(form) { const user = userFromForm(form) let response = await webauthn.withAuth(form.getAttribute("action"), { credentials: "include", method: "POST", body: JSON.stringify(user), headers: { 'Content-Type': 'application/json' } }) if (!response.ok) { throw new Error("Could not create user:", response) } form.reset() window.location.hash = "#invitades" } async function CreateSubscription(subData) { let response = await webauthn.withAuth("/api/push/subscribe", { credentials: "include", method: "POST", body: subData, headers: { 'Content-Type': 'application/json' } }) if (!response.ok) { throw new Error("Could not create subscription:", response) } } async function DeleteSubscription(subData) { let response = await webauthn.withAuth("/api/push/unsubscribe", { credentials: "include", method: "POST", body: subData, headers: { 'Content-Type': 'application/json' } }) if (!response.ok) { throw new Error("Could not delete subscription:", response) } } async function switchTab() { let tabName = window.location.hash.toLowerCase().replace("#", "") let activate = async () => true console.log(`switching to tab ${tabName}`) switch (tabName) { case "crear": break; case "registro": activate = fetchLog break; case "": tabName = "invitades" case "invitades": activate = fetchUsers break; default: throw new Error(`unknown tab ${tabName}`) } console.log(`activating tab ${tabName}`) let open = document.querySelector(".tab-open") if (open) { open.classList.remove("tab-open") open.classList.add("hidden") } let tab = document.querySelector(`#${tabName}`) tab.classList.add("tab-open") tab.classList.remove("hidden") await activate(tab) } window.addEventListener("load", async function() { const form = document.querySelector("#create-user") form.addEventListener("submit", async (evt) => { evt.preventDefault() await CreateUser(form) }) switchTab() const pnb = document.querySelector("#push-notifications") let reg try { reg = await navigator.serviceWorker.register("/admin-serviceworker.js", { type: "module", scope: "/" }) console.info(`Registered service worker`, reg) } catch(err) { console.log(`Could not register serviceworker: ${err}`) pnb.style.display = "none" return } try { let sub sub = await reg.pushManager.getSubscription() if (sub) { pnb.classList.add("subscribed") pnb.innerHTML = "🔕" } else { pnb.classList.remove("subscribed") pnb.innerHTML = "🔔" } } catch(err) { console.error("Could not get pushmanager subscription", err) pnb.style.display = "none" } pnb.addEventListener('click', async evt =>{ let sub try { sub = await reg.pushManager.getSubscription() } catch(err) { console.error("Could not get pushmanager subscription", err) } if (!pnb.classList.contains("subscribed")) { if (await createPushSubscription()) { pnb.classList.add("subscribed") pnb.innerHTML = "🔕" } } else { if (await deletePushSubscription()) { pnb.classList.remove("subscribed") pnb.innerHTML = "🔔" } } if (sub) { pnb.classList.add("subscribed") pnb.innerHTML = "🔕" } else { pnb.classList.remove("subscribed") pnb.innerHTML = "🔔" } }) }) async function createPushSubscription() { const registration = await navigator.serviceWorker.getRegistration() const result = await Notification.requestPermission(); if (result == "denied") { return false } const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64ToUint8Array(window._PushKey) }) return await CreateSubscription(JSON.stringify(subscription.toJSON())) } async function deletePushSubscription() { const registration = await navigator.serviceWorker.getRegistration() const subscription = await registration.pushManager.getSubscription() if (await subscription.unsubscribe()) { return await DeleteSubscription(JSON.stringify(subscription.toJSON())) } } const urlB64ToUint8Array = (base64String) => { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/\-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; }; window.addEventListener('hashchange', () => { switchTab() })