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:
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);
}