rspace-online/shared/components/rstack-space-settings.ts

771 lines
18 KiB
TypeScript

/**
* <rstack-space-settings> — Space settings slide-out panel.
*
* Shows members list, add member (by username or email), pending invites.
* Only admins/owners see the full panel; others see a read-only member list.
*/
const SESSION_KEY = "encryptid_session";
interface MemberInfo {
did: string;
role: string;
displayName?: string;
joinedAt?: number;
}
function getSession(): { accessToken: string; claims: { sub: string; username?: string } } | null {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch { return null; }
}
function getToken(): string | null {
return getSession()?.accessToken || null;
}
export class RStackSpaceSettings extends HTMLElement {
private _open = false;
private _space = "";
private _members: MemberInfo[] = [];
private _ownerDID = "";
private _myRole: string = "viewer";
private _isOwner = false;
private _invites: any[] = [];
private _addMode: "username" | "email" = "username";
private _lookupResult: { did: string; username: string; displayName: string } | null = null;
private _lookupError = "";
static define() {
if (!customElements.get("rstack-space-settings")) {
customElements.define("rstack-space-settings", RStackSpaceSettings);
}
}
static get observedAttributes() { return ["space"]; }
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === "space") this._space = val;
}
connectedCallback() {
this._space = this.getAttribute("space") || "";
this.attachShadow({ mode: "open" });
this._render();
}
open() {
this._open = true;
this._loadData();
this._render();
}
close() {
this._open = false;
this._render();
}
toggle() {
if (this._open) this.close(); else this.open();
}
private async _loadData() {
if (!this._space) return;
const session = getSession();
const token = getToken();
// Load community data from WS-synced doc (via global)
const sync = (window as any).__rspaceCommunitySync;
if (sync?.doc) {
const data = sync.doc;
this._ownerDID = data.meta?.ownerDID || "";
this._members = [];
if (data.members) {
for (const [did, m] of Object.entries(data.members)) {
const member = m as MemberInfo;
this._members.push({ did, role: member.role, displayName: member.displayName, joinedAt: member.joinedAt });
}
}
} else {
// Fallback: fetch from API
try {
const res = await fetch(`/${this._space}/rspace/api/meta`, {
headers: token ? { "Authorization": `Bearer ${token}` } : {},
});
if (res.ok) {
const json = await res.json();
this._ownerDID = json.meta?.ownerDID || "";
if (json.meta?.members) {
this._members = [];
for (const [did, m] of Object.entries(json.meta.members)) {
const member = m as MemberInfo;
this._members.push({ did, role: member.role, displayName: member.displayName });
}
}
}
} catch {}
}
// Determine my role
if (session?.claims?.sub) {
this._isOwner = session.claims.sub === this._ownerDID;
const myMember = this._members.find(m => m.did === session.claims.sub);
this._myRole = this._isOwner ? "admin" : (myMember?.role || "viewer");
}
// Load invites (admin only)
if (this._isAdmin && token) {
try {
const res = await fetch(`/${this._space}/invites`, {
headers: { "Authorization": `Bearer ${token}` },
});
if (res.ok) {
const json = await res.json();
this._invites = json.invites || [];
}
} catch {}
}
this._render();
}
private get _isAdmin(): boolean {
return this._isOwner || this._myRole === "admin";
}
private _render() {
if (!this.shadowRoot) return;
if (!this._open) {
this.shadowRoot.innerHTML = "";
return;
}
const roleOptions = (currentRole: string) => {
const roles = ["viewer", "member", "moderator", "admin"];
return roles.map(r => `<option value="${r}" ${r === currentRole ? "selected" : ""}>${r}</option>`).join("");
};
const membersHTML = this._members.map(m => {
const isOwnerRow = m.did === this._ownerDID;
const displayName = m.displayName || m.did.slice(0, 16) + "…";
const initial = displayName.charAt(0).toUpperCase();
const roleBadge = isOwnerRow
? `<span class="role-badge role-owner">Owner</span>`
: `<span class="role-badge role-${m.role}">${m.role}</span>`;
let controls = "";
if (this._isAdmin && !isOwnerRow) {
controls = `
<select class="role-select" data-did="${m.did}">
${roleOptions(m.role)}
</select>
<button class="remove-btn" data-did="${m.did}" title="Remove member">&times;</button>
`;
}
return `
<div class="member-row">
<div class="member-avatar">${initial}</div>
<div class="member-info">
<div class="member-name">${this._esc(displayName)}</div>
${roleBadge}
</div>
<div class="member-controls">${controls}</div>
</div>
`;
}).join("");
const invitesHTML = this._invites
.filter(inv => inv.status === "pending")
.map(inv => {
const expiry = new Date(inv.expiresAt).toLocaleDateString();
return `
<div class="invite-row">
<div class="invite-info">
<span class="invite-email">${this._esc(inv.email || "Link invite")}</span>
<span class="invite-role">${inv.role}</span>
<span class="invite-expiry">expires ${expiry}</span>
</div>
<button class="revoke-btn" data-invite-id="${inv.id}">Revoke</button>
</div>
`;
}).join("") || '<div class="empty-state">No pending invites</div>';
this.shadowRoot.innerHTML = `
<style>${PANEL_CSS}</style>
<div class="overlay" id="overlay"></div>
<div class="panel">
<div class="panel-header">
<h2>Space Settings</h2>
<button class="close-btn" id="close-btn">&times;</button>
</div>
<div class="panel-content">
<section class="section">
<h3>Members <span class="count">${this._members.length}</span></h3>
<div class="members-list">${membersHTML}</div>
</section>
${this._isAdmin ? `
<section class="section">
<h3>Add Member</h3>
<div class="add-toggle">
<button class="toggle-btn ${this._addMode === "username" ? "active" : ""}" data-mode="username">By Username</button>
<button class="toggle-btn ${this._addMode === "email" ? "active" : ""}" data-mode="email">By Email</button>
</div>
${this._addMode === "username" ? `
<div class="add-form">
<input type="text" class="input" id="add-username" placeholder="Username…" />
${this._lookupResult ? `<div class="lookup-result">Found: <strong>${this._esc(this._lookupResult.displayName)}</strong> (@${this._esc(this._lookupResult.username)})</div>` : ""}
${this._lookupError ? `<div class="error-msg">${this._esc(this._lookupError)}</div>` : ""}
<div class="add-row">
<select class="input role-input" id="add-role">
<option value="member">member</option>
<option value="viewer">viewer</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
<button class="add-btn" id="add-by-username">Add</button>
</div>
</div>
` : `
<div class="add-form">
<input type="email" class="input" id="add-email" placeholder="Email address…" />
<div class="add-row">
<select class="input role-input" id="add-email-role">
<option value="member">member</option>
<option value="viewer">viewer</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
<button class="add-btn" id="add-by-email">Send Invite</button>
</div>
</div>
`}
</section>
<section class="section">
<h3>Pending Invites</h3>
<div class="invites-list">${invitesHTML}</div>
</section>
` : ""}
</div>
</div>
`;
this._bindEvents();
}
private _bindEvents() {
const sr = this.shadowRoot!;
sr.getElementById("close-btn")?.addEventListener("click", () => this.close());
sr.getElementById("overlay")?.addEventListener("click", () => this.close());
// Toggle add mode
sr.querySelectorAll(".toggle-btn").forEach(btn => {
btn.addEventListener("click", () => {
this._addMode = (btn as HTMLElement).dataset.mode as "username" | "email";
this._lookupResult = null;
this._lookupError = "";
this._render();
});
});
// Username lookup with debounce
const usernameInput = sr.getElementById("add-username") as HTMLInputElement;
if (usernameInput) {
let debounce: ReturnType<typeof setTimeout>;
usernameInput.addEventListener("input", () => {
clearTimeout(debounce);
this._lookupResult = null;
this._lookupError = "";
debounce = setTimeout(() => this._lookupUser(usernameInput.value), 300);
});
}
// Add by username
sr.getElementById("add-by-username")?.addEventListener("click", () => this._addByUsername());
// Add by email
sr.getElementById("add-by-email")?.addEventListener("click", () => this._addByEmail());
// Role changes
sr.querySelectorAll(".role-select").forEach(sel => {
sel.addEventListener("change", (e) => {
const target = e.target as HTMLSelectElement;
this._changeRole(target.dataset.did!, target.value);
});
});
// Remove member
sr.querySelectorAll(".remove-btn").forEach(btn => {
btn.addEventListener("click", () => {
const did = (btn as HTMLElement).dataset.did!;
this._removeMember(did);
});
});
// Revoke invite
sr.querySelectorAll(".revoke-btn").forEach(btn => {
btn.addEventListener("click", () => {
const id = (btn as HTMLElement).dataset.inviteId!;
this._revokeInvite(id);
});
});
}
private async _lookupUser(username: string) {
if (!username || username.length < 2) return;
const token = getToken();
if (!token) return;
try {
const res = await fetch(`https://auth.rspace.online/api/users/lookup?username=${encodeURIComponent(username)}`, {
headers: { "Authorization": `Bearer ${token}` },
});
if (res.ok) {
this._lookupResult = await res.json();
this._lookupError = "";
} else {
this._lookupResult = null;
this._lookupError = username.length > 2 ? "User not found" : "";
}
} catch {
this._lookupError = "Lookup failed";
}
this._render();
}
private async _addByUsername() {
const sr = this.shadowRoot!;
const input = sr.getElementById("add-username") as HTMLInputElement;
const roleSelect = sr.getElementById("add-role") as HTMLSelectElement;
if (!input?.value) return;
const token = getToken();
if (!token) return;
try {
const res = await fetch(`/${this._space}/members/add`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ username: input.value, role: roleSelect.value }),
});
if (res.ok) {
input.value = "";
this._lookupResult = null;
this._loadData();
} else {
const err = await res.json();
this._lookupError = err.error || "Failed to add member";
this._render();
}
} catch {
this._lookupError = "Network error";
this._render();
}
}
private async _addByEmail() {
const sr = this.shadowRoot!;
const input = sr.getElementById("add-email") as HTMLInputElement;
const roleSelect = sr.getElementById("add-email-role") as HTMLSelectElement;
if (!input?.value) return;
const token = getToken();
if (!token) return;
try {
const res = await fetch(`/${this._space}/invite`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ email: input.value, role: roleSelect.value }),
});
if (res.ok) {
input.value = "";
this._loadData();
}
} catch {}
}
private async _changeRole(did: string, newRole: string) {
const token = getToken();
if (!token) return;
try {
await fetch(`/${this._space}/members/${encodeURIComponent(did)}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ role: newRole }),
});
this._loadData();
} catch {}
}
private async _removeMember(did: string) {
const token = getToken();
if (!token) return;
try {
await fetch(`/${this._space}/members/${encodeURIComponent(did)}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` },
});
this._loadData();
} catch {}
}
private async _revokeInvite(id: string) {
const token = getToken();
if (!token) return;
try {
await fetch(`https://auth.rspace.online/api/spaces/${this._space}/invites/${id}/revoke`, {
method: "POST",
headers: { "Authorization": `Bearer ${token}` },
});
this._loadData();
} catch {}
}
private _esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
}
const PANEL_CSS = `
:host {
display: contents;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9998;
animation: fadeIn 0.2s ease;
}
.panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(380px, 90vw);
background: #1e293b;
border-left: 1px solid rgba(255,255,255,0.1);
z-index: 9999;
display: flex;
flex-direction: column;
animation: slideIn 0.25s ease;
color: #e2e8f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.panel-header h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
background: linear-gradient(135deg, #14b8a6, #22d3ee);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.close-btn {
background: none;
border: none;
color: #64748b;
font-size: 1.5rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
line-height: 1;
}
.close-btn:hover { color: #e2e8f0; background: rgba(255,255,255,0.08); }
.panel-content {
flex: 1;
overflow-y: auto;
padding: 0 20px 20px;
}
.section {
padding-top: 16px;
}
.section h3 {
font-size: 0.82rem;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 10px;
}
.count {
font-weight: 400;
color: #64748b;
font-size: 0.75rem;
margin-left: 4px;
}
/* Members */
.members-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.member-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s;
}
.member-row:hover { background: rgba(255,255,255,0.04); }
.member-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(20,184,166,0.15);
color: #14b8a6;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
flex-shrink: 0;
}
.member-info {
flex: 1;
min-width: 0;
}
.member-name {
font-size: 0.85rem;
font-weight: 500;
color: #e2e8f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.role-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
padding: 1px 6px;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.role-owner { background: rgba(124,58,237,0.2); color: #a78bfa; }
.role-admin { background: rgba(239,68,68,0.15); color: #f87171; }
.role-moderator { background: rgba(245,158,11,0.15); color: #fbbf24; }
.role-member { background: rgba(20,184,166,0.15); color: #14b8a6; }
.role-viewer { background: rgba(148,163,184,0.15); color: #94a3b8; }
.member-controls {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.role-select {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: #e2e8f0;
border-radius: 6px;
padding: 3px 6px;
font-size: 0.72rem;
cursor: pointer;
}
.role-select:focus { outline: none; border-color: #14b8a6; }
.remove-btn {
background: none;
border: none;
color: #64748b;
font-size: 1.1rem;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
line-height: 1;
}
.remove-btn:hover { color: #f87171; background: rgba(239,68,68,0.1); }
/* Add member */
.add-toggle {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.toggle-btn {
flex: 1;
padding: 6px 10px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
color: #94a3b8;
border-radius: 6px;
font-size: 0.78rem;
cursor: pointer;
transition: all 0.15s;
}
.toggle-btn.active {
background: rgba(20,184,166,0.1);
border-color: rgba(20,184,166,0.3);
color: #14b8a6;
}
.add-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.input {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: #e2e8f0;
border-radius: 8px;
padding: 8px 12px;
font-size: 0.85rem;
width: 100%;
box-sizing: border-box;
}
.input:focus { outline: none; border-color: #14b8a6; }
.input::placeholder { color: #64748b; }
.add-row {
display: flex;
gap: 8px;
}
.role-input {
flex: 0 0 auto;
width: auto;
padding: 8px 10px;
}
.add-btn {
flex-shrink: 0;
padding: 8px 16px;
background: linear-gradient(135deg, #14b8a6, #0d9488);
border: none;
color: white;
border-radius: 8px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.add-btn:hover { opacity: 0.9; }
.lookup-result {
font-size: 0.78rem;
color: #14b8a6;
padding: 4px 0;
}
.error-msg {
font-size: 0.78rem;
color: #f87171;
padding: 4px 0;
}
/* Invites */
.invites-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.invite-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255,255,255,0.02);
}
.invite-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.invite-email {
font-size: 0.82rem;
color: #e2e8f0;
}
.invite-role {
font-size: 0.7rem;
color: #14b8a6;
}
.invite-expiry {
font-size: 0.65rem;
color: #64748b;
}
.revoke-btn {
background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.2);
color: #f87171;
border-radius: 6px;
padding: 4px 10px;
font-size: 0.72rem;
cursor: pointer;
transition: all 0.15s;
}
.revoke-btn:hover { background: rgba(239,68,68,0.2); }
.empty-state {
font-size: 0.8rem;
color: #64748b;
padding: 12px 0;
text-align: center;
}
`;