rspace-online/lib/folk-multisig-email.ts

515 lines
14 KiB
TypeScript

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
min-width: 360px;
min-height: 320px;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #6366f1;
color: white;
font-size: 12px;
font-weight: 600;
cursor: move;
border-radius: 8px 8px 0 0;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.status-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.status-draft { background: #334155; color: #94a3b8; }
.status-pending { background: #fbbf24; color: #1e293b; }
.status-approved { background: #22c55e; color: white; }
.status-sent { background: #22c55e; color: white; }
.status-rejected { background: #ef4444; color: white; }
.body {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
height: calc(100% - 36px);
overflow-y: auto;
}
.field {
display: flex;
gap: 6px;
font-size: 12px;
align-items: baseline;
}
.field-label {
color: #64748b;
font-weight: 600;
min-width: 50px;
flex-shrink: 0;
}
.field-value {
color: #cbd5e1;
word-break: break-all;
}
.field-input {
flex: 1;
background: rgba(0,0,0,0.3);
border: 1px solid #334155;
border-radius: 4px;
padding: 3px 6px;
color: #e2e8f0;
font-size: 12px;
font-family: inherit;
outline: none;
}
.field-input:focus { border-color: #6366f1; }
.field-textarea {
flex: 1;
background: rgba(0,0,0,0.3);
border: 1px solid #334155;
border-radius: 4px;
padding: 4px 6px;
color: #e2e8f0;
font-size: 12px;
font-family: inherit;
outline: none;
resize: vertical;
min-height: 48px;
max-height: 120px;
}
.field-textarea:focus { border-color: #6366f1; }
.divider {
border: none;
border-top: 1px solid #1e293b;
margin: 4px 0;
}
.body-preview {
font-size: 11px;
color: #94a3b8;
line-height: 1.4;
max-height: 60px;
overflow: hidden;
padding: 6px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
border-left: 3px solid #334155;
}
/* Progress */
.progress-section {
margin-top: auto;
}
.progress-bar-wrap {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.progress-bar {
flex: 1;
height: 6px;
background: #1e293b;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s;
}
.progress-fill.pending { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
.progress-fill.approved, .progress-fill.sent { background: linear-gradient(90deg, #22c55e, #16a34a); }
.progress-text {
font-size: 10px;
color: #64748b;
flex-shrink: 0;
}
.signer-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
padding: 2px 0;
}
.signer-dot {
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
color: white;
flex-shrink: 0;
}
.signer-dot.signed { background: #22c55e; }
.signer-dot.waiting { background: #334155; }
.signer-name { flex: 1; }
.signer-name.signed { color: #e2e8f0; }
.signer-name.waiting { color: #64748b; }
.signer-status { font-size: 10px; font-weight: 600; }
.signer-status.signed { color: #4ade80; }
.signer-status.waiting { color: #64748b; }
/* Actions */
.actions {
display: flex;
gap: 6px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid #1e293b;
}
.btn {
padding: 4px 12px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: #6366f1; color: white; }
.btn-success { background: #22c55e; color: white; }
.btn-danger { background: #ef4444; color: white; }
.btn-secondary { background: #334155; color: #94a3b8; }
`;
export class FolkMultisigEmail extends FolkShape {
static tagName = "folk-multisig-email";
// Shape data
mailboxSlug = "";
toAddresses: string[] = [];
ccAddresses: string[] = [];
subject = "";
bodyText = "";
bodyHtml = "";
replyToThreadId: string | null = null;
replyType: 'new' | 'reply' | 'forward' = 'new';
approvalId: string | null = null;
status: 'draft' | 'pending' | 'approved' | 'sent' | 'rejected' = 'draft';
requiredSignatures = 2;
signatures: Array<{ id: string; signerId: string; vote: string; signedAt: string }> = [];
private _pollInterval: ReturnType<typeof setInterval> | null = null;
static get observedAttributes() {
return [...super.observedAttributes, "mailbox-slug", "subject", "status", "approval-id"];
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
super.attributeChangedCallback(name, oldValue, newValue);
switch (name) {
case "mailbox-slug": this.mailboxSlug = newValue || ""; break;
case "subject": this.subject = newValue || ""; break;
case "status": this.status = (newValue || "draft") as any; break;
case "approval-id": this.approvalId = newValue || null; break;
}
}
connectedCallback() {
super.connectedCallback();
this.shadowRoot!.adoptedStyleSheets = [...this.shadowRoot!.adoptedStyleSheets, styles];
this.renderContent();
if (this.status === 'pending' && this.approvalId) {
this.startPolling();
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.stopPolling();
}
private getApiBase(): string {
const space = (this as any).spaceSlug || (window as any).__communitySync?.communitySlug || "demo";
return `/${space}/rinbox`;
}
private getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
const token = localStorage.getItem("encryptid-token");
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}
private startPolling() {
this.stopPolling();
this._pollInterval = setInterval(() => this.pollApproval(), 5000);
}
private stopPolling() {
if (this._pollInterval) {
clearInterval(this._pollInterval);
this._pollInterval = null;
}
}
private async pollApproval() {
if (!this.approvalId) return;
try {
const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}`, {
headers: this.getAuthHeaders(),
});
if (!resp.ok) return;
const data = await resp.json();
const newStatus = data.status?.toLowerCase();
if (newStatus && newStatus !== this.status) {
this.status = newStatus;
this.signatures = data.signatures || [];
this.renderContent();
this.dispatchEvent(new CustomEvent("content-change", { bubbles: true }));
if (newStatus !== 'pending') this.stopPolling();
} else {
// Update signature count even if status didn't change
if (data.signatures) {
this.signatures = data.signatures;
this.renderContent();
}
}
} catch { /* ignore polling errors */ }
}
private renderContent() {
const sr = this.shadowRoot;
if (!sr) return;
const isDraft = this.status === 'draft';
const sigCount = this.signatures.filter(s => s.vote === 'APPROVE').length;
const pct = this.requiredSignatures > 0 ? Math.min(100, Math.round((sigCount / this.requiredSignatures) * 100)) : 0;
let contentEl = sr.querySelector(".msig-content") as HTMLDivElement;
if (!contentEl) {
contentEl = document.createElement("div");
contentEl.className = "msig-content";
contentEl.style.cssText = "display:flex;flex-direction:column;height:100%;";
sr.appendChild(contentEl);
}
if (isDraft) {
contentEl.innerHTML = html`
<div class="header">
<div class="header-title">&#9993; Multi-Sig Email</div>
<span class="status-badge status-draft">Draft</span>
</div>
<div class="body">
<div class="field">
<span class="field-label">From:</span>
<span class="field-value" style="color:#6366f1">${this.mailboxSlug || "team"}@rspace.online</span>
</div>
<div class="field">
<span class="field-label">To:</span>
<input class="field-input" data-field="to" value="${this.toAddresses.join(', ')}" placeholder="recipient@example.com" />
</div>
<div class="field">
<span class="field-label">Subject:</span>
<input class="field-input" data-field="subject" value="${this.escapeAttr(this.subject)}" placeholder="Email subject" />
</div>
<hr class="divider" />
<textarea class="field-textarea" data-field="body" placeholder="Write your email...">${this.escapeHtml(this.bodyText)}</textarea>
<div class="actions">
<button class="btn btn-primary" data-action="submit">Submit for Approval</button>
</div>
</div>
`;
} else {
const snippet = this.bodyText.length > 120 ? this.bodyText.slice(0, 120) + '...' : this.bodyText;
contentEl.innerHTML = html`
<div class="header" style="background:${this.status === 'sent' || this.status === 'approved' ? '#16a34a' : this.status === 'rejected' ? '#dc2626' : '#6366f1'}">
<div class="header-title">&#9993; Multi-Sig Email</div>
<span class="status-badge status-${this.status}">${this.status}</span>
</div>
<div class="body">
<div class="field">
<span class="field-label">From:</span>
<span class="field-value" style="color:#6366f1">${this.mailboxSlug || "team"}@rspace.online</span>
</div>
<div class="field">
<span class="field-label">To:</span>
<span class="field-value">${this.toAddresses.join(', ') || '(none)'}</span>
</div>
<div class="field">
<span class="field-label">Subject:</span>
<span class="field-value" style="font-weight:600">${this.subject || '(no subject)'}</span>
</div>
<hr class="divider" />
<div class="body-preview">${this.escapeHtml(snippet)}</div>
<div class="progress-section">
<div class="progress-bar-wrap">
<div class="progress-bar">
<div class="progress-fill ${this.status}" style="width:${pct}%"></div>
</div>
<span class="progress-text">${sigCount}/${this.requiredSignatures} signatures</span>
</div>
${this.signatures.map(s => html`
<div class="signer-row">
<div class="signer-dot ${s.vote === 'APPROVE' ? 'signed' : 'waiting'}">${s.vote === 'APPROVE' ? '&#10003;' : ''}</div>
<span class="signer-name ${s.vote === 'APPROVE' ? 'signed' : 'waiting'}">${s.signerId?.slice(0, 12) || 'Unknown'}...</span>
<span class="signer-status ${s.vote === 'APPROVE' ? 'signed' : 'waiting'}">${s.vote === 'APPROVE' ? 'Signed' : 'Awaiting'}</span>
</div>
`).join('')}
</div>
${this.status === 'pending' ? html`
<div class="actions">
<button class="btn btn-success" data-action="approve">&#10003; Approve</button>
<button class="btn btn-danger" data-action="reject">&#10007; Reject</button>
</div>
` : ''}
${this.status === 'draft' ? html`
<div class="actions">
<button class="btn btn-secondary" data-action="edit">Edit</button>
<button class="btn btn-primary" data-action="submit">Submit</button>
</div>
` : ''}
</div>
`;
}
this.bindShapeEvents(contentEl);
}
private escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
private escapeAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
private bindShapeEvents(root: HTMLElement) {
// Draft field changes
root.querySelectorAll("[data-field]").forEach(el => {
el.addEventListener("input", () => {
const field = (el as HTMLElement).dataset.field;
const value = (el as HTMLInputElement | HTMLTextAreaElement).value;
switch (field) {
case 'to':
this.toAddresses = value.split(',').map(s => s.trim()).filter(Boolean);
break;
case 'subject':
this.subject = value;
break;
case 'body':
this.bodyText = value;
break;
}
this.dispatchEvent(new CustomEvent("content-change", { bubbles: true }));
});
});
// Submit
root.querySelector("[data-action='submit']")?.addEventListener("click", async () => {
if (!this.subject || this.toAddresses.length === 0) {
alert("Please fill To and Subject fields.");
return;
}
try {
const resp = await fetch(`${this.getApiBase()}/api/approvals`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({
mailbox_slug: this.mailboxSlug || undefined,
subject: this.subject,
body_text: this.bodyText,
to_addresses: this.toAddresses,
cc_addresses: this.ccAddresses,
reply_type: this.replyType,
thread_id: this.replyToThreadId,
}),
});
if (resp.ok) {
const data = await resp.json();
this.approvalId = data.id;
this.status = 'pending';
this.requiredSignatures = data.required_signatures || 2;
this.renderContent();
this.dispatchEvent(new CustomEvent("content-change", { bubbles: true }));
this.startPolling();
}
} catch (e) {
console.error("[MultiSigEmail] Submit error:", e);
}
});
// Approve
root.querySelector("[data-action='approve']")?.addEventListener("click", async () => {
if (!this.approvalId) return;
try {
const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}/sign`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({ vote: "APPROVE" }),
});
if (resp.ok) {
const data = await resp.json();
if (data.status) {
this.status = data.status.toLowerCase();
if (this.status !== 'pending') this.stopPolling();
}
this.renderContent();
this.dispatchEvent(new CustomEvent("content-change", { bubbles: true }));
}
} catch (e) {
console.error("[MultiSigEmail] Approve error:", e);
}
});
// Reject
root.querySelector("[data-action='reject']")?.addEventListener("click", async () => {
if (!this.approvalId) return;
try {
const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}/sign`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({ vote: "REJECT" }),
});
if (resp.ok) {
const data = await resp.json();
if (data.status) this.status = data.status.toLowerCase();
this.stopPolling();
this.renderContent();
this.dispatchEvent(new CustomEvent("content-change", { bubbles: true }));
}
} catch (e) {
console.error("[MultiSigEmail] Reject error:", e);
}
});
}
}
customElements.define(FolkMultisigEmail.tagName, FolkMultisigEmail);