diff --git a/convey/static/pairing-qr.js b/convey/static/pairing-qr.js new file mode 100644 index 000000000..ee3c8e6d0 --- /dev/null +++ b/convey/static/pairing-qr.js @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc +/* + * QR Code Generator for JavaScript + * Copyright (c) 2009 Kazuhiko Arase + * URL: http://www.d-project.com/ + * Licensed under the MIT license: + * http://www.opensource.org/licenses/mit-license.php + * Source: https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js + */ +/** + * Minified by jsDelivr using Terser v5.37.0. + * Original file: /npm/qrcode-generator@1.4.4/qrcode.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +var qrcode=function(){var t=function(t,r){var e=t,n=g[r],o=null,i=0,a=null,u=[],f={},c=function(t,r){o=function(t){for(var r=new Array(t),e=0;e=7&&v(t),null==a&&(a=p(e,n,u)),w(a,r)},l=function(t,r){for(var e=-1;e<=7;e+=1)if(!(t+e<=-1||i<=t+e))for(var n=-1;n<=7;n+=1)r+n<=-1||i<=r+n||(o[t+e][r+n]=0<=e&&e<=6&&(0==n||6==n)||0<=n&&n<=6&&(0==e||6==e)||2<=e&&e<=4&&2<=n&&n<=4)},h=function(){for(var t=8;t>n&1);o[Math.floor(n/3)][n%3+i-8-3]=a}for(n=0;n<18;n+=1){a=!t&&1==(r>>n&1);o[n%3+i-8-3][Math.floor(n/3)]=a}},d=function(t,r){for(var e=n<<3|r,a=B.getBCHTypeInfo(e),u=0;u<15;u+=1){var f=!t&&1==(a>>u&1);u<6?o[u][8]=f:u<8?o[u+1][8]=f:o[i-15+u][8]=f}for(u=0;u<15;u+=1){f=!t&&1==(a>>u&1);u<8?o[8][i-u-1]=f:u<9?o[8][15-u-1+1]=f:o[8][15-u-1]=f}o[i-8][8]=!t},w=function(t,r){for(var e=-1,n=i-1,a=7,u=0,f=B.getMaskFunction(r),c=i-1;c>0;c-=2)for(6==c&&(c-=1);;){for(var g=0;g<2;g+=1)if(null==o[n][c-g]){var l=!1;u>>a&1)),f(n,c-g)&&(l=!l),o[n][c-g]=l,-1==(a-=1)&&(u+=1,a=7)}if((n+=e)<0||i<=n){n-=e,e=-e;break}}},p=function(t,r,e){for(var n=A.getRSBlocks(t,r),o=b(),i=0;i8*u)throw"code length overflow. ("+o.getLengthInBits()+">"+8*u+")";for(o.getLengthInBits()+4<=8*u&&o.put(0,4);o.getLengthInBits()%8!=0;)o.putBit(!1);for(;!(o.getLengthInBits()>=8*u||(o.put(236,8),o.getLengthInBits()>=8*u));)o.put(17,8);return function(t,r){for(var e=0,n=0,o=0,i=new Array(r.length),a=new Array(r.length),u=0;u=0?h.getAt(s):0}}var v=0;for(g=0;gn)&&(t=n,r=e)}return r}())},f.createTableTag=function(t,r){t=t||2;var e="";e+='',e+="";for(var n=0;n";for(var o=0;o';e+=""}return e+="",e+="
"},f.createSvgTag=function(t,r,e,n){var o={};"object"==typeof arguments[0]&&(t=(o=arguments[0]).cellSize,r=o.margin,e=o.alt,n=o.title),t=t||2,r=void 0===r?4*t:r,(e="string"==typeof e?{text:e}:e||{}).text=e.text||null,e.id=e.text?e.id||"qrcode-description":null,(n="string"==typeof n?{text:n}:n||{}).text=n.text||null,n.id=n.text?n.id||"qrcode-title":null;var i,a,u,c,g=f.getModuleCount()*t+2*r,l="";for(c="l"+t+",0 0,"+t+" -"+t+",0 0,-"+t+"z ",l+=''+y(n.text)+"":"",l+=e.text?''+y(e.text)+"":"",l+='',l+='":r+=">";break;case"&":r+="&";break;case'"':r+=""";break;default:r+=n}}return r};return f.createASCII=function(t,r){if((t=t||1)<2)return function(t){t=void 0===t?2:t;var r,e,n,o,i,a=1*f.getModuleCount()+2*t,u=t,c=a-t,g={"██":"█","█ ":"▀"," █":"▄"," ":" "},l={"██":"▀","█ ":"▀"," █":" "," ":" "},h="";for(r=0;r=c?l[i]:g[i];h+="\n"}return a%2&&t>0?h.substring(0,h.length-a-1)+Array(a+1).join("▀"):h.substring(0,h.length-1)}(r);t-=1,r=void 0===r?2*t:r;var e,n,o,i,a=f.getModuleCount()*t+2*r,u=r,c=a-r,g=Array(t+1).join("██"),l=Array(t+1).join(" "),h="",s="";for(e=0;e>>8),r.push(255&a)):r.push(n)}}return r}};var r,e,n,o,i,a=1,u=2,f=4,c=8,g={L:1,M:0,Q:3,H:2},l=0,h=1,s=2,v=3,d=4,w=5,p=6,y=7,B=(r=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],e=1335,n=7973,i=function(t){for(var r=0;0!=t;)r+=1,t>>>=1;return r},(o={}).getBCHTypeInfo=function(t){for(var r=t<<10;i(r)-i(e)>=0;)r^=e<=0;)r^=n<5&&(e+=3+i-5)}for(n=0;n=256;)r-=255;return t[r]}};return n}();function k(t,r){if(void 0===t.length)throw t.length+"/"+r;var e=function(){for(var e=0;e>>7-r%8&1)},put:function(t,r){for(var n=0;n>>r-n-1&1))},getLengthInBits:function(){return r},putBit:function(e){var n=Math.floor(r/8);t.length<=n&&t.push(0),e&&(t[n]|=128>>>r%8),r+=1}};return e},M=function(t){var r=a,e=t,n={getMode:function(){return r},getLength:function(t){return e.length},write:function(t){for(var r=e,n=0;n+2>>8&255)+(255&n),t.put(n,13),e+=2}if(e>>8)},writeBytes:function(t,e,n){e=e||0,n=n||t.length;for(var o=0;o0&&(r+=","),r+=t[e];return r+="]"}};return r},S=function(t){var r=t,e=0,n=0,o=0,i={read:function(){for(;o<8;){if(e>=r.length){if(0==o)return-1;throw"unexpected end of file./"+o}var t=r.charAt(e);if(e+=1,"="==t)return o=0,-1;t.match(/^\s$/)||(n=n<<6|a(t.charCodeAt(0)),o+=6)}var i=n>>>o-8&255;return o-=8,i}},a=function(t){if(65<=t&&t<=90)return t-65;if(97<=t&&t<=122)return t-97+26;if(48<=t&&t<=57)return t-48+52;if(43==t)return 62;if(47==t)return 63;throw"c:"+t};return i},I=function(t,r,e){for(var n=function(t,r){var e=t,n=r,o=new Array(t*r),i={setPixel:function(t,r,n){o[r*e+t]=n},write:function(t){t.writeString("GIF87a"),t.writeShort(e),t.writeShort(n),t.writeByte(128),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(255),t.writeByte(255),t.writeByte(255),t.writeString(","),t.writeShort(0),t.writeShort(0),t.writeShort(e),t.writeShort(n),t.writeByte(0);var r=a(2);t.writeByte(2);for(var o=0;r.length-o>255;)t.writeByte(255),t.writeBytes(r,o,255),o+=255;t.writeByte(r.length-o),t.writeBytes(r,o,r.length-o),t.writeByte(0),t.writeString(";")}},a=function(t){for(var r=1<>>r!=0)throw"length over";for(;c+r>=8;)f.writeByte(255&(t<>>=8-c,g=0,c=0;g|=t<0&&f.writeByte(g)}});h.write(r,n);var s=0,v=String.fromCharCode(o[s]);for(s+=1;s=6;)i(t>>>r-6),r-=6},o.flush=function(){if(r>0&&(i(t<<6-r),t=0,r=0),e%3!=0)for(var o=3-e%3,a=0;a>6,128|63&n):n<55296||n>=57344?r.push(224|n>>12,128|n>>6&63,128|63&n):(e++,n=65536+((1023&n)<<10|1023&t.charCodeAt(e)),r.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n))}return r}(t)},function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports&&(module.exports=t())}((function(){return qrcode})); +//# sourceMappingURL=/sm/26b4b0d0b1e283d6b3ec9857ac597d7a60c76ac17be1ef4c965f03086de426bb.map \ No newline at end of file diff --git a/convey/static/pairing.css b/convey/static/pairing.css new file mode 100644 index 000000000..24e864a40 --- /dev/null +++ b/convey/static/pairing.css @@ -0,0 +1,191 @@ +.pairing-page { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(245, 188, 94, 0.18), transparent 28rem), + radial-gradient(circle at bottom right, rgba(85, 144, 111, 0.18), transparent 30rem), + #f8f3e8; + color: #1f1f1a; + font-family: "Comfortaa Variable", "Avenir Next", sans-serif; +} + +.pairing-shell { + box-sizing: border-box; + max-width: 72rem; + margin: 0 auto; + padding: 2rem 1.25rem 3rem; + display: grid; + gap: 1.5rem; +} + +.pairing-panel { + padding: 1.5rem; + border: 1px solid rgba(31, 31, 26, 0.1); + border-radius: 1.25rem; + background: rgba(255, 252, 245, 0.92); + box-shadow: 0 1rem 2.5rem rgba(51, 45, 35, 0.08); +} + +.pairing-kicker { + margin: 0 0 0.5rem; + font-size: 0.8rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #7f5b2a; +} + +.pairing-panel h1, +.pairing-panel h2 { + margin: 0; + font-size: clamp(1.8rem, 3vw, 2.6rem); +} + +.pairing-panel h2 { + font-size: clamp(1.35rem, 2.3vw, 1.8rem); +} + +.pairing-intro { + max-width: 44rem; + margin: 1rem 0 0; + line-height: 1.6; +} + +.pairing-actions { + margin-top: 1.5rem; + display: grid; + gap: 0.75rem; +} + +.pairing-button, +.pairing-unpair { + appearance: none; + border: 0; + border-radius: 999px; + padding: 0.9rem 1.3rem; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.pairing-button { + width: fit-content; + background: #1f1f1a; + color: #fff7ec; +} + +.pairing-button:disabled, +.pairing-unpair:disabled { + opacity: 0.6; + cursor: default; +} + +.pairing-feedback { + min-height: 1.2rem; + margin: 0; + color: #5c4d38; +} + +.pairing-feedback[data-state="error"] { + color: #8c2d2d; +} + +.pairing-minted { + margin-top: 1.5rem; + display: grid; + gap: 1rem; + grid-template-columns: minmax(14rem, 18rem) 1fr; + align-items: start; +} + +.pairing-qr-wrap { + padding: 1rem; + border-radius: 1rem; + background: #fff; + border: 1px solid rgba(31, 31, 26, 0.08); +} + +.pairing-qr { + width: 100%; + aspect-ratio: 1 / 1; + display: grid; + place-items: center; +} + +.pairing-qr svg { + width: 100%; + height: auto; +} + +.pairing-label { + margin: 0; + font-size: 0.85rem; + color: #7f5b2a; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.pairing-url { + display: block; + margin-top: 0.5rem; + padding: 0.85rem 1rem; + border-radius: 0.85rem; + background: #f2ebdc; + word-break: break-all; + line-height: 1.5; +} + +.pairing-countdown { + margin: 0.75rem 0 0; + font-weight: 700; +} + +.pairing-devices-list { + display: grid; + gap: 0.9rem; +} + +.pairing-empty { + color: #5c4d38; +} + +.pairing-device { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + padding: 1rem 1.1rem; + border-radius: 1rem; + background: #fff; + border: 1px solid rgba(31, 31, 26, 0.08); +} + +.pairing-device h3 { + margin: 0; + font-size: 1rem; +} + +.pairing-device-details { + margin: 0.35rem 0 0; + color: #5c4d38; + line-height: 1.45; +} + +.pairing-unpair { + background: #ebe1d2; + color: #4a3721; +} + +@media (max-width: 720px) { + .pairing-shell { + padding: 1rem 0.9rem 2rem; + } + + .pairing-minted { + grid-template-columns: 1fr; + } + + .pairing-device { + flex-direction: column; + align-items: stretch; + } +} diff --git a/convey/static/pairing.js b/convey/static/pairing.js new file mode 100644 index 000000000..abe410b83 --- /dev/null +++ b/convey/static/pairing.js @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +(function () { + function $(id) { + return document.getElementById(id); + } + + const generateButton = $("pairing-generate"); + const feedback = $("pairing-feedback"); + const mintedSection = $("pairing-minted"); + const qrContainer = $("pairing-qr"); + const pairingUrl = $("pairing-url"); + const countdown = $("pairing-countdown"); + const devicesEmpty = $("pairing-devices-empty"); + const devicesList = $("pairing-devices-list"); + + let countdownTimer = null; + let pollTimer = null; + + async function fetchJson(url, options) { + const response = await fetch( + url, + Object.assign({ credentials: "same-origin" }, options || {}), + ); + + let payload = null; + try { + payload = await response.json(); + } catch (error) { + payload = null; + } + + if (!response.ok) { + const message = payload && payload.error ? payload.error : "Request failed"; + throw new Error(message); + } + + return payload || {}; + } + + function setFeedback(message, isError) { + if (!feedback) { + return; + } + feedback.textContent = message || ""; + feedback.dataset.state = isError ? "error" : "info"; + } + + function renderQr(data) { + if (!qrContainer) { + return; + } + qrContainer.innerHTML = ""; + + if (typeof window.qrcode !== "function") { + qrContainer.textContent = "QR unavailable"; + return; + } + + const qr = window.qrcode(0, "M"); + qr.addData(data); + qr.make(); + qrContainer.innerHTML = qr.createSvgTag({ + cellSize: 6, + margin: 0, + scalable: true, + title: "Phone pairing code", + alt: "Phone pairing code", + }); + } + + function renderCountdown(expiresAt) { + if (!countdown) { + return; + } + + function update() { + const remaining = Math.max(0, expiresAt - Math.floor(Date.now() / 1000)); + countdown.textContent = + remaining > 0 ? `Expires in ${remaining}s` : "Expired"; + if (remaining === 0 && countdownTimer !== null) { + window.clearInterval(countdownTimer); + countdownTimer = null; + } + } + + if (countdownTimer !== null) { + window.clearInterval(countdownTimer); + } + update(); + countdownTimer = window.setInterval(update, 1000); + } + + function renderDevices(devices) { + if (!devicesList || !devicesEmpty) { + return; + } + + devicesList.innerHTML = ""; + const rows = Array.isArray(devices) ? devices : []; + devicesEmpty.hidden = rows.length > 0; + + rows.forEach((device) => { + const row = document.createElement("article"); + row.className = "pairing-device"; + + const meta = document.createElement("div"); + meta.className = "pairing-device-meta"; + + const name = document.createElement("h3"); + name.textContent = device.name || "Unnamed phone"; + meta.appendChild(name); + + const details = document.createElement("p"); + details.className = "pairing-device-details"; + details.textContent = [ + device.platform || "unknown", + `paired ${device.paired_at || "unknown"}`, + `last seen ${device.last_seen_at || "never"}`, + ].join(" · "); + meta.appendChild(details); + + const button = document.createElement("button"); + button.type = "button"; + button.className = "pairing-unpair"; + button.textContent = "Unpair"; + button.addEventListener("click", async function () { + button.disabled = true; + try { + await fetchJson(`/api/pairing/devices/${encodeURIComponent(device.id)}`, { + method: "DELETE", + }); + setFeedback("Phone removed.", false); + await refreshDevices(); + } catch (error) { + setFeedback(error.message, true); + button.disabled = false; + } + }); + + row.appendChild(meta); + row.appendChild(button); + devicesList.appendChild(row); + }); + } + + async function refreshDevices() { + try { + const payload = await fetchJson("/api/pairing/devices"); + renderDevices(payload.devices); + } catch (error) { + setFeedback(error.message, true); + } + } + + async function mintPairingCode() { + if (!generateButton) { + return; + } + generateButton.disabled = true; + setFeedback("", false); + + try { + const payload = await fetchJson("/api/pairing/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + + if (mintedSection) { + mintedSection.hidden = false; + } + if (pairingUrl) { + pairingUrl.textContent = payload.pairing_url || ""; + } + renderQr(payload.qr_data || ""); + renderCountdown(payload.expires_at || 0); + } catch (error) { + setFeedback(error.message, true); + } finally { + generateButton.disabled = false; + } + } + + if (generateButton) { + generateButton.addEventListener("click", mintPairingCode); + } + + refreshDevices(); + pollTimer = window.setInterval(refreshDevices, 5000); + window.addEventListener("beforeunload", function () { + if (countdownTimer !== null) { + window.clearInterval(countdownTimer); + } + if (pollTimer !== null) { + window.clearInterval(pollTimer); + } + }); +})(); diff --git a/convey/templates/pairing.html b/convey/templates/pairing.html new file mode 100644 index 000000000..9a7c378af --- /dev/null +++ b/convey/templates/pairing.html @@ -0,0 +1,57 @@ + + + + + + Pair a phone - solstone + + + + +
+
+

Phone pairing

+

Pair a phone

+

+ Generate a short-lived code to pair your phone with this solstone journal. + Paired phones show up below as listening devices, and you can remove them from here. +

+ +
+ +

+
+ + +
+ +
+
+
+

Listening devices

+

Paired phones

+
+
+ +
+ No phones are paired yet. +
+
+
+
+ + + + + diff --git a/tests/test_pairing_ui.py b/tests/test_pairing_ui.py new file mode 100644 index 000000000..9cbd9cefb --- /dev/null +++ b/tests/test_pairing_ui.py @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import pytest + +from convey import create_app + + +@pytest.fixture +def pairing_ui_client(journal_copy): + app = create_app(str(journal_copy)) + app.config["TESTING"] = True + client = app.test_client() + with client.session_transaction() as session: + session["logged_in"] = True + session.permanent = True + return client + + +def test_pairing_ui_smoke(pairing_ui_client): + response = pairing_ui_client.get("/app/pairing/") + + assert response.status_code == 200 + body = response.get_data(as_text=True) + assert "Pair a phone" in body + assert "/static/pairing-qr.js" in body + assert "/static/pairing.js" in body + assert "/static/pairing.css" in body + for forbidden in ("keeper", "assistant", "record", "capture", "Capture"): + assert forbidden not in body