From 0c2754d2d4cb1905d5e324f56fbc64e4afae526f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 31 Aug 2026 01:06:06 +0200 Subject: refactor(ui): extract Explore, Login, Register and CreateGroup from app.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExplorePage → explore-page.js (static import), LoginPage/RegisterPage/ FirstRunPage → auth-page.js (static import, LoginPage receives onLogin as a prop), CreateGroupPage/wizard → create-group-page.js (lazy-loaded via dynamic import(), same pattern as AdminPage/NodePage). app.js goes from 1803 to 914 lines. webapp.py _ASSETS extended with the three new files and the previously missing extracted pages. Test fixtures updated to follow the moved components. Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 7 +- packages/meshbay-hub/src/meshbay_hub/static/app.js | 919 +-------------------- .../src/meshbay_hub/static/auth-page.js | 201 +++++ .../src/meshbay_hub/static/create-group-page.js | 513 ++++++++++++ .../src/meshbay_hub/static/explore-page.js | 100 +++ packages/meshbay-hub/tests/test_hook_ordering.py | 1 + .../meshbay-hub/tests/test_hub_address_seam.py | 3 +- .../meshbay-hub/tests/test_transport_contracts.py | 18 +- 8 files changed, 851 insertions(+), 911 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/auth-page.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/explore-page.js diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 6a96602..0821809 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -35,7 +35,12 @@ _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js", "icon.js", "file-utils.js", "hub-client.js", "apps.js", "chat-app.js", "files-app.js", "video-player.js", "video-app.js", "music-app.js", "music-player.js", "photos-app.js", - "group-settings.js", "group-page.js") + "group-settings.js", "group-page.js", + # Pages extracted from app.js — statically imported or lazy-loaded, + # but all must participate in the content hash. + "auth-page.js", "explore-page.js", "create-group-page.js", + "admin-page.js", "node-page.js", "group-name.js", + "search-page.js", "settings-page.js", "profile-page.js") def _asset_version() -> str: diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 99eb7ab..acd4c1d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -17,10 +17,10 @@ import { import { GroupPage } from './group-page.js'; import { SearchPage, ConnectionPool } from './search-page.js'; import { MusicPlayerBar } from './music-player.js'; -import { GroupName } from './group-name.js'; -import { APPS } from './apps.js'; import { SettingsPage } from './settings-page.js'; import { ProfilePage } from './profile-page.js'; +import { ExplorePage } from './explore-page.js'; +import { FirstRunPage, LoginPage, RegisterPage } from './auth-page.js'; // ── Constants ──────────────────────────────────────────────────────────────── @@ -29,47 +29,6 @@ import { ProfilePage } from './profile-page.js'; const TOKEN_CHECK_MS = 60000; const THEME_KEY = 'mb_theme'; -/** - * Rough passphrase strength, in bits, and what it is up against. - * - * This number carries more weight here than in most applications. The encrypted - * keypair bundle is protected by PBKDF2-SHA512 (600k) and sits on every node - * whose group you join, so the people who host your groups can attack it offline - * (finding C4). PBKDF2 is memory-light, which is exactly what GPUs are good at. - * - * The estimate is deliberately conservative — character classes and length, with - * a penalty for repetition and for the handful of patterns everyone tries. It is - * a guide, not a guarantee, and it says so in the UI. - */ -function passwordBits(pw) { - if (!pw) return 0; - let pool = 0; - if (/[a-z]/.test(pw)) pool += 26; - if (/[A-Z]/.test(pw)) pool += 26; - if (/[0-9]/.test(pw)) pool += 10; - if (/[^A-Za-z0-9]/.test(pw)) pool += 32; - let bits = pw.length * Math.log2(pool || 1); - - const unique = new Set(pw).size; - if (unique < pw.length / 2) bits *= 0.6; // "aaaaaaaa", "abcabcabc" - if (/^[0-9]+$/.test(pw)) bits *= 0.5; // dates, PINs - if (/(password|motdepasse|azerty|qwerty|123456|meshbay)/i.test(pw)) bits *= 0.3; - return Math.round(bits); -} - -const PASSWORD_MIN_BITS = 60; // refuse below this -const PASSWORD_MIN_LEN = 12; - -/** Public X25519 key from our own secret — never read back from the hub. */ -async function _pkXFromSk(skPkcs8B64) { - const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0)); - const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'X25519' }, true, ['deriveBits']); - const jwk = await crypto.subtle.exportKey('jwk', sk); - const b64 = jwk.x.replace(/-/g, '+').replace(/_/g, '/'); - const pad = b64.length % 4; - return pad ? b64 + '='.repeat(4 - pad) : b64; -} - // ── Theme ──────────────────────────────────────────────────────────────────── function getInitialTheme() { @@ -370,200 +329,6 @@ function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, ha `; } -// ── Login Page ─────────────────────────────────────────────────────────────── - -/** - * Which hub, asked once on a desktop build. - * - * There is no default. A client that picks its own hub is a client that can be - * pointed at one, and the address is the whole of what the application trusts - * the hub for — its API, and nothing else: the interface comes from the package. - * - * Changing it restarts the window, because the address reaches the interface as - * a process argument. Reloading in place would leave it talking to the old hub - * with nothing on screen to say so. - */ -function FirstRunPage({ onSet }) { - const [url, setUrl] = useState(''); - const [error, setError] = useState(''); - const [busy, setBusy] = useState(false); - - const submit = async (e) => { - e.preventDefault(); - setError(''); - setBusy(true); - try { - await window.meshbay.setHubBase(url.trim()); - onSet(); - } catch (err) { - setError(platform.bridgeMessage(err)); - setBusy(false); - } - }; - - return html` -
- -
- `; -} - -function LoginPage() { - const auth = useAuth(); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - - const onSubmit = async (e) => { - e.preventDefault(); - if (!username || !password) return; - setError(''); - setLoading(true); - try { - await auth.login(username, password); - navigate('/'); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - }; - - return html` -
- -
- `; -} - -// ── Register Page ──────────────────────────────────────────────────────────── - -function RegisterPage() { - const [username, setUsername] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirm, setConfirm] = useState(''); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(false); - const [loading, setLoading] = useState(false); - - const onSubmit = async (e) => { - e.preventDefault(); - if (password !== confirm) { setError(t('register.err_mismatch')); return; } - if (password.length < PASSWORD_MIN_LEN) { - setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return; - } - // The floor can only live here: with the password split (T1) the hub never - // sees the password, so it cannot enforce anything about it. - if (passwordBits(password) < PASSWORD_MIN_BITS) { - setError(t('register.err_too_weak')); return; - } - setError(''); - setLoading(true); - try { - if (window.MeshBayKeys) { - await window.MeshBayKeys.registerUser(username, email, password); - } else { - await hubFetch('/v1/users/register', { - method: 'POST', - body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' }, - }); - } - setSuccess(true); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - }; - - if (success) { - return html` -
- -
- `; - } - - return html` -
- -
- `; -} - // ── Home Page ──────────────────────────────────────────────────────────────── function NotificationFeed({ notifications, onMarkRead, onPurge }) { @@ -635,103 +400,6 @@ function HomePage({ groups, notifications, onMarkRead, onPurge }) { `; } -// ── Explore Page ───────────────────────────────────────────────────────────── - -function ExplorePage({ token, myGroupIds, allowPublicGroups = true }) { - const [groups, setGroups] = useState([]); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(''); - const [joining, setJoining] = useState(null); - - const doSearch = useCallback((q) => { - setLoading(true); - const url = q ? `/v1/groups?q=${encodeURIComponent(q)}` : '/v1/groups'; - hubFetch(url, { token }) - .then(data => setGroups(data.groups || [])) - .catch(() => {}) - .finally(() => setLoading(false)); - }, [token]); - - useEffect(() => { doSearch(''); }, [token]); - - const onSearch = useCallback((e) => { - const q = e.target.value; - setSearch(q); - doSearch(q); - }, [doSearch]); - - const joinGroup = useCallback(async (gid) => { - setJoining(gid); - try { - await hubFetch(`/v1/groups/${gid}/join`, { method: 'POST', token }); - navigate(`/group/${gid}`); - setTimeout(() => window.location.reload(), 100); - } catch (err) { - if (err.message.includes('Already a member')) { - navigate(`/group/${gid}`); - } else { - alert(err.message); - } - } finally { - setJoining(null); - } - }, [token]); - - const isMember = (gid) => myGroupIds && myGroupIds.includes(gid); - - // The hub can switch public groups off instance-wide (the server already - // returns nothing here). Say so plainly rather than showing an empty - // "Public groups" screen — nothing on this page has anything to do. - if (!allowPublicGroups) { - return html`

${t('explore.disabled')}

`; - } - - return html` -
-
-

${t('explore.title')}

- ${platform.node.available && html` - ${t('explore.create_group')} - `} -
-
- -
- ${loading - ? html`

${t('explore.loading')}

` - : groups.length === 0 - ? html`

${t('explore.empty')}

` - : html` -
- ${groups.map(g => html` -
- -

<${GroupName} name=${g.name} - owner=${g.source && g.source !== 'local' ? g.source : g.owner_username} />

- ${g.description && html`

${g.description}

`} -
- ${g.join_policy} - ${' '} - ${isMember(g.id) - ? html`${t('explore.member')}` - : g.join_policy === 'open' && html` - - ` - } -
- `)} -
- ` - } -
- `; -} - // ── First-run welcome (Electron-only, shown once on empty home) ───────────── function SetupWelcome({ onDismiss }) { @@ -748,576 +416,19 @@ function SetupWelcome({ onDismiss }) { `; } -// ── Create Group Page ──────────────────────────────────────────────────────── +// ── Lazy-loaded Create Group page ──────────────────────────────────────────── -function CreateGroupPage(props) { - if (platform.node.available) return html`<${CreateGroupWizard} ...${props} />`; - return html`<${CreateGroupFormSimple} ...${props} />`; -} - -function CreateGroupFormSimple({ token, onCreated, allowPublicGroups = true }) { - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - // Only ever anything other than 'invite' when the hub allows public groups — - // the join-policy section is not rendered otherwise, so there is nothing to - // set it 'open'. - const [joinPolicy, setJoinPolicy] = useState('invite'); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - - const onSubmit = async (e) => { - e.preventDefault(); - if (!name.trim()) return; - setLoading(true); - setError(''); - try { - const body = { name: name.trim(), join_policy: joinPolicy, - visibility: joinPolicy === 'open' ? 'public' : 'private' }; - if (description.trim()) body.description = description.trim().slice(0, 512); - const data = await hubFetch('/v1/groups', { - method: 'POST', token, body, - }); - - if (onCreated) onCreated(); - navigate('/'); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - }; - - return html` -
-

${t('create_group.title')}

-

${t('create_group.hint')}

- ${error && html`
${error}
`} - -
-
-
- - setName(e.target.value)} required autofocus /> -
- -
- -