515 lines
14 KiB
TypeScript
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">✉ 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">✉ 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' ? '✓' : ''}</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">✓ Approve</button>
|
|
<button class="btn btn-danger" data-action="reject">✗ 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
private escapeAttr(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
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);
|