feat: space access request flow with notifications
Add "Request Access" flow for inaccessible spaces: authenticated users see all spaces in the dropdown (categorized as Your/Public/Discover), can request access to restricted spaces, and space owners get in-app notification badges with inline approve/deny actions. - API: GET /api/spaces returns accessible/relationship/pendingRequest fields - API: POST/PATCH /api/spaces/:slug/access-requests + GET /notifications - Space switcher: 3-section layout with Discover section + Request Access modal - Identity: notification polling (30s), red badge on avatar, approve/deny in dropdown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9e4648be62
commit
286f08fadb
194
server/spaces.ts
194
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<string, PendingNestRequest>();
|
||||
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<string, AccessRequest>();
|
||||
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<string>();
|
||||
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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<typeof setInterval> | 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 = `
|
||||
<div class="dropdown-divider"></div>
|
||||
<div class="dropdown-section-label">Access Requests</div>
|
||||
${this.#notifications.map((n) => `
|
||||
<div class="notif-item">
|
||||
<div class="notif-text"><strong>${(n.requesterUsername || "Someone").replace(/</g, "<")}</strong> wants to join <strong>${n.spaceSlug.replace(/</g, "<")}</strong></div>
|
||||
${n.message ? `<div class="notif-msg">"${n.message.replace(/</g, "<")}"</div>` : ""}
|
||||
<div class="notif-actions">
|
||||
<button class="notif-btn notif-btn--approve" data-notif-action="approve" data-slug="${n.spaceSlug}" data-req-id="${n.id}">Approve</button>
|
||||
<button class="notif-btn notif-btn--deny" data-notif-action="deny" data-slug="${n.spaceSlug}" data-req-id="${n.id}">Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
`;
|
||||
}
|
||||
|
||||
this.#shadow.innerHTML = `
|
||||
<style>${STYLES}</style>
|
||||
<div class="user ${theme}" id="user-toggle">
|
||||
<div class="avatar">${initial}</div>
|
||||
<div class="avatar-wrap">
|
||||
<div class="avatar">${initial}</div>
|
||||
<span class="notif-badge" style="display:${notifCount > 0 ? "flex" : "none"}">${notifCount > 0 ? notifCount : ""}</span>
|
||||
</div>
|
||||
<span class="name">${displayName}</span>
|
||||
<div class="dropdown" id="dropdown">
|
||||
<div class="dropdown-header">${displayName}</div>
|
||||
${notifsHTML}
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" data-action="add-email">✉️ Add Email</button>
|
||||
<button class="dropdown-item" data-action="add-device">📱 Add Second Device</button>
|
||||
<button class="dropdown-item" data-action="add-recovery">🛡️ Add Social Recovery</button>
|
||||
<button class="dropdown-item" data-action="add-email">\u2709\uFE0F Add Email</button>
|
||||
<button class="dropdown-item" data-action="add-device">\uD83D\uDCF1 Add Second Device</button>
|
||||
<button class="dropdown-item" data-action="add-recovery">\uD83D\uDEE1\uFE0F Add Social Recovery</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item dropdown-item--danger" data-action="signout">🚪 Sign Out</button>
|
||||
<button class="dropdown-item dropdown-item--danger" data-action="signout">\uD83D\uDEAA Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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 += `<div class="section-label">Your spaces</div>`;
|
||||
html += mySpaces
|
||||
|
|
@ -140,15 +146,15 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
return `
|
||||
<a class="item ${vis.cls} ${s.slug === current ? "active" : ""}"
|
||||
href="${rspaceNavUrl(s.slug, moduleId)}">
|
||||
<span class="item-icon">${s.icon || "🌐"}</span>
|
||||
<span class="item-icon">${s.icon || "\uD83C\uDF10"}</span>
|
||||
<span class="item-name">${s.name}</span>
|
||||
<span class="item-vis ${vis.cls}">${vis.label}</span>
|
||||
</a>
|
||||
`;
|
||||
</a>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// ── Public spaces ──
|
||||
if (publicSpaces.length > 0) {
|
||||
if (mySpaces.length > 0) html += `<div class="divider"></div>`;
|
||||
html += `<div class="section-label">Public spaces</div>`;
|
||||
|
|
@ -158,11 +164,32 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
return `
|
||||
<a class="item ${vis.cls} ${s.slug === current ? "active" : ""}"
|
||||
href="${rspaceNavUrl(s.slug, moduleId)}">
|
||||
<span class="item-icon">${s.icon || "🌐"}</span>
|
||||
<span class="item-icon">${s.icon || "\uD83C\uDF10"}</span>
|
||||
<span class="item-name">${s.name}</span>
|
||||
<span class="item-vis ${vis.cls}">${vis.label}</span>
|
||||
</a>
|
||||
`;
|
||||
</a>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// ── Discover (inaccessible spaces) ──
|
||||
if (auth && discoverSpaces.length > 0) {
|
||||
html += `<div class="divider"></div>`;
|
||||
html += `<div class="section-label">Discover</div>`;
|
||||
html += discoverSpaces
|
||||
.map((s) => {
|
||||
const vis = this.#visibilityInfo(s);
|
||||
const pending = s.pendingRequest;
|
||||
return `
|
||||
<div class="item item--discover ${vis.cls}">
|
||||
<span class="item-icon">${s.icon || "\uD83C\uDF10"}</span>
|
||||
<span class="item-name">${s.name}</span>
|
||||
<span class="item-vis ${vis.cls}">${vis.label}</span>
|
||||
${pending
|
||||
? `<span class="item-badge item-badge--pending">Requested</span>`
|
||||
: `<button class="item-request-btn" data-slug="${s.slug}" data-name="${s.name.replace(/"/g, """)}">Request Access</button>`
|
||||
}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
|
@ -171,6 +198,83 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
html += `<a class="item item--create" href="/new">+ Create new space</a>`;
|
||||
|
||||
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 = `
|
||||
<style>${REQUEST_MODAL_STYLES}</style>
|
||||
<div class="auth-modal">
|
||||
<button class="close-btn" data-action="close">×</button>
|
||||
<h2>Request Access</h2>
|
||||
<p>Request access to <strong>${spaceName.replace(/</g, "<")}</strong></p>
|
||||
<textarea class="input" id="ra-message" rows="3" placeholder="Optional message to the space owner..." style="resize:vertical"></textarea>
|
||||
<div class="actions">
|
||||
<button class="btn btn--secondary" data-action="close">Cancel</button>
|
||||
<button class="btn btn--primary" data-action="submit">Send Request</button>
|
||||
</div>
|
||||
<div class="error" id="ra-error"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<span class="spinner"></span> 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); } }
|
||||
`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue