diff --git a/apps/web/src/components/AccountView.svelte b/apps/web/src/components/AccountView.svelte index e6eb69d..42df7f2 100644 --- a/apps/web/src/components/AccountView.svelte +++ b/apps/web/src/components/AccountView.svelte @@ -90,6 +90,59 @@ } } + type ShareEntry = { user_id: string; email?: string }; + let shareExpanded = $state(""); + let shares = $state([]); + let shareEmail = $state(""); + let shareError = $state(""); + let sharing = $state(false); + + async function openShares(addr: string) { + if (shareExpanded === addr) { + shareExpanded = ""; + shares = []; + return; + } + shareExpanded = addr; + shareEmail = ""; + shareError = ""; + if (!user.apiKey) return; + try { + const res = await api.listInboxShares(addr, user.apiKey); + shares = res.shares || []; + } catch { + shares = []; + } + } + + async function addShare() { + if (!user.apiKey || !shareExpanded || !shareEmail.trim()) return; + shareError = ""; + sharing = true; + try { + const entry = await api.addInboxShare(shareExpanded, shareEmail.trim(), user.apiKey); + if (!shares.find((s) => s.user_id === entry.user_id)) { + shares = [...shares, entry]; + } + shareEmail = ""; + } catch (e: any) { + const msg = e?.message || ""; + if (msg.includes("404")) shareError = t("shareUserNotFound"); + else if (msg.includes("400")) shareError = t("shareSelf"); + else shareError = t("genericError"); + } finally { + sharing = false; + } + } + + async function removeShare(uid: string) { + if (!user.apiKey || !shareExpanded) return; + try { + await api.removeInboxShare(shareExpanded, uid, user.apiKey); + shares = shares.filter((s) => s.user_id !== uid); + } catch { /* silent */ } + } + async function loadPlanData() { if (!user.info?.user_id) return; try { @@ -506,14 +559,65 @@
{#each activeInboxes as addr} - -
- {addr} -
+
+
+ +
+ {addr} +
+ +
+ + {#if shareExpanded === addr} +
+

{t("shareInboxDescription")}

+
+ + +
+ {#if shareError} +
{shareError}
+ {/if} + {#if shares.length > 0} +
+
{t("sharedWith")}
+ {#each shares as s} +
+ {s.email || s.user_id} + +
+ {/each} +
+ {/if} +
+ {/if} +
{/each}
diff --git a/apps/web/src/components/AdminView.svelte b/apps/web/src/components/AdminView.svelte index c8d17fc..3bd1bcd 100644 --- a/apps/web/src/components/AdminView.svelte +++ b/apps/web/src/components/AdminView.svelte @@ -23,6 +23,152 @@ let selectedMessage = $state(null); let loading = $state(true); let pollInterval: ReturnType | undefined; + let searchQuery = $state(""); + + type StatsSnapshot = { + users: number; + premium_users: number; + active_inboxes: number; + messages_24h: number; + messages_7d: number; + generated_at: number; + }; + let stats = $state(null); + let statsLoading = $state(false); + + type AdminUser = { + user_id: string; + email?: string; + tier: string; + role?: string; + no_ads: boolean; + has_webhook: boolean; + created_at: number; + }; + let activeView = $state<"inboxes" | "users">("inboxes"); + let users = $state([]); + let usersLoading = $state(false); + let userSearch = $state(""); + let filteredUsers = $derived.by(() => { + const q = userSearch.trim().toLowerCase(); + if (!q) return users; + return users.filter((u) => + (u.user_id || "").toLowerCase().includes(q) || + (u.email || "").toLowerCase().includes(q) || + (u.tier || "").toLowerCase().includes(q) || + (u.role || "").toLowerCase().includes(q) + ); + }); + + async function loadStats() { + if (!user.apiKey) return; + statsLoading = true; + try { + const res = await fetch(`${import.meta.env.PUBLIC_API_URL || ""}/admin/stats`, { + headers: { Authorization: `Bearer ${user.apiKey}` }, + }); + if (res.ok) stats = await res.json(); + } catch { /* silent */ } + finally { statsLoading = false; } + } + + async function loadUsers() { + if (!user.apiKey) return; + usersLoading = true; + try { + const res = await fetch(`${import.meta.env.PUBLIC_API_URL || ""}/admin/users`, { + headers: { Authorization: `Bearer ${user.apiKey}` }, + }); + if (res.ok) { + const data = await res.json(); + users = data.users || []; + } + } catch { /* silent */ } + finally { usersLoading = false; } + } + + function showUsersTab() { + activeView = "users"; + if (users.length === 0) loadUsers(); + } + + type AdminTemplate = { + id: string; + name: string; + subject: string; + body: string; + created_at: number; + }; + let templates = $state([]); + let templateSelected = $state(""); + let savingTemplate = $state(false); + let savingTemplateName = $state(""); + + async function loadTemplates() { + if (!user.apiKey) return; + try { + const res = await fetch(`${import.meta.env.PUBLIC_API_URL || ""}/admin/templates`, { + headers: { Authorization: `Bearer ${user.apiKey}` }, + }); + if (res.ok) { + const data = await res.json(); + templates = data.templates || []; + } + } catch { /* silent */ } + } + + function applyTemplate(id: string) { + if (!id) return; + const t = templates.find((x) => x.id === id); + if (!t) return; + composeSubject = t.subject; + composeBody = t.body; + templateSelected = ""; + } + + async function saveAsTemplate() { + if (!user.apiKey || !savingTemplateName.trim() || !composeBody.trim()) return; + savingTemplate = true; + try { + const res = await fetch(`${import.meta.env.PUBLIC_API_URL || ""}/admin/templates`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${user.apiKey}` }, + body: JSON.stringify({ + name: savingTemplateName.trim(), + subject: composeSubject, + body: composeBody, + }), + }); + if (res.ok) { + savingTemplateName = ""; + await loadTemplates(); + } + } finally { + savingTemplate = false; + } + } + + async function deleteTemplate(id: string) { + if (!user.apiKey) return; + if (!confirm("Delete this template?")) return; + try { + await fetch(`${import.meta.env.PUBLIC_API_URL || ""}/admin/templates/${id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${user.apiKey}` }, + }); + templates = templates.filter((t) => t.id !== id); + } catch { /* silent */ } + } + + let filteredMessages = $derived.by(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return messages; + return messages.filter((m) => + (m.from || "").toLowerCase().includes(q) || + (m.subject || "").toLowerCase().includes(q) || + (m.preview || "").toLowerCase().includes(q) + ); + }); // Compose state let showCompose = $state(false); @@ -145,6 +291,8 @@ await initAdminInboxes(); await loadMessages(); + loadStats(); + loadTemplates(); loading = false; pollInterval = setInterval(loadMessages, 10000); @@ -170,6 +318,104 @@
{:else} + +
+
+
USERS
+
{stats?.users ?? "—"}
+
+
+
PREMIUM
+
{stats?.premium_users ?? "—"}
+
+
+
ACTIVE INBOXES
+
{stats?.active_inboxes ?? "—"}
+
+
+
MSG / 24h
+
{stats?.messages_24h ?? "—"}
+
+
+
MSG / 7d
+
{stats?.messages_7d ?? "—"}
+
+
+ + +
+ + +
+ + {#if activeView === "users"} +
+
+

All users

+ +
+ + {#if usersLoading} +
Loading...
+ {:else if filteredUsers.length === 0} +
No users.
+ {:else} +
+ + + + + + + + + + + + + {#each filteredUsers as u} + + + + + + + + + {/each} + +
USEREMAILTIERROLEWEBHOOKCREATED
{u.user_id}{u.email || "—"} + {u.tier} + + {u.role || "user"} + + {u.has_webhook ? "yes" : "—"} + + {new Date(u.created_at * 1000).toLocaleDateString(getLocale())} +
+
+ {/if} +
+ {:else} +
+ {#if templates.length > 0} +
+ TEMPLATE + +
+ {/if}