771 lines
18 KiB
TypeScript
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">×</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">×</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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
`;
|