From 990deb6ed0ddcb50eeb7b04a835a71b610fd528e Mon Sep 17 00:00:00 2001 From: Fady Mondy Date: Mon, 29 Jun 2026 00:45:02 +0300 Subject: [PATCH] feat(dashboard): functional user-management + mail admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships real Users + Mail admin pages (consuming @togo-framework/ui@^0.2.0's admin suite) instead of the read-only generic resource admin. Wires user CRUD/ impersonation to the auth plugin's /api/auth/admin/* and adds a mail backend (GET/PUT/POST /api/dashboard/admin/mail) — SMTP config in a dashboard_kv table, test-send via net/smtp — guarded by auth RequireRole("admin") + CSRF. --- dashboard.go | 3 + go.mod | 6 +- go.sum | 4 - mail.go | 270 ++++++++++++++++++++++++ web/app/(app)/admin/mail/page.tsx | 42 ++++ web/app/(app)/admin/users/[id]/page.tsx | 116 ++++++++++ web/app/(app)/admin/users/page.tsx | 79 +++++++ web/app/(app)/layout.tsx | 19 +- web/lib/admin-users.ts | 68 ++++++ web/lib/impersonation.ts | 53 +++++ web/lib/mail.ts | 57 +++++ 11 files changed, 709 insertions(+), 8 deletions(-) create mode 100644 mail.go create mode 100644 web/app/(app)/admin/mail/page.tsx create mode 100644 web/app/(app)/admin/users/[id]/page.tsx create mode 100644 web/app/(app)/admin/users/page.tsx create mode 100644 web/lib/admin-users.ts create mode 100644 web/lib/impersonation.ts create mode 100644 web/lib/mail.ts diff --git a/dashboard.go b/dashboard.go index 0dbfec7..392f9d8 100644 --- a/dashboard.go +++ b/dashboard.go @@ -17,6 +17,9 @@ func init() { if k.Log != nil { k.Log.Info("dashboard plugin active", "ui", "auth suite injected") } + // Mount the dashboard's own admin surface: SMTP/mail config + test-send. + // Runs after auth (PriorityLate+5) so the auth service is on the kernel. + mountMailRoutes(k) return nil }) } diff --git a/go.mod b/go.mod index 1b3c7f7..45f7780 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.26.4 require github.com/togo-framework/togo v0.19.0 -require github.com/togo-framework/auth v0.8.0 +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/togo-framework/auth v0.8.0 +) require ( - github.com/go-chi/chi/v5 v5.1.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/togo-framework/orm v0.1.0 // indirect golang.org/x/crypto v0.27.0 // indirect diff --git a/go.sum b/go.sum index 146c7d5..8a5df52 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,10 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/togo-framework/auth v0.3.1 h1:/JeP1o6gGI4+e2t4bXspxR0U29ggqjLlnaKzXyg5I48= -github.com/togo-framework/auth v0.3.1/go.mod h1:5WlyAVb7WIwn0z61u/8sqN0ML5I7SNaPuXRYmE0dc6k= github.com/togo-framework/auth v0.8.0 h1:wpbR6x50gbvZZ3pcZYOripRxs+yO2vHqycH3MpqHA6c= github.com/togo-framework/auth v0.8.0/go.mod h1:dPa8CdFlMGggb/PSHlhhqJvEqBelgf36t+qP/vkq2vQ= github.com/togo-framework/orm v0.1.0 h1:ip3QCIMv0VSsUIqAXdrayqJvK+eAyF/DPZy5sZKjNJk= github.com/togo-framework/orm v0.1.0/go.mod h1:1Z207HME5xC5PX3NqJjOPRiOTqsgW9q8YrqVCjJCTOc= -github.com/togo-framework/togo v0.18.0 h1:f1ImZ2vF0/jgGEBrXViztjz4Ra9HPOdpv7qOdXVZsIs= -github.com/togo-framework/togo v0.18.0/go.mod h1:/ybNefL1VWo0Pod32+YwW7ZgyInyXzOq3jGPBjUJYtc= github.com/togo-framework/togo v0.19.0 h1:N7R392wjYq9O1VI1ozIeHaL7vPgCLWnb/K2eFPVhL1w= github.com/togo-framework/togo v0.19.0/go.mod h1:/ybNefL1VWo0Pod32+YwW7ZgyInyXzOq3jGPBjUJYtc= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= diff --git a/mail.go b/mail.go new file mode 100644 index 0000000..36e327e --- /dev/null +++ b/mail.go @@ -0,0 +1,270 @@ +package dashboard + +import ( + "context" + "crypto/subtle" + "crypto/tls" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/smtp" + + "github.com/go-chi/chi/v5" + "github.com/togo-framework/auth" + "github.com/togo-framework/togo" +) + +// Mail / SMTP admin surface for the dashboard plugin. +// +// The auth plugin intentionally leaves outbound email OUT of scope (its +// reset/magic-link endpoints return the link in the response with emailed:false +// and fire an event so a mail plugin can deliver it). The dashboard is the +// admin/ops plugin, so it owns SMTP configuration + a test-send here. +// +// Routes mount under /api/dashboard/admin/mail behind the auth plugin's +// RequireRole("admin") guard; writes also carry a double-submit CSRF check +// (bearer requests are exempt, mirroring the auth plugin's admin surface). The +// config persists in a small kv table (dashboard_kv) — the same reliable +// pattern Fort uses — so it works across every SQL driver togo supports. + +const ( + csrfCookieName = "togo_csrf" // matches the auth plugin's CSRF cookie + mailKVKey = "smtp" // dashboard_kv row holding the SMTP config JSON + maskedSecret = "••••••••" //nolint:gosmopolitan // UI mask, not a credential +) + +// smtpConfig is the persisted SMTP configuration. Field names match the kit's +// MailConfig type (host/port/username/password/from/secure) so MailSettingsForm +// round-trips it directly. +type smtpConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + From string `json:"from"` + Secure bool `json:"secure"` +} + +// mailAdmin carries the kernel handle for the mail routes. +type mailAdmin struct{ k *togo.Kernel } + +// mountMailRoutes wires the dashboard SMTP/mail admin API. It is a no-op (with a +// warning) when the auth service isn't on the kernel, since the routes are +// guarded by auth's RequireRole. +func mountMailRoutes(k *togo.Kernel) { + svc, ok := auth.FromKernel(k) + if !ok { + if k.Log != nil { + k.Log.Warn("dashboard mail admin disabled", "reason", "auth service unavailable") + } + return + } + m := &mailAdmin{k: k} + k.Router.Route("/api/dashboard/admin", func(r chi.Router) { + r.Use(svc.RequireRole("admin")) + r.Get("/mail", m.getMail) + r.With(csrfGuard).Put("/mail", m.putMail) + r.With(csrfGuard).Post("/mail/test", m.testMail) + }) + if k.Log != nil { + k.Log.Info("dashboard mail admin active", "routes", "/api/dashboard/admin/mail") + } +} + +// ---- HTTP handlers ------------------------------------------------------- + +func (m *mailAdmin) getMail(w http.ResponseWriter, r *http.Request) { + cfg, _ := m.loadSMTP(r.Context()) + if cfg.Password != "" { + cfg.Password = maskedSecret // never leak the stored secret + } + writeJSON(w, http.StatusOK, cfg) +} + +func (m *mailAdmin) putMail(w http.ResponseWriter, r *http.Request) { + var cfg smtpConfig + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + // Keep the existing password when the caller echoes the mask (or sends none). + if cfg.Password == "" || cfg.Password == maskedSecret { + if old, ok := m.loadSMTP(r.Context()); ok { + cfg.Password = old.Password + } else { + cfg.Password = "" + } + } + if err := m.saveSMTP(r.Context(), cfg); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (m *mailAdmin) testMail(w http.ResponseWriter, r *http.Request) { + var body struct { + To string `json:"to"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + cfg, ok := m.loadSMTP(r.Context()) + if !ok { + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "SMTP is not configured — set host/port/from first"}) + return + } + if body.To == "" { + body.To = cfg.From + } + if err := sendSMTP(cfg, body.To, "togo SMTP test", "This is a test email from your togo dashboard. SMTP is working."); err != nil { + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// ---- persistence (dashboard_kv) ------------------------------------------ + +func (m *mailAdmin) db(ctx context.Context) (*sql.DB, func(int) string) { + db, err := m.k.SQL(ctx) + if err != nil || db == nil { + return nil, nil + } + return db, m.k.Dialect().Placeholder +} + +func (m *mailAdmin) kvSet(ctx context.Context, key, val string) error { + db, ph := m.db(ctx) + if db == nil { + return fmt.Errorf("no database") + } + if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS dashboard_kv (k TEXT PRIMARY KEY, v TEXT)`); err != nil { + return err + } + _, err := db.ExecContext(ctx, + `INSERT INTO dashboard_kv (k,v) VALUES (`+ph(1)+`,`+ph(2)+`) ON CONFLICT(k) DO UPDATE SET v=`+ph(2), + key, val) + return err +} + +func (m *mailAdmin) kvGet(ctx context.Context, key string) (string, bool) { + db, ph := m.db(ctx) + if db == nil { + return "", false + } + _, _ = db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS dashboard_kv (k TEXT PRIMARY KEY, v TEXT)`) + var v string + if err := db.QueryRowContext(ctx, `SELECT v FROM dashboard_kv WHERE k=`+ph(1), key).Scan(&v); err != nil { + return "", false + } + return v, true +} + +func (m *mailAdmin) loadSMTP(ctx context.Context) (smtpConfig, bool) { + raw, ok := m.kvGet(ctx, mailKVKey) + if !ok { + return smtpConfig{}, false + } + var cfg smtpConfig + _ = json.Unmarshal([]byte(raw), &cfg) + return cfg, cfg.Host != "" +} + +func (m *mailAdmin) saveSMTP(ctx context.Context, cfg smtpConfig) error { + b, _ := json.Marshal(cfg) + return m.kvSet(ctx, mailKVKey, string(b)) +} + +// ---- SMTP send (stdlib net/smtp) ----------------------------------------- + +func sendSMTP(cfg smtpConfig, to, subject, body string) error { + if cfg.Host == "" { + return fmt.Errorf("smtp host not set") + } + port := cfg.Port + if port == 0 { + port = 587 + } + addr := fmt.Sprintf("%s:%d", cfg.Host, port) + from := cfg.From + if from == "" { + from = cfg.Username + } + msg := []byte("From: " + from + "\r\nTo: " + to + "\r\nSubject: " + subject + + "\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n" + body + "\r\n") + + var smtpAuth smtp.Auth + if cfg.Username != "" { + smtpAuth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + } + + // Implicit TLS (SMTPS) on 465; STARTTLS upgrade is handled by smtp.SendMail. + if cfg.Secure && port == 465 { + c, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}) + if err != nil { + return err + } + defer func() { _ = c.Close() }() + cl, err := smtp.NewClient(c, cfg.Host) + if err != nil { + return err + } + defer func() { _ = cl.Close() }() + if smtpAuth != nil { + if err := cl.Auth(smtpAuth); err != nil { + return err + } + } + if err := cl.Mail(from); err != nil { + return err + } + if err := cl.Rcpt(to); err != nil { + return err + } + wc, err := cl.Data() + if err != nil { + return err + } + if _, err := wc.Write(msg); err != nil { + return err + } + return wc.Close() + } + return smtp.SendMail(addr, smtpAuth, from, []string{to}, msg) +} + +// ---- middleware + helpers ------------------------------------------------ + +// csrfGuard enforces double-submit CSRF on unsafe methods for COOKIE-authed +// requests, mirroring the auth plugin. Bearer (API/impersonation) requests are +// exempt — they're not cookie-driven, so not CSRF-prone. +func csrfGuard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + next.ServeHTTP(w, r) + return + } + if h := r.Header.Get("Authorization"); len(h) > 7 && h[:7] == "Bearer " { + next.ServeHTTP(w, r) + return + } + c, err := r.Cookie(csrfCookieName) + header := r.Header.Get("X-CSRF-Token") + if err != nil || header == "" || subtle.ConstantTimeCompare([]byte(c.Value), []byte(header)) != 1 { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "invalid csrf token"}) + return + } + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/web/app/(app)/admin/mail/page.tsx b/web/app/(app)/admin/mail/page.tsx new file mode 100644 index 0000000..40235d0 --- /dev/null +++ b/web/app/(app)/admin/mail/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + PageHeader, MailSettingsForm, useLanguage, type MailConfig, +} from "@togo-framework/ui"; +import { loadMail, saveMail, testMail } from "@/lib/mail"; +import { trans } from "@/lib/i18n"; + +export default function AdminMailPage() { + const { language } = useLanguage(); + const [config, setConfig] = useState({ port: 587, secure: true }); + const [available, setAvailable] = useState(true); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + loadMail() + .then(({ config: c, available: a }) => { setConfig(c); setAvailable(a); }) + .catch(() => setAvailable(false)) + .finally(() => setLoaded(true)); + }, []); + + return ( +
+ +
+ {/* key flips once loaded so the kit form re-seeds from `value`. */} + +
+
+ ); +} diff --git a/web/app/(app)/admin/users/[id]/page.tsx b/web/app/(app)/admin/users/[id]/page.tsx new file mode 100644 index 0000000..128026b --- /dev/null +++ b/web/app/(app)/admin/users/[id]/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { use, useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Users, ShieldCheck, KeyRound, ArrowLeft } from "lucide-react"; +import { + PageHeader, Card, CardHeader, CardTitle, CardContent, EmptyState, Badge, + Avatar, AvatarFallback, UserActionsMenu, useLanguage, type AdminUser, +} from "@togo-framework/ui"; +import { adminUsers } from "@/lib/admin-users"; +import { setImpersonation } from "@/lib/impersonation"; +import { trans } from "@/lib/i18n"; + +function Chips({ items, empty, mono }: { items: string[]; empty: string; mono?: boolean }) { + if (!items.length) return

{empty}

; + return ( +
+ {items.map((x) => {x})} +
+ ); +} + +export default function AdminUserDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const router = useRouter(); + const { language } = useLanguage(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + setLoading(true); + try { + setUser(await adminUsers.get(id)); + } catch { + setUser(null); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + refresh(); + }, [refresh]); + + async function impersonate(u: AdminUser) { + const r = await adminUsers.impersonate(u.id!); + setImpersonation({ + id: u.id ?? r.identity?.id ?? "", + email: u.email ?? r.identity?.email ?? "", + token: r.token, + }); + router.push("/admin"); + } + + return ( +
+ + + { await adminUsers.update(user.id!, input); await refresh(); }} + onImpersonate={() => impersonate(user)} + onResetPassword={({ password }) => adminUsers.resetPassword(user.id!, password)} + onSendMagicLink={() => adminUsers.magicLink(user.id!)} + onDelete={async () => { await adminUsers.remove(user.id!); router.push("/admin/users"); }} + /> + ) : undefined + } + /> + + {!user && !loading ? ( + } /> + ) : ( +
+ + + {(user?.email ?? "?").charAt(0).toUpperCase()} +
+
{user?.email}
+
{user?.id}
+
+ {user?.created_at ? ( +
+ {trans("admin.joined", "Joined")} {new Date(user.created_at).toLocaleDateString()} +
+ ) : null} +
+
+
+ + {trans("admin.roles", "Roles")} + + + + {trans("admin.permissions", "Permissions")} + + +
+
+ )} +
+ ); +} diff --git a/web/app/(app)/admin/users/page.tsx b/web/app/(app)/admin/users/page.tsx new file mode 100644 index 0000000..3478265 --- /dev/null +++ b/web/app/(app)/admin/users/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + PageHeader, + UserManagementTable, + UserActionsMenu, + AddUserButton, + useLanguage, + type AdminUser, +} from "@togo-framework/ui"; +import { adminUsers } from "@/lib/admin-users"; +import { setImpersonation } from "@/lib/impersonation"; +import { trans } from "@/lib/i18n"; + +export default function AdminUsersPage() { + const router = useRouter(); + const { language } = useLanguage(); + const [users, setUsers] = useState(null); + const [err, setErr] = useState(""); + + const refresh = useCallback(async () => { + try { + setUsers(await adminUsers.list()); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + setUsers([]); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + async function impersonate(u: AdminUser) { + const r = await adminUsers.impersonate(u.id!); + setImpersonation({ + id: u.id ?? r.identity?.id ?? "", + email: u.email ?? r.identity?.email ?? "", + token: r.token, + }); + router.push("/admin"); + } + + return ( +
+ { await adminUsers.create(input); await refresh(); }} />} + /> + + {err &&

{err}

} + +
+ u.id && router.push(`/admin/users/${u.id}`)} + emptyTitle={trans("admin.no_users", "No users")} + emptyDescription={trans("admin.no_users_desc", "New sign-ups and accounts created here will appear in this list.")} + renderActions={(u) => ( + { await adminUsers.update(u.id!, input); await refresh(); }} + onImpersonate={() => impersonate(u)} + onResetPassword={({ password }) => adminUsers.resetPassword(u.id!, password)} + onSendMagicLink={() => adminUsers.magicLink(u.id!)} + onDelete={async () => { await adminUsers.remove(u.id!); await refresh(); }} + /> + )} + /> +
+
+ ); +} diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx index f44efa9..ab37526 100644 --- a/web/app/(app)/layout.tsx +++ b/web/app/(app)/layout.tsx @@ -2,17 +2,18 @@ import { usePathname, useRouter } from "next/navigation"; import { ReactNode, useEffect, useState } from "react"; -import { LayoutGrid, Table2, User, LogOut, Layers, ChevronDown } from "lucide-react"; +import { LayoutGrid, Table2, User, LogOut, Layers, ChevronDown, Users, Mail } from "lucide-react"; import { SidebarProvider, Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarInset, SidebarTrigger, Avatar, AvatarFallback, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, - StatusBadge, + StatusBadge, ImpersonationBanner, useLanguage, } from "@togo-framework/ui"; import { auth } from "@/lib/auth"; import { trans } from "@/lib/i18n"; +import { getImpersonation, onImpersonationChange, clearImpersonation, type Impersonation } from "@/lib/impersonation"; const API = process.env.NEXT_PUBLIC_API_ORIGIN ?? ""; const APP = process.env.NEXT_PUBLIC_APP_NAME ?? "togo"; @@ -20,9 +21,11 @@ const APP = process.env.NEXT_PUBLIC_APP_NAME ?? "togo"; export default function DashboardLayout({ children }: { children: ReactNode }) { const router = useRouter(); const pathname = usePathname(); + const { language } = useLanguage(); const [me, setMe] = useState(null); const [resources, setResources] = useState<{ name: string; table: string }[]>([]); const [live, setLive] = useState(false); + const [imp, setImp] = useState(null); useEffect(() => { auth.me().then((u) => { @@ -36,9 +39,16 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { return () => es.close(); }, [router]); + useEffect(() => { + setImp(getImpersonation()); + return onImpersonationChange(() => setImp(getImpersonation())); + }, []); + const primary = [ { href: "/dashboard", label: trans("nav.dashboard", "Dashboard"), icon: }, { href: "/admin", label: trans("nav.admin", "Admin"), icon: }, + { href: "/admin/users", label: trans("nav.users", "Users"), icon: }, + { href: "/admin/mail", label: trans("nav.mail", "Mail"), icon: }, ]; const initial = (me?.email ?? "?").charAt(0).toUpperCase(); @@ -101,6 +111,11 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { + { clearImpersonation(); router.push("/admin/users"); }} + />
diff --git a/web/lib/admin-users.ts b/web/lib/admin-users.ts new file mode 100644 index 0000000..d5290ca --- /dev/null +++ b/web/lib/admin-users.ts @@ -0,0 +1,68 @@ +// Admin user-management client — talks to the auth plugin's /api/auth/admin/* +// surface (guarded by role=admin + double-submit CSRF; bearer requests are +// CSRF-exempt). Mirrors the existing lib/auth.ts fetch pattern: HttpOnly cookie +// session + a CSRF token fetched from /api/auth/csrf, plus an Authorization +// bearer header while impersonating. +"use client"; + +import type { AdminUser, AddUserInput, EditUserInput, AdminLinkResult } from "@togo-framework/ui"; +import { impersonationHeaders } from "./impersonation"; + +const API = process.env.NEXT_PUBLIC_API_ORIGIN ?? ""; + +async function csrf(): Promise { + const res = await fetch(`${API}/api/auth/csrf`, { credentials: "include" }); + const data = await res.json().catch(() => ({})); + return data.csrf_token ?? ""; +} + +async function req(path: string, init: RequestInit & { write?: boolean } = {}): Promise { + const { write, headers, ...rest } = init; + const h: Record = { + "Content-Type": "application/json", + ...impersonationHeaders(), + ...(headers as Record | undefined), + }; + if (write) h["X-CSRF-Token"] = await csrf(); + const res = await fetch(`${API}/api/auth/admin${path}`, { credentials: "include", headers: h, ...rest }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || data.detail || `request failed (${res.status})`); + return data as T; +} + +export const adminUsers = { + list: (q = ""): Promise => + req(`/users${q ? `?q=${encodeURIComponent(q)}` : ""}`).then((d) => (Array.isArray(d) ? d : [])), + + get: (id: string): Promise => req(`/users/${id}`), + + create: (input: AddUserInput): Promise => + req(`/users`, { + method: "POST", + write: true, + body: JSON.stringify({ email: input.email, password: input.password || undefined, roles: input.roles }), + }).then(() => undefined), + + update: (id: string, input: EditUserInput): Promise => + req(`/users/${id}`, { + method: "PATCH", + write: true, + body: JSON.stringify({ email: input.email, roles: input.roles, permissions: input.permissions }), + }).then(() => undefined), + + remove: (id: string): Promise => + req(`/users/${id}`, { method: "DELETE", write: true }).then(() => undefined), + + impersonate: (id: string): Promise<{ token?: string; identity?: { id?: string; email?: string } }> => + req(`/users/${id}/impersonate`, { method: "POST", write: true }), + + resetPassword: (id: string, password?: string): Promise => + req(`/users/${id}/reset-password`, { + method: "POST", + write: true, + body: JSON.stringify(password ? { password } : {}), + }), + + magicLink: (id: string): Promise => + req(`/users/${id}/magic-link`, { method: "POST", write: true }), +}; diff --git a/web/lib/impersonation.ts b/web/lib/impersonation.ts new file mode 100644 index 0000000..07c7093 --- /dev/null +++ b/web/lib/impersonation.ts @@ -0,0 +1,53 @@ +// Impersonation state for the dashboard admin. +// +// When an admin impersonates a user, the auth plugin issues a bearer token for +// that identity (POST /api/auth/admin/users/{id}/impersonate). The dashboard's +// session is an HttpOnly cookie that JS can't overwrite, so — like Fort — we +// stash {id,email,token} client-side and send `Authorization: Bearer ` +// on admin API calls while impersonating (bearer requests are also CSRF-exempt). +// A window event + the `storage` event keep the ImpersonationBanner in sync +// across tabs. +"use client"; + +const KEY = "togo_impersonate"; +const EVENT = "togo-impersonation"; + +export type Impersonation = { id: string; email: string; token?: string } | null; + +export function getImpersonation(): Impersonation { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(KEY); + return raw ? (JSON.parse(raw) as Impersonation) : null; + } catch { + return null; + } +} + +export function setImpersonation(imp: Impersonation) { + if (typeof window === "undefined") return; + if (imp) window.localStorage.setItem(KEY, JSON.stringify(imp)); + else window.localStorage.removeItem(KEY); + window.dispatchEvent(new Event(EVENT)); +} + +export function clearImpersonation() { + setImpersonation(null); +} + +/** Subscribe to impersonation changes (this tab + other tabs). */ +export function onImpersonationChange(fn: () => void): () => void { + if (typeof window === "undefined") return () => {}; + window.addEventListener(EVENT, fn); + window.addEventListener("storage", fn); + return () => { + window.removeEventListener(EVENT, fn); + window.removeEventListener("storage", fn); + }; +} + +/** Authorization header to send while impersonating (empty otherwise). */ +export function impersonationHeaders(): Record { + const imp = getImpersonation(); + return imp?.token ? { Authorization: `Bearer ${imp.token}` } : {}; +} diff --git a/web/lib/mail.ts b/web/lib/mail.ts new file mode 100644 index 0000000..2ecb27f --- /dev/null +++ b/web/lib/mail.ts @@ -0,0 +1,57 @@ +// Mail/SMTP admin client — talks to the dashboard plugin's own backend under +// /api/dashboard/admin/mail (GET/PUT) + /api/dashboard/admin/mail/test. Guarded +// by role=admin + double-submit CSRF on writes (same pattern as the auth admin +// surface). Field names match the kit's MailConfig so MailSettingsForm round- +// trips the value directly. +"use client"; + +import type { MailConfig, MailTestResult } from "@togo-framework/ui"; + +const API = process.env.NEXT_PUBLIC_API_ORIGIN ?? ""; + +async function csrf(): Promise { + const res = await fetch(`${API}/api/auth/csrf`, { credentials: "include" }); + const data = await res.json().catch(() => ({})); + return data.csrf_token ?? ""; +} + +export type MailLoad = { config: MailConfig; available: boolean }; + +export async function loadMail(): Promise { + const res = await fetch(`${API}/api/dashboard/admin/mail`, { credentials: "include" }); + if (res.status === 404) return { config: { port: 587, secure: true }, available: false }; + if (!res.ok) throw new Error(`load failed (${res.status})`); + const config = (await res.json().catch(() => ({}))) as MailConfig; + return { config: { port: 587, secure: true, ...config }, available: true }; +} + +export async function saveMail(config: MailConfig): Promise { + const token = await csrf(); + const res = await fetch(`${API}/api/dashboard/admin/mail`, { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json", "X-CSRF-Token": token }, + body: JSON.stringify(config), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d.error || `save failed (${res.status})`); + } +} + +export async function testMail(to: string): Promise { + try { + const token = await csrf(); + const res = await fetch(`${API}/api/dashboard/admin/mail/test`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json", "X-CSRF-Token": token }, + body: JSON.stringify({ to }), + }); + const d = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }; + if (!res.ok) return { ok: false, error: d.error || `test failed (${res.status})` }; + return { ok: !!d.ok, error: d.error }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : "Test failed" }; + } +}