diff --git a/server/spaces.ts b/server/spaces.ts index e4f5ff2..809db99 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -22,6 +22,7 @@ import { capPermissions, findNestedIn, setEncryption, + setMember, DEFAULT_COMMUNITY_NEST_POLICY, } from "./community-store"; import type { @@ -43,6 +44,21 @@ import { getAllModules } from "../shared/module"; const nestRequests = new Map(); let nestRequestCounter = 0; +// ── In-memory access requests (move to DB later) ── +interface AccessRequest { + id: string; + spaceSlug: string; + requesterDID: string; + requesterUsername: string; + message?: string; + status: "pending" | "approved" | "denied"; + createdAt: number; + resolvedAt?: number; + resolvedBy?: string; +} +const accessRequests = new Map(); +let accessRequestCounter = 0; + const spaces = new Hono(); // ── List spaces (public + user's own/member spaces) ── @@ -71,16 +87,40 @@ spaces.get("/", async (c) => { const memberEntry = claims ? data.members?.[claims.sub] : undefined; const isMember = !!memberEntry; - // Include if: public/public_read OR user is owner OR user is member - if (vis === "public" || vis === "public_read" || isOwner || isMember) { - spacesList.push({ - slug: data.meta.slug, - name: data.meta.name, - visibility: vis, - createdAt: data.meta.createdAt, - role: isOwner ? "owner" : memberEntry?.role || undefined, - }); - } + // Determine accessibility + const isPublic = vis === "public" || vis === "public_read"; + const isAuthenticated = vis === "authenticated"; + const accessible = isPublic || isOwner || isMember || (isAuthenticated && !!claims); + + // For unauthenticated: only show public spaces + if (!claims && !isPublic) continue; + + // Determine relationship + const relationship = isOwner + ? "owner" as const + : isMember + ? "member" as const + : slug === "demo" + ? "demo" as const + : "other" as const; + + // Check for pending access request + const pendingRequest = claims + ? Array.from(accessRequests.values()).some( + (r) => r.spaceSlug === slug && r.requesterDID === claims.sub && r.status === "pending" + ) + : false; + + spacesList.push({ + slug: data.meta.slug, + name: data.meta.name, + visibility: vis, + createdAt: data.meta.createdAt, + role: isOwner ? "owner" : memberEntry?.role || undefined, + accessible, + relationship, + pendingRequest: pendingRequest || undefined, + }); } } @@ -683,4 +723,138 @@ spaces.patch("/:slug/encryption", async (c) => { }); }); +// ══════════════════════════════════════════════════════════════════════════════ +// ACCESS REQUEST API (space membership request flow) +// ══════════════════════════════════════════════════════════════════════════════ + +// ── Create an access request for a space ── + +spaces.post("/:slug/access-requests", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + // Check user is not already owner or member + if (data.meta.ownerDID === claims.sub) { + return c.json({ error: "You are the owner of this space" }, 400); + } + const memberEntry = data.members?.[claims.sub]; + if (memberEntry) { + return c.json({ error: "You are already a member of this space" }, 400); + } + + // Check for duplicate pending request + const existing = Array.from(accessRequests.values()).find( + (r) => r.spaceSlug === slug && r.requesterDID === claims.sub && r.status === "pending" + ); + if (existing) { + return c.json({ error: "You already have a pending access request", requestId: existing.id }, 409); + } + + const body = await c.req.json<{ message?: string }>().catch(() => ({} as { message?: string })); + const reqId = `access-req-${++accessRequestCounter}`; + const request: AccessRequest = { + id: reqId, + spaceSlug: slug, + requesterDID: claims.sub, + requesterUsername: claims.username || claims.sub.slice(0, 16), + message: body.message, + status: "pending", + createdAt: Date.now(), + }; + accessRequests.set(reqId, request); + + return c.json({ id: reqId, status: "pending" }, 201); +}); + +// ── Get pending access requests for spaces the user owns ── + +spaces.get("/notifications", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const slugs = await listCommunities(); + const ownedSlugs = new Set(); + + for (const slug of slugs) { + await loadCommunity(slug); + const data = getDocumentData(slug); + if (data?.meta?.ownerDID === claims.sub) { + ownedSlugs.add(slug); + } + } + + const requests = Array.from(accessRequests.values()).filter( + (r) => ownedSlugs.has(r.spaceSlug) && r.status === "pending" + ); + + return c.json({ requests }); +}); + +// ── Approve or deny an access request ── + +spaces.patch("/:slug/access-requests/:reqId", async (c) => { + const slug = c.req.param("slug"); + const reqId = c.req.param("reqId"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + // Must be owner or admin + const member = data.members?.[claims.sub]; + const isOwner = data.meta.ownerDID === claims.sub; + if (!isOwner && member?.role !== "admin") { + return c.json({ error: "Admin access required" }, 403); + } + + const request = accessRequests.get(reqId); + if (!request || request.spaceSlug !== slug) { + return c.json({ error: "Access request not found" }, 404); + } + if (request.status !== "pending") { + return c.json({ error: `Request already ${request.status}` }, 400); + } + + const body = await c.req.json<{ + action: "approve" | "deny"; + role?: "viewer" | "participant"; + }>(); + + if (body.action === "deny") { + request.status = "denied"; + request.resolvedAt = Date.now(); + request.resolvedBy = claims.sub; + return c.json({ ok: true, status: "denied" }); + } + + if (body.action === "approve") { + request.status = "approved"; + request.resolvedAt = Date.now(); + request.resolvedBy = claims.sub; + + // Add requester as member + setMember(slug, request.requesterDID, body.role || "viewer", request.requesterUsername); + + return c.json({ ok: true, status: "approved" }); + } + + return c.json({ error: "action must be 'approve' or 'deny'" }, 400); +}); + export { spaces }; diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 9d66671..6fe0658 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -163,8 +163,20 @@ function _navUrl(space: string, moduleId: string): string { // ── The custom element ── +interface AccessNotification { + id: string; + spaceSlug: string; + requesterDID: string; + requesterUsername: string; + message?: string; + status: string; + createdAt: number; +} + export class RStackIdentity extends HTMLElement { #shadow: ShadowRoot; + #notifications: AccessNotification[] = []; + #notifTimer: ReturnType | null = null; constructor() { super(); @@ -173,6 +185,47 @@ export class RStackIdentity extends HTMLElement { connectedCallback() { this.#render(); + this.#startNotifPolling(); + } + + disconnectedCallback() { + this.#stopNotifPolling(); + } + + #startNotifPolling() { + this.#stopNotifPolling(); + if (!getSession()) return; + this.#fetchNotifications(); + this.#notifTimer = setInterval(() => this.#fetchNotifications(), 30_000); + } + + #stopNotifPolling() { + if (this.#notifTimer) { clearInterval(this.#notifTimer); this.#notifTimer = null; } + } + + async #fetchNotifications() { + const token = getAccessToken(); + if (!token) { this.#notifications = []; return; } + try { + const res = await fetch("/api/spaces/notifications", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + const prev = this.#notifications.length; + this.#notifications = data.requests || []; + // Update badge without full re-render + if (prev !== this.#notifications.length) this.#updateBadge(); + } + } catch { /* offline */ } + } + + #updateBadge() { + const badge = this.#shadow.querySelector(".notif-badge") as HTMLElement; + if (badge) { + badge.textContent = this.#notifications.length > 0 ? String(this.#notifications.length) : ""; + badge.style.display = this.#notifications.length > 0 ? "flex" : "none"; + } } #render() { @@ -184,20 +237,44 @@ export class RStackIdentity extends HTMLElement { const did = session.claims.did || session.claims.sub; const displayName = username || (did.length > 24 ? did.slice(0, 16) + "..." + did.slice(-6) : did); const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase(); + const notifCount = this.#notifications.length; + + // Build notifications HTML + let notifsHTML = ""; + if (notifCount > 0) { + notifsHTML = ` + + + ${this.#notifications.map((n) => ` +
+
${(n.requesterUsername || "Someone").replace(/ wants to join ${n.spaceSlug.replace(/
+ ${n.message ? `
"${n.message.replace(/` : ""} +
+ + +
+
+ `).join("")} + `; + } this.#shadow.innerHTML = `
-
${initial}
+
+
${initial}
+ ${notifCount > 0 ? notifCount : ""} +
${displayName}
`; @@ -212,6 +289,37 @@ export class RStackIdentity extends HTMLElement { document.addEventListener("click", () => dropdown.classList.remove("open")); + // Notification approve/deny handlers + this.#shadow.querySelectorAll("[data-notif-action]").forEach((el) => { + el.addEventListener("click", async (e) => { + e.stopPropagation(); + const btn = el as HTMLButtonElement; + const action = btn.dataset.notifAction as "approve" | "deny"; + const slug = btn.dataset.slug!; + const reqId = btn.dataset.reqId!; + btn.disabled = true; + btn.textContent = action === "approve" ? "Approving..." : "Denying..."; + try { + const token = getAccessToken(); + const res = await fetch(`/api/spaces/${slug}/access-requests/${reqId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ action }), + }); + if (!res.ok) throw new Error("Failed"); + // Refresh notifications and re-render + await this.#fetchNotifications(); + this.#render(); + } catch { + btn.disabled = false; + btn.textContent = action === "approve" ? "Approve" : "Deny"; + } + }); + }); + this.#shadow.querySelectorAll("[data-action]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); @@ -219,6 +327,8 @@ export class RStackIdentity extends HTMLElement { dropdown.classList.remove("open"); if (action === "signout") { clearSession(); + this.#stopNotifPolling(); + this.#notifications = []; this.#render(); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); } else if (action === "add-email") { @@ -330,13 +440,14 @@ export class RStackIdentity extends HTMLElement { storeSession(data.token, data.username || "", data.did || ""); close(); this.#render(); + this.#startNotifPolling(); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); callbacks?.onSuccess?.(); // Auto-redirect to personal space autoResolveSpace(data.token, data.username || ""); } catch (err: any) { btn.disabled = false; - btn.innerHTML = "🔑 Sign In with Passkey"; + btn.innerHTML = "\uD83D\uDD11 Sign In with Passkey"; errEl.textContent = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed."; } }; @@ -405,6 +516,7 @@ export class RStackIdentity extends HTMLElement { storeSession(data.token, username, data.did || ""); close(); this.#render(); + this.#startNotifPolling(); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); callbacks?.onSuccess?.(); // Auto-redirect to personal space @@ -847,6 +959,44 @@ const STYLES = ` .dropdown-divider { height: 1px; margin: 4px 0; } .user.light .dropdown-divider { background: rgba(0,0,0,0.08); } .user.dark .dropdown-divider { background: rgba(255,255,255,0.08); } + +/* Avatar wrapper + notification badge */ +.avatar-wrap { position: relative; } +.notif-badge { + position: absolute; top: -4px; right: -4px; + min-width: 16px; height: 16px; border-radius: 8px; + background: #ef4444; color: white; font-size: 0.6rem; font-weight: 700; + display: flex; align-items: center; justify-content: center; + padding: 0 4px; border: 2px solid #1e293b; line-height: 1; +} +.user.light .notif-badge { border-color: white; } + +/* Notification items in dropdown */ +.dropdown-section-label { + padding: 8px 16px 4px; font-size: 0.65rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; +} +.notif-item { + padding: 10px 16px; border-left: 3px solid #fbbf24; +} +.notif-text { font-size: 0.8rem; line-height: 1.4; } +.user.light .notif-text { color: #374151; } +.user.dark .notif-text { color: #e2e8f0; } +.notif-msg { + font-size: 0.75rem; color: #94a3b8; font-style: italic; + margin-top: 4px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; max-width: 240px; +} +.notif-actions { display: flex; gap: 8px; margin-top: 8px; } +.notif-btn { + padding: 4px 12px; border-radius: 6px; border: none; + font-size: 0.75rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; +} +.notif-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.notif-btn--approve { background: #059669; color: white; } +.notif-btn--approve:hover:not(:disabled) { opacity: 0.85; } +.notif-btn--deny { background: rgba(239,68,68,0.15); color: #ef4444; } +.notif-btn--deny:hover:not(:disabled) { background: rgba(239,68,68,0.25); } `; const MODAL_STYLES = ` diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index 4001753..9994952 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -18,6 +18,9 @@ interface SpaceInfo { icon?: string; role?: string; visibility?: string; + accessible?: boolean; + relationship?: "owner" | "member" | "demo" | "other"; + pendingRequest?: boolean; } export class RStackSpaceSwitcher extends HTMLElement { @@ -125,13 +128,16 @@ export class RStackSpaceSwitcher extends HTMLElement { } const moduleId = this.#getCurrentModule(); + const auth = isAuthenticated(); - // Separate user's own spaces (has a role) from public-only + // 3-section split const mySpaces = this.#spaces.filter((s) => s.role); - const publicSpaces = this.#spaces.filter((s) => !s.role); + const publicSpaces = this.#spaces.filter((s) => s.accessible !== false && !s.role); + const discoverSpaces = this.#spaces.filter((s) => s.accessible === false); let html = ""; + // ── Your spaces ── if (mySpaces.length > 0) { html += ``; html += mySpaces @@ -140,15 +146,15 @@ export class RStackSpaceSwitcher extends HTMLElement { return ` - ${s.icon || "🌐"} + ${s.icon || "\uD83C\uDF10"} ${s.name} ${vis.label} - - `; + `; }) .join(""); } + // ── Public spaces ── if (publicSpaces.length > 0) { if (mySpaces.length > 0) html += `
`; html += ``; @@ -158,11 +164,32 @@ export class RStackSpaceSwitcher extends HTMLElement { return ` - ${s.icon || "🌐"} + ${s.icon || "\uD83C\uDF10"} ${s.name} ${vis.label} - - `; + `; + }) + .join(""); + } + + // ── Discover (inaccessible spaces) ── + if (auth && discoverSpaces.length > 0) { + html += `
`; + html += ``; + html += discoverSpaces + .map((s) => { + const vis = this.#visibilityInfo(s); + const pending = s.pendingRequest; + return ` +
+ ${s.icon || "\uD83C\uDF10"} + ${s.name} + ${vis.label} + ${pending + ? `Requested` + : `` + } +
`; }) .join(""); } @@ -171,6 +198,83 @@ export class RStackSpaceSwitcher extends HTMLElement { html += `+ Create new space`; menu.innerHTML = html; + + // Attach Request Access button listeners + menu.querySelectorAll(".item-request-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const el = btn as HTMLElement; + this.#showRequestAccessModal(el.dataset.slug!, el.dataset.name!); + }); + }); + } + + #showRequestAccessModal(slug: string, spaceName: string) { + if (document.querySelector(".rstack-auth-overlay")) return; + + const overlay = document.createElement("div"); + overlay.className = "rstack-auth-overlay"; + overlay.innerHTML = ` + +
+ +

Request Access

+

Request access to ${spaceName.replace(/

+ +
+ + +
+
+
+ `; + + const close = () => overlay.remove(); + overlay.querySelectorAll('[data-action="close"]').forEach((el) => + el.addEventListener("click", close) + ); + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + + overlay.querySelector('[data-action="submit"]')?.addEventListener("click", async () => { + const btn = overlay.querySelector('[data-action="submit"]') as HTMLButtonElement; + const errEl = overlay.querySelector("#ra-error") as HTMLElement; + const msg = (overlay.querySelector("#ra-message") as HTMLTextAreaElement).value.trim(); + errEl.textContent = ""; + btn.disabled = true; + btn.innerHTML = ' Sending...'; + + try { + const token = getAccessToken(); + const res = await fetch(`/api/spaces/${slug}/access-requests`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ message: msg || undefined }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to send request"); + + // Mark as pending in local state + const space = this.#spaces.find((s) => s.slug === slug); + if (space) space.pendingRequest = true; + + close(); + + // Re-render menu if open + const menu = this.#shadow.getElementById("menu"); + if (menu?.classList.contains("open")) { + this.#renderMenu(menu, this.current); + } + } catch (err: any) { + btn.disabled = false; + btn.innerHTML = "Send Request"; + errEl.textContent = err.message || "Failed to send request"; + } + }); + + document.body.appendChild(overlay); } #getCurrentModule(): string { @@ -260,4 +364,78 @@ const STYLES = ` .menu-loading, .menu-empty { padding: 16px; text-align: center; font-size: 0.85rem; color: #94a3b8; } + +/* Discover section — non-navigable items */ +.item--discover { cursor: default; flex-wrap: wrap; } +.item--discover:hover { background: transparent !important; } +:host-context([data-theme="light"]) .item--discover:hover { background: transparent !important; } +:host-context([data-theme="dark"]) .item--discover:hover { background: transparent !important; } + +.item-request-btn { + margin-left: auto; padding: 4px 10px; border-radius: 6px; border: none; + font-size: 0.75rem; font-weight: 600; cursor: pointer; + background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; + transition: opacity 0.15s; white-space: nowrap; +} +.item-request-btn:hover { opacity: 0.85; } + +.item-badge { font-size: 0.7rem; padding: 3px 8px; border-radius: 6px; margin-left: auto; white-space: nowrap; } +.item-badge--pending { + background: rgba(251,191,36,0.15); color: #fbbf24; font-weight: 600; +} +:host-context([data-theme="light"]) .item-badge--pending { background: #fef3c7; color: #d97706; } +`; + +const REQUEST_MODAL_STYLES = ` +.rstack-auth-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); display: flex; align-items: center; + justify-content: center; z-index: 10000; animation: fadeIn 0.2s; +} +.auth-modal { + background: #1e293b; border: 1px solid rgba(255,255,255,0.1); + border-radius: 16px; padding: 2rem; max-width: 420px; width: 90%; + text-align: center; color: white; box-shadow: 0 20px 60px rgba(0,0,0,0.4); + animation: slideUp 0.3s; position: relative; +} +.auth-modal h2 { + font-size: 1.5rem; margin-bottom: 0.5rem; + background: linear-gradient(135deg, #06b6d4, #7c3aed); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.auth-modal p { color: #94a3b8; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; } +.input { + width: 100%; padding: 12px 16px; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); + color: white; font-size: 0.9rem; margin-bottom: 1rem; outline: none; + transition: border-color 0.2s; box-sizing: border-box; font-family: inherit; +} +.input:focus { border-color: #06b6d4; } +.input::placeholder { color: #64748b; } +.actions { display: flex; gap: 12px; margin-top: 0.5rem; } +.btn { + flex: 1; padding: 12px 20px; border-radius: 8px; border: none; + font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s; +} +.btn--primary { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } +.btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); } +.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } +.btn--secondary { background: rgba(255,255,255,0.08); color: #94a3b8; border: 1px solid rgba(255,255,255,0.1); } +.btn--secondary:hover { background: rgba(255,255,255,0.12); color: white; } +.error { color: #ef4444; font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; } +.close-btn { + position: absolute; top: 12px; right: 16px; + background: none; border: none; color: #64748b; font-size: 1.5rem; + cursor: pointer; line-height: 1; padding: 4px 8px; border-radius: 6px; +} +.close-btn:hover { color: white; background: rgba(255,255,255,0.1); } +.spinner { + display: inline-block; width: 18px; height: 18px; + border: 2px solid transparent; border-top-color: currentColor; + border-radius: 50%; animation: spin 0.7s linear infinite; + vertical-align: middle; margin-right: 6px; +} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } +@keyframes spin { to { transform: rotate(360deg); } } `;