From cc90dc943fcd0e7bbf52674fb3f95ff097026f4a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 11 Aug 2026 14:55:52 +0200 Subject: feat: Phase 10b — Self-service UI (group create/join, upload, IndexedDB, search) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six self-service features for the web SPA: - Group creation UI with GEK auto-generation (AES-256-GCM ECIES) - Member management + invite by username (GEK wrapping for invitee) - Open group self-join flow (POST /v1/groups/{id}/join) - File upload client→node (FILE_UPLOAD MNP type, .uploads/ staging) - IndexedDB caching of group file indexes (instant display on revisit) - Cross-group file search (SearchPage, pure client-side on cached indexes) 11 new tests (166 total): 8 group self-service + 3 AES GEK wrap/unwrap. Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-hub/src/meshbay_hub/api/groups.py | 53 +++ packages/meshbay-hub/src/meshbay_hub/static/app.js | 423 ++++++++++++++++++++- .../meshbay-hub/src/meshbay_hub/static/crypto.js | 79 +++- .../meshbay-hub/src/meshbay_hub/static/i18n.js | 41 ++ .../src/meshbay_hub/static/transport.js | 12 + 5 files changed, 597 insertions(+), 11 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index a8a45c9..e0ea016 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -162,6 +162,59 @@ async def swarm_sources( } +@router.get("/{group_id}/members") +async def group_members( + group_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + + mem = await db.get(GroupMember, (group_id, current_user.id)) + if not mem: + raise HTTPException(status_code=403, detail="Not a member") + + result = await db.execute( + select(User.id, User.username) + .join(GroupMember, User.id == GroupMember.user_id) + .where(GroupMember.group_id == group_id) + ) + members = [{"user_id": uid, "username": uname} for uid, uname in result.all()] + return { + "group_id": group_id, + "admin_id": group.admin_id, + "members": members, + } + + +@router.post("/{group_id}/join") +async def join_group( + group_id: str, + request: Request, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + if group.status != "active": + raise HTTPException(status_code=403, detail="Group is not active") + if group.join_policy != "open": + raise HTTPException(status_code=403, detail="Group does not allow open joining") + + existing = await db.get(GroupMember, (group_id, current_user.id)) + if existing: + raise HTTPException(status_code=409, detail="Already a member") + + db.add(GroupMember(group_id=group_id, user_id=current_user.id)) + db.add(IPLog(user_id=current_user.id, event="group_join", + ip_address=_ip(request), detail=group.name)) + await db.commit() + return {"status": "joined", "group_id": group_id, "name": group.name} + + class GroupCreateRequest(BaseModel): name: str visibility: str = "private" # public|private diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 4ef334b..dda4a09 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -9,6 +9,59 @@ import { t, getLocale, setLocale, LOCALES } from './i18n.js'; const HUB = ''; const AUTH_KEY = 'mb_auth'; const THEME_KEY = 'mb_theme'; +const IDB_NAME = 'meshbay'; +const IDB_VERSION = 1; +const IDB_STORE = 'group_indexes'; + +// ── IndexedDB cache ───────────────────────────────────────────────────────── + +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(IDB_NAME, IDB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + db.createObjectStore(IDB_STORE, { keyPath: 'groupId' }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function cacheGroupIndex(groupId, groupName, entries) { + try { + const db = await openDB(); + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).put({ + groupId, groupName, entries, cachedAt: Date.now(), + }); + await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); + db.close(); + } catch { /* best-effort */ } +} + +async function getCachedGroupIndex(groupId) { + try { + const db = await openDB(); + const tx = db.transaction(IDB_STORE, 'readonly'); + const req = tx.objectStore(IDB_STORE).get(groupId); + const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); + db.close(); + return result || null; + } catch { return null; } +} + +async function getAllCachedIndexes() { + try { + const db = await openDB(); + const tx = db.transaction(IDB_STORE, 'readonly'); + const req = tx.objectStore(IDB_STORE).getAll(); + const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); + db.close(); + return result || []; + } catch { return []; } +} // ── Auth persistence ───────────────────────────────────────────────────────── @@ -139,6 +192,10 @@ function Sidebar({ groups, route, menuOpen, role }) { ${t('sidebar.public_groups')} + ${t('sidebar.search')} + ${t('sidebar.create_group')} ${t('sidebar.settings')} ${isStaff && html` @@ -333,10 +390,11 @@ function HomePage({ groups, notifications, onMarkRead }) { // ── Explore Page ───────────────────────────────────────────────────────────── -function ExplorePage({ token }) { +function ExplorePage({ token, myGroupIds }) { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); + const [joining, setJoining] = useState(null); const doSearch = useCallback((q) => { setLoading(true); @@ -355,9 +413,26 @@ function ExplorePage({ token }) { doSearch(q); }, [doSearch]); + const joinGroup = useCallback(async (gid) => { + setJoining(gid); + try { + await hubFetch(`/v1/groups/${gid}/join`, { method: 'POST', token }); + window.location.reload(); + } catch (err) { + alert(err.message); + } finally { + setJoining(null); + } + }, [token]); + + const isMember = (gid) => myGroupIds && myGroupIds.includes(gid); + return html`
-

${t('explore.title')}

+
+

${t('explore.title')}

+ ${t('explore.create_group')} +
@@ -369,13 +444,26 @@ function ExplorePage({ token }) { : html`
${groups.map(g => html` - -

${g.name}

+
+ +

${g.name}

+
${g.join_policy} ${g.source && g.source !== 'local' && html` ${' '}${g.source} `} - + ${' '} + ${isMember(g.id) + ? html`${t('explore.member')}` + : g.join_policy === 'open' && html` + + ` + } +
`)}
` @@ -384,6 +472,82 @@ function ExplorePage({ token }) { `; } +// ── Create Group Page ──────────────────────────────────────────────────────── + +function CreateGroupPage({ token, onCreated }) { + const [name, setName] = useState(''); + const [visibility, setVisibility] = useState('private'); + 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 data = await hubFetch('/v1/groups', { + method: 'POST', token, + body: { name: name.trim(), visibility, join_policy: joinPolicy }, + }); + + if (window.MeshBayCrypto && window.MeshBayKeys && _sessionKeys) { + const gek = window.MeshBayCrypto.generateGEK(); + const skXB64 = _sessionKeys.skXB64; + const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0)); + const skX = await crypto.subtle.importKey( + 'pkcs8', skXRaw, { name: 'X25519' }, true, ['deriveBits']); + const pkXRaw = new Uint8Array( + await crypto.subtle.exportKey('raw', + (await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits'])).publicKey)); + + const meResp = await hubFetch('/v1/users/me', { token }); + const pubkeys = await hubFetch(`/v1/users/${meResp.username}/pubkeys`); + const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0)); + + const bundle = await window.MeshBayCrypto.wrapGEK(gek, pkXBytes); + await hubFetch(`/v1/groups/${data.group_id}/members/${meResp.username}/gek`, { + method: 'POST', token, body: bundle, + }); + } + + if (onCreated) onCreated(); + navigate('/'); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return html` +
+

${t('create_group.title')}

+ ${error && html`

${error}

`} + +
+ `; +} + // ── Helpers ────────────────────────────────────────────────────────────────── const FILE_ICONS = { @@ -449,6 +613,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk function GroupPage({ groupId, group, token, username }) { const [status, setStatus] = useState('idle'); const [entries, setEntries] = useState([]); + const [cached, setCached] = useState(false); const [error, setError] = useState(''); const [sortKey, setSortKey] = useState('name'); const [sortAsc, setSortAsc] = useState(true); @@ -457,15 +622,23 @@ function GroupPage({ groupId, group, token, username }) { const [dlState, setDlState] = useState(null); const [videoEntry, setVideoEntry] = useState(null); const [tab, setTab] = useState('files'); + const [uploading, setUploading] = useState(false); const transportRef = useRef(null); const gekRef = useRef(null); useEffect(() => { let cancelled = false; + + getCachedGroupIndex(groupId).then(hit => { + if (hit && !cancelled) { + setEntries(hit.entries || []); + setCached(true); + } + }); + const connect = async () => { setStatus('discovering'); setError(''); - setEntries([]); gekRef.current = null; try { const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); @@ -486,8 +659,12 @@ function GroupPage({ groupId, group, token, username }) { const indexMsg = await transport.fetchIndex(); if (cancelled) return; - setEntries(indexMsg.entries || []); + const freshEntries = indexMsg.entries || []; + setEntries(freshEntries); + setCached(false); setStatus('connected'); + + cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries); } catch (err) { if (!cancelled) { setError(err.message); @@ -563,6 +740,30 @@ function GroupPage({ groupId, group, token, username }) { } }, []); + const uploadFile = useCallback(async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ''; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setUploading(true); + setError(''); + try { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + for (let i = 0; i < totalChunks; i++) { + const slice = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE); + const buf = new Uint8Array(await slice.arrayBuffer()); + await transport.uploadChunk(file.name, i, totalChunks, buf); + } + const indexMsg = await transport.fetchIndex(); + setEntries(indexMsg.entries || []); + } catch (err) { + setError(err.message); + } finally { + setUploading(false); + } + }, []); + const toggleSort = useCallback((key) => { setSortAsc(prev => sortKey === key ? !prev : true); setSortKey(key); @@ -594,7 +795,7 @@ function GroupPage({ groupId, group, token, username }) { const subdirs = [...dirs].sort(); - const statusLabel = { + const baseLabel = { idle: t('status.idle'), discovering: t('status.discovering'), connecting: t('status.connecting'), @@ -603,6 +804,9 @@ function GroupPage({ groupId, group, token, username }) { offline: t('status.offline'), error: t('status.error'), }[status] || status; + const statusLabel = (cached && status !== 'connected') + ? `${baseLabel} (${t('status.cached', { n: entries.length })})` + : baseLabel; const statusClass = status === 'connected' ? 'status-ok' : status === 'error' || status === 'offline' ? 'status-err' : 'status-busy'; @@ -631,10 +835,17 @@ function GroupPage({ groupId, group, token, username }) { onClick=${() => setTab('files')}>${t('group.tab_files')} +
${tab === 'files' && html`
+