From 1a6b36070165e458e48ed87b5dd976659faeec6b Mon Sep 17 00:00:00 2001 From: 1tzEmerald Date: Sun, 26 Jul 2026 18:38:25 +0100 Subject: [PATCH] Dashboard 1.0 Added Addon Builder Added Emerald SRV API for One-Click Addon install Added API Config Update Checker for Bot and Panel --- dashboard/src/App.tsx | 12 +- dashboard/src/api/client.ts | 20 +- dashboard/src/components/app-sidebar.tsx | 34 +-- .../config/config-visual-editor.tsx | 88 ++++--- dashboard/src/components/dashboard-search.tsx | 15 ++ .../settings/dashboard-api-settings.tsx | 166 +++++++++++++ .../settings/dashboard-branding-settings.tsx | 178 +++++++------- dashboard/src/lib/permissions.ts | 2 +- dashboard/src/lib/plugins.ts | 2 +- dashboard/src/pages/AddonBuilder.tsx | 223 ++++++++++++++++++ dashboard/src/pages/Addons.tsx | 57 +++-- dashboard/src/pages/Configs.tsx | 2 +- dashboard/src/pages/Settings.tsx | 134 ++++++----- 13 files changed, 720 insertions(+), 213 deletions(-) create mode 100644 dashboard/src/components/settings/dashboard-api-settings.tsx create mode 100644 dashboard/src/pages/AddonBuilder.tsx diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 8e7ea31..f0b25b2 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -7,12 +7,14 @@ import Login from "./pages/Login"; import Setup from "./pages/Setup"; import { HomeRedirect } from "./components/home-redirect"; import { RequirePermission } from "./components/require-permission"; +import Addons from "./pages/Addons"; +import { AddonBuilder } from "@/pages/AddonBuilder"; +import { DashboardSettings } from "@/pages/DashboardSettings"; import Settings from "./pages/Settings"; import SystemDetails from "./pages/SystemDetails"; import Logs from "./pages/Logs"; import Transcripts from "./pages/Transcripts"; import Users from "./pages/Users"; -import Addons from "./pages/Addons"; function Protected({ children }: { children: React.ReactNode }) { const { isAuthenticated, loading } = useAuth(); @@ -112,6 +114,14 @@ export default function App() { } /> + + + + } + /> } /> diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index 8d1cba0..a5b9532 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -85,15 +85,21 @@ export interface ApiResponse { export interface CatalogAddon { id: string; name: string; + slug?: string; description: string; repositoryUrl: string; + price?: number; + is_external?: number; + external_store?: string; jsFiles: string[]; configFiles: string[]; - fileCount: number; + fileCount?: number; installed?: boolean; } export interface UpdateCheckResult { + id: string; + name: string; current: string; latest: string; updateAvailable: boolean; @@ -269,12 +275,12 @@ export const api = { }), checkForUpdates: () => - request>("/api/system/update-check"), + request>("/api/system/update-check"), - runUpdate: (version: string) => + runUpdate: (repoId: string, version: string, url: string) => request>("/api/system/update", { method: "POST", - body: JSON.stringify({ version }), + body: JSON.stringify({ repoId, version, url }), }), reloadConfigs: (file?: string) => @@ -547,6 +553,12 @@ export const api = { body: JSON.stringify({ filename, data }), }), + buildAddon: (payload: { name: string; description: string; permission: string; embed: any }) => + request>("/api/addons/build", { + method: "POST", + body: JSON.stringify(payload), + }), + getSetupStatus: () => request>("/api/setup/status"), diff --git a/dashboard/src/components/app-sidebar.tsx b/dashboard/src/components/app-sidebar.tsx index c3f1ff2..fe0e2fe 100644 --- a/dashboard/src/components/app-sidebar.tsx +++ b/dashboard/src/components/app-sidebar.tsx @@ -52,42 +52,42 @@ const mainNav: { icon: IconSvgElement; end?: boolean; permission?: string; -}[] = [ - { title: "Dashboard", url: "/", icon: DashboardSquare02Icon, end: true, permission: "overview" }, -]; +}[] = []; const sections: { label: string; - items: { title: string; url: string; icon: IconSvgElement; permission?: string }[]; + items: { title: string; url: string; icon: IconSvgElement; permission?: string; end?: boolean }[]; }[] = [ + { + label: "Overview", + items: [ + { title: "Dashboard", url: "/", icon: DashboardSquare02Icon, end: true, permission: "overview" }, + { title: "Console", url: "/logs", icon: ComputerTerminal01Icon, permission: "logs" }, + ], + }, { label: "Configuration", items: [ { title: "Bot Settings", url: "/configs?file=supportbot", icon: BotIcon }, - { title: "Commands", url: "/configs?file=commands", icon: CommandIcon }, - { title: "Messages", url: "/configs?file=messages", icon: Message01Icon }, - ], - }, - { - label: "Features", - items: [ - { title: "Ticket System", url: "/configs?file=ticket-panel", icon: Ticket01Icon }, + { title: "Ticket Panel", url: "/configs?file=ticket-panel", icon: Ticket01Icon }, { title: "AI Assistant", url: "/configs?file=supportbot-ai", icon: AiBrain01Icon }, - { title: "Transcripts", url: "/transcripts", icon: File02Icon, permission: "transcripts" }, + { title: "Messages", url: "/configs?file=messages", icon: Message01Icon }, + { title: "Commands", url: "/configs?file=commands", icon: CommandIcon }, ], }, { - label: "Management", + label: "Access & Logs", items: [ { title: "Users", url: "/users", icon: UserMultiple02Icon, permission: "users.view" }, { title: "Staff Roles", url: "/configs?file=supportbot", icon: UserMultiple02Icon }, - { title: "Console Logs", url: "/logs", icon: ComputerTerminal01Icon, permission: "logs" }, + { title: "Transcripts", url: "/transcripts", icon: File02Icon, permission: "transcripts" }, ], }, { label: "System", items: [ - { title: "Addon Manager", url: "/addons", icon: PackageIcon, permission: "settings.view" }, + { title: "Addons", url: "/addons", icon: PackageIcon, permission: "settings.view" }, + { title: "Addon Builder", url: "/addons/builder", icon: CommandIcon, permission: "settings.update" }, { title: "Dashboard Settings", url: "/settings", icon: Settings02Icon, permission: "settings.view" }, ], }, @@ -113,6 +113,7 @@ export function AppSidebar() { title: addonConfigLabel(filename), url: `/configs?file=${addonConfigFileParam(filename)}`, icon: PackageIcon, + end: false, })), } : null; @@ -227,6 +228,7 @@ export function AppSidebar() { cn( (isActive || isConfigActive) && "bg-sidebar-accent font-medium", )} diff --git a/dashboard/src/components/config/config-visual-editor.tsx b/dashboard/src/components/config/config-visual-editor.tsx index b8eaf41..70b2cf9 100644 --- a/dashboard/src/components/config/config-visual-editor.tsx +++ b/dashboard/src/components/config/config-visual-editor.tsx @@ -17,6 +17,7 @@ import { getByPath, setByPath } from "@/lib/config-utils"; import { mergeSchemaWithData } from "@/lib/merge-config-schema"; import { useAuth } from "@/context/AuthContext"; import { notifyConfigSave } from "@/lib/notify-config-save"; +import { cn } from "@/lib/utils"; import { Textarea } from "@/components/ui/textarea"; import Editor from "@monaco-editor/react"; import { ConfigFieldInput } from "./config-field"; @@ -91,6 +92,7 @@ export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) { const [mode, setMode] = useState("visual"); const [rawYaml, setRawYaml] = useState(""); const [rawOriginal, setRawOriginal] = useState(""); + const [activeSectionTitle, setActiveSectionTitle] = useState(null); const baseSchema = getSchema(configFile); @@ -99,6 +101,14 @@ export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) { return mergeSchemaWithData(baseSchema, data); }, [baseSchema, data]); + useEffect(() => { + if (effectiveSchema && effectiveSchema !== "commands") { + if (!activeSectionTitle || !effectiveSchema.find(s => s.title === activeSectionTitle)) { + setActiveSectionTitle(effectiveSchema[0]?.title || null); + } + } + }, [effectiveSchema, activeSectionTitle]); + const load = useCallback(async () => { setLoading(true); setError(""); @@ -321,35 +331,57 @@ export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) { onChange={(next) => setData(next)} /> ) : effectiveSchema && effectiveSchema !== "commands" ? ( - effectiveSchema.map((section) => ( - - - {section.title} - {section.description ? ( - {section.description} - ) : null} - - - {section.fields.map((field) => ( -
- patch(field.path, v)} - /> -
+
+ +
+ {effectiveSchema + .filter((section) => section.title === activeSectionTitle) + .map((section) => ( + + + {section.title} + {section.description ? ( + {section.description} + ) : null} + + + {section.fields.map((field) => ( +
+ patch(field.path, v)} + /> +
+ ))} +
+
))} - - - )) +
+
) : null} ); diff --git a/dashboard/src/components/dashboard-search.tsx b/dashboard/src/components/dashboard-search.tsx index e624d3b..e6dd25b 100644 --- a/dashboard/src/components/dashboard-search.tsx +++ b/dashboard/src/components/dashboard-search.tsx @@ -75,6 +75,18 @@ export function DashboardSearch({ className }: { className?: string }) { return () => document.removeEventListener("mousedown", onPointerDown); }, []); + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + inputRef.current?.focus(); + setOpen(true); + } + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, []); + function selectItem(item: SearchItem) { navigate(item.href); setQuery(""); @@ -111,6 +123,9 @@ export function DashboardSearch({ className }: { className?: string }) { aria-controls="dashboard-search-results" aria-autocomplete="list" /> + + ⌘K + {showPanel && panelRect ? createPortal( diff --git a/dashboard/src/components/settings/dashboard-api-settings.tsx b/dashboard/src/components/settings/dashboard-api-settings.tsx new file mode 100644 index 0000000..7b29008 --- /dev/null +++ b/dashboard/src/components/settings/dashboard-api-settings.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from "react"; +import { api } from "@/api/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useAuth } from "@/context/AuthContext"; + +export function DashboardApiSettings() { + const { hasPermission } = useAuth(); + const canEdit = hasPermission("settings.update"); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(""); + const [config, setConfig] = useState(null); + + useEffect(() => { + api.getConfigJson("api") + .then((res) => { + if (res.data) setConfig(res.data); + }) + .catch(() => setMessage("Failed to load API config")) + .finally(() => setLoading(false)); + }, []); + + async function saveConfig() { + if (!canEdit || !config) return; + setSaving(true); + setMessage(""); + try { + // Create a flat update payload for updateConfigFields + const updates = { + "api:API.Port": config.API?.Port, + "api:API.EmeraldAPIKey": config.API?.EmeraldAPIKey, + "api:API.TrustProxy": config.API?.TrustProxy, + "api:API.OAuth.Enabled": config.API?.OAuth?.Enabled, + "api:API.OAuth.ClientId": config.API?.OAuth?.ClientId, + "api:API.OAuth.ClientSecret": config.API?.OAuth?.ClientSecret, + "api:API.OAuth.RedirectUri": config.API?.OAuth?.RedirectUri, + }; + + await api.updateConfigFields(updates); + setMessage("API settings saved and backend restarted."); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Save failed"); + } finally { + setSaving(false); + } + } + + if (loading) return
; + if (!config) return null; + + return ( +
+
+
+

Core API Settings

+

+ Configure the internal API that powers this dashboard. +

+
+ +
+
+ + setConfig({ ...config, API: { ...config.API, Port: parseInt(e.target.value) || 3000 }})} + className="border-border bg-secondary/30" + /> +
+
+ + setConfig({ ...config, API: { ...config.API, EmeraldAPIKey: e.target.value }})} + className="border-border bg-secondary/30" + /> +
+
+ +
+ setConfig({ ...config, API: { ...config.API, TrustProxy: c }})} + /> + +
+
+ +
+
+

OAuth Authentication

+

+ Discord application credentials for dashboard login. +

+
+ +
+ setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, Enabled: c } }})} + /> + +
+ +
+
+ + setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, ClientId: e.target.value } }})} + className="border-border bg-secondary/30" + /> +
+
+ + setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, ClientSecret: e.target.value } }})} + className="border-border bg-secondary/30" + /> +
+
+ + setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, RedirectUri: e.target.value } }})} + className="border-border bg-secondary/30" + /> +

+ Must match the OAuth2 callback URL registered in the Discord Developer Portal exactly. +

+
+
+
+ + {canEdit ? ( +
+ + {message ? ( + {message} + ) : null} +
+ ) : null} +
+ ); +} diff --git a/dashboard/src/components/settings/dashboard-branding-settings.tsx b/dashboard/src/components/settings/dashboard-branding-settings.tsx index e42f872..32f507c 100644 --- a/dashboard/src/components/settings/dashboard-branding-settings.tsx +++ b/dashboard/src/components/settings/dashboard-branding-settings.tsx @@ -105,61 +105,80 @@ export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettings title.trim() !== branding.title || pageTitle.trim() !== branding.pageTitle; return ( -
-
-

Branding

-

- Sidebar title, browser tab title, and favicon — saved on the server for - everyone using this dashboard. -

+
+ {/* Text Branding */} +
+
+

Branding Text

+

+ Sidebar title and browser tab title — saved on the server for everyone using this dashboard. +

+
+ +
+
+ + setTitle(e.target.value)} + className="border-border bg-secondary/30" + placeholder="SupportBot" + /> +
+
+ + setPageTitle(e.target.value)} + className="border-border bg-secondary/30" + placeholder="SupportBot Dashboard" + /> +
+
+ + {canEdit ? ( +
+ + {message && textDirty ? ( + {message} + ) : null} +
+ ) : null}
-
-
- - setTitle(e.target.value)} - className="border-border bg-secondary/30" - placeholder="SupportBot" - /> + {/* Favicon Branding */} +
+
+

Favicon

+

+ PNG, ICO, SVG, JPEG, or WebP — max 512 KB. Shown in the browser tab and beside the sidebar title. +

-
- - setPageTitle(e.target.value)} - className="border-border bg-secondary/30" - placeholder="SupportBot Dashboard" - /> -
-
- -
- -

- PNG, ICO, SVG, JPEG, or WebP — max 512 KB. Shown in the browser tab and - beside the sidebar title. -

-
-
+
+
{faviconPreview ? ( Favicon preview ) : ( )}
@@ -171,60 +190,57 @@ export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettings disabled={!canEdit || uploading} onChange={(e) => void onFaviconSelected(e.target.files?.[0] ?? null)} /> - {canEdit ? ( - <> - - {branding.hasFavicon ? ( +
+ {canEdit ? ( +
- ) : null} - - ) : ( - View only - )} + {branding.hasFavicon ? ( + + ) : null} +
+ ) : ( + View only + )} + {!textDirty && message ? ( + {message} + ) : null} +
+ {/* Danger Zone */} {canEdit ? ( -
+
+
+

Danger Zone

+

+ Reset all branding text and favicon to their original defaults. +

+
-
) : null} - - {message ? ( -

{message}

- ) : null}
); } diff --git a/dashboard/src/lib/permissions.ts b/dashboard/src/lib/permissions.ts index 72b0cf4..cc9e7a4 100644 --- a/dashboard/src/lib/permissions.ts +++ b/dashboard/src/lib/permissions.ts @@ -67,7 +67,7 @@ export const ROLE_HINTS: Record = { export const CONFIG_FILE_LABELS: Record = { supportbot: "Bot config", - "ticket-panel": "Ticket system", + "ticket-panel": "Ticket panel", commands: "Commands", messages: "Messages", "supportbot-ai": "AI assistant", diff --git a/dashboard/src/lib/plugins.ts b/dashboard/src/lib/plugins.ts index 78860f6..cd29245 100644 --- a/dashboard/src/lib/plugins.ts +++ b/dashboard/src/lib/plugins.ts @@ -26,7 +26,7 @@ export interface PluginModule { export const plugins: PluginModule[] = [ { id: "tickets", - title: "Ticket system", + title: "Ticket panel", description: "Create and manage support tickets with departments, priorities, and AI assistance.", icon: Ticket01Icon, diff --git a/dashboard/src/pages/AddonBuilder.tsx b/dashboard/src/pages/AddonBuilder.tsx new file mode 100644 index 0000000..de99f68 --- /dev/null +++ b/dashboard/src/pages/AddonBuilder.tsx @@ -0,0 +1,223 @@ +import { useState } from "react"; +import { CommandIcon, SparklesIcon } from "@hugeicons/core-free-icons"; +import { Icon } from "@/components/icon"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { api } from "@/api/client"; +import { toast } from "sonner"; +import { useAuth } from "@/context/AuthContext"; +import { canEditConfig } from "@/lib/permissions"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { CheckCircle2Icon } from "lucide-react"; + +export function AddonBuilder() { + const { permissions } = useAuth(); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [restartMessage, setRestartMessage] = useState(""); + + const [commandName, setCommandName] = useState(""); + const [description, setDescription] = useState(""); + const [permission, setPermission] = useState("Administrator"); + const [embedTitle, setEmbedTitle] = useState(""); + const [embedDescription, setEmbedDescription] = useState(""); + const [embedColor, setEmbedColor] = useState("General"); + + const canEdit = canEditConfig(permissions, "supportbot"); + + const handleBuild = async () => { + if (!commandName.match(/^[a-z0-9_-]+$/i)) { + toast.error("Command name can only contain letters, numbers, underscores and dashes."); + return; + } + + setLoading(true); + setSuccess(false); + try { + // NOTE: the endpoint isn't added to client.ts yet, we'll do that next + const res = await (api as any).buildAddon({ + name: commandName.toLowerCase(), + description: description || "A custom command", + permission, + embed: { + title: embedTitle, + description: embedDescription || "Hello from your custom command!", + color: embedColor + } + }); + setSuccess(true); + setRestartMessage(res.message || "Restart required."); + toast.success(`Addon ${commandName}.js created successfully!`); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to build addon"); + } finally { + setLoading(false); + } + }; + + if (!canEdit) { + return ( +
+ You don't have permission to build addons. +
+ ); + } + + return ( +
+
+

Addon Builder

+

+ Visually scaffold a custom slash command addon for your bot. +

+
+ + {success ? ( + + + + Successfully built {commandName}.js! {restartMessage} + + + ) : null} + +
+
+ + + Command Details + Configure how users trigger the command. + + +
+ +
+ / + setCommandName(e.target.value.replace(/[^a-z0-9_-]/gi, '').toLowerCase())} + /> +
+
+
+ + setDescription(e.target.value)} + /> +
+
+ + +
+
+
+ + + + Response Embed + What the bot will reply with. + + +
+ + setEmbedTitle(e.target.value)} + /> +
+
+ +