diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-31 01:06:06 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-31 01:06:06 +0200 |
| commit | 0c2754d2d4cb1905d5e324f56fbc64e4afae526f (patch) | |
| tree | c081859830e017af09af9d2f73c6ff8442aacce4 /packages/meshbay-hub/src/meshbay_hub | |
| parent | f6e80064ff8c4869a6ba2cef908cc54326948463 (diff) | |
| download | meshbay-0c2754d2d4cb1905d5e324f56fbc64e4afae526f.tar.gz | |
refactor(ui): extract Explore, Login, Register and CreateGroup from app.js
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
5 files changed, 835 insertions, 905 deletions
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` - <div class="page-center"> - <div class="card login-card"> - <h2>${t('firstrun.title')}</h2> - <p class="settings-hint" style="margin-bottom:16px">${t('firstrun.hint')}</p> - <form onSubmit=${submit}> - <input type="text" placeholder="https://meshbay.org" required - autofocus value=${url} - onInput=${e => setUrl(e.target.value)} /> - <button type="submit" disabled=${busy}> - ${busy ? t('firstrun.checking') : t('firstrun.btn')} - </button> - </form> - ${error && html`<div class="error-msg">${error}</div>`} - <p class="settings-hint" style="margin-top:16px">${t('firstrun.note')}</p> - </div> - </div> - `; -} - -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` - <div class="page-center"> - <div class="card login-card"> - <h2>${t('login.title')}</h2> - <form onSubmit=${onSubmit}> - <input type="text" placeholder="${t('login.username')}" value=${username} - onInput=${e => setUsername(e.target.value)} - autocomplete="username" required autofocus /> - <input type="password" placeholder="${t('login.password')}" value=${password} - onInput=${e => setPassword(e.target.value)} - autocomplete="current-password" required /> - ${error && html`<div class="error-msg">${error}</div>`} - <button type="submit" disabled=${loading}> - ${loading ? t('login.loading') : t('login.submit')} - </button> - </form> - <div class="login-footer"> - ${t('login.no_account')} <a href="#/register">${t('login.register_link')}</a> - </div> - </div> - </div> - `; -} - -// ── 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` - <div class="page-center"> - <div class="card login-card"> - <h2>${t('register.success_title')}</h2> - <p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)"> - ${t('register.success_msg')} - </p> - <a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a> - </div> - </div> - `; - } - - return html` - <div class="page-center"> - <div class="card login-card"> - <h2>${t('register.title')}</h2> - <form onSubmit=${onSubmit}> - <input type="text" placeholder="${t('register.username')}" value=${username} - onInput=${e => setUsername(e.target.value)} - autocomplete="username" required /> - <input type="email" placeholder="${t('register.email')}" value=${email} - onInput=${e => setEmail(e.target.value)} - autocomplete="email" required /> - <input type="password" placeholder="${t('register.password')}" value=${password} - onInput=${e => setPassword(e.target.value)} - autocomplete="new-password" required minlength="8" /> - ${password && html` - <div style="margin:-4px 0 10px"> - <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden"> - <div style=${`height:100%;width:${Math.min(100, passwordBits(password) / 100 * 100)}%; - background:${passwordBits(password) < PASSWORD_MIN_BITS ? 'var(--error)' - : passwordBits(password) < 80 ? 'var(--yellow, #f59e0b)' : 'var(--success)'}`}></div> - </div> - <p style="font-size:0.8em;color:var(--text-dim);margin-top:4px"> - ${t('register.strength', { bits: passwordBits(password) })} - </p> - </div> - `} - <input type="password" placeholder="${t('register.confirm')}" value=${confirm} - onInput=${e => setConfirm(e.target.value)} - autocomplete="new-password" required /> - ${error && html`<div class="error-msg">${error}</div>`} - <button type="submit" disabled=${loading}> - ${loading ? t('register.loading') : t('register.submit')} - </button> - </form> - <div class="login-footer"> - ${t('register.has_account')} <a href="#/login">${t('register.login_link')}</a> - </div> - </div> - </div> - `; -} - // ── 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`<div><p class="page-message">${t('explore.disabled')}</p></div>`; - } - - return html` - <div> - <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px"> - <h2 style="margin:0">${t('explore.title')}</h2> - ${platform.node.available && html` - <a class="admin-btn" href="#/create-group">${t('explore.create_group')}</a> - `} - </div> - <div class="file-toolbar" style="margin-bottom:16px"> - <input type="text" class="admin-search" placeholder="${t('explore.search')}" - value=${search} onInput=${onSearch} /> - </div> - ${loading - ? html`<p class="page-message">${t('explore.loading')}</p>` - : groups.length === 0 - ? html`<p class="page-message">${t('explore.empty')}</p>` - : html` - <div class="group-grid"> - ${groups.map(g => html` - <div key=${g.id} class="group-card"> - <a href="#/group/${g.id}" style="text-decoration:none;color:inherit"> - <h3><${GroupName} name=${g.name} - owner=${g.source && g.source !== 'local' ? g.source : g.owner_username} /></h3> - ${g.description && html`<p class="group-card-desc">${g.description}</p>`} - </a> - <span class="badge">${g.join_policy}</span> - ${' '} - ${isMember(g.id) - ? html`<span class="badge">${t('explore.member')}</span>` - : g.join_policy === 'open' && html` - <button class="admin-btn" style="margin-top:8px" - disabled=${joining === g.id} - onClick=${() => joinGroup(g.id)}> - ${joining === g.id ? '...' : t('explore.join')} - </button> - ` - } - </div> - `)} - </div> - ` - } - </div> - `; -} - // ── First-run welcome (Electron-only, shown once on empty home) ───────────── function SetupWelcome({ onDismiss }) { @@ -748,576 +416,19 @@ function SetupWelcome({ onDismiss }) { </div>`; } -// ── 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` - <div> - <h2>${t('create_group.title')}</h2> - <p class="page-message">${t('create_group.hint')}</p> - ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`} - - <form onSubmit=${onSubmit}> - <div class="settings-section"> - <div class="form-field"> - <label class="form-label">${t('create_group.name')}</label> - <input type="text" placeholder="${t('create_group.name_placeholder')}" - value=${name} onInput=${e => setName(e.target.value)} required autofocus /> - </div> - - <div class="form-field" style="margin-bottom:0"> - <label class="form-label">${t('create_group.description')}</label> - <textarea class="form-textarea" rows="3" maxlength="512" - placeholder="${t('create_group.description_hint')}" - value=${description} - onInput=${e => setDescription(e.target.value)} /> - <div class="form-char-count">${description.length}/512</div> - </div> - </div> - - ${allowPublicGroups && html` - <div class="settings-section"> - <h3 class="settings-heading">${t('create_group.join_policy')}</h3> - <div class="choice-list"> - <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}"> - <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'} - onChange=${() => setJoinPolicy('invite')} /> - <${Icon} name="lock" cls="choice-icon" /> - <span class="choice-text"> - <span class="choice-title">${t('create_group.invite')}</span> - <span class="choice-desc">${t('create_group.invite_desc')}</span> - </span> - </label> - <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}"> - <input type="radio" name="join_policy" checked=${joinPolicy === 'open'} - onChange=${() => setJoinPolicy('open')} /> - <${Icon} name="globe" cls="choice-icon" /> - <span class="choice-text"> - <span class="choice-title">${t('create_group.open')}</span> - <span class="choice-desc">${t('create_group.open_desc')}</span> - </span> - </label> - </div> - </div> - `} - - <button class="btn-primary" type="submit" disabled=${loading}> - ${loading ? t('create_group.creating') : t('create_group.submit')} - </button> - </form> - </div> - `; -} - -// ── Create Group Wizard (Electron-only) ───────────────────────────────────── - -function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = true }) { - const [step, setStep] = useState(0); // 0=node check, 1=details, 2=setup, 3=done - const [nodeStatus, setNodeStatus] = useState(null); // null=loading, object=result - const [nodeStarting, setNodeStarting] = useState(false); - const [error, setError] = useState(''); - - // Step 1 fields - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - // See CreateGroupFormSimple: stays 'invite' unless the hub allows public - // groups, since the join-policy section is not rendered otherwise. - const [joinPolicy, setJoinPolicy] = useState('invite'); - const [roots, setRoots] = useState([]); - const [uploadIdx, setUploadIdx] = useState(0); - // Every registered app, on by default — narrowing this down here means - // members never briefly see one the operator meant to leave off, the way - // toggling it afterward from Settings would. - const [enabledApps, setEnabledApps] = useState(() => APPS.map(a => a.key)); - const toggleWizardApp = useCallback((key) => { - setEnabledApps(prev => prev.includes(key) - ? prev.filter(k => k !== key) - : [...prev, key]); - }, []); - - // Step 2 progress - const [setupSteps, setSetupSteps] = useState([]); - const [setupError, setSetupError] = useState(''); - const [groupId, setGroupId] = useState(''); - // Bytes-based, not file-count-based: one 20 GB file finishing last must - // not read as "8 of 9 done" while it is still the only thing running. - const [indexProgress, setIndexProgress] = useState(null); - - const linkNodeKey = useCallback(async (pk) => { - if (!pk) return; - try { - await hubFetch('/v1/users/me/node_key', { - method: 'PUT', token, body: { pk_node_ed25519: pk }, - }); - } catch { /* already linked or same key */ } - }, [token]); - - // Step 0: detect node - const detectNode = useCallback(async () => { - setNodeStatus(null); - setError(''); - try { - const result = await platform.node.detect(); - if (result.detected) { - await linkNodeKey(result.pk_node_ed25519); - setNodeStatus(result); - setStep(1); - return; - } - // Not responding — check if the unit is installed (for the Start button) - const inst = await platform.node.installed(); - setNodeStatus({ detected: false, configured: result.configured, installed: inst.installed }); - } catch (err) { - setError(err.message); - setNodeStatus({ detected: false }); - } - }, [token, linkNodeKey]); - - const startNode = useCallback(async () => { - setNodeStarting(true); - setError(''); - try { - const result = await platform.node.start({ hubUrl: HUB, username, token }); - setNodeStatus({ detected: true, ...result }); - setNodeStarting(false); - setStep(1); - } catch (err) { - setError(platform.bridgeMessage(err)); - setNodeStarting(false); - } - }, [linkNodeKey]); - - useEffect(() => { detectNode(); }, [detectNode]); - - const addRoot = useCallback(async () => { - const chosen = await platform.rootPicker.choose(); - if (!chosen) return; - if (roots.some(r => r.path === chosen.path)) return; - setRoots(prev => [...prev, chosen]); - }, [roots]); - - const removeRoot = useCallback((idx) => { - setRoots(prev => { - const next = prev.filter((_, i) => i !== idx); - if (uploadIdx >= next.length && next.length > 0) setUploadIdx(0); - return next; - }); - }, [uploadIdx]); - - const runSetup = useCallback(async () => { - setStep(2); - setSetupError(''); - const steps = [ - { label: t('wizard.step_create_hub'), status: 'pending' }, - { label: t('wizard.step_attach'), status: 'pending' }, - ]; - steps.push({ label: t('wizard.step_index'), status: 'pending' }); - // Always a step: the node's own default for a brand-new group is - // `chat, files` only (Roster.DEFAULT_APPS) — narrower than "every app - // checked" here, which is this wizard's own default. Skipping this call - // whenever nothing was *unchecked* used to assume those two defaults - // agreed; they don't, so leaving every box checked — the common, - // recommended case — silently left Videos/Music/Photos disabled on the - // node (found live 2026-08-25: no `set_enabled_apps`/"Enabled apps for - // group" ever logged for a group created with every app left on). - steps.push({ label: t('wizard.step_apps'), status: 'pending' }); - if (roots.length > 1) - steps.push({ label: t('wizard.step_add_roots'), status: 'pending' }); - steps.push({ label: t('wizard.step_gek'), status: 'pending' }); - steps.push({ label: t('wizard.step_pair'), status: 'pending' }); - setSetupSteps([...steps]); - setIndexProgress(null); - - let si = 0; - const update = (status) => { - steps[si].status = status; - setSetupSteps([...steps]); - }; - const advance = () => { si++; }; - - // Every step from here on is scoped to the group the node just attached. - // The node hot-loads a brand-new group synchronously — scan included — - // before it is added to groups_ctx or its own in-memory config - // (daemon.py _reload_config_inner: the config swap is the *last* thing - // that function does, a beat after the scan, not atomic with it) — so a - // call that lands in that beat gets refused even though the wait above - // already reported the scan as done. A handful of short retries absorbs - // that gap without a real cross-process synchronization primitive. - const withRetry = async (fn, attempts = 5, delayMs = 400) => { - for (let i = 0; i < attempts; i++) { - try { - return await fn(); - } catch (err) { - const msg = String((err && err.message) || ''); - const notHostedYet = /not configured on this node|not hosted on this node/i.test(msg); - if (!notHostedYet || i === attempts - 1) throw err; - await new Promise((r) => setTimeout(r, delayMs)); - } - } - }; - - try { - // 1. Create group on hub - update('running'); - 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 }); - const gid = data.group_id; - setGroupId(gid); - update('done'); - advance(); - - // 2. Attach to node with first root - update('running'); - const mainRoot = roots[uploadIdx] || roots[0]; - const attachBody = { name: name.trim(), shared_dir: mainRoot.path }; - if (roots.length === 1 || uploadIdx === 0) { - attachBody.upload_dir = mainRoot.path; - } - await platform.node.call('POST', '/api/groups/attach', attachBody); - // Fire-and-forget on the node's side (ui/app.py) — this call itself - // returns immediately, well before the scan below finishes. It used - // to be the thing the wizard waited on, which is exactly what made - // "Attaching to node" time out on a real library (see ops.start_reload). - await platform.node.call('POST', '/api/reload'); - update('done'); - advance(); - - // 3. Wait for the node's own initial scan of this group to finish — - // the group is not usable for anything below (apps, extra roots, GEK) - // until this finishes: daemon.py registers a brand-new group in - // groups_ctx only once its initial scan completes (ui/app.py's - // index-status docstring — "it is not yet authorized for member - // connections either way"), so nothing scoped to the group can - // succeed before this, no matter how many times it's retried. Can - // take tens of minutes on a slow disk with a large library — the node - // keeps scanning on its own either way (test_hot_reload_survives_ - // client_close.py); this step is only about not lying about it. - update('running'); - await platform.waitForGroupHosted(gid, setIndexProgress); - update('done'); - advance(); - - // 4. Set the enabled apps — unconditionally (see the step-list - // comment above on why "only if narrowed" was wrong: the node's own - // default is not "every app", so leaving every box checked must still - // be told to the node explicitly). - // Used to run *before* the scan above, reasoning that a member - // joining mid-scan should never briefly see an app meant to be off — - // but nobody can join before the group is hosted either (same - // authorization gate the comment above names), so that concern never - // applied, and placing it here means the retry below is defensive - // rather than the only thing standing between this step and an - // indefinite "Group not hosted on this node" (found live against a - // real, several-thousand-file library: withRetry's five attempts - // don't come close to covering a scan that takes minutes). - update('running'); - await withRetry(() => platform.node.call( - 'PUT', `/api/groups/${gid}/apps`, { apps: enabledApps })); - update('done'); - advance(); - - // 5. Add extra roots (if >1) - if (roots.length > 1) { - update('running'); - for (let i = 0; i < roots.length; i++) { - if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue; - const r = roots[i]; - await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/roots`, { - path: r.path, name: r.name, - upload: i === uploadIdx, - })); - } - // Each add above only schedules its scan (platform.js's - // waitForRootsIndexed docstring) — wait for it to actually finish, - // reusing the same progress bar step 3 fed, or this step reports - // "done" while the node is still hashing gigabytes behind the - // scenes (found live 2026-08-25). - await platform.waitForRootsIndexed(gid, setIndexProgress); - update('done'); - advance(); - } - - // 6. GEK init - update('running'); - await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`)); - update('done'); - advance(); +// ── Lazy-loaded Create Group page ──────────────────────────────────────────── - // 7. Generate pairing code - update('running'); - const pairResult = await platform.node.call('POST', '/api/operator/pair'); - if (pairResult && pairResult.code) { - await platform.node.setPairingCode(pairResult.code); - session.pendingJoinCode = pairResult.code; - } - update('done'); - - // Reload once more so any roots added at step 4 are picked up. - try { await platform.node.call('POST', '/api/reload'); } catch { /* best effort */ } - - setStep(3); - if (onCreated) onCreated(); - } catch (err) { - update('error'); - setSetupError(platform.bridgeMessage(err)); - } - }, [name, description, joinPolicy, roots, uploadIdx, enabledApps, token, onCreated]); - - // Step 0: Node detection - if (step === 0) { - if (nodeStatus === null) { - return html`<div class="page-content"> - <h2>${t('wizard.title')}</h2> - <p class="page-message">${t('wizard.detecting')}</p> - </div>`; - } - if (!nodeStatus.detected) { - const canStart = nodeStatus.installed || nodeStatus.configured; - return html`<div class="page-content"> - <h2>${t('wizard.title')}</h2> - <p class="page-message">${canStart - ? t('setup.node_not_running') - : t('setup.node_not_installed')}</p> - ${!canStart && html`<p class="page-message" style="margin-top:8px"> - ${t('setup.node_install_hint')}</p>`} - ${error && html`<div class="error-msg" style="margin-top:8px">${error}</div>`} - <div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap"> - ${canStart && html` - <button class="btn btn-primary" disabled=${nodeStarting} - onClick=${startNode}> - ${nodeStarting ? t('setup.node_starting') : t('setup.node_start')}</button>`} - <button class="btn btn-secondary" onClick=${detectNode}> - ${t('wizard.retry')}</button> - </div> - </div>`; - } - } - - // Node detected but not fully ready — provision config and/or link key, - // then wait for 'running' before showing the group form. - const provisionAttempted = useRef(false); +let _CreateGroupPage = null; +function LazyCreateGroupPage(props) { + const [loaded, setLoaded] = useState(!!_CreateGroupPage); useEffect(() => { - if (step === 1 && nodeStatus && !nodeStarting - && !provisionAttempted.current - && (nodeStatus.status === 'waiting_for_account' - || nodeStatus.status === 'waiting_for_node_key' - || nodeStatus.status === 'starting')) { - provisionAttempted.current = true; - startNode(); + if (!_CreateGroupPage) { + import('./create-group-page.js').then(m => { _CreateGroupPage = m.CreateGroupPage; setLoaded(true); }); } - }, [step, nodeStatus, nodeStarting, startNode]); - - if (step === 1 && nodeStatus - && nodeStatus.status !== 'running' && nodeStatus.status !== undefined) { - return html`<div class="page-content"> - <h2>${t('wizard.title')}</h2> - <p class="page-message">${error - ? t('wizard.wrong_account') - : t('wizard.detecting')}</p> - ${error && html`<button class="btn btn-secondary" onClick=${detectNode}> - ${t('wizard.retry')}</button>`} - </div>`; - } - if (step === 1) { - const canProceed = name.trim() && roots.length > 0 && enabledApps.length > 0; - return html`<div class="page-content"> - <h2>${t('wizard.title')}</h2> - ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`} - - <div class="settings-section"> - <div class="form-field"> - <label class="form-label">${t('create_group.name')}</label> - <input type="text" placeholder="${t('create_group.name_placeholder')}" - value=${name} onInput=${e => setName(e.target.value)} required autofocus /> - </div> - - <div class="form-field"> - <label class="form-label">${t('create_group.description')}</label> - <textarea class="form-textarea" rows="3" maxlength="512" - placeholder="${t('create_group.description_hint')}" - value=${description} - onInput=${e => setDescription(e.target.value)} /> - <div class="form-char-count">${description.length}/512</div> - </div> - </div> - - ${allowPublicGroups && html` - <div class="settings-section"> - <h3 class="settings-heading">${t('create_group.join_policy')}</h3> - <div class="choice-list"> - <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}"> - <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'} - onChange=${() => setJoinPolicy('invite')} /> - <${Icon} name="lock" cls="choice-icon" /> - <span class="choice-text"> - <span class="choice-title">${t('create_group.invite')}</span> - <span class="choice-desc">${t('create_group.invite_desc')}</span> - </span> - </label> - <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}"> - <input type="radio" name="join_policy" checked=${joinPolicy === 'open'} - onChange=${() => setJoinPolicy('open')} /> - <${Icon} name="globe" cls="choice-icon" /> - <span class="choice-text"> - <span class="choice-title">${t('create_group.open')}</span> - <span class="choice-desc">${t('create_group.open_desc')}</span> - </span> - </label> - </div> - </div> - `} - - <div class="settings-section"> - <h3 class="settings-heading">${t('members.apps_title')}</h3> - <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px"> - ${t('members.apps_hint')}</p> - <ul class="apps-toggle-list"> - ${APPS.map(a => html` - <li key=${a.key} class="settings-row"> - <label class="settings-label"> - <input type="checkbox" checked=${enabledApps.includes(a.key)} - onChange=${() => toggleWizardApp(a.key)} /> - ${' '}${t(a.labelKey)} - </label> - </li> - `)} - </ul> - ${enabledApps.length === 0 && html` - <p class="error-msg">${t('members.apps_need_one')}</p>`} - </div> - - <div class="settings-section"> - <h3 class="settings-heading">${t('wizard.directories')}</h3> - <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px"> - ${t('wizard.directories_hint')}</p> - ${roots.map((r, i) => html` - <div class="wizard-root" key=${r.path}> - <div class="wizard-root-info"> - <${Icon} name="folder" /> - <span class="wizard-root-name">${r.name}</span> - <span class="wizard-root-path">${r.path}</span> - ${i === uploadIdx && html` - <span class="node-root-badge">${t('wizard.upload_target')}</span>`} - </div> - <div class="wizard-root-actions"> - ${roots.length > 1 && i !== uploadIdx && html` - <button class="btn btn-small btn-secondary" - onClick=${() => setUploadIdx(i)}> - ${t('wizard.set_upload')}</button>`} - <button class="btn btn-small btn-danger" - onClick=${() => removeRoot(i)}> - ${t('wizard.remove')}</button> - </div> - </div> - `)} - <button class="btn btn-secondary" style="margin-top:8px" - onClick=${addRoot}> - <${Icon} name="folder-plus" /> ${t('wizard.add_directory')} - </button> - </div> - - <div style="display:flex;gap:8px;margin-top:16px"> - <button class="btn btn-primary" disabled=${!canProceed} - onClick=${runSetup}> - ${t('wizard.create_and_setup')}</button> - </div> - </div>`; - } - - // Step 2: Automatic setup progress - if (step === 2) { - const pct = indexProgress && indexProgress.total_bytes - ? Math.min(100, Math.round(100 * indexProgress.scanned_bytes / indexProgress.total_bytes)) - : 0; - return html`<div class="page-content"> - <h2>${t('wizard.title')}</h2> - <p class="page-message">${t('wizard.setting_up')}</p> - <div class="wizard-progress"> - ${setupSteps.map((s, i) => html` - <div class="wizard-step wizard-step-${s.status}" key=${i}> - <span class="wizard-step-icon"> - ${s.status === 'running' ? html`<span class="spinner"></span>` : - s.status === 'done' ? '✓' : - s.status === 'error' ? '✗' : '○'} - </span> - <span>${s.label}</span> - </div> - `)} - </div> - ${indexProgress && indexProgress.scanning && html` - <div class="index-progress" style="margin-top:12px"> - <div class="index-progress-bar"> - <div class="index-progress-fill" style="width:${pct}%"></div> - </div> - <div class="index-progress-label">${t('wizard.indexing_progress', { pct })}</div> - ${indexProgress.current_dir && html` - <div class="index-progress-dir"> - ${t('wizard.indexing_current_dir', { dir: indexProgress.current_dir })} - </div> - `} - </div> - `} - ${setupError && html` - <div class="error-msg" style="margin-top:16px">${setupError}</div> - <div style="display:flex;gap:8px;margin-top:8px"> - <button class="btn btn-primary" onClick=${runSetup}> - ${t('wizard.retry')}</button> - <button class="btn btn-secondary" onClick=${() => { - if (onCreated) onCreated(); - navigate('/'); - }}> - ${t('wizard.finish_later')}</button> - </div> - `} - </div>`; - } - - // Step 3: Done - return html`<div class="page-content"> - <h2>${t('wizard.done_title')}</h2> - <p class="page-message">${t('wizard.done_message')}</p> - <button class="btn btn-primary" style="margin-top:16px" - onClick=${() => navigate(`/groups/${groupId}`)}> - ${t('wizard.go_to_group')}</button> - </div>`; + }, []); + if (!loaded) return html`<div class="page-content"> + <p class="page-message"><span class="spinner"></span></p></div>`; + return html`<${_CreateGroupPage} ...${props} />`; } // ── Settings Page ─────────────────────────────────────────────────────────── @@ -1696,9 +807,9 @@ function App() { } else if (route === '/login' || route === '/register') { page = route === '/register' ? html`<${RegisterPage} />` - : html`<${LoginPage} />`; + : html`<${LoginPage} onLogin=${authCtx.login} />`; } else if (!user) { - page = html`<${LoginPage} />`; + page = html`<${LoginPage} onLogin=${authCtx.login} />`; } else if (route === '/search') { page = html`<${SearchPage} token=${user.token} username=${user.username} userId=${user.userId} @@ -1709,7 +820,7 @@ function App() { myGroupIds=${groups.map(g => g.id)} allowPublicGroups=${allowPublicGroups} />`; } else if (route === '/create-group') { - page = html`<${CreateGroupPage} token=${user.token} username=${user.username} + page = html`<${LazyCreateGroupPage} token=${user.token} username=${user.username} allowPublicGroups=${allowPublicGroups} onCreated=${() => { hubFetch('/v1/groups/mine', { token: user.token }) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js new file mode 100644 index 0000000..1cca56f --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -0,0 +1,201 @@ +import { + html, useState, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { hubFetch, navigate } from './hub-client.js'; +import * as platform from './platform.js'; + +const PASSWORD_MIN_BITS = 60; +const PASSWORD_MIN_LEN = 12; + +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; + if (/^[0-9]+$/.test(pw)) bits *= 0.5; + if (/(password|motdepasse|azerty|qwerty|123456|meshbay)/i.test(pw)) bits *= 0.3; + return Math.round(bits); +} + +export 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` + <div class="page-center"> + <div class="card login-card"> + <h2>${t('firstrun.title')}</h2> + <p class="settings-hint" style="margin-bottom:16px">${t('firstrun.hint')}</p> + <form onSubmit=${submit}> + <input type="text" placeholder="https://meshbay.org" required + autofocus value=${url} + onInput=${e => setUrl(e.target.value)} /> + <button type="submit" disabled=${busy}> + ${busy ? t('firstrun.checking') : t('firstrun.btn')} + </button> + </form> + ${error && html`<div class="error-msg">${error}</div>`} + <p class="settings-hint" style="margin-top:16px">${t('firstrun.note')}</p> + </div> + </div> + `; +} + +export function LoginPage({ onLogin }) { + 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 onLogin(username, password); + navigate('/'); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return html` + <div class="page-center"> + <div class="card login-card"> + <h2>${t('login.title')}</h2> + <form onSubmit=${onSubmit}> + <input type="text" placeholder="${t('login.username')}" value=${username} + onInput=${e => setUsername(e.target.value)} + autocomplete="username" required autofocus /> + <input type="password" placeholder="${t('login.password')}" value=${password} + onInput=${e => setPassword(e.target.value)} + autocomplete="current-password" required /> + ${error && html`<div class="error-msg">${error}</div>`} + <button type="submit" disabled=${loading}> + ${loading ? t('login.loading') : t('login.submit')} + </button> + </form> + <div class="login-footer"> + ${t('login.no_account')} <a href="#/register">${t('login.register_link')}</a> + </div> + </div> + </div> + `; +} + +export 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; + } + 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` + <div class="page-center"> + <div class="card login-card"> + <h2>${t('register.success_title')}</h2> + <p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)"> + ${t('register.success_msg')} + </p> + <a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a> + </div> + </div> + `; + } + + return html` + <div class="page-center"> + <div class="card login-card"> + <h2>${t('register.title')}</h2> + <form onSubmit=${onSubmit}> + <input type="text" placeholder="${t('register.username')}" value=${username} + onInput=${e => setUsername(e.target.value)} + autocomplete="username" required /> + <input type="email" placeholder="${t('register.email')}" value=${email} + onInput=${e => setEmail(e.target.value)} + autocomplete="email" required /> + <input type="password" placeholder="${t('register.password')}" value=${password} + onInput=${e => setPassword(e.target.value)} + autocomplete="new-password" required minlength="8" /> + ${password && html` + <div style="margin:-4px 0 10px"> + <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden"> + <div style=${`height:100%;width:${Math.min(100, passwordBits(password) / 100 * 100)}%; + background:${passwordBits(password) < PASSWORD_MIN_BITS ? 'var(--error)' + : passwordBits(password) < 80 ? 'var(--yellow, #f59e0b)' : 'var(--success)'}`}></div> + </div> + <p style="font-size:0.8em;color:var(--text-dim);margin-top:4px"> + ${t('register.strength', { bits: passwordBits(password) })} + </p> + </div> + `} + <input type="password" placeholder="${t('register.confirm')}" value=${confirm} + onInput=${e => setConfirm(e.target.value)} + autocomplete="new-password" required /> + ${error && html`<div class="error-msg">${error}</div>`} + <button type="submit" disabled=${loading}> + ${loading ? t('register.loading') : t('register.submit')} + </button> + </form> + <div class="login-footer"> + ${t('register.has_account')} <a href="#/login">${t('register.login_link')}</a> + </div> + </div> + </div> + `; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js new file mode 100644 index 0000000..b23b504 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js @@ -0,0 +1,513 @@ +import { + html, useState, useEffect, useCallback, useRef, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { HUB, hubFetch, session, navigate } from './hub-client.js'; +import * as platform from './platform.js'; +import { Icon } from './icon.js'; +import { APPS } from './apps.js'; + +export 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(''); + 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` + <div> + <h2>${t('create_group.title')}</h2> + <p class="page-message">${t('create_group.hint')}</p> + ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`} + + <form onSubmit=${onSubmit}> + <div class="settings-section"> + <div class="form-field"> + <label class="form-label">${t('create_group.name')}</label> + <input type="text" placeholder="${t('create_group.name_placeholder')}" + value=${name} onInput=${e => setName(e.target.value)} required autofocus /> + </div> + + <div class="form-field" style="margin-bottom:0"> + <label class="form-label">${t('create_group.description')}</label> + <textarea class="form-textarea" rows="3" maxlength="512" + placeholder="${t('create_group.description_hint')}" + value=${description} + onInput=${e => setDescription(e.target.value)} /> + <div class="form-char-count">${description.length}/512</div> + </div> + </div> + + ${allowPublicGroups && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('create_group.join_policy')}</h3> + <div class="choice-list"> + <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'} + onChange=${() => setJoinPolicy('invite')} /> + <${Icon} name="lock" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.invite')}</span> + <span class="choice-desc">${t('create_group.invite_desc')}</span> + </span> + </label> + <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'open'} + onChange=${() => setJoinPolicy('open')} /> + <${Icon} name="globe" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.open')}</span> + <span class="choice-desc">${t('create_group.open_desc')}</span> + </span> + </label> + </div> + </div> + `} + + <button class="btn-primary" type="submit" disabled=${loading}> + ${loading ? t('create_group.creating') : t('create_group.submit')} + </button> + </form> + </div> + `; +} + +function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = true }) { + const [step, setStep] = useState(0); + const [nodeStatus, setNodeStatus] = useState(null); + const [nodeStarting, setNodeStarting] = useState(false); + const [error, setError] = useState(''); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [joinPolicy, setJoinPolicy] = useState('invite'); + const [roots, setRoots] = useState([]); + const [uploadIdx, setUploadIdx] = useState(0); + const [enabledApps, setEnabledApps] = useState(() => APPS.map(a => a.key)); + const toggleWizardApp = useCallback((key) => { + setEnabledApps(prev => prev.includes(key) + ? prev.filter(k => k !== key) + : [...prev, key]); + }, []); + + const [setupSteps, setSetupSteps] = useState([]); + const [setupError, setSetupError] = useState(''); + const [groupId, setGroupId] = useState(''); + const [indexProgress, setIndexProgress] = useState(null); + + const linkNodeKey = useCallback(async (pk) => { + if (!pk) return; + try { + await hubFetch('/v1/users/me/node_key', { + method: 'PUT', token, body: { pk_node_ed25519: pk }, + }); + } catch { /* already linked or same key */ } + }, [token]); + + const detectNode = useCallback(async () => { + setNodeStatus(null); + setError(''); + try { + const result = await platform.node.detect(); + if (result.detected) { + await linkNodeKey(result.pk_node_ed25519); + setNodeStatus(result); + setStep(1); + return; + } + const inst = await platform.node.installed(); + setNodeStatus({ detected: false, configured: result.configured, installed: inst.installed }); + } catch (err) { + setError(err.message); + setNodeStatus({ detected: false }); + } + }, [token, linkNodeKey]); + + const startNode = useCallback(async () => { + setNodeStarting(true); + setError(''); + try { + const result = await platform.node.start({ hubUrl: HUB, username, token }); + setNodeStatus({ detected: true, ...result }); + setNodeStarting(false); + setStep(1); + } catch (err) { + setError(platform.bridgeMessage(err)); + setNodeStarting(false); + } + }, [linkNodeKey]); + + useEffect(() => { detectNode(); }, [detectNode]); + + const addRoot = useCallback(async () => { + const chosen = await platform.rootPicker.choose(); + if (!chosen) return; + if (roots.some(r => r.path === chosen.path)) return; + setRoots(prev => [...prev, chosen]); + }, [roots]); + + const removeRoot = useCallback((idx) => { + setRoots(prev => { + const next = prev.filter((_, i) => i !== idx); + if (uploadIdx >= next.length && next.length > 0) setUploadIdx(0); + return next; + }); + }, [uploadIdx]); + + const runSetup = useCallback(async () => { + setStep(2); + setSetupError(''); + const steps = [ + { label: t('wizard.step_create_hub'), status: 'pending' }, + { label: t('wizard.step_attach'), status: 'pending' }, + ]; + steps.push({ label: t('wizard.step_index'), status: 'pending' }); + steps.push({ label: t('wizard.step_apps'), status: 'pending' }); + if (roots.length > 1) + steps.push({ label: t('wizard.step_add_roots'), status: 'pending' }); + steps.push({ label: t('wizard.step_gek'), status: 'pending' }); + steps.push({ label: t('wizard.step_pair'), status: 'pending' }); + setSetupSteps([...steps]); + setIndexProgress(null); + + let si = 0; + const update = (status) => { + steps[si].status = status; + setSetupSteps([...steps]); + }; + const advance = () => { si++; }; + + const withRetry = async (fn, attempts = 5, delayMs = 400) => { + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + const msg = String((err && err.message) || ''); + const notHostedYet = /not configured on this node|not hosted on this node/i.test(msg); + if (!notHostedYet || i === attempts - 1) throw err; + await new Promise((r) => setTimeout(r, delayMs)); + } + } + }; + + try { + // 1. Create group on hub + update('running'); + 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 }); + const gid = data.group_id; + setGroupId(gid); + update('done'); + advance(); + + // 2. Attach to node with first root + update('running'); + const mainRoot = roots[uploadIdx] || roots[0]; + const attachBody = { name: name.trim(), shared_dir: mainRoot.path }; + if (roots.length === 1 || uploadIdx === 0) { + attachBody.upload_dir = mainRoot.path; + } + await platform.node.call('POST', '/api/groups/attach', attachBody); + await platform.node.call('POST', '/api/reload'); + update('done'); + advance(); + + // 3. Wait for initial scan + update('running'); + await platform.waitForGroupHosted(gid, setIndexProgress); + update('done'); + advance(); + + // 4. Set enabled apps + update('running'); + await withRetry(() => platform.node.call( + 'PUT', `/api/groups/${gid}/apps`, { apps: enabledApps })); + update('done'); + advance(); + + // 5. Add extra roots (if >1) + if (roots.length > 1) { + update('running'); + for (let i = 0; i < roots.length; i++) { + if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue; + const r = roots[i]; + await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/roots`, { + path: r.path, name: r.name, + upload: i === uploadIdx, + })); + } + await platform.waitForRootsIndexed(gid, setIndexProgress); + update('done'); + advance(); + } + + // 6. GEK init + update('running'); + await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`)); + update('done'); + advance(); + + // 7. Generate pairing code + update('running'); + const pairResult = await platform.node.call('POST', '/api/operator/pair'); + if (pairResult && pairResult.code) { + await platform.node.setPairingCode(pairResult.code); + session.pendingJoinCode = pairResult.code; + } + update('done'); + + try { await platform.node.call('POST', '/api/reload'); } catch { /* best effort */ } + + setStep(3); + if (onCreated) onCreated(); + } catch (err) { + update('error'); + setSetupError(platform.bridgeMessage(err)); + } + }, [name, description, joinPolicy, roots, uploadIdx, enabledApps, token, onCreated]); + + // Step 0: Node detection + if (step === 0) { + if (nodeStatus === null) { + return html`<div class="page-content"> + <h2>${t('wizard.title')}</h2> + <p class="page-message">${t('wizard.detecting')}</p> + </div>`; + } + if (!nodeStatus.detected) { + const canStart = nodeStatus.installed || nodeStatus.configured; + return html`<div class="page-content"> + <h2>${t('wizard.title')}</h2> + <p class="page-message">${canStart + ? t('setup.node_not_running') + : t('setup.node_not_installed')}</p> + ${!canStart && html`<p class="page-message" style="margin-top:8px"> + ${t('setup.node_install_hint')}</p>`} + ${error && html`<div class="error-msg" style="margin-top:8px">${error}</div>`} + <div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap"> + ${canStart && html` + <button class="btn btn-primary" disabled=${nodeStarting} + onClick=${startNode}> + ${nodeStarting ? t('setup.node_starting') : t('setup.node_start')}</button>`} + <button class="btn btn-secondary" onClick=${detectNode}> + ${t('wizard.retry')}</button> + </div> + </div>`; + } + } + + const provisionAttempted = useRef(false); + useEffect(() => { + if (step === 1 && nodeStatus && !nodeStarting + && !provisionAttempted.current + && (nodeStatus.status === 'waiting_for_account' + || nodeStatus.status === 'waiting_for_node_key' + || nodeStatus.status === 'starting')) { + provisionAttempted.current = true; + startNode(); + } + }, [step, nodeStatus, nodeStarting, startNode]); + + if (step === 1 && nodeStatus + && nodeStatus.status !== 'running' && nodeStatus.status !== undefined) { + return html`<div class="page-content"> + <h2>${t('wizard.title')}</h2> + <p class="page-message">${error + ? t('wizard.wrong_account') + : t('wizard.detecting')}</p> + ${error && html`<button class="btn btn-secondary" onClick=${detectNode}> + ${t('wizard.retry')}</button>`} + </div>`; + } + if (step === 1) { + const canProceed = name.trim() && roots.length > 0 && enabledApps.length > 0; + return html`<div class="page-content"> + <h2>${t('wizard.title')}</h2> + ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`} + + <div class="settings-section"> + <div class="form-field"> + <label class="form-label">${t('create_group.name')}</label> + <input type="text" placeholder="${t('create_group.name_placeholder')}" + value=${name} onInput=${e => setName(e.target.value)} required autofocus /> + </div> + + <div class="form-field"> + <label class="form-label">${t('create_group.description')}</label> + <textarea class="form-textarea" rows="3" maxlength="512" + placeholder="${t('create_group.description_hint')}" + value=${description} + onInput=${e => setDescription(e.target.value)} /> + <div class="form-char-count">${description.length}/512</div> + </div> + </div> + + ${allowPublicGroups && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('create_group.join_policy')}</h3> + <div class="choice-list"> + <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'} + onChange=${() => setJoinPolicy('invite')} /> + <${Icon} name="lock" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.invite')}</span> + <span class="choice-desc">${t('create_group.invite_desc')}</span> + </span> + </label> + <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'open'} + onChange=${() => setJoinPolicy('open')} /> + <${Icon} name="globe" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.open')}</span> + <span class="choice-desc">${t('create_group.open_desc')}</span> + </span> + </label> + </div> + </div> + `} + + <div class="settings-section"> + <h3 class="settings-heading">${t('members.apps_title')}</h3> + <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px"> + ${t('members.apps_hint')}</p> + <ul class="apps-toggle-list"> + ${APPS.map(a => html` + <li key=${a.key} class="settings-row"> + <label class="settings-label"> + <input type="checkbox" checked=${enabledApps.includes(a.key)} + onChange=${() => toggleWizardApp(a.key)} /> + ${' '}${t(a.labelKey)} + </label> + </li> + `)} + </ul> + ${enabledApps.length === 0 && html` + <p class="error-msg">${t('members.apps_need_one')}</p>`} + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('wizard.directories')}</h3> + <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px"> + ${t('wizard.directories_hint')}</p> + ${roots.map((r, i) => html` + <div class="wizard-root" key=${r.path}> + <div class="wizard-root-info"> + <${Icon} name="folder" /> + <span class="wizard-root-name">${r.name}</span> + <span class="wizard-root-path">${r.path}</span> + ${i === uploadIdx && html` + <span class="node-root-badge">${t('wizard.upload_target')}</span>`} + </div> + <div class="wizard-root-actions"> + ${roots.length > 1 && i !== uploadIdx && html` + <button class="btn btn-small btn-secondary" + onClick=${() => setUploadIdx(i)}> + ${t('wizard.set_upload')}</button>`} + <button class="btn btn-small btn-danger" + onClick=${() => removeRoot(i)}> + ${t('wizard.remove')}</button> + </div> + </div> + `)} + <button class="btn btn-secondary" style="margin-top:8px" + onClick=${addRoot}> + <${Icon} name="folder-plus" /> ${t('wizard.add_directory')} + </button> + </div> + + <div style="display:flex;gap:8px;margin-top:16px"> + <button class="btn btn-primary" disabled=${!canProceed} + onClick=${runSetup}> + ${t('wizard.create_and_setup')}</button> + </div> + </div>`; + } + + // Step 2: Automatic setup progress + if (step === 2) { + const pct = indexProgress && indexProgress.total_bytes + ? Math.min(100, Math.round(100 * indexProgress.scanned_bytes / indexProgress.total_bytes)) + : 0; + return html`<div class="page-content"> + <h2>${t('wizard.title')}</h2> + <p class="page-message">${t('wizard.setting_up')}</p> + <div class="wizard-progress"> + ${setupSteps.map((s, i) => html` + <div class="wizard-step wizard-step-${s.status}" key=${i}> + <span class="wizard-step-icon"> + ${s.status === 'running' ? html`<span class="spinner"></span>` : + s.status === 'done' ? '✓' : + s.status === 'error' ? '✗' : '○'} + </span> + <span>${s.label}</span> + </div> + `)} + </div> + ${indexProgress && indexProgress.scanning && html` + <div class="index-progress" style="margin-top:12px"> + <div class="index-progress-bar"> + <div class="index-progress-fill" style="width:${pct}%"></div> + </div> + <div class="index-progress-label">${t('wizard.indexing_progress', { pct })}</div> + ${indexProgress.current_dir && html` + <div class="index-progress-dir"> + ${t('wizard.indexing_current_dir', { dir: indexProgress.current_dir })} + </div> + `} + </div> + `} + ${setupError && html` + <div class="error-msg" style="margin-top:16px">${setupError}</div> + <div style="display:flex;gap:8px;margin-top:8px"> + <button class="btn btn-primary" onClick=${runSetup}> + ${t('wizard.retry')}</button> + <button class="btn btn-secondary" onClick=${() => { + if (onCreated) onCreated(); + navigate('/'); + }}> + ${t('wizard.finish_later')}</button> + </div> + `} + </div>`; + } + + // Step 3: Done + return html`<div class="page-content"> + <h2>${t('wizard.done_title')}</h2> + <p class="page-message">${t('wizard.done_message')}</p> + <button class="btn btn-primary" style="margin-top:16px" + onClick=${() => navigate(`/groups/${groupId}`)}> + ${t('wizard.go_to_group')}</button> + </div>`; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/explore-page.js b/packages/meshbay-hub/src/meshbay_hub/static/explore-page.js new file mode 100644 index 0000000..486c1e6 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/explore-page.js @@ -0,0 +1,100 @@ +import { + html, useState, useEffect, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { hubFetch, navigate } from './hub-client.js'; +import * as platform from './platform.js'; +import { GroupName } from './group-name.js'; +import { Icon } from './icon.js'; + +export 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); + + if (!allowPublicGroups) { + return html`<div><p class="page-message">${t('explore.disabled')}</p></div>`; + } + + return html` + <div> + <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px"> + <h2 style="margin:0">${t('explore.title')}</h2> + ${platform.node.available && html` + <a class="admin-btn" href="#/create-group">${t('explore.create_group')}</a> + `} + </div> + <div class="file-toolbar" style="margin-bottom:16px"> + <input type="text" class="admin-search" placeholder="${t('explore.search')}" + value=${search} onInput=${onSearch} /> + </div> + ${loading + ? html`<p class="page-message">${t('explore.loading')}</p>` + : groups.length === 0 + ? html`<p class="page-message">${t('explore.empty')}</p>` + : html` + <div class="group-grid"> + ${groups.map(g => html` + <div key=${g.id} class="group-card"> + <a href="#/group/${g.id}" style="text-decoration:none;color:inherit"> + <h3><${GroupName} name=${g.name} + owner=${g.source && g.source !== 'local' ? g.source : g.owner_username} /></h3> + ${g.description && html`<p class="group-card-desc">${g.description}</p>`} + </a> + <span class="badge">${g.join_policy}</span> + ${' '} + ${isMember(g.id) + ? html`<span class="badge">${t('explore.member')}</span>` + : g.join_policy === 'open' && html` + <button class="admin-btn" style="margin-top:8px" + disabled=${joining === g.id} + onClick=${() => joinGroup(g.id)}> + ${joining === g.id ? '...' : t('explore.join')} + </button> + ` + } + </div> + `)} + </div> + ` + } + </div> + `; +} |