rspace-online/shared/components/rstack-identity.ts

1307 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <rstack-identity> — Custom element for EncryptID sign-in/sign-out.
*
* Renders either a "Sign In" button or the user avatar + dropdown.
* Contains the full WebAuthn auth modal (sign-in + register).
* Refactored from lib/rspace-header.ts into a standalone web component.
*/
import { rspaceNavUrl, getCurrentModule } from "../url-helpers";
const SESSION_KEY = "encryptid_session";
const ENCRYPTID_URL = "https://auth.rspace.online";
interface SessionState {
accessToken: string;
claims: {
sub: string;
exp: number;
username?: string;
did?: string;
eid: {
authLevel: number;
capabilities: { encrypt: boolean; sign: boolean; wallet: boolean };
};
};
}
// ── Session helpers (exported for use by other code) ──
export function getSession(): SessionState | null {
try {
const stored = localStorage.getItem(SESSION_KEY);
if (!stored) return null;
const session = JSON.parse(stored) as SessionState;
if (Math.floor(Date.now() / 1000) >= session.claims.exp) {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
return null;
}
return session;
} catch {
return null;
}
}
export function clearSession(): void {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
}
export function isAuthenticated(): boolean {
return getSession() !== null;
}
export function getAccessToken(): string | null {
return getSession()?.accessToken ?? null;
}
export function getUserDID(): string | null {
return getSession()?.claims.did ?? getSession()?.claims.sub ?? null;
}
export function getUsername(): string | null {
return getSession()?.claims.username ?? null;
}
// ── Helpers ──
function base64urlToBuffer(b64url: string): ArrayBuffer {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const bin = atob(b64 + pad);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
function bufferToBase64url(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let bin = "";
for (let i = 0; i < bytes.byteLength; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
function parseJWT(token: string): Record<string, unknown> {
const parts = token.split(".");
if (parts.length < 2) return {};
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
return JSON.parse(atob(b64 + pad));
} catch {
return {};
}
}
function storeSession(token: string, username: string, did: string): void {
const payload = parseJWT(token) as Record<string, any>;
const session: SessionState = {
accessToken: token,
claims: {
sub: payload.sub || "",
exp: payload.exp || 0,
username,
did,
eid: payload.eid || { authLevel: 3, capabilities: { encrypt: true, sign: true, wallet: false } },
},
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
if (username) localStorage.setItem("rspace-username", username);
}
// ── Auto-space resolution after auth ──
function autoResolveSpace(token: string, username: string): void {
if (!username) return;
// Detect current space
const currentSpace = _getCurrentSpace();
if (currentSpace !== "demo") return; // Already on a non-demo space
// Provision personal space and redirect
fetch("/api/spaces/auto-provision", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((r) => r.json())
.then((data) => {
if (!data.slug) return;
const moduleId = _getCurrentModule();
window.location.replace(_navUrl(data.slug, moduleId));
})
.catch(() => {});
}
// ── Inline URL helpers (avoid import cycle with url-helpers) ──
const _RESERVED = ["www", "rspace", "create", "new", "start", "auth"];
function _isSubdomain(): boolean {
const p = window.location.host.split(":")[0].split(".");
return p.length >= 3 && p.slice(-2).join(".") === "rspace.online" && !_RESERVED.includes(p[0]);
}
function _getCurrentSpace(): string {
if (_isSubdomain()) return window.location.host.split(":")[0].split(".")[0];
return window.location.pathname.split("/").filter(Boolean)[0] || "demo";
}
function _getCurrentModule(): string {
const parts = window.location.pathname.split("/").filter(Boolean);
return _isSubdomain() ? (parts[0] || "rspace") : (parts[1] || "rspace");
}
function _navUrl(space: string, moduleId: string): string {
const h = window.location.host.split(":")[0].split(".");
const onSub = h.length >= 3 && h.slice(-2).join(".") === "rspace.online" && !_RESERVED.includes(h[0]);
if (onSub) {
if (h[0] === space) return "/" + moduleId;
return window.location.protocol + "//" + space + "." + h.slice(-2).join(".") + "/" + moduleId;
}
if (window.location.host.includes("rspace.online") && !window.location.host.startsWith("www")) {
return window.location.protocol + "//" + space + ".rspace.online/" + moduleId;
}
return "/" + space + "/" + moduleId;
}
// ── 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();
this.#shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.#refreshIfNeeded();
this.#render();
this.#startNotifPolling();
}
disconnectedCallback() {
this.#stopNotifPolling();
}
async #refreshIfNeeded() {
const session = getSession();
if (!session) return;
const now = Math.floor(Date.now() / 1000);
const remaining = session.claims.exp - now;
if (remaining >= 7 * 24 * 60 * 60) return; // more than 7 days left, skip
try {
const res = await fetch(`${ENCRYPTID_URL}/api/session/refresh`, {
method: "POST",
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (!res.ok) return;
const { token } = await res.json();
if (token) {
storeSession(token, session.claims.username || "", session.claims.did || "");
this.#render();
}
} catch { /* silently ignore user keeps current token */ }
}
#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() {
const session = getSession();
const theme = this.closest("[data-theme]")?.getAttribute("data-theme") || "light";
if (session) {
const username = session.claims.username || "";
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, "&lt;")}</strong> wants to join <strong>${n.spaceSlug.replace(/</g, "&lt;")}</strong></div>
${n.message ? `<div class="notif-msg">"${n.message.replace(/</g, "&lt;")}"</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-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="my-spaces">🌐 My Spaces</button>
<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>
<div class="dropdown-divider"></div>
<button class="dropdown-item dropdown-item--danger" data-action="signout">🚪 Sign Out</button>
</div>
</div>
`;
const toggle = this.#shadow.getElementById("user-toggle")!;
const dropdown = this.#shadow.getElementById("dropdown")!;
toggle.addEventListener("click", (e) => {
e.stopPropagation();
dropdown.classList.toggle("open");
});
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();
const action = (el as HTMLElement).dataset.action;
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") {
this.#showAddEmailModal();
} else if (action === "add-device") {
this.#showAddDeviceModal();
} else if (action === "add-recovery") {
this.#showAddRecoveryModal();
} else if (action === "my-spaces") {
this.#showSpacesModal();
}
});
});
} else {
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<button class="signin-btn ${theme}" id="signin-btn">🔑 Sign In</button>
`;
this.#shadow.getElementById("signin-btn")!.addEventListener("click", () => {
this.showAuthModal();
});
}
}
/** Public method: show the auth modal programmatically */
showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
let mode: "signin" | "register" = "signin";
const render = () => {
overlay.innerHTML = mode === "signin" ? signinHTML() : registerHTML();
attachListeners();
};
const signinHTML = () => `
<style>${MODAL_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="cancel">&times;</button>
<h2>Sign up / Sign in</h2>
<p>Secure, passwordless authentication powered by passkeys.</p>
<div class="actions actions--stack">
<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>
<button class="btn btn--outline" data-action="switch-register">🔐 Create New Account</button>
</div>
<div class="error" id="auth-error"></div>
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
</div>
`;
const registerHTML = () => `
<style>${MODAL_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="cancel">&times;</button>
<h2>Create your EncryptID</h2>
<p>Set up a secure, passwordless identity.</p>
<input class="input" id="auth-username" type="text" placeholder="Choose a username" autocomplete="username webauthn" maxlength="32" />
<div class="actions">
<button class="btn btn--secondary" data-action="switch-signin">Back</button>
<button class="btn btn--primary" data-action="register">🔐 Create Passkey</button>
</div>
<div class="error" id="auth-error"></div>
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
</div>
`;
const close = () => {
overlay.remove();
};
const handleSignIn = async () => {
const errEl = overlay.querySelector("#auth-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement;
errEl.textContent = "";
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Authenticating...';
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!startRes.ok) throw new Error("Failed to start authentication");
const { options: serverOptions } = await startRes.json();
const credential = (await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rpId: serverOptions.rpId || "rspace.online",
userVerification: "required",
timeout: 60000,
},
})) as PublicKeyCredential;
if (!credential) throw new Error("Authentication failed");
const completeRes = await fetch(`${ENCRYPTID_URL}/api/auth/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: { credentialId: bufferToBase64url(credential.rawId) },
}),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) throw new Error(data.error || "Authentication failed");
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";
errEl.textContent = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
}
};
const handleRegister = async () => {
const usernameInput = overlay.querySelector("#auth-username") as HTMLInputElement;
const errEl = overlay.querySelector("#auth-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement;
const username = usernameInput.value.trim();
if (!username) {
errEl.textContent = "Please enter a username.";
usernameInput.focus();
return;
}
errEl.textContent = "";
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Creating passkey...';
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/register/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, displayName: username }),
});
if (!startRes.ok) throw new Error("Failed to start registration");
const { options: serverOptions, userId } = await startRes.json();
const credential = (await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" },
user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: "public-key" as const },
{ alg: -257, type: "public-key" as const },
],
authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" },
attestation: "none",
timeout: 60000,
},
})) as PublicKeyCredential;
if (!credential) throw new Error("Failed to create credential");
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = response.getPublicKey?.();
const completeRes = await fetch(`${ENCRYPTID_URL}/api/register/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : "",
transports: response.getTransports?.() || [],
},
userId,
username,
}),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) throw new Error(data.error || "Registration failed");
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
autoResolveSpace(data.token, username);
} catch (err: any) {
btn.disabled = false;
btn.innerHTML = "🔐 Create Passkey";
errEl.textContent = err.name === "NotAllowedError" ? "Passkey creation was cancelled." : err.message || "Registration failed.";
}
};
const attachListeners = () => {
overlay.querySelector('[data-action="cancel"]')?.addEventListener("click", () => {
close();
callbacks?.onCancel?.();
});
overlay.querySelector('[data-action="signin"]')?.addEventListener("click", handleSignIn);
overlay.querySelector('[data-action="register"]')?.addEventListener("click", handleRegister);
overlay.querySelector('[data-action="switch-register"]')?.addEventListener("click", () => {
mode = "register";
render();
setTimeout(() => (overlay.querySelector("#auth-username") as HTMLInputElement)?.focus(), 50);
});
overlay.querySelector('[data-action="switch-signin"]')?.addEventListener("click", () => {
mode = "signin";
render();
});
overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") handleRegister();
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) {
close();
callbacks?.onCancel?.();
}
});
};
document.body.appendChild(overlay);
render();
}
// ── Settings modals ──
#showAddEmailModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
let step: "input" | "verify" = "input";
let emailAddr = "";
const render = () => {
overlay.innerHTML = step === "input" ? `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Add Email</h2>
<p>Link an email for notifications and account recovery.</p>
<input class="input" id="s-email" type="email" placeholder="you@example.com" />
<div class="actions">
<button class="btn btn--primary" data-action="send-code">Send Verification Code</button>
</div>
<div class="error" id="s-error"></div>
</div>
` : `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Verify Email</h2>
<p>Enter the 6-digit code sent to <strong>${emailAddr.replace(/</g, "&lt;")}</strong></p>
<input class="input" id="s-code" type="text" placeholder="000000" maxlength="6" inputmode="numeric" />
<div class="actions">
<button class="btn btn--secondary" data-action="back">Back</button>
<button class="btn btn--primary" data-action="verify">Verify</button>
</div>
<div class="error" id="s-error"></div>
</div>
`;
attach();
};
const close = () => overlay.remove();
const attach = () => {
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="send-code"]')?.addEventListener("click", async () => {
const input = overlay.querySelector("#s-email") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="send-code"]') as HTMLButtonElement;
emailAddr = input.value.trim();
if (!emailAddr || !emailAddr.includes("@")) { err.textContent = "Enter a valid email address."; input.focus(); return; }
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Sending...';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/email/start`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ email: emailAddr }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to send verification code");
step = "verify"; render();
setTimeout(() => (overlay.querySelector("#s-code") as HTMLInputElement)?.focus(), 50);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Send Verification Code";
err.textContent = e.message;
}
});
overlay.querySelector('[data-action="back"]')?.addEventListener("click", () => { step = "input"; render(); });
overlay.querySelector('[data-action="verify"]')?.addEventListener("click", async () => {
const input = overlay.querySelector("#s-code") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="verify"]') as HTMLButtonElement;
const code = input.value.trim();
if (!code) { err.textContent = "Enter the verification code."; input.focus(); return; }
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Verifying...';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/email/verify`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ email: emailAddr, code }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Verification failed");
close();
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "email-added", email: emailAddr } }));
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Verify";
err.textContent = e.message;
}
});
overlay.querySelector("#s-email")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="send-code"]') as HTMLElement)?.click();
});
overlay.querySelector("#s-code")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="verify"]') as HTMLElement)?.click();
});
};
document.body.appendChild(overlay);
render();
}
#showAddDeviceModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const session = getSession();
if (!session) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Add Second Device</h2>
<p>Register an additional passkey for backup access. Use this on a different device or browser.</p>
<div class="actions actions--stack">
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
</div>
<div class="error" id="s-error"></div>
<div class="info-text">Each device you register can independently sign in to your account.</div>
</div>
`;
const close = () => overlay.remove();
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="register-device"]')?.addEventListener("click", async () => {
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="register-device"]') as HTMLButtonElement;
err.textContent = "";
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Registering...';
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/account/device/start`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
});
if (!startRes.ok) throw new Error((await startRes.json().catch(() => ({}))).error || "Failed to start device registration");
const { options: serverOptions, userId } = await startRes.json();
const username = session.claims.username || "";
const credential = (await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" },
user: {
id: new Uint8Array(base64urlToBuffer(serverOptions.user?.id || userId)),
name: username || session.claims.sub,
displayName: username || session.claims.sub,
},
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: "public-key" as const },
{ alg: -257, type: "public-key" as const },
],
authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" },
attestation: "none",
timeout: 60000,
},
})) as PublicKeyCredential;
if (!credential) throw new Error("Passkey creation failed");
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = response.getPublicKey?.();
const completeRes = await fetch(`${ENCRYPTID_URL}/api/account/device/complete`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : "",
transports: response.getTransports?.() || [],
},
}),
});
if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed");
btn.innerHTML = "Device Registered";
btn.className = "btn btn--success";
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "device-added" } }));
setTimeout(close, 1500);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "🔑 Register Passkey on This Device";
err.textContent = e.name === "NotAllowedError" ? "Passkey creation was cancelled." : e.message;
}
});
document.body.appendChild(overlay);
}
#showAddRecoveryModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
let guardians: { id: string; name: string; email?: string; status: string }[] = [];
let threshold = 2;
let loading = true;
const render = () => {
const guardiansHTML = guardians.length > 0
? `<div class="contact-list">${guardians.map(g => `
<div class="contact-item">
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1">
<span>${g.name.replace(/</g, "&lt;")}${g.email ? ` <span style="opacity:0.5;font-size:0.8rem">${g.email.replace(/</g, "&lt;")}</span>` : ""}</span>
<span style="font-size:0.7rem;color:${g.status === "accepted" ? "#34d399" : "#fbbf24"}">${g.status === "accepted" ? "Accepted" : "Pending invite"}</span>
</div>
<button class="contact-remove" data-remove-id="${g.id}">&times;</button>
</div>
`).join("")}</div>`
: "";
const infoHTML = guardians.length < 2
? `<div class="info-text">Add at least 2 trusted guardians to enable social recovery. Threshold: ${threshold} of ${Math.max(guardians.length, 2)} needed to recover.</div>`
: `<div class="info-text" style="color:#34d399">Social recovery is active. ${threshold} of ${guardians.length} guardians needed to recover your account.</div>`;
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Social Recovery</h2>
<p>Choose trusted contacts who can help recover your account.</p>
${loading ? '<div style="text-align:center;padding:1rem;color:#94a3b8"><span class="spinner"></span> Loading guardians...</div>' : `
${guardians.length < 3 ? `<div class="input-row">
<input class="input input--inline" id="s-name" type="text" placeholder="Guardian name" />
<input class="input input--inline" id="s-email" type="email" placeholder="Email (optional)" />
<button class="btn btn--small btn--primary" data-action="add-guardian">Add</button>
</div>` : ""}
${guardiansHTML}
${infoHTML}
`}
<div class="error" id="s-error"></div>
</div>
`;
attach();
};
const close = () => overlay.remove();
const loadGuardians = async () => {
try {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (res.ok) {
const data = await res.json();
guardians = data.guardians || [];
threshold = data.threshold || 2;
}
} catch { /* offline */ }
loading = false;
render();
};
const attach = () => {
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="add-guardian"]')?.addEventListener("click", async () => {
const nameInput = overlay.querySelector("#s-name") as HTMLInputElement;
const emailInput = overlay.querySelector("#s-email") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="add-guardian"]') as HTMLButtonElement;
const name = nameInput.value.trim();
const email = emailInput.value.trim();
if (!name) { err.textContent = "Enter a guardian name."; nameInput.focus(); return; }
err.textContent = "";
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ name, email: email || undefined }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to add guardian");
guardians.push({ id: data.guardian.id, name: data.guardian.name, email: data.guardian.email, status: data.guardian.status });
render();
setTimeout(() => (overlay.querySelector("#s-name") as HTMLInputElement)?.focus(), 50);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Add";
err.textContent = e.message;
}
});
overlay.querySelector("#s-name")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
});
overlay.querySelector("#s-email")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
});
overlay.querySelectorAll("[data-remove-id]").forEach(el => {
el.addEventListener("click", async () => {
const id = (el as HTMLElement).dataset.removeId!;
const err = overlay.querySelector("#s-error") as HTMLElement;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) throw new Error("Failed to remove guardian");
guardians = guardians.filter(g => g.id !== id);
render();
} catch (e: any) {
err.textContent = e.message;
}
});
});
};
document.body.appendChild(overlay);
render();
loadGuardians();
}
// ── Spaces modal ──
#showSpacesModal(): void {
if (document.querySelector(".rstack-spaces-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-spaces-overlay";
const close = () => overlay.remove();
const renderLoading = () => {
overlay.innerHTML = `
<style>${MODAL_STYLES}${SPACES_STYLES}</style>
<div class="spaces-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>My Spaces</h2>
<p>Loading your spaces...</p>
<div style="text-align:center;padding:2rem 0"><span class="spinner"></span></div>
</div>
`;
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
};
const visIcon = (v: string) =>
v === "members_only" ? "🔒" : v === "authenticated" ? "🔑" : "🔓";
const renderSpaces = (spaces: any[]) => {
const yourSpaces = spaces.filter((s) => s.role);
const publicSpaces = spaces.filter((s) => !s.role && s.accessible);
const cardHTML = (s: any) => `
<button class="space-card" data-slug="${s.slug}">
<div class="space-card-initial">${(s.name || s.slug).charAt(0).toUpperCase()}</div>
<div class="space-card-name">${(s.name || s.slug).replace(/</g, "&lt;")}</div>
<div class="space-card-meta">
<span class="space-vis">${visIcon(s.visibility)} ${s.visibility.replace(/_/g, " ")}</span>
${s.role ? `<span class="space-role">${s.role}</span>` : ""}
</div>
</button>
`;
const yourSection = yourSpaces.length
? `<div class="spaces-section-label">Your Spaces</div>
<div class="spaces-grid">${yourSpaces.map(cardHTML).join("")}</div>`
: "";
const publicSection = publicSpaces.length
? `<div class="spaces-section-label">Public Spaces</div>
<div class="spaces-grid">${publicSpaces.map(cardHTML).join("")}</div>`
: "";
const emptyState = !yourSpaces.length && !publicSpaces.length
? `<p style="color:#94a3b8;text-align:center;padding:1rem 0">No spaces yet. Create one to get started!</p>`
: "";
overlay.innerHTML = `
<style>${MODAL_STYLES}${SPACES_STYLES}</style>
<div class="spaces-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>My Spaces</h2>
${emptyState}
${yourSection}
${publicSection}
<div class="spaces-grid" style="margin-top:12px">
<button class="space-card space-card--create" data-action="create-space">
<div class="space-card-initial">+</div>
<div class="space-card-name">Create New Space</div>
</button>
</div>
</div>
`;
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelectorAll("[data-slug]").forEach((el) => {
el.addEventListener("click", () => {
const slug = (el as HTMLElement).dataset.slug!;
close();
window.location.href = rspaceNavUrl(slug, getCurrentModule());
});
});
overlay.querySelector('[data-action="create-space"]')?.addEventListener("click", () => {
close();
window.location.href = "/create-space";
});
};
document.body.appendChild(overlay);
renderLoading();
const token = getAccessToken();
fetch("/api/spaces", {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((data) => renderSpaces(data.spaces || []))
.catch(() => {
overlay.innerHTML = `
<style>${MODAL_STYLES}${SPACES_STYLES}</style>
<div class="spaces-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>My Spaces</h2>
<p style="color:#ef4444">Failed to load spaces. Please try again.</p>
</div>
`;
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
});
}
static define(tag = "rstack-identity") {
if (!customElements.get(tag)) customElements.define(tag, RStackIdentity);
}
}
// ── Require auth helper (for use by module code) ──
export function requireAuth(onAuthenticated: () => void): boolean {
if (isAuthenticated()) return true;
const el = document.querySelector("rstack-identity") as RStackIdentity | null;
if (el) {
el.showAuthModal({ onSuccess: onAuthenticated });
}
return false;
}
// ── Styles ──
const STYLES = `
:host { display: contents; }
.signin-btn {
display: flex; align-items: center; gap: 8px;
padding: 8px 20px; border-radius: 8px; border: none;
font-size: 0.875rem; font-weight: 600; cursor: pointer;
transition: all 0.2s; text-decoration: none;
}
.signin-btn.light {
background: linear-gradient(135deg, #06b6d4, #0891b2); color: white;
}
.signin-btn.dark {
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
}
.signin-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
.user {
display: flex; align-items: center; gap: 10px;
position: relative; cursor: pointer;
}
.avatar {
width: 34px; height: 34px; border-radius: 50%;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.8rem; color: white;
}
.name {
font-size: 0.8rem; max-width: 140px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.user.light .name { color: #64748b; }
.user.dark .name { color: #94a3b8; }
.dropdown {
position: absolute; top: 100%; right: 0; margin-top: 8px;
min-width: 200px; border-radius: 10px; overflow: hidden;
box-shadow: 0 8px 30px rgba(0,0,0,0.2); display: none; z-index: 100;
}
.dropdown.open { display: block; }
.user.light .dropdown { background: white; border: 1px solid rgba(0,0,0,0.1); }
.user.dark .dropdown { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); }
.dropdown-item {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; font-size: 0.875rem; cursor: pointer;
transition: background 0.15s; border: none; background: none;
width: 100%; text-align: left;
}
.user.light .dropdown-item { color: #374151; }
.user.light .dropdown-item:hover { background: #f1f5f9; }
.user.dark .dropdown-item { color: #e2e8f0; }
.user.dark .dropdown-item:hover { background: rgba(255,255,255,0.05); }
.dropdown-item--danger { color: #ef4444 !important; }
.dropdown-header {
padding: 10px 16px 6px; font-size: 0.8rem; font-weight: 700;
letter-spacing: 0.02em; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; max-width: 200px;
}
.user.light .dropdown-header { color: #1e293b; }
.user.dark .dropdown-header { color: #e2e8f0; }
.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 = `
.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;
}
.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: 1.5rem; }
.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: 1rem; margin-bottom: 1rem; outline: none;
transition: border-color 0.2s; box-sizing: border-box;
}
.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; }
.toggle { margin-top: 1rem; font-size: 0.85rem; color: #64748b; }
.toggle a { color: #06b6d4; cursor: pointer; text-decoration: none; }
.toggle a:hover { text-decoration: underline; }
.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;
}
.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;
transition: all 0.15s;
}
.close-btn:hover { color: white; background: rgba(255,255,255,0.1); }
.auth-modal { position: relative; }
.actions--stack { flex-direction: column; }
.btn--outline {
background: transparent; color: #94a3b8;
border: 1px solid rgba(255,255,255,0.15);
padding: 12px 20px; border-radius: 8px; font-size: 0.95rem;
font-weight: 600; cursor: pointer; transition: all 0.2s;
}
.btn--outline:hover { border-color: #06b6d4; color: white; background: rgba(6,182,212,0.08); }
.learn-more { margin-top: 1.5rem; font-size: 0.8rem; color: #475569; }
.learn-more a { color: #06b6d4; text-decoration: none; }
.learn-more a:hover { text-decoration: underline; }
@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); } }
`;
const SETTINGS_STYLES = `
.info-text { margin-top: 1rem; font-size: 0.8rem; color: #475569; line-height: 1.5; }
.btn--success { background: #059669 !important; color: white; cursor: default; }
.btn--small { padding: 10px 16px; flex: none; }
.input-row { display: flex; gap: 8px; align-items: stretch; }
.input--inline { flex: 1; margin-bottom: 0; }
.contact-list { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; }
.contact-item {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-radius: 8px; background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1); font-size: 0.9rem; color: #e2e8f0;
}
.contact-remove {
background: none; border: none; color: #64748b; font-size: 1.2rem;
cursor: pointer; padding: 2px 6px; border-radius: 4px; line-height: 1;
}
.contact-remove:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
.threshold-row {
display: flex; align-items: center; gap: 8px; margin-top: 12px;
font-size: 0.85rem; color: #94a3b8;
}
.threshold-row label { white-space: nowrap; }
.threshold-row select {
padding: 6px 10px; border-radius: 6px; background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15); color: white; font-size: 0.85rem;
}
.threshold-hint { color: #64748b; font-size: 0.8rem; }
`;
const SPACES_STYLES = `
.rstack-spaces-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;
}
.spaces-modal {
background: #1e293b; border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px; padding: 2rem; max-width: 720px; width: 92%;
max-height: 85vh; overflow-y: auto; color: white; position: relative;
box-shadow: 0 20px 60px rgba(0,0,0,0.4); animation: slideUp 0.3s;
}
.spaces-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;
}
.spaces-section-label {
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: #64748b; margin: 1rem 0 0.5rem;
}
.spaces-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.space-card {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 20px 12px; border-radius: 12px; cursor: pointer;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
transition: all 0.2s; text-align: center; color: white; font-family: inherit;
font-size: inherit;
}
.space-card:hover {
background: rgba(255,255,255,0.08); border-color: rgba(6,182,212,0.4);
transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.2);
}
.space-card-initial {
width: 48px; height: 48px; border-radius: 50%;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; font-weight: 700; color: white;
}
.space-card-name {
font-size: 0.9rem; font-weight: 600; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.space-card-meta {
display: flex; gap: 6px; flex-wrap: wrap; justify-content: center;
}
.space-vis {
font-size: 0.7rem; color: #94a3b8; background: rgba(255,255,255,0.06);
padding: 2px 8px; border-radius: 10px;
}
.space-role {
font-size: 0.7rem; color: #06b6d4; background: rgba(6,182,212,0.1);
padding: 2px 8px; border-radius: 10px; font-weight: 600;
}
.space-card--create {
border-style: dashed; border-color: rgba(255,255,255,0.15);
}
.space-card--create .space-card-initial {
background: rgba(255,255,255,0.08); font-size: 1.5rem;
}
.space-card--create:hover .space-card-initial {
background: linear-gradient(135deg, #06b6d4, #7c3aed);
}
`;