aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-31 01:06:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-31 01:06:06 +0200
commit0c2754d2d4cb1905d5e324f56fbc64e4afae526f (patch)
treec081859830e017af09af9d2f73c6ff8442aacce4 /packages/meshbay-hub/src/meshbay_hub/static/app.js
parentf6e80064ff8c4869a6ba2cef908cc54326948463 (diff)
downloadmeshbay-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/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js919
1 files changed, 15 insertions, 904 deletions
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 })