forked from enderofwings/NexusOS
Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
183 lines
9.6 KiB
React
183 lines
9.6 KiB
React
import { useEffect, useState } from "react";
|
|
import { API_BASE } from "../../config";
|
|
|
|
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", width: "100%", boxSizing: "border-box" };
|
|
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
|
|
const labelStyle = { display: "block", fontSize: "0.72rem", color: "#888", marginBottom: "0.3rem", textTransform: "uppercase", letterSpacing: "0.05em" };
|
|
|
|
const BLANK = {
|
|
label: "", username: "", password: "", from_addr: "", from_name: "",
|
|
imap_host: "imap.mail.me.com", imap_port: 993, smtp_host: "smtp.mail.me.com", smtp_port: 587,
|
|
};
|
|
|
|
// macOS-Mail-style "Internet Accounts" window: accounts on the left, the
|
|
// selected account's config on the right. Lets you manage several mailboxes
|
|
// that happen to share the same IMAP/SMTP server.
|
|
export function MailAccounts({ onClose, onChanged }) {
|
|
const [accounts, setAccounts] = useState(null); // null = loading
|
|
const [selectedId, setSelectedId] = useState(null); // null = "add account" form
|
|
const [form, setForm] = useState(BLANK);
|
|
const [busy, setBusy] = useState(false);
|
|
const [note, setNote] = useState("");
|
|
|
|
const selectAccount = (id, list = accounts) => {
|
|
setNote("");
|
|
setSelectedId(id);
|
|
if (id === null) { setForm(BLANK); return; }
|
|
const a = (list || []).find(x => x.id === id);
|
|
if (a) setForm({ ...a, password: "" });
|
|
};
|
|
|
|
const load = async (selectAfter) => {
|
|
const r = await fetch(`${API_BASE}/mail/accounts`);
|
|
const list = r.ok ? (await r.json()).accounts || [] : [];
|
|
setAccounts(list);
|
|
onChanged && onChanged(list);
|
|
if (selectAfter !== undefined) selectAccount(selectAfter, list);
|
|
return list;
|
|
};
|
|
|
|
useEffect(() => {
|
|
load().then(list => { if (list.length) selectAccount(list[0].id, list); });
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
setBusy(true); setNote("Saving…");
|
|
try {
|
|
const url = selectedId ? `${API_BASE}/mail/accounts/${selectedId}` : `${API_BASE}/mail/accounts`;
|
|
const r = await fetch(url, {
|
|
method: selectedId ? "PUT" : "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(form),
|
|
});
|
|
if (!r.ok) { setNote("Save failed."); return; }
|
|
const saved = await r.json();
|
|
setNote("Saved.");
|
|
await load(saved.id);
|
|
} finally { setBusy(false); }
|
|
};
|
|
|
|
const test = async () => {
|
|
if (!selectedId) { setNote("Save the account first."); return; }
|
|
setBusy(true); setNote("Testing…");
|
|
try {
|
|
const r = await fetch(`${API_BASE}/mail/accounts/${selectedId}/test`, { method: "POST" });
|
|
const d = await r.json().catch(() => ({}));
|
|
setNote(d.ok ? `Connected — ${d.folders} folders.` : `Connection failed: ${d.error || "check credentials"}`);
|
|
} finally { setBusy(false); }
|
|
};
|
|
|
|
const remove = async () => {
|
|
if (!selectedId) return;
|
|
if (!window.confirm(`Delete account "${form.label || form.from_addr || form.username}"?`)) return;
|
|
setBusy(true);
|
|
try {
|
|
await fetch(`${API_BASE}/mail/accounts/${selectedId}`, { method: "DELETE" });
|
|
const list = await load();
|
|
selectAccount(list.length ? list[0].id : null, list);
|
|
} finally { setBusy(false); }
|
|
};
|
|
|
|
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
|
|
|
return (
|
|
<div onClick={onClose}
|
|
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 200, padding: "2rem" }}>
|
|
<div onClick={e => e.stopPropagation()}
|
|
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", width: "720px", maxWidth: "100%", height: "520px", maxHeight: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.85rem 1.1rem", borderBottom: "1px solid #2a2a2a", flexShrink: 0 }}>
|
|
<strong>Mail Accounts</strong>
|
|
<button onClick={onClose} style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}>✕</button>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", flexGrow: 1, minHeight: 0 }}>
|
|
{/* Left: accounts bar */}
|
|
<div style={{ width: "200px", flexShrink: 0, borderRight: "1px solid #2a2a2a", display: "flex", flexDirection: "column" }}>
|
|
<div style={{ flexGrow: 1, overflowY: "auto", padding: "0.5rem" }}>
|
|
{accounts === null ? (
|
|
<div style={{ color: "#666", fontSize: "0.82rem", padding: "0.5rem" }}>Loading…</div>
|
|
) : accounts.length === 0 ? (
|
|
<div style={{ color: "#666", fontSize: "0.8rem", padding: "0.5rem" }}>No accounts yet.</div>
|
|
) : accounts.map(a => (
|
|
<div key={a.id} onClick={() => selectAccount(a.id)}
|
|
style={{
|
|
padding: "0.5rem 0.6rem", borderRadius: "6px", cursor: "pointer", marginBottom: "0.2rem",
|
|
background: selectedId === a.id ? "#1c2a3a" : "transparent",
|
|
color: selectedId === a.id ? "#fff" : "#ccc",
|
|
}}>
|
|
<div style={{ fontSize: "0.85rem", fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
{a.label || a.from_addr || a.username || "(unnamed)"}
|
|
</div>
|
|
<div style={{ fontSize: "0.72rem", color: a.configured ? "#8aff8a" : "#c9a227", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
{a.configured ? (a.from_addr || a.username) : "needs password"}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div style={{ padding: "0.5rem", borderTop: "1px solid #2a2a2a" }}>
|
|
<button onClick={() => selectAccount(null)} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc", width: "100%" }}>+ Add account</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: selected account form */}
|
|
<div style={{ flexGrow: 1, padding: "1.1rem", overflowY: "auto" }}>
|
|
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#eee" }}>
|
|
{selectedId ? "Edit account" : "New account"}
|
|
</h3>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem", maxWidth: "440px" }}>
|
|
<div>
|
|
<label style={labelStyle}>Nickname (optional)</label>
|
|
<input value={form.label} onChange={e => update("label", e.target.value)} placeholder="e.g. Personal, Work" style={inputStyle} />
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Apple ID (login)</label>
|
|
<input value={form.username} onChange={e => update("username", e.target.value)} placeholder="you@icloud.com" style={inputStyle} />
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>{selectedId ? "App-specific password (blank = keep current)" : "App-specific password"}</label>
|
|
<input type="password" value={form.password} onChange={e => update("password", e.target.value)} style={inputStyle} />
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>From address</label>
|
|
<input value={form.from_addr} onChange={e => update("from_addr", e.target.value)} placeholder="nexus@enderofwings.com" style={inputStyle} />
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>From name (optional)</label>
|
|
<input value={form.from_name} onChange={e => update("from_name", e.target.value)} style={inputStyle} />
|
|
</div>
|
|
<div style={{ display: "flex", gap: "0.6rem" }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={labelStyle}>IMAP host</label>
|
|
<input value={form.imap_host} onChange={e => update("imap_host", e.target.value)} style={inputStyle} />
|
|
</div>
|
|
<div style={{ width: "90px" }}>
|
|
<label style={labelStyle}>Port</label>
|
|
<input type="number" value={form.imap_port} onChange={e => update("imap_port", parseInt(e.target.value) || 0)} style={inputStyle} />
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "flex", gap: "0.6rem" }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={labelStyle}>SMTP host</label>
|
|
<input value={form.smtp_host} onChange={e => update("smtp_host", e.target.value)} style={inputStyle} />
|
|
</div>
|
|
<div style={{ width: "90px" }}>
|
|
<label style={labelStyle}>Port</label>
|
|
<input type="number" value={form.smtp_port} onChange={e => update("smtp_port", parseInt(e.target.value) || 0)} style={inputStyle} />
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center", marginTop: "0.4rem" }}>
|
|
<button onClick={save} disabled={busy} style={btn(busy ? "#555" : "#007acc")}>{selectedId ? "Save" : "Create"}</button>
|
|
{selectedId && <button onClick={test} disabled={busy} style={btn("#2a4a6a")}>Test connection</button>}
|
|
{selectedId && <button onClick={remove} disabled={busy} style={{ ...btn("#3a1a1a"), color: "#ff8a80" }}>Delete</button>}
|
|
</div>
|
|
<span style={{ color: "#8ab4ff", fontSize: "0.85rem" }}>{note}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|