cq

Distributed social media platform
git clone git://git.finwo.net/app/cq
Log | Files | Refs

commit 83d8e66433d4a94b31604ef56f4efd2edb1b07e3
parent 8c49886d53083be6382221ec1b91f5c7432428fb
Author: finwo <finwo@pm.me>
Date:   Sun, 14 Sep 2025 00:14:32 +0200

Generalized account require util, start of account/add screen

Diffstat:
Apackages/app/src/component/screen-account-add.tsx | 21+++++++++++++++++++++
Mpackages/app/src/component/screen-account-create.tsx | 176+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mpackages/app/src/component/screen-contact-add.tsx | 5+++++
Mpackages/app/src/component/screen-contact-list.tsx | 4++++
Mpackages/app/src/component/screen-device-add.tsx | 13+++++--------
Mpackages/app/src/component/screen-device-list.tsx | 11++---------
Mpackages/app/src/component/screen-home.tsx | 8++------
Mpackages/app/src/component/screen-menu.tsx | 3++-
Mpackages/app/src/global.css | 8++++++++
Mpackages/app/src/main.ts | 6++++--
Apackages/app/src/util/account-require.ts | 29+++++++++++++++++++++++++++++
Mpackages/app/src/util/opfs.ts | 4++--
12 files changed, 189 insertions(+), 99 deletions(-)

diff --git a/packages/app/src/component/screen-account-add.tsx b/packages/app/src/component/screen-account-add.tsx @@ -0,0 +1,21 @@ +type _Vnode = Vnode<{}, typeof AccountAddScreen>; + +export const AccountAddScreen = { + routePath: '/account/add', + + view(vnode: _Vnode) { + return ( + <div class="main"> + <h3>Add account</h3> + + {/* <a href={`#!${ContactListScreen.routePath}`} style="position:absolute;right:0;top:-0.9em;background:none;border:none;"> */} + {/* <svg ns="http://www.w3.org/2000/svg" fill="transparent" style="vertical-align:bottom;width:1em;height:1em;"> */} + {/* {BackIcon.map(el => m(...el))} */} + {/* </svg> */} + {/* </a> */} + + TODO + </div> + ); + }, +}; diff --git a/packages/app/src/component/screen-account-create.tsx b/packages/app/src/component/screen-account-create.tsx @@ -26,6 +26,7 @@ export const AccountCreateScreen = { accountId : '', accountName: '', + deviceName : '', seedPhrase : '', handler: { @@ -37,12 +38,99 @@ export const AccountCreateScreen = { }; }, goto: (vnode: _Vnode, step) => { - return () => { + return async () => { vnode.state.step = step; + if (step === 0) { + await new Promise(r => setTimeout(r, 10)); + document.getElementById('accountName')?.focus(); + } if (step === 2) { - setTimeout(() => { - document.getElementById('phraseConfirm')?.focus(); - }, 10); + await new Promise(r => setTimeout(r, 10)); + document.getElementById('phraseConfirm')?.focus(); + } + if (step === 4) { + await new Promise(r => setTimeout(r, 10)); + const outputEl = document.getElementById('buildOutput') || document.createElement('div'); + + // // Store seed (TODO: don't) + // const accountSeedHandle = await getFileHandle(`/local/accounts/${vnode.state.accountId}/seed.txt`, { create: true }) + // const accountSeedWritable = await accountSeedHandle.createWritable(); + // await accountSeedWritable.write(vnode.state.seedPhrase); + // await accountSeedWritable.close(); + + // Generate root key + outputEl.innerText += 'Generating recovery key\n'; + const rootSeed = await mnemonicToSeed(vnode.state.seedPhrase); + const rootKey = await KeyPair.create(rootSeed.slice(0,32)); + await new Promise(r => setTimeout(r, 500)); + + // Build root certificate + outputEl.innerText += 'Generating recovery certificate\n'; + const rootCertificateId = randomString(32); + const rootCertificateMeta = base64url.encode(JSON.stringify({ + typ: 'certificate', + iat: Date.now(), + iss: rootCertificateId, + sub: rootCertificateId, + acc: vnode.state.accountId, + roles: ['root'], + })); + const rootCertificateBody = base64url.encode(rootKey.publicKey); + const rootCertificateSignature = base64url.encode(await rootKey.sign(`${rootCertificateMeta}.${rootCertificateBody}`)); + await new Promise(r => setTimeout(r, 500)); + + // Generate device key + outputEl.innerText += 'Generating device key\n'; + const deviceSeed = Buffer.from(await crypto.getRandomValues(new Uint8Array(32))); + const deviceKey = await KeyPair.create(deviceSeed); + await new Promise(r => setTimeout(r, 500)); + + // Build device certificate + const deviceCertificateId = randomString(32); + const deviceCertificateMeta = base64url.encode(JSON.stringify({ + typ: 'certificate', + iat: Date.now(), + iss: rootCertificateId, + sub: deviceCertificateId, + acc: vnode.state.accountId, + roles: ['device'], + })); + const deviceCertificateBody = base64url.encode(deviceKey.publicKey); + const deviceCertificateSignature = base64url.encode(await rootKey.sign(`${deviceCertificateMeta}.${deviceCertificateBody}`)); + await new Promise(r => setTimeout(r, 500)); + + // Build basic config + outputEl.innerText += 'Generating base configuration\n'; + const accountConfigHandle = await getFileHandle(`/local/accounts/${vnode.state.accountId}/config.json`, { create: true }) + const accountConfigWritable = await accountConfigHandle.createWritable(); + await accountConfigWritable.write(JSON.stringify({ + displayName: vnode.state.accountName + }, null, 2)); + await accountConfigWritable.close(); + await new Promise(r => setTimeout(r, 500)); + + // Store device key + outputEl.innerText += 'Safeguarding device key\n'; + const deviceKeyHandle = await getFileHandle(`/local/accounts/${vnode.state.accountId}/device-key.json`, { create: true }) + const deviceKeyWritable = await deviceKeyHandle.createWritable(); + await deviceKeyWritable.write(JSON.stringify(deviceKey.toJSON(), null, 2)); + await deviceKeyWritable.close(); + await new Promise(r => setTimeout(r, 500)); + + // Build keychain + outputEl.innerText += 'Generating key chain\n'; + const keychainHandle = await getFileHandle(`/remote/accounts/${vnode.state.accountId}/keychain`, { create: true }); + const keychainWritable = await keychainHandle.createWritable(); + await keychainWritable.write(`${rootCertificateMeta}.${rootCertificateBody}.${rootCertificateSignature}\n`); + await keychainWritable.write(`${deviceCertificateMeta}.${deviceCertificateBody}.${deviceCertificateSignature}\n`); + await keychainWritable.close(); + await new Promise(r => setTimeout(r, 500)); + + // Select the account + outputEl.innerText += 'Switching to new account\n'; + localStorage.selectedAccount = vnode.state.accountId; + await new Promise(r => setTimeout(r, 500)); + document.location.href=`#!${HomeScreen.routePath}`; } } }, @@ -54,71 +142,6 @@ export const AccountCreateScreen = { return; } vnode.state.step++; - - // // Store seed (TODO: don't) - // const accountSeedHandle = await getFileHandle(`/local/accounts/${vnode.state.accountId}/seed.txt`, { create: true }) - // const accountSeedWritable = await accountSeedHandle.createWritable(); - // await accountSeedWritable.write(vnode.state.seedPhrase); - // await accountSeedWritable.close(); - - // Build basic config - const accountConfigHandle = await getFileHandle(`/local/accounts/${vnode.state.accountId}/config.json`, { create: true }) - const accountConfigWritable = await accountConfigHandle.createWritable(); - await accountConfigWritable.write(JSON.stringify({ - displayName: vnode.state.accountName - }, null, 2)); - await accountConfigWritable.close(); - - // Generate root key - const rootSeed = await mnemonicToSeed(vnode.state.seedPhrase); - const rootKey = await KeyPair.create(rootSeed.slice(0,32)); - - // Generate device key - const deviceSeed = Buffer.from(await crypto.getRandomValues(new Uint8Array(32))); - const deviceKey = await KeyPair.create(deviceSeed); - - // Build basic config - const deviceKeyHandle = await getFileHandle(`/local/accounts/${vnode.state.accountId}/device-key.json`, { create: true }) - const deviceKeyWritable = await deviceKeyHandle.createWritable(); - await deviceKeyWritable.write(JSON.stringify(deviceKey.toJSON(), null, 2)); - await deviceKeyWritable.close(); - - // Build root certificate - const rootCertificateId = randomString(32); - const rootCertificateMeta = base64url.encode(JSON.stringify({ - typ: 'certificate', - iat: Date.now(), - iss: rootCertificateId, - sub: rootCertificateId, - acc: vnode.state.accountId, - roles: ['root'], - })); - const rootCertificateBody = base64url.encode(rootKey.publicKey); - const rootCertificateSignature = base64url.encode(await rootKey.sign(`${rootCertificateMeta}.${rootCertificateBody}`)); - - // Build device certificate - const deviceCertificateId = randomString(32); - const deviceCertificateMeta = base64url.encode(JSON.stringify({ - typ: 'certificate', - iat: Date.now(), - iss: rootCertificateId, - sub: deviceCertificateId, - acc: vnode.state.accountId, - roles: ['device'], - })); - const deviceCertificateBody = base64url.encode(deviceKey.publicKey); - const deviceCertificateSignature = base64url.encode(await rootKey.sign(`${deviceCertificateMeta}.${deviceCertificateBody}`)); - - // Build keychain - const keychainHandle = await getFileHandle(`/remote/accounts/${vnode.state.accountId}/keychain`, { create: true }); - const keychainWritable = await keychainHandle.createWritable(); - await keychainWritable.write(`${rootCertificateMeta}.${rootCertificateBody}.${rootCertificateSignature}\n`); - await keychainWritable.write(`${deviceCertificateMeta}.${deviceCertificateBody}.${deviceCertificateSignature}\n`); - await keychainWritable.close(); - - // Select the account - localStorage.selectedAccount = vnode.state.accountId; - document.location.href=`#!${HomeScreen.routePath}`; } }, taGrow() { @@ -134,7 +157,7 @@ export const AccountCreateScreen = { <p> Give your new account a name you'll recognize (not public) </p> - <input id="accountName" value={vnode.state.accountName} style="display: block; background: transparent; padding: 0.5em 0px; color: inherit; border-width: medium medium 2px; border-style: none none solid; border-color: currentcolor currentcolor rgb(255, 255, 255); border-image: none;width: calc(100% - 2rem);" type="text"/> + <input id="accountName" value={vnode.state.accountName} style="display: block; width: calc(100% - 2rem);" type="text"/> <button onclick={vnode.state.handler.setAccountName(vnode)}>Next</button> </div> ), @@ -163,8 +186,16 @@ export const AccountCreateScreen = { (vnode: _Vnode) => ( <div class="main"> <p> - Building account... + Give this device a name (publicly readable) </p> + <input id="deviceName" value={vnode.state.deviceName} style="display: block; width: calc(100% - 2rem);" type="text"/> + <button onclick={vnode.state.handler.goto(vnode, 2)}>Back</button> + <button onclick={vnode.state.handler.goto(vnode, 4)}>Next</button> + </div> + ), + (vnode: _Vnode) => ( + <div class="main"> + <p id="buildOutput">Generating new account</p> </div> ) ], @@ -172,6 +203,9 @@ export const AccountCreateScreen = { async oninit(vnode: _Vnode) { vnode.state.seedPhrase = generateMnemonic(); vnode.state.accountId = randomString(32); + setTimeout(() => { + document.getElementById('accountName')?.focus(); + }, 10); }, view(vnode: _Vnode) { diff --git a/packages/app/src/component/screen-contact-add.tsx b/packages/app/src/component/screen-contact-add.tsx @@ -2,10 +2,15 @@ type _Vnode = Vnode<{}, typeof ContactAddScreen>; import BackIcon from 'lucide/dist/esm/icons/arrow-left.js'; import {ContactListScreen} from './screen-contact-list'; +import {requireAccount} from '../util/account-require'; export const ContactAddScreen = { routePath: '/contacts/add', + async oninit(vnode: _Vnode) { + if (!await requireAccount(true)) return; + }, + view(vnode: _Vnode) { return ( <div class="main"> diff --git a/packages/app/src/component/screen-contact-list.tsx b/packages/app/src/component/screen-contact-list.tsx @@ -16,6 +16,10 @@ export const ContactListScreen = { } }, + async oninit(vnode: _Vnode) { + if (!await requireAccount(true)) return; + }, + view(vnode: _Vnode) { return ( <div class="main"> diff --git a/packages/app/src/component/screen-device-add.tsx b/packages/app/src/component/screen-device-add.tsx @@ -3,7 +3,8 @@ type _Vnode = Vnode<{}, typeof DeviceAddScreen>; import BackIcon from 'lucide/dist/esm/icons/arrow-left.js'; import * as QRCode from 'qrcode'; import {DeviceListScreen} from './screen-device-list'; -import {AccountSelectScreen} from './screen-account-select'; +import {requireAccount} from '../util/account-require'; +import {AccountAddScreen} from './screen-account-add'; export const DeviceAddScreen = { routePath: '/devices/add', @@ -12,14 +13,10 @@ export const DeviceAddScreen = { addDeviceURL: '', async oninit(vnode: _Vnode) { + if (!await requireAccount(true)) return; - // Redirect to account selection if not there - if (!localStorage.selectedAccount) { - document.location.href = `#!${AccountSelectScreen.routePath}`; - return; - } - - vnode.state.addDeviceURL = `https://cq.finwo.net/account/add?id=${localStorage.selectedAccount}`; + // vnode.state.addDeviceURL = `https://cq.finwo.net/account/add?id=${localStorage.selectedAccount}`; + vnode.state.addDeviceURL = `http://localhost:4000/#!${AccountAddScreen.routePath}?id=${localStorage.selectedAccount}`; vnode.state.addDeviceQR = await QRCode.toDataURL([ { data: vnode.state.addDeviceURL }, diff --git a/packages/app/src/component/screen-device-list.tsx b/packages/app/src/component/screen-device-list.tsx @@ -4,20 +4,13 @@ import BackIcon from 'lucide/dist/esm/icons/arrow-left.js'; import {AccountSelectScreen} from './screen-account-select'; import {MenuScreen} from './screen-menu'; import {DeviceAddScreen} from './screen-device-add'; +import {requireAccount} from '../util/account-require'; export const DeviceListScreen = { routePath: '/devices', async oninit(node: _Vnode) { - - // Redirect to account selection if not there - if (!localStorage.selectedAccount) { - document.location.href = `#!${AccountSelectScreen.routePath}`; - return; - } - - - + if (!await requireAccount(true)) return; }, view(vnode: _Vnode) { diff --git a/packages/app/src/component/screen-home.tsx b/packages/app/src/component/screen-home.tsx @@ -1,4 +1,5 @@ import {getFileHandle} from "../util/opfs"; +import {requireAccount} from '../util/account-require'; import { AccountSelectScreen } from "./screen-account-select"; import MenuIcon from 'lucide/dist/esm/icons/menu.js'; @@ -16,12 +17,7 @@ export const HomeScreen = { contacts: [] as Contact[], async oninit(vnode: _Vnode) { - - // Redirect to account selection if not there - if (!localStorage.selectedAccount) { - document.location.href = `#!${AccountSelectScreen.routePath}`; - return; - } + if (!await requireAccount(true)) return; // Fetch contacts/follows const contactsHandle = await getFileHandle(`/local/accounts/${vnode.state.accountId}/contacts.json`, { create: true }) diff --git a/packages/app/src/component/screen-menu.tsx b/packages/app/src/component/screen-menu.tsx @@ -16,7 +16,8 @@ export const MenuScreen = { } }, - async oninit(vnode: _Vnode) { + async oninit(node: _Vnode) { + if (!await requireAccount(true)) return; }, view(vnode: _Vnode) { diff --git a/packages/app/src/global.css b/packages/app/src/global.css @@ -55,6 +55,14 @@ button { } } +input { + background: transparent; + padding: 0.5em 0px; + color: inherit; + border: none; + border-bottom: 2px solid #FFF; +} + svg { stroke: #FFF; } diff --git a/packages/app/src/main.ts b/packages/app/src/main.ts @@ -1,7 +1,8 @@ globalThis.m = require('mithril'); -import { AccountCreateScreen } from './component/screen-account-create.tsx'; -import { AccountSelectScreen } from './component/screen-account-select.tsx'; +import {AccountAddScreen} from './component/screen-account-add.js'; +import { AccountCreateScreen } from './component/screen-account-create.js'; +import { AccountSelectScreen } from './component/screen-account-select.js'; import {ContactAddScreen} from './component/screen-contact-add.js'; import {ContactListScreen} from './component/screen-contact-list.js'; import {DeviceAddScreen} from './component/screen-device-add.js'; @@ -13,6 +14,7 @@ m.route(document.body, "/", { [HomeScreen.routePath ]: HomeScreen, [AccountSelectScreen.routePath]: AccountSelectScreen, [AccountCreateScreen.routePath]: AccountCreateScreen, + [AccountAddScreen.routePath ]: AccountAddScreen, [MenuScreen.routePath ]: MenuScreen, [ContactListScreen.routePath ]: ContactListScreen, [ContactAddScreen.routePath ]: ContactAddScreen, diff --git a/packages/app/src/util/account-require.ts b/packages/app/src/util/account-require.ts @@ -0,0 +1,29 @@ +import {AccountSelectScreen} from "../component/screen-account-select"; +import {getFileHandle} from "./opfs"; + +export async function requireAccount(redirect: boolean = false) { + + // Redirect to account selection if not there + if (!localStorage.selectedAccount) { + + if (redirect) { + document.location.href = `#!${AccountSelectScreen.routePath}`; + return; + } else { + return false; + } + } + + try { + const deviceKeyHandle = await getFileHandle(`/local/accounts/${localStorage.selectedAccount}/device-key.json`, {}, {}); + return true; + } catch { + console.log('broken?'); + if (redirect) { + document.location.href = `#!${AccountSelectScreen.routePath}`; + return; + } else { + return false; + } + } +} diff --git a/packages/app/src/util/opfs.ts b/packages/app/src/util/opfs.ts @@ -6,11 +6,11 @@ export async function getDirectoryHandle(path: string) { return reference; } -export async function getFileHandle(path: string, options?: FileSystemGetFileOptions) { +export async function getFileHandle(path: string, options?: FileSystemGetFileOptions, dirOptions: FileSystemGetDirectoryOptions = { create: true }) { const tokens = path.split('/') as string[]; if (tokens[0] == '') tokens.shift(); const filename = tokens.pop() as string; let reference = await navigator.storage.getDirectory(); - while(tokens.length) reference = await reference.getDirectoryHandle(tokens.shift() as string, { create: true }) + while(tokens.length) reference = await reference.getDirectoryHandle(tokens.shift() as string, dirOptions) return await reference.getFileHandle(filename, options); }