feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats
Settings fixes: - MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings table (DB > ENV > default), so dashboard/settings changes apply without restart. Sync cache refreshed on every save or delete. - /api/me reports the live DB value for max_sites_per_user. Admin improvements: - Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS). - Admin Overview now shows platform stats: registered users, new users (7d), total user sites, available tools. Plugin cleanup: - Appwrite and Directus plugins removed from the active registry (8 plugins now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Plugin code is retained for future re-enabling. - Settings page plugin visibility list updated to match. Mobile onboarding: - Stepper steps on narrow viewports stack vertically with correct full border and rounded corners on each step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
171
web/src/App.tsx
Normal file
171
web/src/App.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useEffect } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { Shell } from "./components/Shell";
|
||||
import { applyUiToDom, useUiStore } from "./lib/store";
|
||||
import { useSession } from "./lib/queries";
|
||||
import { Toast } from "./components/primitives";
|
||||
import { ApiError, setCsrfToken } from "./lib/api";
|
||||
|
||||
import { LandingPage } from "./pages/Landing";
|
||||
import { LoginPage } from "./pages/Login";
|
||||
import { OnboardingPage } from "./pages/Onboarding";
|
||||
import { OverviewPage } from "./pages/Overview";
|
||||
import { SitesPage } from "./pages/Sites";
|
||||
import { SiteToolsPage } from "./pages/SiteTools";
|
||||
import { ConnectPage } from "./pages/Connect";
|
||||
import { ApiKeysPage } from "./pages/ApiKeys";
|
||||
import { OAuthClientsPage } from "./pages/OAuthClients";
|
||||
import { HealthPage } from "./pages/Health";
|
||||
import { AuditLogsPage } from "./pages/AuditLogs";
|
||||
import { SettingsPage } from "./pages/Settings";
|
||||
import { NotFoundPage } from "./pages/NotFound";
|
||||
|
||||
function RequireAuth({ children, adminOnly = false }: { children: React.ReactNode; adminOnly?: boolean }) {
|
||||
const { data, isLoading, error } = useSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const unauthenticated =
|
||||
(error instanceof ApiError && error.status === 401) || data?.authenticated === false;
|
||||
|
||||
if (unauthenticated) {
|
||||
// Post-G.12 the SPA owns `/dashboard/*`; the login page is its own SPA
|
||||
// route so we can client-side navigate instead of a full reload. Wrap in
|
||||
// a Navigate to preserve scroll + state across the protected → login
|
||||
// transition without a flash of the legacy form.
|
||||
const next = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`/login?next=${next}`} replace />;
|
||||
}
|
||||
|
||||
if (adminOnly && data && !data.is_admin) {
|
||||
return <Navigate to="/sites" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function RootPage() {
|
||||
const session = useSession();
|
||||
const isDashboardRoot =
|
||||
typeof window !== "undefined" &&
|
||||
(window.location.pathname === "/dashboard" || window.location.pathname === "/dashboard/");
|
||||
if (isDashboardRoot && session.data?.authenticated === true) {
|
||||
return <Navigate to="/overview" replace />;
|
||||
}
|
||||
return <LandingPage />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const ui = useUiStore();
|
||||
const { theme, lang, brandHue, density } = ui;
|
||||
const session = useSession();
|
||||
|
||||
// Apply theme/lang/hue/density to <html> on every change.
|
||||
useEffect(() => {
|
||||
applyUiToDom({ theme, lang, brandHue, density });
|
||||
}, [theme, lang, brandHue, density]);
|
||||
|
||||
// When the user picks "system" theme, follow OS-level preference changes
|
||||
// live (e.g. macOS auto dark mode at sunset) without a reload.
|
||||
useEffect(() => {
|
||||
if (theme !== "system" || typeof window === "undefined" || !window.matchMedia) return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => applyUiToDom({ theme, lang, brandHue, density });
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [theme, lang, brandHue, density]);
|
||||
|
||||
// Sync language from server session on the user's *first* visit only.
|
||||
// The zustand-persist middleware seeds `lang` from localStorage
|
||||
// synchronously, so if the user already has a stored choice we must not
|
||||
// overwrite it with whatever Accept-Language the server inferred — that
|
||||
// was forcing a refresh on /dashboard/* to flip back to English.
|
||||
useEffect(() => {
|
||||
if (!session.data?.lang) return;
|
||||
let userPicked = false;
|
||||
try {
|
||||
const persisted = localStorage.getItem("mcphub-ui");
|
||||
if (persisted) {
|
||||
const parsed = JSON.parse(persisted);
|
||||
if (parsed?.state?.lang) userPicked = true;
|
||||
}
|
||||
} catch {
|
||||
/* localStorage unavailable / corrupt — fall through to server hint */
|
||||
}
|
||||
if (userPicked) return;
|
||||
if (session.data.lang !== lang) ui.setLang(session.data.lang);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.data?.lang]);
|
||||
|
||||
// Mirror the server-issued CSRF token into the api.ts module so every
|
||||
// mutating request can echo it as X-CSRF-Token. /api/me is fetched on
|
||||
// every mount and refreshed by react-query, so the token stays current.
|
||||
useEffect(() => {
|
||||
setCsrfToken(session.data?.csrf_token ?? null);
|
||||
}, [session.data?.csrf_token]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
{/* Public */}
|
||||
<Route path="/" element={<RootPage />} />
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
|
||||
{/* Protected — wrapped in Shell */}
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Shell />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/overview" element={<OverviewPage />} />
|
||||
<Route path="/sites" element={<SitesPage />} />
|
||||
<Route path="/sites/:id/tools" element={<SiteToolsPage />} />
|
||||
<Route path="/connect" element={<ConnectPage />} />
|
||||
<Route path="/api-keys" element={<ApiKeysPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
|
||||
{/* Admin only */}
|
||||
<Route
|
||||
path="/oauth-clients"
|
||||
element={
|
||||
<RequireAuth adminOnly>
|
||||
<OAuthClientsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/health"
|
||||
element={
|
||||
<RequireAuth adminOnly>
|
||||
<HealthPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit-logs"
|
||||
element={
|
||||
<RequireAuth adminOnly>
|
||||
<AuditLogsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
<Toast msg={ui.toast} onClose={() => ui.setToast("")} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
65
web/src/components/Logo.tsx
Normal file
65
web/src/components/Logo.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
// Logo + wordmark — uses the original brand mark from core/templates/static/logo.svg.
|
||||
// Path data is inlined so we can drive fills with CSS variables (theme-aware).
|
||||
// `original=true` swaps to the source palette (#51b9f4 / #fec13d) for marketing.
|
||||
|
||||
type LogoProps = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
/** When true, use the original brand colors instead of the theme tokens. */
|
||||
original?: boolean;
|
||||
};
|
||||
|
||||
export function Logo({ size = 28, className, original = false }: LogoProps) {
|
||||
const a = original ? "#51b9f4" : "var(--brand-500)";
|
||||
const b = original ? "#fec13d" : "var(--accent-500)";
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="MCP Hub"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill={a}
|
||||
d="m496.4 11.5q5.4-0.4 10.9-0.5 5.4-0.1 10.9 0.1 5.4 0.2 10.8 0.7 5.5 0.4 10.9 1.2c56.2 7.7 99.4 36 133.3 81.3 31 41.4 41 94.3 34.8 144.5-6.9 54.7-31.6 89.2-71.8 124.8-9.7 8.6-19.4 21-29.9 28.7 8 7.2 17.5 17 25.1 24.8 8.6-11.9 22.8-24.8 33.4-35.4 10.6-10.1 20.7-22.2 32.2-31.2 44.5-34.8 109.1-43.7 163.2-31.3 97.6 22.4 162.8 115.9 151.4 215.3-12.8 111.6-114.9 189.3-225.7 173.7-56.9-8-89.1-29.9-126.7-72.5-8.4-9.5-20.3-19-28.1-29.2-3 4.4-20.1 21-24.7 25.2q0.8 0.7 1.6 1.4c10.3 8.9 20.5 20.3 30 29.2 31.4 29.2 55.6 55.7 65.1 98.8 3 13.7 4.9 22.1 6.1 36.6 4.1 52.5-11.2 105-46 144.9-28.1 32.3-64.1 56.5-106.4 64.9-9.6 2-17.3 3.9-27.3 4.6-11.3 1.4-27.3 0.9-38.7-0.3-55.1-6.2-100.6-31.3-135.2-74.7-6.8-8.5-12.8-17.6-18.1-27.2-5.2-9.5-9.7-19.4-13.3-29.7-3.6-10.3-6.3-20.8-8.1-31.6-1.9-10.7-2.8-21.5-2.8-32.4-0.3-41.9 11.1-88.4 38.8-121 5-5.8 10.5-11.8 16.2-17.1 15.8-14.8 31.2-33.3 48-46.7-7.7-8-17.1-16.5-24.2-24.6-8.4 9.8-18.5 19-27.4 28.3-10.5 10.8-20.7 21.7-31.8 31.9-36.3 32.8-85.8 44.5-133.6 42.7-12.8-0.5-25.5-2.3-38-5.3-12.5-3-24.6-7.3-36.2-12.7-11.7-5.4-22.7-11.9-33.1-19.4-10.3-7.6-19.9-16.2-28.6-25.6-46.8-50.7-62.6-116.6-46.9-183.1 8.7-41.1 30.3-72.1 60.7-100.4 64.9-60.6 180.9-67.1 250.4-10.7 10.9 8.8 22.5 21.5 32.4 31.7 10.5 10.9 21.9 21.7 32.1 33 8.1-11 14.4-15.9 23.9-24.9-37.7-41.9-77.8-63.2-94-122.1-17.9-65.2-8.7-131.9 34.5-185.2 37.9-46.8 81.1-66.8 139.9-73.5z"
|
||||
/>
|
||||
<path
|
||||
fill={b}
|
||||
d="M210 432c44 0.6 79 36 79 80 0 44-36 79-80 79-44 0-80-36-80-80 0-44 37-80 81-79z m294-302c44-5 83 27 87 71 5 44-27 83-71 87-44 4-83-27-87-71-4-44 27-83 71-87z m0 606c44-5 83 27 88 70 5 44-27 83-71 88-44 5-83-27-88-71-5-43 27-83 71-87z m302-302c44-5 83 26 88 70 5 44-26 83-70 88-44 5-83-26-88-70-5-44 26-83 70-88z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type WordmarkProps = {
|
||||
/** Logo mark size in px. The wordmark text auto-scales to feel proportional. */
|
||||
size?: number;
|
||||
/** Hide the text — useful when the sidebar is collapsed. */
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LogoWordmark({ size = 28, iconOnly = false, className }: WordmarkProps) {
|
||||
// The brand "MCP Hub" must always read left-to-right regardless of page
|
||||
// direction — flipping it to "Hub MCP" in RTL would mangle the proper noun.
|
||||
// `dir="ltr"` pins the inline flow, then `unicode-bidi: bidi-override` (set
|
||||
// via .logo-text in CSS) keeps the order stable even when the surrounding
|
||||
// paragraph is RTL.
|
||||
return (
|
||||
<span
|
||||
className={`logo-wrap${className ? ` ${className}` : ""}`}
|
||||
style={{ ["--logo-size" as string]: `${size}px` }}
|
||||
dir="ltr"
|
||||
>
|
||||
<Logo size={size} />
|
||||
{iconOnly ? null : (
|
||||
<span className="logo-text" aria-hidden={false}>
|
||||
<span className="logo-text-primary">MCP</span>
|
||||
<span className="logo-text-secondary">Hub</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
89
web/src/components/PublicControls.tsx
Normal file
89
web/src/components/PublicControls.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icons } from "./icons";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useUiStore, type ThemePref } from "../lib/store";
|
||||
|
||||
const THEME_ORDER: ThemePref[] = ["light", "dark", "system"];
|
||||
|
||||
export function PublicControls({ compact = false }: { compact?: boolean }) {
|
||||
const t = useT();
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
const langMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const { theme, setTheme, lang, setLang } = useUiStore();
|
||||
const ThemeIcon = theme === "dark" ? Icons.moon : theme === "light" ? Icons.sun : Icons.monitor;
|
||||
|
||||
const cycleTheme = () => {
|
||||
const idx = THEME_ORDER.indexOf(theme);
|
||||
setTheme(THEME_ORDER[(idx + 1) % THEME_ORDER.length]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!langMenuRef.current?.contains(event.target as Node)) {
|
||||
setLangOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
return () => window.removeEventListener("pointerdown", onPointerDown);
|
||||
}, [langOpen]);
|
||||
|
||||
const chooseLang = (next: "en" | "fa") => {
|
||||
setLang(next);
|
||||
setLangOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="public-controls" aria-label={t("topbar.change_language", "Change language")}>
|
||||
<div className="lang-menu-wrap" ref={langMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm topbar-lang-btn"
|
||||
onClick={() => setLangOpen((open) => !open)}
|
||||
title={t("topbar.change_language", "Change language")}
|
||||
aria-label={t("topbar.change_language", "Change language")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={langOpen}
|
||||
style={{ padding: compact ? "6px 8px" : "6px 10px" }}
|
||||
>
|
||||
<Icons.globe style={{ width: 14, height: 14 }} />
|
||||
<span style={{ fontSize: 11.5, fontWeight: 600 }}>{lang === "fa" ? "FA" : "EN"}</span>
|
||||
</button>
|
||||
{langOpen ? (
|
||||
<div className="lang-menu" role="menu" aria-label={t("topbar.change_language", "Change language")}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={lang === "en"}
|
||||
className={lang === "en" ? "is-active" : ""}
|
||||
onClick={() => chooseLang("en")}
|
||||
>
|
||||
<span>English</span>
|
||||
{lang === "en" ? <Icons.check style={{ width: 14, height: 14 }} /> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={lang === "fa"}
|
||||
className={lang === "fa" ? "is-active" : ""}
|
||||
onClick={() => chooseLang("fa")}
|
||||
>
|
||||
<span>فارسی</span>
|
||||
{lang === "fa" ? <Icons.check style={{ width: 14, height: 14 }} /> : null}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={cycleTheme}
|
||||
title={`${t("topbar.cycle_theme", "Switch theme")} · ${t(`theme.${theme}`, theme)}`}
|
||||
aria-label={t("topbar.cycle_theme", "Switch theme")}
|
||||
style={{ padding: 6 }}
|
||||
>
|
||||
<ThemeIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
web/src/components/Shell.tsx
Normal file
54
web/src/components/Shell.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { useSession } from "../lib/queries";
|
||||
import { useUiStore } from "../lib/store";
|
||||
|
||||
export function Shell() {
|
||||
const { data: session } = useSession();
|
||||
const collapsed = useUiStore((s) => s.sidebarCollapsed);
|
||||
const mobileOpen = useUiStore((s) => s.sidebarMobileOpen);
|
||||
const setMobileOpen = useUiStore((s) => s.setSidebarMobileOpen);
|
||||
const location = useLocation();
|
||||
|
||||
// Auto-close the mobile drawer whenever the route changes — tapping a nav
|
||||
// item should feel like "take me there + dismiss the drawer".
|
||||
useEffect(() => {
|
||||
if (mobileOpen) setMobileOpen(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname]);
|
||||
|
||||
// Prevent body scroll while the drawer is open (mobile UX expectation).
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
const classes = [
|
||||
"shell",
|
||||
collapsed ? "is-collapsed" : "",
|
||||
mobileOpen ? "is-mobile-open" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<Sidebar session={session} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="sidebar-backdrop"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
tabIndex={mobileOpen ? 0 : -1}
|
||||
/>
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
web/src/components/Sidebar.tsx
Normal file
165
web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { Icons, type IconName } from "./icons";
|
||||
import { LogoWordmark } from "./Logo";
|
||||
import { Avatar } from "./primitives";
|
||||
import type { Session } from "../lib/types";
|
||||
import { useSites, useUserKeys, useAdminApiKeys } from "../lib/queries";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useUiStore } from "../lib/store";
|
||||
import { fmtInt } from "../lib/format";
|
||||
|
||||
type NavItem = { id: string; label: string; icon: IconName; to: string; count?: number; adminOnly?: boolean };
|
||||
|
||||
const SUPPORT_URL = "https://nowpayments.io/donation/airano";
|
||||
|
||||
export function Sidebar({ session }: { session: Session | undefined }) {
|
||||
const t = useT();
|
||||
const collapsed = useUiStore((s) => s.sidebarCollapsed);
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
const isAdmin = session?.is_admin ?? false;
|
||||
const isAdminKeySession = isAdmin && session?.type !== "oauth_user";
|
||||
|
||||
const sites = useSites();
|
||||
const userKeys = useUserKeys();
|
||||
const adminKeys = useAdminApiKeys();
|
||||
|
||||
// NavLink `to` props are relative to the BrowserRouter basename
|
||||
// (`/dashboard`) — do NOT prefix them or you'll get
|
||||
// `/dashboard/dashboard/...`.
|
||||
const groups: { label: string; items: NavItem[] }[] = [
|
||||
{
|
||||
label: t("nav.manage", "Manage"),
|
||||
items: [
|
||||
{ id: "overview", label: t("nav.overview", "Overview"), icon: "home", to: "/overview" },
|
||||
{
|
||||
id: "sites",
|
||||
label: t("nav.sites", "Sites"),
|
||||
icon: "sites",
|
||||
to: "/sites",
|
||||
count: sites.data?.length,
|
||||
},
|
||||
{ id: "connect", label: t("nav.connect", "Connect"), icon: "plug", to: "/connect" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("nav.access", "Access"),
|
||||
items: [
|
||||
{
|
||||
id: "apikeys",
|
||||
label: t("nav.api_keys", "API Keys"),
|
||||
icon: "key",
|
||||
to: "/api-keys",
|
||||
count: isAdminKeySession ? adminKeys.data?.length : userKeys.data?.length,
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
label: t("nav.observability", "Observability"),
|
||||
items: [
|
||||
{
|
||||
id: "health",
|
||||
label: t("nav.health", "Health"),
|
||||
icon: "activity" as IconName,
|
||||
to: "/health",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "audit",
|
||||
label: t("nav.audit", "Audit Logs"),
|
||||
icon: "logs" as IconName,
|
||||
to: "/audit-logs",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t("nav.account", "Account"),
|
||||
items: [
|
||||
{ id: "settings", label: t("nav.settings", "Settings"), icon: "settings", to: "/settings" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside data-collapsed={collapsed ? "true" : "false"}>
|
||||
<NavLink
|
||||
to="/landing"
|
||||
className="logo-link"
|
||||
style={{ padding: "4px 8px 16px", display: "flex", justifyContent: collapsed ? "center" : "flex-start" }}
|
||||
title="MCP Hub"
|
||||
>
|
||||
<LogoWordmark size={28} iconOnly={collapsed} />
|
||||
</NavLink>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="jumpto" aria-hidden="false">
|
||||
<Icons.search style={{ width: 14, height: 14 }} />
|
||||
<span style={{ flex: 1 }}>{t("nav.jump_to", "Jump to…")}</span>
|
||||
<span className="kbd">⌘K</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((g) => (
|
||||
<div key={g.label} className="nav-group">
|
||||
{!collapsed && (
|
||||
<div className="eyebrow nav-group-label">{g.label}</div>
|
||||
)}
|
||||
{g.items.map((it) => {
|
||||
const Ic = Icons[it.icon];
|
||||
return (
|
||||
<NavLink
|
||||
key={it.id}
|
||||
to={it.to}
|
||||
className={({ isActive }) => `nav-item ${isActive ? "is-active" : ""}`}
|
||||
title={collapsed ? it.label : undefined}
|
||||
aria-label={it.label}
|
||||
>
|
||||
<Ic />
|
||||
{!collapsed && <span className="nav-item-label">{it.label}</span>}
|
||||
{!collapsed && it.count != null ? <span className="count">{fmtInt(it.count, lang)}</span> : null}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<a
|
||||
href={SUPPORT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-item sidebar-support-link"
|
||||
title={collapsed ? t("support_mcphub", "Support MCP Hub") : undefined}
|
||||
aria-label={t("support_mcphub", "Support MCP Hub")}
|
||||
>
|
||||
<Icons.link />
|
||||
{!collapsed && <span className="nav-item-label">{t("support_mcphub", "Support MCP Hub")}</span>}
|
||||
</a>
|
||||
<div className="sidebar-user">
|
||||
<Avatar name={session?.name || session?.email || "User"} size={30} />
|
||||
{!collapsed && (
|
||||
<div className="sidebar-user-meta">
|
||||
<div className="sidebar-user-name">{session?.name || "User"}</div>
|
||||
<div className="caption sidebar-user-email">{session?.email || ""}</div>
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<a
|
||||
href="/dashboard/logout"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: 6 }}
|
||||
title={t("nav.logout", "Log out")}
|
||||
aria-label={t("nav.logout", "Log out")}
|
||||
>
|
||||
<Icons.logout style={{ width: 14, height: 14 }} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
487
web/src/components/SiteFormDialog.tsx
Normal file
487
web/src/components/SiteFormDialog.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { Card, Btn } from "./primitives";
|
||||
import { Icons } from "./icons";
|
||||
import {
|
||||
usePluginCatalog,
|
||||
useCreateSite,
|
||||
useUpdateSite,
|
||||
type PluginFieldDef,
|
||||
} from "../lib/queries";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useUiStore } from "../lib/store";
|
||||
import type { Site } from "../lib/types";
|
||||
|
||||
type Mode = "create" | "edit";
|
||||
|
||||
function advancedFieldsLabel(
|
||||
showAdvanced: boolean,
|
||||
pluginName: string | undefined,
|
||||
fields: PluginFieldDef[],
|
||||
t: (key: string, fallback?: string) => string,
|
||||
): string {
|
||||
const advanced = fields.filter((field) => field.advanced);
|
||||
const service = pluginName ?? t("sites.selected_service", "selected service");
|
||||
const fieldNames = advanced
|
||||
.slice(0, 3)
|
||||
.map((field) => field.label)
|
||||
.join(", ");
|
||||
const extra = advanced.length > 3 ? ` +${advanced.length - 3}` : "";
|
||||
const suffix = fieldNames ? `: ${fieldNames}${extra}` : "";
|
||||
return showAdvanced
|
||||
? t("sites.hide_advanced_for_service", "Hide advanced {service} fields").replace("{service}", service)
|
||||
: t("sites.show_advanced_for_service", "Show advanced {service} fields").replace("{service}", service) + suffix;
|
||||
}
|
||||
|
||||
function setupGuidance(
|
||||
pluginType: string,
|
||||
t: (key: string, fallback?: string) => string,
|
||||
): { title: string; items: string[] } | null {
|
||||
if (pluginType === "wordpress" || pluginType === "wordpress_specialist") {
|
||||
return {
|
||||
title:
|
||||
pluginType === "wordpress_specialist"
|
||||
? t("sites.guidance.wordpress_specialist_title", "WordPress Specialist requirements")
|
||||
: t("sites.guidance.wordpress_title", "WordPress requirements"),
|
||||
items: [
|
||||
t(
|
||||
"sites.guidance.wp_username",
|
||||
"Username: WordPress admin username that owns the Application Password. Required.",
|
||||
),
|
||||
t(
|
||||
"sites.guidance.wp_app_password",
|
||||
"Application Password: WP Admin -> Users -> Profile -> Application Passwords. User must have manage_options. Required.",
|
||||
),
|
||||
t(
|
||||
"sites.guidance.bridge_version",
|
||||
"Airano MCP Bridge v2.11.0+ is recommended for companion-backed tools.",
|
||||
),
|
||||
t(
|
||||
"sites.guidance.bridge_lag",
|
||||
"The WordPress.org plugin page can lag behind repository builds while publishing/review completes; do not assume the newest repo feature is already available there.",
|
||||
),
|
||||
t(
|
||||
"sites.guidance.companion_copy",
|
||||
"Airano MCP Bridge — companion plugin (optional but recommended). Installing it unlocks larger uploads, unified site-health snapshot, cache purge, transient flush, bulk meta writes, structured export, capability probe, and audit-hook webhooks. Without it, basic tools still work but these features remain unavailable.",
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (pluginType === "woocommerce") {
|
||||
return {
|
||||
title: t("sites.guidance.woocommerce_title", "WooCommerce requirements"),
|
||||
items: [
|
||||
t(
|
||||
"sites.guidance.wc_consumer_key",
|
||||
"Consumer Key: WooCommerce -> Settings -> Advanced -> REST API -> Add Key. Read/Write permission. Required.",
|
||||
),
|
||||
t(
|
||||
"sites.guidance.wc_consumer_secret",
|
||||
"Consumer Secret: shown once, starts with cs_, save immediately. Required.",
|
||||
),
|
||||
t(
|
||||
"sites.guidance.wc_no_extra_key",
|
||||
"No extra API key field exists for WooCommerce REST auth.",
|
||||
),
|
||||
t(
|
||||
"sites.guidance.wc_media_username",
|
||||
"WordPress Username for media tools: only required for AI/media tools like upload_and_attach_to_product, attach_media_to_product, set_featured_image, generate_and_upload_image with attach_to_post. Optional.",
|
||||
),
|
||||
t(
|
||||
"sites.guidance.wc_media_password",
|
||||
"WordPress Application Password for media tools: required only for WC media uploads to /wp/v2/media; Consumer Key/Secret do not work for that. Optional.",
|
||||
),
|
||||
t(
|
||||
"sites.guidance.bridge_version",
|
||||
"Airano MCP Bridge v2.11.0+ is recommended for companion-backed tools.",
|
||||
),
|
||||
t(
|
||||
"sites.guidance.bridge_lag",
|
||||
"The WordPress.org plugin page can lag behind repository builds while publishing/review completes; do not assume the newest repo feature is already available there.",
|
||||
),
|
||||
t(
|
||||
"sites.guidance.companion_copy",
|
||||
"Airano MCP Bridge — companion plugin (optional but recommended). Installing it unlocks larger uploads, unified site-health snapshot, cache purge, transient flush, bulk meta writes, structured export, capability probe, and audit-hook webhooks. Without it, basic tools still work but these features remain unavailable.",
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function supportsAiImageGeneration(pluginType: string): boolean {
|
||||
return pluginType === "wordpress" || pluginType === "woocommerce";
|
||||
}
|
||||
|
||||
// Replaces the legacy `/dashboard-legacy/sites/add` and
|
||||
// `/dashboard-legacy/sites/:id/edit` Jinja round-trips with a
|
||||
// native SPA dialog. On edit, credentials are NOT prefilled (the API never
|
||||
// returns them); leave the field blank to keep the existing secret, or
|
||||
// type a new value to replace it. We pass credentials as a partial object
|
||||
// so unset fields are preserved server-side (PATCH semantics).
|
||||
export function SiteFormDialog({
|
||||
mode,
|
||||
site,
|
||||
onCancel,
|
||||
onDone,
|
||||
}: {
|
||||
mode: Mode;
|
||||
site?: Site;
|
||||
onCancel: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
const setToast = useUiStore((s) => s.setToast);
|
||||
|
||||
const plugins = usePluginCatalog();
|
||||
const create = useCreateSite();
|
||||
const update = useUpdateSite(site?.id ?? "");
|
||||
|
||||
const [pluginType, setPluginType] = useState<string>(site?.plugin_type ?? "");
|
||||
const [alias, setAlias] = useState<string>(site?.alias ?? "");
|
||||
const [url, setUrl] = useState<string>(site?.url ?? "");
|
||||
const [creds, setCreds] = useState<Record<string, string>>({});
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Default the plugin type to the first public one once the catalog loads
|
||||
// (creating mode only — edit mode locks the type).
|
||||
useEffect(() => {
|
||||
if (mode !== "create") return;
|
||||
if (pluginType) return;
|
||||
if (!plugins.data || plugins.data.length === 0) return;
|
||||
setPluginType(plugins.data[0].type);
|
||||
}, [mode, pluginType, plugins.data]);
|
||||
|
||||
const currentPlugin = useMemo(
|
||||
() => (plugins.data ?? []).find((p) => p.type === pluginType),
|
||||
[plugins.data, pluginType],
|
||||
);
|
||||
const guidance = useMemo(() => setupGuidance(pluginType, t), [pluginType, t]);
|
||||
const showAiImageSetup = supportsAiImageGeneration(pluginType);
|
||||
const fields: PluginFieldDef[] = currentPlugin?.fields ?? [];
|
||||
const advancedFields = fields.filter((f) => f.advanced);
|
||||
const requiredFieldsMissing =
|
||||
mode === "create" &&
|
||||
fields.filter((f) => f.required).some((f) => !creds[f.name]?.trim());
|
||||
const baseFieldsMissing =
|
||||
mode === "create" && (!alias.trim() || !url.trim() || !pluginType);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (submitting) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Drop blank credential fields — on edit, the backend keeps the
|
||||
// stored value when the field is omitted from the PATCH payload.
|
||||
const cleanedCreds: Record<string, string> = {};
|
||||
Object.entries(creds).forEach(([k, v]) => {
|
||||
if (v && v.trim()) cleanedCreds[k] = v.trim();
|
||||
});
|
||||
|
||||
if (mode === "create") {
|
||||
await create.mutateAsync({
|
||||
plugin_type: pluginType,
|
||||
alias: alias.trim(),
|
||||
url: url.trim(),
|
||||
credentials: cleanedCreds,
|
||||
});
|
||||
setToast(t("sites.toast_created", "Site created"));
|
||||
} else {
|
||||
// Edit: PATCH /api/sites/{id}. URL + alias may change; credentials
|
||||
// only update when the user types something into a field.
|
||||
await update.mutateAsync({
|
||||
alias: alias.trim() || undefined,
|
||||
url: url.trim() || undefined,
|
||||
credentials: Object.keys(cleanedCreds).length > 0 ? cleanedCreds : undefined,
|
||||
});
|
||||
setToast(t("sites.toast_updated", "Site updated"));
|
||||
}
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
const msg = e?.body?.error ?? e?.message ?? String(e);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dialog-backdrop" style={backdropStyle} onClick={onCancel}>
|
||||
<div
|
||||
className="dialog"
|
||||
style={dialogStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<Card style={{ width: "100%" }}>
|
||||
<div style={headerStyle}>
|
||||
<div className="h-2" style={{ margin: 0 }}>
|
||||
{mode === "create"
|
||||
? t("sites.dialog_add_title", "Add a site")
|
||||
: t("sites.dialog_edit_title", "Edit site")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={onCancel}
|
||||
aria-label={t("cancel", "Cancel")}
|
||||
style={{ padding: 6 }}
|
||||
>
|
||||
<Icons.x style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={bodyStyle}>
|
||||
{/* Plugin picker — locked on edit since changing plugin_type
|
||||
would invalidate stored credentials. */}
|
||||
<div className="field">
|
||||
<label>{t("sites.field_plugin_type", "Plugin")}</label>
|
||||
{plugins.isLoading ? (
|
||||
<div className="shimmer" style={{ height: 36, borderRadius: 8 }} />
|
||||
) : mode === "edit" ? (
|
||||
<input
|
||||
className="input"
|
||||
value={currentPlugin?.name ?? pluginType}
|
||||
disabled
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
className="input"
|
||||
value={pluginType}
|
||||
onChange={(e) => {
|
||||
setPluginType(e.target.value);
|
||||
setCreds({});
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
{(plugins.data ?? []).map((p) => (
|
||||
<option key={p.type} value={p.type}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{guidance ? (
|
||||
<div className="alert alert-info" style={guidanceStyle}>
|
||||
<Icons.info style={{ width: 16, height: 16, flexShrink: 0, marginTop: 2 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{guidance.title}</div>
|
||||
<ul style={guidanceListStyle}>
|
||||
{guidance.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showAiImageSetup ? (
|
||||
<div className="alert alert-info" style={aiSetupStyle}>
|
||||
<Icons.spark style={{ width: 16, height: 16, flexShrink: 0, marginTop: 2 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>
|
||||
{t("sites.ai_image.title", "AI Image Generation")}
|
||||
</div>
|
||||
<div className="caption" style={{ color: "var(--text-muted)" }}>
|
||||
{mode === "edit" && site?.id
|
||||
? t(
|
||||
"sites.ai_image.edit_body",
|
||||
"Image generation is configured per service in Tool access. Add an OpenAI, Stability AI, Replicate, or OpenRouter key there; OpenRouter can also use a default image model.",
|
||||
)
|
||||
: t(
|
||||
"sites.ai_image.create_body",
|
||||
"After creating this service, open Tool access to add an OpenAI, Stability AI, Replicate, or OpenRouter key. The image generation tool stays unavailable until a provider key is saved and the service connection is healthy.",
|
||||
)}
|
||||
</div>
|
||||
{mode === "edit" && site?.id ? (
|
||||
<a
|
||||
className="btn btn-secondary btn-sm"
|
||||
href={`/dashboard/sites/${site.id}/tools`}
|
||||
style={{ marginTop: 10, alignSelf: "flex-start" }}
|
||||
>
|
||||
{t("sites.ai_image.open_tools", "Open AI Image Generation settings")}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="field">
|
||||
<label>{t("sites.field_alias", "Alias")}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
placeholder={t("sites.alias_placeholder", "short-id-for-this-site")}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div className="caption" style={{ marginTop: 4 }}>
|
||||
{t(
|
||||
"sites.alias_hint",
|
||||
"Short identifier the AI sees as `site=…`. Stick to lowercase letters, digits, and dashes.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>{t("sites.field_url", "URL")}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Credential fields per plugin */}
|
||||
{fields.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div className="eyebrow">{t("sites.credentials", "Credentials")}</div>
|
||||
{fields
|
||||
.filter((f) => !f.advanced || showAdvanced)
|
||||
.map((field) => (
|
||||
<div key={field.name} className="field">
|
||||
<label>
|
||||
{field.label}
|
||||
{field.required ? " *" : ""}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type={field.type === "password" ? "password" : "text"}
|
||||
value={creds[field.name] ?? ""}
|
||||
onChange={(e) =>
|
||||
setCreds((prev) => ({ ...prev, [field.name]: e.target.value }))
|
||||
}
|
||||
placeholder={
|
||||
mode === "edit" && !field.required
|
||||
? t("sites.cred_unchanged", "Leave blank to keep current")
|
||||
: ""
|
||||
}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{field.hint ? (
|
||||
<div className="caption" style={{ marginTop: 4 }}>
|
||||
{field.hint}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{advancedFields.length > 0 ? (
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
type="button"
|
||||
style={{ alignSelf: lang === "fa" ? "flex-end" : "flex-start" }}
|
||||
>
|
||||
{advancedFieldsLabel(showAdvanced, currentPlugin?.name, fields, t)}
|
||||
</Btn>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-danger">
|
||||
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, fontSize: 13 }}>{error}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={footerStyle}>
|
||||
<Btn variant="ghost" onClick={onCancel} disabled={submitting}>
|
||||
{t("cancel", "Cancel")}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
onClick={onSubmit}
|
||||
disabled={
|
||||
submitting ||
|
||||
plugins.isLoading ||
|
||||
baseFieldsMissing ||
|
||||
requiredFieldsMissing
|
||||
}
|
||||
>
|
||||
{submitting
|
||||
? "…"
|
||||
: mode === "create"
|
||||
? t("sites.dialog_add_submit", "Add site")
|
||||
: t("sites.dialog_edit_submit", "Save changes")}
|
||||
</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const backdropStyle: CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "oklch(0.10 0.012 250 / 0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
zIndex: 60,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
overflowY: "auto",
|
||||
};
|
||||
|
||||
const dialogStyle: CSSProperties = {
|
||||
width: "min(720px, 100%)",
|
||||
maxHeight: "calc(100dvh - 40px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
};
|
||||
|
||||
const headerStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "18px 22px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
};
|
||||
|
||||
const bodyStyle: CSSProperties = {
|
||||
padding: "20px 22px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
overflowY: "auto",
|
||||
};
|
||||
|
||||
const guidanceStyle: CSSProperties = {
|
||||
alignItems: "flex-start",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const guidanceListStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
paddingInlineStart: 18,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
const aiSetupStyle: CSSProperties = {
|
||||
alignItems: "flex-start",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const footerStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
justifyContent: "flex-end",
|
||||
padding: "16px 22px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
};
|
||||
143
web/src/components/Topbar.tsx
Normal file
143
web/src/components/Topbar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Icons } from "./icons";
|
||||
import { isMobileViewport, useUiStore, type ThemePref } from "../lib/store";
|
||||
import { useT } from "../lib/i18n";
|
||||
|
||||
const THEME_ORDER: ThemePref[] = ["light", "dark", "system"];
|
||||
|
||||
export function Topbar({
|
||||
title,
|
||||
crumbs = [],
|
||||
actions,
|
||||
showThemeToggle = true,
|
||||
}: {
|
||||
title?: string;
|
||||
crumbs?: string[];
|
||||
actions?: ReactNode;
|
||||
showThemeToggle?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
const langMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const { theme, setTheme, lang, setLang, sidebarCollapsed, toggleSidebar, sidebarMobileOpen, toggleSidebarMobile } =
|
||||
useUiStore();
|
||||
const ThemeIcon = theme === "dark" ? Icons.moon : theme === "light" ? Icons.sun : Icons.monitor;
|
||||
const cycleTheme = () => {
|
||||
const idx = THEME_ORDER.indexOf(theme);
|
||||
setTheme(THEME_ORDER[(idx + 1) % THEME_ORDER.length]);
|
||||
};
|
||||
// Same button serves two layouts: on phones it toggles the slide-in
|
||||
// drawer, on desktops it collapses the sidebar to an icon rail.
|
||||
const onMenuClick = () => {
|
||||
if (isMobileViewport()) toggleSidebarMobile();
|
||||
else toggleSidebar();
|
||||
};
|
||||
const menuExpanded = isMobileViewport() ? sidebarMobileOpen : !sidebarCollapsed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!langMenuRef.current?.contains(event.target as Node)) {
|
||||
setLangOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
return () => window.removeEventListener("pointerdown", onPointerDown);
|
||||
}, [langOpen]);
|
||||
|
||||
const chooseLang = (next: "en" | "fa") => {
|
||||
setLang(next);
|
||||
setLangOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="topbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm topbar-menu-btn"
|
||||
onClick={onMenuClick}
|
||||
title={t("topbar.toggle_sidebar", "Toggle sidebar")}
|
||||
aria-label={t("topbar.toggle_sidebar", "Toggle sidebar")}
|
||||
aria-expanded={menuExpanded}
|
||||
style={{ padding: 6 }}
|
||||
>
|
||||
<Icons.menu style={{ width: 18, height: 18 }} />
|
||||
</button>
|
||||
<div className="topbar-crumbs">
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 10 }}>
|
||||
{i > 0 ? <Icons.chevR style={{ width: 12, height: 12, color: "var(--text-subtle)" }} /> : null}
|
||||
<span style={{ color: i === crumbs.length - 1 ? "var(--text)" : "var(--text-muted)" }}>{c}</span>
|
||||
</span>
|
||||
))}
|
||||
{!crumbs.length && title ? <span style={{ fontWeight: 500 }}>{title}</span> : null}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
{actions ? <div className="topbar-actions">{actions}</div> : null}
|
||||
{showThemeToggle ? (
|
||||
<div className="topbar-controls">
|
||||
<div className="lang-menu-wrap" ref={langMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm topbar-lang-btn"
|
||||
onClick={() => setLangOpen((open) => !open)}
|
||||
title={t("topbar.change_language", "Change language")}
|
||||
aria-label={t("topbar.change_language", "Change language")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={langOpen}
|
||||
style={{ padding: "6px 10px", display: "inline-flex", alignItems: "center", gap: 6 }}
|
||||
>
|
||||
<Icons.globe style={{ width: 14, height: 14 }} />
|
||||
<span style={{ fontSize: 11.5, fontWeight: 600, letterSpacing: "0.06em" }}>
|
||||
{lang === "fa" ? "FA" : "EN"}
|
||||
</span>
|
||||
</button>
|
||||
{langOpen ? (
|
||||
<div className="lang-menu" role="menu" aria-label={t("topbar.change_language", "Change language")}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={lang === "en"}
|
||||
className={lang === "en" ? "is-active" : ""}
|
||||
onClick={() => chooseLang("en")}
|
||||
>
|
||||
<span>English</span>
|
||||
{lang === "en" ? <Icons.check style={{ width: 14, height: 14 }} /> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={lang === "fa"}
|
||||
className={lang === "fa" ? "is-active" : ""}
|
||||
onClick={() => chooseLang("fa")}
|
||||
>
|
||||
<span>فارسی</span>
|
||||
{lang === "fa" ? <Icons.check style={{ width: 14, height: 14 }} /> : null}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={cycleTheme}
|
||||
title={`${t("topbar.cycle_theme", "Switch theme")} · ${t(`theme.${theme}`, theme)}`}
|
||||
aria-label={t("topbar.cycle_theme", "Switch theme")}
|
||||
style={{ padding: 6 }}
|
||||
>
|
||||
<ThemeIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm topbar-notifs"
|
||||
style={{ padding: 6 }}
|
||||
title={t("topbar.notifications", "Notifications")}
|
||||
aria-label={t("topbar.notifications", "Notifications")}
|
||||
>
|
||||
<Icons.bell style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
301
web/src/components/icons.tsx
Normal file
301
web/src/components/icons.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
// Lucide-style inline SVG icons.
|
||||
// Stroke 1.75, 24px viewBox. Color via currentColor so they inherit from text.
|
||||
import type { SVGProps, ReactNode, FC } from "react";
|
||||
|
||||
type Props = SVGProps<SVGSVGElement>;
|
||||
|
||||
const make =
|
||||
(path: ReactNode, viewBox = "0 0 24 24"): FC<Props> =>
|
||||
(props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={viewBox}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
width={16}
|
||||
height={16}
|
||||
{...props}
|
||||
>
|
||||
{path}
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Icons = {
|
||||
home: make(
|
||||
<>
|
||||
<path d="M3 12 12 3l9 9" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
</>,
|
||||
),
|
||||
sites: make(
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M3 9h18" />
|
||||
<path d="M8 4v5" />
|
||||
</>,
|
||||
),
|
||||
plug: make(
|
||||
<>
|
||||
<path d="M9 2v6" />
|
||||
<path d="M15 2v6" />
|
||||
<path d="M6 8h12v4a6 6 0 0 1-12 0V8Z" />
|
||||
<path d="M12 18v4" />
|
||||
</>,
|
||||
),
|
||||
key: make(
|
||||
<>
|
||||
<circle cx="8" cy="15" r="4" />
|
||||
<path d="m10.5 12.5 9-9" />
|
||||
<path d="m18 5 3 3" />
|
||||
<path d="m15 8 3 3" />
|
||||
</>,
|
||||
),
|
||||
shield: make(<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />),
|
||||
activity: make(<path d="M22 12h-4l-3 9L9 3l-3 9H2" />),
|
||||
logs: make(
|
||||
<>
|
||||
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9Z" />
|
||||
<path d="M14 3v6h6" />
|
||||
<path d="M8 13h8" />
|
||||
<path d="M8 17h6" />
|
||||
</>,
|
||||
),
|
||||
settings: make(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h0A1.7 1.7 0 0 0 10 3.1V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
|
||||
</>,
|
||||
),
|
||||
search: make(
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</>,
|
||||
),
|
||||
user: make(
|
||||
<>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21a8 8 0 0 1 16 0" />
|
||||
</>,
|
||||
),
|
||||
bell: make(
|
||||
<>
|
||||
<path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a2 2 0 0 0 3.4 0" />
|
||||
</>,
|
||||
),
|
||||
chev: make(<path d="m6 9 6 6 6-6" />),
|
||||
chevR: make(<path d="m9 6 6 6-6 6" />),
|
||||
check: make(<path d="M20 6 9 17l-5-5" />),
|
||||
plus: make(
|
||||
<>
|
||||
<path d="M12 5v14" />
|
||||
<path d="M5 12h14" />
|
||||
</>,
|
||||
),
|
||||
arrow: make(
|
||||
<>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</>,
|
||||
),
|
||||
copy: make(
|
||||
<>
|
||||
<rect x="8" y="8" width="13" height="13" rx="2" />
|
||||
<path d="M5 16V5a2 2 0 0 1 2-2h11" />
|
||||
</>,
|
||||
),
|
||||
eye: make(
|
||||
<>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>,
|
||||
),
|
||||
eyeOff: make(
|
||||
<>
|
||||
<path d="M10.7 5.1A8 8 0 0 1 12 5c6.5 0 10 7 10 7a16 16 0 0 1-3.3 4" />
|
||||
<path d="M6.1 6.1A16 16 0 0 0 2 12s3.5 7 10 7a10 10 0 0 0 4.5-1" />
|
||||
<path d="m2 2 20 20" />
|
||||
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
||||
</>,
|
||||
),
|
||||
trash: make(
|
||||
<>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
</>,
|
||||
),
|
||||
edit: make(
|
||||
<>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5Z" />
|
||||
</>,
|
||||
),
|
||||
download: make(
|
||||
<>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M12 15V3" />
|
||||
</>,
|
||||
),
|
||||
link: make(
|
||||
<>
|
||||
<path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1" />
|
||||
<path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1" />
|
||||
</>,
|
||||
),
|
||||
github: make(
|
||||
<>
|
||||
<path d="M15 22v-4a3.4 3.4 0 0 0-1-2.8c3.3-.3 6.6-1.6 6.6-7A5.5 5.5 0 0 0 19 4.8 5 5 0 0 0 18.9 1s-1.2-.3-4 1.5a13.4 13.4 0 0 0-7 0C5.1.7 3.9 1 3.9 1A5 5 0 0 0 3.8 4.8 5.5 5.5 0 0 0 2.4 8.2c0 5.4 3.3 6.7 6.5 7A3.4 3.4 0 0 0 8 18v4" />
|
||||
<path d="M9 18c-4 1.5-4.5-2-6.5-2" />
|
||||
</>,
|
||||
),
|
||||
terminal: make(
|
||||
<>
|
||||
<path d="m4 17 6-6-6-6" />
|
||||
<path d="M12 19h8" />
|
||||
</>,
|
||||
),
|
||||
chart: make(
|
||||
<>
|
||||
<path d="M3 3v18h18" />
|
||||
<path d="m7 15 4-4 4 4 6-6" />
|
||||
</>,
|
||||
),
|
||||
spark: make(
|
||||
<path d="M12 3v3M12 18v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M3 12h3M18 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" />,
|
||||
),
|
||||
zap: make(<path d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z" />),
|
||||
globe: make(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M12 3a14 14 0 0 1 0 18" />
|
||||
<path d="M12 3a14 14 0 0 0 0 18" />
|
||||
</>,
|
||||
),
|
||||
users: make(
|
||||
<>
|
||||
<circle cx="9" cy="8" r="4" />
|
||||
<path d="M2 21a7 7 0 0 1 14 0" />
|
||||
<path d="M16 3.1a4 4 0 0 1 0 7.8" />
|
||||
<path d="M22 21a7 7 0 0 0-6-6.9" />
|
||||
</>,
|
||||
),
|
||||
logout: make(
|
||||
<>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</>,
|
||||
),
|
||||
moon: make(<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />),
|
||||
monitor: make(
|
||||
<>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</>,
|
||||
),
|
||||
sun: make(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</>,
|
||||
),
|
||||
menu: make(
|
||||
<>
|
||||
<path d="M4 6h16" />
|
||||
<path d="M4 12h16" />
|
||||
<path d="M4 18h16" />
|
||||
</>,
|
||||
),
|
||||
x: make(
|
||||
<>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</>,
|
||||
),
|
||||
sparkles: make(
|
||||
<>
|
||||
<path d="M12 3v4M12 17v4M5 12H1M23 12h-4M7 7 4 4M20 20l-3-3M7 17l-3 3M20 4l-3 3" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>,
|
||||
),
|
||||
info: make(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 8v.01" />
|
||||
<path d="M11 12h1v4h1" />
|
||||
</>,
|
||||
),
|
||||
warning: make(
|
||||
<>
|
||||
<path d="m12 3 10 17H2L12 3z" />
|
||||
<path d="M12 9v5" />
|
||||
<path d="M12 18v.01" />
|
||||
</>,
|
||||
),
|
||||
server: make(
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="7" rx="2" />
|
||||
<rect x="3" y="13" width="18" height="7" rx="2" />
|
||||
<path d="M7 8h.01M7 17h.01" />
|
||||
</>,
|
||||
),
|
||||
filter: make(<path d="M4 4h16l-6 8v7l-4-2v-5L4 4z" />),
|
||||
refresh: make(
|
||||
<>
|
||||
<path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
|
||||
<path d="M3 21v-5h5" />
|
||||
</>,
|
||||
),
|
||||
clock: make(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</>,
|
||||
),
|
||||
cube: make(
|
||||
<>
|
||||
<path d="M21 8 12 3 3 8l9 5 9-5Z" />
|
||||
<path d="M3 8v8l9 5 9-5V8" />
|
||||
<path d="M12 13v8" />
|
||||
</>,
|
||||
),
|
||||
rocket: make(
|
||||
<>
|
||||
<path d="M5 13 3 19l6-2" />
|
||||
<path d="M14 6s6-5 8-2-2 8-2 8l-9 9-6-6 9-9Z" />
|
||||
<circle cx="15" cy="9" r="1.5" />
|
||||
</>,
|
||||
),
|
||||
wrench: make(
|
||||
<path d="M15 5a4 4 0 0 1-4.5 4L4 15.5 8.5 20 15 13.5a4 4 0 0 1 5-5l-3-3 3-3a4 4 0 0 0-5 2.5Z" />,
|
||||
),
|
||||
bolt: make(<path d="M13 3 3 14h7l-1 7 10-11h-7l1-7Z" />),
|
||||
docs: make(
|
||||
<>
|
||||
<path d="M4 4v16a2 2 0 0 0 2 2h12V2H6a2 2 0 0 0-2 2Z" />
|
||||
<path d="M18 2v20" />
|
||||
<path d="M8 7h6M8 11h6M8 15h4" />
|
||||
</>,
|
||||
),
|
||||
command: make(
|
||||
<path d="M6 2a4 4 0 1 1-4 4V2h4Zm12 0a4 4 0 1 0 4 4V2h-4ZM6 22a4 4 0 1 0-4-4v4h4Zm12 0a4 4 0 1 1 4-4v4h-4ZM6 6h12v12H6V6Z" />,
|
||||
),
|
||||
external: make(
|
||||
<>
|
||||
<path d="M14 3h7v7" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5" />
|
||||
</>,
|
||||
),
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof Icons;
|
||||
365
web/src/components/primitives/index.tsx
Normal file
365
web/src/components/primitives/index.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
// Shared UI primitives — typed ports of prototype/src/primitives.jsx.
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode, ButtonHTMLAttributes, HTMLAttributes, FC } from "react";
|
||||
import { Icons, type IconName } from "../icons";
|
||||
|
||||
// ---------- Badge ----------
|
||||
type BadgeVariant = "default" | "success" | "warning" | "danger" | "info" | "brand";
|
||||
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
variant?: BadgeVariant;
|
||||
dot?: boolean;
|
||||
};
|
||||
export function Badge({ children, variant = "default", dot = false, className = "", ...rest }: BadgeProps) {
|
||||
const cls =
|
||||
"badge " +
|
||||
(variant !== "default" ? `badge-${variant} ` : "") +
|
||||
(dot ? "badge-dot " : "") +
|
||||
className;
|
||||
return (
|
||||
<span className={cls} {...rest}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Button ----------
|
||||
type BtnVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type BtnProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: BtnVariant;
|
||||
size?: "" | "sm" | "lg";
|
||||
icon?: IconName | FC<any>;
|
||||
iconRight?: IconName | FC<any>;
|
||||
children?: ReactNode;
|
||||
};
|
||||
export function Btn({
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "",
|
||||
icon,
|
||||
iconRight,
|
||||
className,
|
||||
...rest
|
||||
}: BtnProps) {
|
||||
const Ic = typeof icon === "string" ? Icons[icon] : icon;
|
||||
const IcR = typeof iconRight === "string" ? Icons[iconRight] : iconRight;
|
||||
const cls = `btn btn-${variant}${size ? " btn-" + size : ""}${className ? " " + className : ""}`;
|
||||
return (
|
||||
<button type="button" className={cls} {...rest}>
|
||||
{Ic ? <Ic style={{ width: 14, height: 14 }} /> : null}
|
||||
{children}
|
||||
{IcR ? <IcR style={{ width: 14, height: 14 }} /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Card ----------
|
||||
export function Card({ children, className = "", ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={`card ${className}`} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHead({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
icon,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
action?: ReactNode;
|
||||
icon?: IconName | FC<any>;
|
||||
}) {
|
||||
const Ic = typeof icon === "string" ? Icons[icon] : icon;
|
||||
return (
|
||||
<div className="card-head">
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
{Ic ? (
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--brand-400)",
|
||||
}}
|
||||
>
|
||||
<Ic style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{title}</div>
|
||||
{subtitle ? <div className="caption" style={{ marginTop: 2 }}>{subtitle}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Switch ----------
|
||||
export function Switch({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<div
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
tabIndex={0}
|
||||
onClick={() => onChange(!on)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onChange(!on);
|
||||
}
|
||||
}}
|
||||
className={`switch ${on ? "on" : ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Segmented ----------
|
||||
export type SegOption<T extends string> = { value: T; label: string };
|
||||
export function Seg<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: SegOption<T>[];
|
||||
}) {
|
||||
return (
|
||||
<div className="seg">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={o.value === value ? "is-active" : ""}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- CopyField ----------
|
||||
export function CopyField({ value, mask = false }: { value: string; mask?: boolean }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [show, setShow] = useState(!mask);
|
||||
const display = mask && !show ? "•".repeat(Math.min(40, value.length)) : value;
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
// ignore — clipboard may be blocked
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1400);
|
||||
};
|
||||
return (
|
||||
<div className="copy-field">
|
||||
<span>{display}</span>
|
||||
{mask ? (
|
||||
<button type="button" onClick={() => setShow((s) => !s)} title={show ? "Hide" : "Show"}>
|
||||
{show ? (
|
||||
<Icons.eyeOff style={{ width: 14, height: 14 }} />
|
||||
) : (
|
||||
<Icons.eye style={{ width: 14, height: 14 }} />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={onCopy}>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Spark ----------
|
||||
export function Spark({ values = [], height = 20 }: { values?: number[]; height?: number }) {
|
||||
const max = Math.max(...values, 1);
|
||||
return (
|
||||
<div className="spark" style={{ height }}>
|
||||
{values.map((v, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
height: `${Math.max(10, (v / max) * 100)}%`,
|
||||
opacity: 0.4 + (i / Math.max(1, values.length)) * 0.6,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Donut ----------
|
||||
export function Donut({ value = 40, size = 72, color }: { value?: number; size?: number; color?: string }) {
|
||||
const r = size / 2 - 6;
|
||||
const c = 2 * Math.PI * r;
|
||||
const stroke = color || "var(--brand-500)";
|
||||
return (
|
||||
<svg width={size} height={size} className="donut">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} stroke="var(--surface-2)" />
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
stroke={stroke}
|
||||
strokeDasharray={c}
|
||||
strokeDashoffset={c - (value / 100) * c}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Avatar ----------
|
||||
export function Avatar({ name = "U", size = 28 }: { name?: string; size?: number }) {
|
||||
const initial = name.trim().slice(0, 1).toUpperCase();
|
||||
const hash = [...name].reduce((a, ch) => a + ch.charCodeAt(0), 0);
|
||||
const hue = (hash * 37) % 360;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
background: `oklch(0.55 0.14 ${hue})`,
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- EmptyState ----------
|
||||
export function EmptyState({
|
||||
icon = "cube",
|
||||
title,
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
icon?: IconName;
|
||||
title: ReactNode;
|
||||
children?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const Ic = Icons[icon];
|
||||
return (
|
||||
<div style={{ padding: "48px 20px", textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
margin: "0 auto 16px",
|
||||
borderRadius: 12,
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--text-subtle)",
|
||||
}}
|
||||
>
|
||||
<Ic style={{ width: 22, height: 22 }} />
|
||||
</div>
|
||||
<div className="h-3" style={{ marginBottom: 6 }}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="caption" style={{ maxWidth: 380, margin: "0 auto 16px" }}>
|
||||
{children}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Toast ----------
|
||||
export function Toast({ msg, onClose }: { msg: string; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
if (!msg) return;
|
||||
const t = setTimeout(onClose, 2800);
|
||||
return () => clearTimeout(t);
|
||||
}, [msg, onClose]);
|
||||
if (!msg) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "var(--bg-elevated)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
padding: "10px 16px",
|
||||
boxShadow: "var(--shadow-pop)",
|
||||
fontSize: 13,
|
||||
zIndex: 200,
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Icons.check style={{ width: 14, height: 14, color: "var(--success)" }} />
|
||||
{msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Step ----------
|
||||
export function Step({
|
||||
n,
|
||||
title,
|
||||
children,
|
||||
ghost = false,
|
||||
done = false,
|
||||
}: {
|
||||
n: number;
|
||||
title: ReactNode;
|
||||
children?: ReactNode;
|
||||
ghost?: boolean;
|
||||
done?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
background: done ? "var(--success)" : ghost ? "var(--surface)" : "var(--brand-500)",
|
||||
color: done ? "#000" : ghost ? "var(--text-muted)" : "#000",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
border: ghost ? "1px solid var(--border)" : "none",
|
||||
}}
|
||||
>
|
||||
{done ? <Icons.check style={{ width: 12, height: 12 }} /> : n}
|
||||
</div>
|
||||
<div style={{ flex: 1, paddingTop: 2 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 10 }}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
web/src/lib/api.ts
Normal file
66
web/src/lib/api.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Thin fetch wrapper: same-origin, credentials, CSRF header, JSON parse, typed errors.
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
body: unknown;
|
||||
constructor(status: number, body: unknown, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
// The DashboardCSRFMiddleware sets `dashboard_csrf` as HttpOnly, so JS cannot
|
||||
// read the cookie. The SPA receives the matching token from /api/me JSON and
|
||||
// stashes it here; mutating requests echo it as X-CSRF-Token. App.tsx keeps
|
||||
// this in sync with the useSession query result.
|
||||
let _csrfToken: string | null = null;
|
||||
|
||||
export function setCsrfToken(token: string | null | undefined): void {
|
||||
_csrfToken = token ?? null;
|
||||
}
|
||||
|
||||
export function getCsrfToken(): string | null {
|
||||
return _csrfToken;
|
||||
}
|
||||
|
||||
type RequestOpts = RequestInit & { json?: unknown };
|
||||
|
||||
export async function request<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> {
|
||||
const headers = new Headers(opts.headers);
|
||||
if (opts.json !== undefined) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (opts.method && opts.method.toUpperCase() !== "GET" && opts.method.toUpperCase() !== "HEAD") {
|
||||
if (_csrfToken) headers.set("X-CSRF-Token", _csrfToken);
|
||||
}
|
||||
const res = await fetch(path, {
|
||||
...opts,
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: opts.json !== undefined ? JSON.stringify(opts.json) : opts.body,
|
||||
});
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
let body: unknown = null;
|
||||
if (ct.includes("application/json")) {
|
||||
body = await res.json().catch(() => null);
|
||||
} else {
|
||||
body = await res.text().catch(() => "");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status} ${res.statusText}`;
|
||||
if (body && typeof body === "object" && "error" in (body as any)) {
|
||||
msg = String((body as any).error);
|
||||
}
|
||||
throw new ApiError(res.status, body, msg);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(p: string) => request<T>(p, { method: "GET" }),
|
||||
post: <T = unknown>(p: string, json?: unknown) => request<T>(p, { method: "POST", json }),
|
||||
put: <T = unknown>(p: string, json?: unknown) => request<T>(p, { method: "PUT", json }),
|
||||
patch: <T = unknown>(p: string, json?: unknown) => request<T>(p, { method: "PATCH", json }),
|
||||
del: <T = unknown>(p: string) => request<T>(p, { method: "DELETE" }),
|
||||
};
|
||||
65
web/src/lib/format.ts
Normal file
65
web/src/lib/format.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Formatting helpers — primarily for bilingual number rendering.
|
||||
// Persian numeral output uses Intl.NumberFormat("fa-IR") which gives both
|
||||
// the digit shaping (۰-۹) and grouping (٬) the user expects.
|
||||
|
||||
import type { Lang } from "./store";
|
||||
|
||||
export function fmtNumber(n: number | string | null | undefined, lang: Lang, fallback = "—"): string {
|
||||
if (n === null || n === undefined || n === "") return fallback;
|
||||
const num = typeof n === "string" ? Number(n) : n;
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return new Intl.NumberFormat(lang === "fa" ? "fa-IR" : "en-US").format(num);
|
||||
}
|
||||
|
||||
export function fmtInt(n: number | null | undefined, lang: Lang, fallback = "—"): string {
|
||||
if (n === null || n === undefined || !Number.isFinite(n)) return fallback;
|
||||
return fmtNumber(Math.round(n), lang, fallback);
|
||||
}
|
||||
|
||||
// Format an ISO timestamp for display. In FA the Persian (Shamsi/Jalali)
|
||||
// calendar is the user expectation — `Intl.DateTimeFormat("fa-IR")` already
|
||||
// emits Shamsi by default, so we just opt into the right field set. Falls
|
||||
// through cleanly when `iso` is null/empty/invalid.
|
||||
export function fmtDateTime(iso: string | null | undefined, lang: Lang, fallback = "—"): string {
|
||||
if (!iso) return fallback;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return fallback;
|
||||
if (lang === "fa") {
|
||||
// Calendar defaults to "persian" for fa-IR, but pass it explicitly so an
|
||||
// older locale data store can't slip back to Gregorian.
|
||||
return new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(d);
|
||||
}
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// Backend writes `status="active"` for sites that pass connection tests
|
||||
// (see core/site_api.py:test_site_connection). The SPA's badges and the
|
||||
// "Healthy sites" card both reason in terms of "healthy". This helper
|
||||
// bridges the vocabulary AND folds the `last_tested_at + untested` race
|
||||
// into a dedicated `unknown` bucket so the UI never says "untested" next
|
||||
// to a real timestamp.
|
||||
export type SiteDisplayStatus = "healthy" | "warning" | "error" | "unknown" | "untested";
|
||||
|
||||
export function normalizeSiteStatus(
|
||||
status: string | null | undefined,
|
||||
lastTestedAt: string | null | undefined,
|
||||
): SiteDisplayStatus {
|
||||
if (status === "healthy" || status === "active") return "healthy";
|
||||
if (status === "warning") return "warning";
|
||||
if (status === "error") return "error";
|
||||
if (status === "unknown") return "unknown";
|
||||
return lastTestedAt ? "unknown" : "untested";
|
||||
}
|
||||
13
web/src/lib/i18n.ts
Normal file
13
web/src/lib/i18n.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Tiny i18n: read translations from /api/i18n/{lang}, fall back to the key.
|
||||
// We don't introduce react-i18next yet — the surface is small and most copy is hard-coded EN in the prototype.
|
||||
import { useTranslations } from "./queries";
|
||||
import { useUiStore } from "./store";
|
||||
|
||||
export function useT() {
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
const { data } = useTranslations(lang);
|
||||
return (key: string, fallback?: string): string => {
|
||||
if (data && key in data) return data[key];
|
||||
return fallback ?? key;
|
||||
};
|
||||
}
|
||||
607
web/src/lib/queries.ts
Normal file
607
web/src/lib/queries.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
// react-query hooks per resource. Keys mirror REST paths for simplicity.
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "./api";
|
||||
import type {
|
||||
ApiKey,
|
||||
AuditEntry,
|
||||
DashboardStats,
|
||||
HealthData,
|
||||
OAuthClient,
|
||||
Project,
|
||||
Session,
|
||||
Site,
|
||||
Translations,
|
||||
UserKey,
|
||||
} from "./types";
|
||||
|
||||
// ---------- Session ----------
|
||||
export function useSession() {
|
||||
return useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.get<Session>("/api/me"),
|
||||
staleTime: 60_000,
|
||||
retry: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- i18n ----------
|
||||
export function useTranslations(lang: "en" | "fa") {
|
||||
return useQuery({
|
||||
queryKey: ["i18n", lang],
|
||||
queryFn: () => api.get<Translations>(`/api/i18n/${lang}`),
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Dashboard stats ----------
|
||||
// Server returns { stats: {...}, projects_by_type, health }; flatten to the
|
||||
// stats object the SPA pages consume directly. User-session vs admin-session
|
||||
// shapes differ, but both nest the headline numbers under `stats`.
|
||||
export function useDashboardStats() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard-stats"],
|
||||
queryFn: () =>
|
||||
api.get<{ stats: DashboardStats; projects_by_type?: unknown; health?: unknown }>(
|
||||
"/api/dashboard/stats",
|
||||
),
|
||||
select: (data) => data?.stats ?? ({} as DashboardStats),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Projects (admin) ----------
|
||||
// Server returns paginated { projects: [...], total_count, ... }; pages
|
||||
// consume the array directly.
|
||||
export function useProjects() {
|
||||
return useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: () =>
|
||||
api.get<{ projects: Project[]; total_count?: number }>("/api/dashboard/projects"),
|
||||
select: (data) => data?.projects ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Admin API keys ----------
|
||||
// /api/dashboard/api-keys (Track G GET endpoint) returns
|
||||
// { keys: [...], total_count, total_pages, current_page, per_page }.
|
||||
export function useAdminApiKeys() {
|
||||
return useQuery({
|
||||
queryKey: ["admin-api-keys"],
|
||||
queryFn: () => api.get<{ keys: ApiKey[] }>("/api/dashboard/api-keys"),
|
||||
select: (data) => data?.keys ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAdminApiKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { description: string; scope?: string; project_id?: string | null }) =>
|
||||
api.post<{ id: string; key: string }>("/api/dashboard/api-keys/create", body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-api-keys"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeAdminApiKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.post(`/api/dashboard/api-keys/${id}/revoke`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-api-keys"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAdminApiKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.del(`/api/dashboard/api-keys/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-api-keys"] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- OAuth clients (admin) ----------
|
||||
// /api/dashboard/oauth-clients (Track G GET endpoint) returns
|
||||
// { clients: [...], total_count }. Admin sees all; OAuth user sees own.
|
||||
export function useOAuthClients() {
|
||||
return useQuery({
|
||||
queryKey: ["oauth-clients"],
|
||||
queryFn: () =>
|
||||
api.get<{ clients: OAuthClient[]; total_count?: number }>("/api/dashboard/oauth-clients"),
|
||||
select: (data) =>
|
||||
(data?.clients ?? []).map((client: any) => ({
|
||||
id: client.id ?? client.client_id,
|
||||
name: client.name ?? client.client_name,
|
||||
redirect_uris: client.redirect_uris ?? [],
|
||||
created_at: client.created_at,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateOAuthClient() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { name: string; redirect_uris: string[] }) =>
|
||||
api.post<{
|
||||
success: boolean;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
client?: {
|
||||
client_id?: string;
|
||||
id?: string;
|
||||
client_name?: string;
|
||||
name?: string;
|
||||
redirect_uris?: string[];
|
||||
created_at?: string;
|
||||
};
|
||||
}>("/api/dashboard/oauth-clients/create", body),
|
||||
onSuccess: (data, variables) => {
|
||||
const created = data.client;
|
||||
if (created) {
|
||||
qc.setQueryData<{ clients: any[]; total_count?: number }>(["oauth-clients"], (prev) => {
|
||||
const clients = prev?.clients ?? [];
|
||||
const client_id = created.client_id ?? created.id ?? data.client_id;
|
||||
return {
|
||||
clients: [
|
||||
{
|
||||
client_id,
|
||||
client_name: created.client_name ?? created.name ?? variables.name,
|
||||
redirect_uris: created.redirect_uris ?? variables.redirect_uris,
|
||||
created_at: created.created_at,
|
||||
},
|
||||
...clients.filter((client: any) => (client.client_id ?? client.id) !== client_id),
|
||||
],
|
||||
total_count: (prev?.total_count ?? clients.length) + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["oauth-clients"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteOAuthClient() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.del(`/api/dashboard/oauth-clients/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["oauth-clients"] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Audit logs ----------
|
||||
// Server returns { logs, stats, total_count, total_pages, current_page, per_page }.
|
||||
// SPA expects { total, entries } — translate at the edge so the page does not need to know.
|
||||
// AuditLogs hook: filters are server-side so we can paginate over millions
|
||||
// of rows. Backend params: event_type, level, date (YYYY-MM-DD), search, page.
|
||||
export function useAuditLogs(opts: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
level?: string;
|
||||
search?: string;
|
||||
date?: string;
|
||||
eventType?: string;
|
||||
} = {}) {
|
||||
const { page = 1, limit = 50, level, search, date, eventType } = opts;
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: String(limit),
|
||||
});
|
||||
if (level && level !== "all") params.set("level", level);
|
||||
if (search) params.set("search", search);
|
||||
if (date) params.set("date", date);
|
||||
if (eventType) params.set("event_type", eventType);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["audit-logs", page, limit, level, search, date, eventType],
|
||||
queryFn: () =>
|
||||
api.get<{
|
||||
logs: AuditEntry[];
|
||||
total_count: number;
|
||||
total_pages?: number;
|
||||
current_page?: number;
|
||||
per_page?: number;
|
||||
}>(`/api/dashboard/audit-logs?${params}`),
|
||||
select: (data) => ({
|
||||
total: data?.total_count ?? 0,
|
||||
pages: data?.total_pages ?? 1,
|
||||
entries: data?.logs ?? [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Health ----------
|
||||
export function useHealth() {
|
||||
return useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: () => api.get<HealthData>("/api/dashboard/health"),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- User sites ----------
|
||||
// Server returns { sites: [...] }; pages consume the array directly.
|
||||
export function useSites() {
|
||||
return useQuery({
|
||||
queryKey: ["sites"],
|
||||
queryFn: () => api.get<{ sites: Site[]; limit?: number; remaining?: number }>("/api/sites"),
|
||||
select: (data) => data?.sites ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export function useSiteLimit() {
|
||||
return useQuery({
|
||||
queryKey: ["sites"],
|
||||
queryFn: () => api.get<{ sites: Site[]; limit?: number; remaining?: number }>("/api/sites"),
|
||||
select: (data) => ({
|
||||
limit: data?.limit,
|
||||
remaining: data?.remaining,
|
||||
count: data?.sites?.length ?? 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Single-site GET is not exposed by the backend; surface the matching site
|
||||
// from the cached list instead of issuing a doomed /api/sites/{id} request.
|
||||
export function useSite(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["sites", id],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ sites: Site[] }>("/api/sites");
|
||||
return (res?.sites ?? []).find((s) => s.id === id);
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSite() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<Site> & { credentials?: Record<string, unknown> }) =>
|
||||
api.post<Site>("/api/sites", body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["sites"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSite(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<Site> & { credentials?: Record<string, unknown> }) =>
|
||||
api.patch<Site>(`/api/sites/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["sites"] });
|
||||
qc.invalidateQueries({ queryKey: ["sites", id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSite() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.del(`/api/sites/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["sites"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestSite() {
|
||||
return useMutation({
|
||||
// Backend response shape (core/dashboard/routes.py:api_test_site):
|
||||
// { ok: bool, message: str, status: "active"|"error", last_tested_at: ISO }
|
||||
mutationFn: (id: string) =>
|
||||
api.post<{
|
||||
ok?: boolean;
|
||||
status: string;
|
||||
message?: string;
|
||||
response_time?: number;
|
||||
last_tested_at?: string;
|
||||
}>(`/api/sites/${id}/test`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSiteConfig(alias: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["config", alias],
|
||||
queryFn: () => api.get<{ client_configs: Record<string, any> }>(`/api/config/${alias}`),
|
||||
enabled: !!alias,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Admin managed settings ----------
|
||||
// GET returns { settings: [{ key, value, source, default, label, hint, ... }] }
|
||||
// POST { key, value } persists to the database; admin-only on both.
|
||||
export type ManagedSetting = {
|
||||
key: string;
|
||||
value: string;
|
||||
source: "database" | "environment" | "default";
|
||||
default: string;
|
||||
label: string;
|
||||
label_fa: string;
|
||||
hint: string;
|
||||
hint_fa: string;
|
||||
};
|
||||
|
||||
export function useManagedSettings() {
|
||||
return useQuery({
|
||||
queryKey: ["managed-settings"],
|
||||
queryFn: () =>
|
||||
api.get<{ settings: ManagedSetting[] }>("/api/dashboard/settings"),
|
||||
select: (data) => data?.settings ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSetting() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { key: string; value: string; action?: "save" | "reset" }) =>
|
||||
api.post("/api/dashboard/settings", body),
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["managed-settings"] });
|
||||
if (variables.key === "ENABLED_PLUGINS") {
|
||||
qc.invalidateQueries({ queryKey: ["plugins"] });
|
||||
qc.invalidateQueries({ queryKey: ["sites"] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetSettings() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => api.post<{ ok: boolean; deleted: number }>("/api/dashboard/settings/reset"),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["managed-settings"] });
|
||||
qc.invalidateQueries({ queryKey: ["plugins"] });
|
||||
qc.invalidateQueries({ queryKey: ["sites"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// PATCH /api/sites/{site_id}/tool-scope — update the site's tier preset.
|
||||
// Body: { scope: "read" | "read:sensitive" | "deploy" | "editor" | "settings"
|
||||
// | "install" | "write" | "admin" | "custom" }.
|
||||
export function useUpdateSiteToolScope() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ siteId, scope }: { siteId: string; scope: string }) =>
|
||||
api.patch<{ ok: boolean; site_id: string; tool_scope: string }>(
|
||||
`/api/sites/${siteId}/tool-scope`,
|
||||
{ scope },
|
||||
),
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["sites"] });
|
||||
qc.invalidateQueries({ queryKey: ["site-tools", variables.siteId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Plugins catalog (G.12 prep) ----------
|
||||
// /api/plugins returns the per-plugin credential field schema. Used by the
|
||||
// SPA's native Site Add/Edit dialog to avoid round-tripping to the legacy
|
||||
// Jinja form. Admins see every plugin; user sessions are filtered to the
|
||||
// public set via ENABLED_PLUGINS.
|
||||
export type PluginFieldDef = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: "text" | "password" | "url" | string;
|
||||
required: boolean;
|
||||
hint?: string;
|
||||
advanced?: boolean;
|
||||
};
|
||||
export type PluginCatalogEntry = {
|
||||
type: string;
|
||||
name: string;
|
||||
fields: PluginFieldDef[];
|
||||
};
|
||||
|
||||
export function usePluginCatalog() {
|
||||
return useQuery({
|
||||
queryKey: ["plugins"],
|
||||
queryFn: () => api.get<{ plugins: PluginCatalogEntry[] }>("/api/plugins"),
|
||||
select: (data) => data?.plugins ?? [],
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Per-site tools (G.5c) ----------
|
||||
// GET /api/sites/{site_id}/tools returns every tool the plugin defines with
|
||||
// its current toggle state and prerequisite metadata. PATCH /tools/{name}
|
||||
// flips a single tool; the bulk-toggle endpoint isn't exposed here yet —
|
||||
// scope-tier preset (above) covers the bulk case.
|
||||
export type SiteTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
plugin_type: string;
|
||||
category: string | null;
|
||||
sensitivity: string | null;
|
||||
required_scope: string;
|
||||
enabled: boolean;
|
||||
provider_key_required: boolean;
|
||||
provider_key_configured: boolean;
|
||||
available: boolean;
|
||||
unavailable_reason: string | null;
|
||||
};
|
||||
|
||||
export type ScopePreset = {
|
||||
value: string;
|
||||
label: string;
|
||||
label_fa?: string;
|
||||
hint: string;
|
||||
hint_fa?: string;
|
||||
};
|
||||
|
||||
export type SiteCapabilityProbe = {
|
||||
ok: boolean;
|
||||
plugin_type?: string;
|
||||
probe_available?: boolean;
|
||||
reason?: string | null;
|
||||
granted?: string[];
|
||||
ai_providers_configured?: string[];
|
||||
tier?: string;
|
||||
fit?: {
|
||||
status?: "ok" | "warning" | "probe_unavailable" | "unknown_tier" | string;
|
||||
required?: string[];
|
||||
missing?: string[];
|
||||
reason?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export function useSiteCapabilities(siteId: string | undefined, tier: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["site-capabilities", siteId, tier],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (tier) params.set("tier", tier);
|
||||
const qs = params.toString();
|
||||
return api.get<SiteCapabilityProbe>(`/api/sites/${siteId}/capabilities${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
enabled: !!siteId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSiteTools(siteId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["site-tools", siteId],
|
||||
queryFn: () =>
|
||||
api.get<{
|
||||
site_id: string;
|
||||
plugin_type: string;
|
||||
tool_scope: string;
|
||||
scope_presets?: ScopePreset[];
|
||||
configured_providers: string[];
|
||||
tools: SiteTool[];
|
||||
}>(`/api/sites/${siteId}/tools`),
|
||||
enabled: !!siteId,
|
||||
});
|
||||
}
|
||||
|
||||
export type SiteProviderKeyState = {
|
||||
ok: boolean;
|
||||
providers: string[];
|
||||
default_models?: Record<string, string | null>;
|
||||
};
|
||||
|
||||
export function useSiteProviderKeys(siteId: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["site-provider-keys", siteId],
|
||||
queryFn: () => api.get<SiteProviderKeyState>(`/api/sites/${siteId}/provider-keys`),
|
||||
enabled: !!siteId && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSiteProviderKey(siteId: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, apiKey }: { provider: string; apiKey: string }) =>
|
||||
api.post<{ ok: boolean; provider: string; secret_last4?: string }>(
|
||||
`/api/sites/${siteId}/provider-keys/${provider}`,
|
||||
{ api_key: apiKey },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["site-provider-keys", siteId] });
|
||||
qc.invalidateQueries({ queryKey: ["site-tools", siteId] });
|
||||
qc.invalidateQueries({ queryKey: ["site-capabilities", siteId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSiteProviderKey(siteId: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (provider: string) =>
|
||||
api.del<{ ok: boolean; provider: string; deleted: boolean }>(
|
||||
`/api/sites/${siteId}/provider-keys/${provider}`,
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["site-provider-keys", siteId] });
|
||||
qc.invalidateQueries({ queryKey: ["site-tools", siteId] });
|
||||
qc.invalidateQueries({ queryKey: ["site-capabilities", siteId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type OpenRouterImageModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
context_length?: number | null;
|
||||
input_modalities?: string[];
|
||||
output_modalities?: string[];
|
||||
price_per_image_usd?: number | null;
|
||||
};
|
||||
|
||||
export function useOpenRouterImageModels(siteId: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["openrouter-image-models", siteId],
|
||||
queryFn: () =>
|
||||
api.get<{ ok: boolean; provider: "openrouter"; models: OpenRouterImageModel[] }>(
|
||||
`/api/providers/openrouter/models?site_id=${encodeURIComponent(siteId ?? "")}`,
|
||||
),
|
||||
select: (data) => data?.models ?? [],
|
||||
enabled: !!siteId && enabled,
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSiteProviderDefaultModel(siteId: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, model }: { provider: string; model: string | null }) =>
|
||||
api.patch<{ ok: boolean; provider: string; default_model: string | null }>(
|
||||
`/api/sites/${siteId}/provider-keys/${provider}/default-model`,
|
||||
{ model },
|
||||
),
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["site-provider-keys", siteId] });
|
||||
if (variables.provider === "openrouter") {
|
||||
qc.invalidateQueries({ queryKey: ["openrouter-image-models", siteId] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleSiteTool(siteId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, enabled, reason }: { name: string; enabled: boolean; reason?: string }) =>
|
||||
api.patch<{ ok: boolean; tool_name: string; enabled: boolean }>(
|
||||
`/api/sites/${siteId}/tools/${name}`,
|
||||
{ enabled, reason },
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["site-tools", siteId] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- User API keys ----------
|
||||
// Server returns { keys: [...] }; pages consume the array directly.
|
||||
export function useUserKeys() {
|
||||
return useQuery({
|
||||
queryKey: ["user-keys"],
|
||||
queryFn: () => api.get<{ keys: UserKey[] }>("/api/keys"),
|
||||
select: (data) =>
|
||||
(data?.keys ?? []).map((key) => {
|
||||
const expiresAt = key.expires_at ? Date.parse(key.expires_at) : Number.NaN;
|
||||
return {
|
||||
...key,
|
||||
prefix: key.prefix ?? key.key_prefix,
|
||||
scope: key.scope ?? key.scopes,
|
||||
status:
|
||||
key.status ??
|
||||
(Number.isFinite(expiresAt) && expiresAt < Date.now() ? "expired" : "active"),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateUserKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { name: string; site_id?: string; expires_in_days?: number }) =>
|
||||
api.post<{ id: string; key: string; name: string }>("/api/keys", body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["user-keys"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteUserKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.del(`/api/keys/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["user-keys"] }),
|
||||
});
|
||||
}
|
||||
90
web/src/lib/store.ts
Normal file
90
web/src/lib/store.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// Zustand UI store — theme, lang, brand hue, density, sidebar collapse.
|
||||
// Persisted to localStorage so the shell remembers preferences across reloads.
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type ThemePref = "dark" | "light" | "system";
|
||||
export type Theme = ThemePref; // legacy alias — keep for callers that import {Theme}
|
||||
export type Lang = "en" | "fa";
|
||||
|
||||
type UiState = {
|
||||
theme: ThemePref;
|
||||
lang: Lang;
|
||||
brandHue: number;
|
||||
density: number;
|
||||
/** Desktop only — narrow icon-rail vs full-width sidebar. Persisted. */
|
||||
sidebarCollapsed: boolean;
|
||||
/** Mobile only — slide-in drawer open vs hidden. Resets on each visit. */
|
||||
sidebarMobileOpen: boolean;
|
||||
toast: string;
|
||||
setTheme: (t: ThemePref) => void;
|
||||
setLang: (l: Lang) => void;
|
||||
setBrandHue: (h: number) => void;
|
||||
setDensity: (d: number) => void;
|
||||
setSidebarCollapsed: (v: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarMobileOpen: (v: boolean) => void;
|
||||
toggleSidebarMobile: () => void;
|
||||
setToast: (m: string) => void;
|
||||
};
|
||||
|
||||
export const useUiStore = create<UiState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: "system",
|
||||
lang: "en",
|
||||
brandHue: 205,
|
||||
density: 1,
|
||||
sidebarCollapsed: false,
|
||||
sidebarMobileOpen: false,
|
||||
toast: "",
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setLang: (lang) => set({ lang }),
|
||||
setBrandHue: (brandHue) => set({ brandHue }),
|
||||
setDensity: (density) => set({ density }),
|
||||
setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
|
||||
toggleSidebar: () => set({ sidebarCollapsed: !get().sidebarCollapsed }),
|
||||
setSidebarMobileOpen: (sidebarMobileOpen) => set({ sidebarMobileOpen }),
|
||||
toggleSidebarMobile: () => set({ sidebarMobileOpen: !get().sidebarMobileOpen }),
|
||||
setToast: (toast) => set({ toast }),
|
||||
}),
|
||||
{
|
||||
name: "mcphub-ui",
|
||||
// Mobile drawer state is intentionally NOT persisted — re-opening the
|
||||
// tab should land you with the drawer closed regardless of how you
|
||||
// last left it.
|
||||
partialize: (s) => ({
|
||||
theme: s.theme,
|
||||
lang: s.lang,
|
||||
brandHue: s.brandHue,
|
||||
density: s.density,
|
||||
sidebarCollapsed: s.sidebarCollapsed,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Single source of truth for the "is this a mobile viewport?" check. Used by
|
||||
// the Topbar toggle button to decide whether to open the mobile drawer or
|
||||
// collapse the desktop sidebar.
|
||||
export const MOBILE_QUERY = "(max-width: 900px)";
|
||||
export function isMobileViewport(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia(MOBILE_QUERY).matches;
|
||||
}
|
||||
|
||||
function resolveTheme(pref: ThemePref): "dark" | "light" {
|
||||
if (pref !== "system") return pref;
|
||||
if (typeof window === "undefined" || !window.matchMedia) return "dark";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function applyUiToDom(state: Pick<UiState, "theme" | "lang" | "brandHue" | "density">) {
|
||||
const html = document.documentElement;
|
||||
html.setAttribute("data-theme", resolveTheme(state.theme));
|
||||
html.setAttribute("data-theme-pref", state.theme);
|
||||
html.setAttribute("dir", state.lang === "fa" ? "rtl" : "ltr");
|
||||
html.setAttribute("lang", state.lang);
|
||||
html.style.setProperty("--brand-hue", String(state.brandHue));
|
||||
html.style.setProperty("--density", String(state.density));
|
||||
}
|
||||
152
web/src/lib/types.ts
Normal file
152
web/src/lib/types.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// Shared types for API responses. Kept loose where the backend already varies.
|
||||
|
||||
export type Session = {
|
||||
authenticated: boolean;
|
||||
user_id?: string;
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
role?: "admin" | "user";
|
||||
type?: "master" | "api_key" | "oauth_user";
|
||||
is_admin: boolean;
|
||||
lang: "en" | "fa";
|
||||
// Provided by /api/me. The dashboard_csrf cookie is HttpOnly, so JS reads
|
||||
// the matching token from this field instead.
|
||||
csrf_token?: string | null;
|
||||
// Mirrors the server-side DISABLE_MASTER_KEY_LOGIN env var (inverted).
|
||||
// When false, the SPA hides the master-key form on /login.
|
||||
master_key_login_enabled?: boolean;
|
||||
max_sites_per_user?: number;
|
||||
};
|
||||
|
||||
export type DashboardStats = {
|
||||
projects_count: number;
|
||||
api_keys_count: number;
|
||||
tools_count: number;
|
||||
uptime_days: number;
|
||||
users_count?: number;
|
||||
user_sites_count?: number;
|
||||
recent_users_count?: number;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
full_id: string;
|
||||
alias: string;
|
||||
plugin_type: string;
|
||||
site_id?: string;
|
||||
status?: "healthy" | "warning" | "error" | "unknown";
|
||||
url?: string;
|
||||
tools_count?: number;
|
||||
};
|
||||
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
description?: string;
|
||||
scope?: string;
|
||||
project_id?: string | null;
|
||||
created_at?: string;
|
||||
last_used?: string | null;
|
||||
status?: "active" | "revoked" | "expired" | "idle";
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
export type Site = {
|
||||
id: string;
|
||||
alias: string;
|
||||
plugin_type: string;
|
||||
url?: string;
|
||||
// Backend persists `"active"` for sites passing the connection test (see
|
||||
// core/site_api.py). Keep both vocabularies so the type matches reality;
|
||||
// use `normalizeSiteStatus` from `lib/format.ts` before rendering.
|
||||
status?: "healthy" | "active" | "warning" | "error" | "unknown" | "untested";
|
||||
status_msg?: string;
|
||||
last_tested_at?: string | null;
|
||||
created_at?: string;
|
||||
// F.19.2.0 — per-site tool tier; values from _VALID_TOOL_SCOPES in
|
||||
// core/dashboard/routes.py (read, read:sensitive, deploy, editor,
|
||||
// settings, install, write, admin, custom). Returned by /api/sites
|
||||
// since the field is on the sites table; defaults to "admin".
|
||||
tool_scope?: string;
|
||||
};
|
||||
|
||||
export type UserKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix?: string;
|
||||
prefix?: string;
|
||||
scopes?: string;
|
||||
scope?: string;
|
||||
site_id?: string | null;
|
||||
expires_at?: string | null;
|
||||
created_at?: string;
|
||||
last_used?: string | null;
|
||||
status?: "active" | "revoked" | "expired";
|
||||
};
|
||||
|
||||
export type OAuthClient = {
|
||||
id: string;
|
||||
name: string;
|
||||
redirect_uris: string[];
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type AuditEntry = {
|
||||
id?: string;
|
||||
timestamp: string;
|
||||
event_type: string;
|
||||
message?: string;
|
||||
level?: "info" | "warn" | "error";
|
||||
actor?: string;
|
||||
target?: string;
|
||||
ip?: string;
|
||||
duration_ms?: number;
|
||||
result?: "ok" | "denied" | "error";
|
||||
};
|
||||
|
||||
// Mirrors get_health_data() in core/dashboard/routes.py — that endpoint
|
||||
// is the source of truth, so the SPA reads its real fields directly.
|
||||
export type HealthAlert = {
|
||||
level?: "info" | "warning" | "warn" | "error" | "critical";
|
||||
message?: string;
|
||||
source?: string;
|
||||
timestamp?: string;
|
||||
status_code?: number;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type HealthData = {
|
||||
system_status?: "healthy" | "degraded" | "down" | "unknown";
|
||||
metrics?: {
|
||||
total_requests?: number;
|
||||
successful_requests?: number;
|
||||
failed_requests?: number;
|
||||
average_response_time_ms?: number;
|
||||
error_rate_percent?: number;
|
||||
requests_per_minute?: number;
|
||||
};
|
||||
uptime?: {
|
||||
start_time?: string;
|
||||
current_time?: string;
|
||||
formatted?: string;
|
||||
days?: number;
|
||||
hours?: number;
|
||||
};
|
||||
alerts?: HealthAlert[];
|
||||
projects_health?: Record<
|
||||
string,
|
||||
{
|
||||
status?: string;
|
||||
latency_ms?: number;
|
||||
uptime_percent?: number;
|
||||
tool_count?: number;
|
||||
last_check?: string;
|
||||
message?: string;
|
||||
}
|
||||
>;
|
||||
projects_summary?: {
|
||||
total?: number;
|
||||
healthy?: number;
|
||||
unhealthy?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type Translations = Record<string, string>;
|
||||
27
web/src/main.tsx
Normal file
27
web/src/main.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import "./styles/globals.css";
|
||||
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: 1, staleTime: 10_000 },
|
||||
},
|
||||
});
|
||||
|
||||
const root = document.getElementById("app");
|
||||
if (!root) throw new Error("#app not found");
|
||||
|
||||
const basename = window.location.pathname.startsWith("/dashboard") ? "/dashboard" : "/";
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter basename={basename}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
682
web/src/pages/ApiKeys.tsx
Normal file
682
web/src/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,682 @@
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Topbar } from "../components/Topbar";
|
||||
import { Card, Badge, Btn, CopyField } from "../components/primitives";
|
||||
import { Icons } from "../components/icons";
|
||||
import {
|
||||
useAdminApiKeys,
|
||||
useCreateAdminApiKey,
|
||||
useCreateUserKey,
|
||||
useDeleteAdminApiKey,
|
||||
useDeleteUserKey,
|
||||
useRevokeAdminApiKey,
|
||||
useSession,
|
||||
useSites,
|
||||
useUserKeys,
|
||||
} from "../lib/queries";
|
||||
import { useUiStore } from "../lib/store";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { fmtDateTime } from "../lib/format";
|
||||
|
||||
// ---------- Scope tiers (mirrors core/tool_access.SCOPE_TO_CATEGORIES + "custom") ----------
|
||||
// Six tiers per F.19.2.2; "admin" is treated as destructive per F.19.2.3.
|
||||
type ScopeTier = "read" | "read:sensitive" | "deploy" | "write" | "admin" | "custom";
|
||||
|
||||
type TierInfo = { value: ScopeTier; label: string; hint: string; danger?: boolean; warning?: boolean };
|
||||
|
||||
const SCOPE_TIERS: TierInfo[] = [
|
||||
{ value: "read", label: "Read", hint: "Read-only listing and inspection across resources." },
|
||||
{
|
||||
value: "read:sensitive",
|
||||
label: "Read sensitive",
|
||||
hint: "Includes backups, env vars, and other privacy-bearing reads.",
|
||||
warning: true,
|
||||
},
|
||||
{ value: "deploy", label: "Deploy", hint: "Trigger deployments and lifecycle actions, no edits." },
|
||||
{ value: "write", label: "Write", hint: "Create / update / delete resources and configuration." },
|
||||
{
|
||||
value: "admin",
|
||||
label: "Admin",
|
||||
hint: "Full control including system-level operations. Treat the key as a credential.",
|
||||
danger: true,
|
||||
},
|
||||
{ value: "custom", label: "Custom", hint: "Pick scopes/categories manually after creation." },
|
||||
];
|
||||
|
||||
function scopeBadgeVariant(scope: string | undefined): "default" | "warning" | "danger" {
|
||||
if (scope === "admin") return "danger";
|
||||
if (scope === "read:sensitive" || scope === "install") return "warning";
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const session = useSession();
|
||||
const isAdminKeySession = (session.data?.is_admin ?? false) && session.data?.type !== "oauth_user";
|
||||
const isAdmin = isAdminKeySession;
|
||||
return isAdmin ? <AdminApiKeys /> : <UserApiKeys />;
|
||||
}
|
||||
|
||||
// ---------- Admin variant ----------
|
||||
|
||||
function AdminApiKeys() {
|
||||
const t = useT();
|
||||
const keys = useAdminApiKeys();
|
||||
const create = useCreateAdminApiKey();
|
||||
const revoke = useRevokeAdminApiKey();
|
||||
const del = useDeleteAdminApiKey();
|
||||
const setToast = useUiStore((s) => s.setToast);
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
const onCreate = async (description: string, scope: string) => {
|
||||
try {
|
||||
const r = await create.mutateAsync({ description, scope });
|
||||
setCreatedKey(r.key);
|
||||
} catch (e: any) {
|
||||
setToast(`Create failed: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
crumbs={[t("workspace", "Workspace"), t("api_keys", "API keys")]}
|
||||
actions={
|
||||
<Btn variant="primary" size="sm" icon="plus" onClick={() => setShowCreate(true)}>
|
||||
{t("generate_key", "New key")}
|
||||
</Btn>
|
||||
}
|
||||
/>
|
||||
<div className="page-pad">
|
||||
<div className="page-head">
|
||||
<h1 className="h-1" style={{ margin: 0 }}>
|
||||
{t("api_keys", "API keys")}
|
||||
</h1>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginTop: 6, maxWidth: 640 }}>
|
||||
{t(
|
||||
"api_keys.admin_intro",
|
||||
"Personal and machine keys for MCP clients. Each key is scoped and logged independently.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createdKey && <CreatedKeyAlert value={createdKey} t={t} onDismiss={() => setCreatedKey(null)} />}
|
||||
|
||||
{showCreate && (
|
||||
<CreateKeyDialog
|
||||
t={t}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onCreate={async (d, s) => {
|
||||
await onCreate(d, s);
|
||||
setShowCreate(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<table className="table mobile-stack">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("table.description", "Description")}</th>
|
||||
<th>{t("table.prefix", "Prefix")}</th>
|
||||
<th>{t("table.scope", "Scope")}</th>
|
||||
<th>{t("table.created", "Created")}</th>
|
||||
<th>{t("table.last_used", "Last used")}</th>
|
||||
<th>{t("status", "Status")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.isLoading ? (
|
||||
<SkeletonRows cols={7} />
|
||||
) : (keys.data ?? []).length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="caption">
|
||||
{t("no_api_keys", "No keys yet")}.{" "}
|
||||
{t("api_keys.admin_empty_cta", "Create one to authenticate MCP clients.")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
(keys.data ?? []).map((k) => (
|
||||
<tr key={k.id}>
|
||||
<td className="row-head" data-label={t("table.description", "Description")} style={{ fontWeight: 500 }}>
|
||||
{k.description || k.id}
|
||||
</td>
|
||||
<td className="mono caption" data-label={t("table.prefix", "Prefix")}>
|
||||
{k.prefix ? `${k.prefix}…` : "—"}
|
||||
</td>
|
||||
<td data-label={t("table.scope", "Scope")}>
|
||||
<Badge variant={scopeBadgeVariant(k.scope)} className="badge-fixed" title={k.scope}>
|
||||
{k.scope || "default"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="caption" data-label={t("table.created", "Created")}>
|
||||
{fmtDateTime(k.created_at, lang)}
|
||||
</td>
|
||||
<td className="caption" data-label={t("table.last_used", "Last used")}>
|
||||
{fmtDateTime(k.last_used, lang)}
|
||||
</td>
|
||||
<td data-label={t("status", "Status")}>
|
||||
{k.status === "active" ? (
|
||||
<Badge variant="success" dot>
|
||||
{t("active", "active")}
|
||||
</Badge>
|
||||
) : k.status === "revoked" ? (
|
||||
<Badge variant="danger">{t("status.revoked", "revoked")}</Badge>
|
||||
) : (
|
||||
<Badge>{k.status ?? "—"}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="cell-actions" style={{ textAlign: "right" }}>
|
||||
<div style={{ display: "inline-flex", gap: 4 }}>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pendingId === k.id || k.status === "revoked"}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!confirm(
|
||||
t(
|
||||
"api_keys.confirm_revoke",
|
||||
'Revoke "{name}"?\nThe key will stop working immediately.',
|
||||
).replace("{name}", k.description || k.id),
|
||||
)
|
||||
)
|
||||
return;
|
||||
setPendingId(k.id);
|
||||
try {
|
||||
await revoke.mutateAsync(k.id);
|
||||
setToast(t("api_keys.toast_revoked", "Key revoked"));
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pendingId === k.id ? <Spinner /> : t("action.revoke", "Revoke")}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pendingId === k.id}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!confirm(
|
||||
t(
|
||||
"api_keys.confirm_delete",
|
||||
'Delete "{name}" permanently?\nThis cannot be undone.',
|
||||
).replace("{name}", k.description || k.id),
|
||||
)
|
||||
)
|
||||
return;
|
||||
setPendingId(k.id);
|
||||
try {
|
||||
await del.mutateAsync(k.id);
|
||||
setToast(t("api_keys.toast_deleted", "Key deleted"));
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
}}
|
||||
style={{ color: "var(--danger)", padding: 6 }}
|
||||
icon={pendingId === k.id ? undefined : "trash"}
|
||||
>
|
||||
{pendingId === k.id ? <Spinner /> : null}
|
||||
</Btn>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- User variant ----------
|
||||
|
||||
function UserApiKeys() {
|
||||
const t = useT();
|
||||
const keys = useUserKeys();
|
||||
const sites = useSites();
|
||||
const create = useCreateUserKey();
|
||||
const del = useDeleteUserKey();
|
||||
const setToast = useUiStore((s) => s.setToast);
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
const siteById = new Map((sites.data ?? []).map((site) => [site.id, site]));
|
||||
const hasServices = (sites.data ?? []).length > 0;
|
||||
|
||||
const onCreate = async (name: string, siteId: string, expiryDays: number | undefined) => {
|
||||
try {
|
||||
const body: { name: string; site_id?: string; expires_in_days?: number } = { name };
|
||||
if (siteId) body.site_id = siteId;
|
||||
if (expiryDays && expiryDays > 0) body.expires_in_days = expiryDays;
|
||||
const r = await create.mutateAsync(body);
|
||||
setCreatedKey(r.key);
|
||||
} catch (e: any) {
|
||||
setToast(`Create failed: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
crumbs={[t("nav.account", "Account"), t("api_keys", "API keys")]}
|
||||
actions={
|
||||
<Btn
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon="plus"
|
||||
disabled={!hasServices && !sites.isLoading}
|
||||
title={!hasServices && !sites.isLoading ? t("api_keys.no_services_title", "Add a service first") : undefined}
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
{t("generate_key", "New key")}
|
||||
</Btn>
|
||||
}
|
||||
/>
|
||||
<div className="page-pad">
|
||||
<div className="page-head">
|
||||
<h1 className="h-1" style={{ margin: 0 }}>
|
||||
{t("user_api_keys", "Your API keys")}
|
||||
</h1>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginTop: 6 }}>
|
||||
{t(
|
||||
"api_keys.user_intro",
|
||||
"Use these to authenticate MCP clients to your hub.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createdKey && <CreatedKeyAlert value={createdKey} t={t} onDismiss={() => setCreatedKey(null)} />}
|
||||
|
||||
{!sites.isLoading && !hasServices ? (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ padding: 20, display: "flex", gap: 12, alignItems: "flex-start" }}>
|
||||
<Icons.sites style={{ width: 18, height: 18, color: "var(--brand-400)", flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t("api_keys.no_services_title", "Add a service before creating API keys")}
|
||||
</div>
|
||||
<div className="caption" style={{ marginBottom: 12 }}>
|
||||
{t(
|
||||
"api_keys.no_services_body",
|
||||
"API keys authenticate clients to your services. Add your first service from Sites, then create a key for that service or all services.",
|
||||
)}
|
||||
</div>
|
||||
<Link to="/sites?create=1" className="btn btn-primary btn-sm">
|
||||
<Icons.plus style={{ width: 12, height: 12 }} />
|
||||
{t("add_site_short", "Add site")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{showCreate && (
|
||||
<CreateKeyDialog
|
||||
t={t}
|
||||
simple
|
||||
sites={sites.data ?? []}
|
||||
sitesLoading={sites.isLoading}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onCreate={async (name, siteId, expiry) => {
|
||||
await onCreate(name, siteId, expiry);
|
||||
setShowCreate(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<table className="table mobile-stack">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("api_key_name", "Name")}</th>
|
||||
<th>{t("table.prefix", "Prefix")}</th>
|
||||
<th>{t("table.service", "Service")}</th>
|
||||
<th>{t("table.created", "Created")}</th>
|
||||
<th>{t("table.expiry", "Expires")}</th>
|
||||
<th>{t("table.last_used", "Last used")}</th>
|
||||
<th>{t("status", "Status")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.isLoading ? (
|
||||
<SkeletonRows cols={7} />
|
||||
) : (keys.data ?? []).length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="caption">
|
||||
{t("no_api_keys", "No keys yet")}.{" "}
|
||||
{t("api_keys.user_empty_cta", "Create one to connect a client.")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
(keys.data ?? []).map((k) => (
|
||||
<tr key={k.id}>
|
||||
<td className="row-head" data-label={t("api_key_name", "Name")} style={{ fontWeight: 500 }}>
|
||||
{k.name}
|
||||
</td>
|
||||
<td className="mono caption" data-label={t("table.prefix", "Prefix")}>
|
||||
{k.prefix ? `${k.prefix}…` : "—"}
|
||||
</td>
|
||||
<td data-label={t("table.service", "Service")}>
|
||||
<Badge className="badge-fixed" title={k.site_id ?? undefined}>
|
||||
{k.site_id ? (siteById.get(k.site_id)?.alias ?? t("site_not_found", "Site not found")) : t("all_sites", "All sites")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="caption" data-label={t("table.created", "Created")}>
|
||||
{fmtDateTime(k.created_at, lang)}
|
||||
</td>
|
||||
<td className="caption" data-label={t("table.expiry", "Expires")}>
|
||||
{k.expires_at ? fmtDateTime(k.expires_at, lang) : t("never", "Never")}
|
||||
</td>
|
||||
<td className="caption" data-label={t("table.last_used", "Last used")}>
|
||||
{fmtDateTime(k.last_used, lang)}
|
||||
</td>
|
||||
<td data-label={t("status", "Status")}>
|
||||
{k.status === "active" ? (
|
||||
<Badge variant="success" dot>
|
||||
{t("active", "active")}
|
||||
</Badge>
|
||||
) : k.status === "expired" ? (
|
||||
<Badge variant="warning">{t("status.expired", "expired")}</Badge>
|
||||
) : (
|
||||
<Badge>{k.status ?? "—"}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="cell-actions" style={{ textAlign: "right" }}>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pendingId === k.id}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!confirm(
|
||||
t(
|
||||
"api_keys.confirm_delete_user",
|
||||
'Delete "{name}"?\nThe key will stop working immediately.',
|
||||
).replace("{name}", k.name),
|
||||
)
|
||||
)
|
||||
return;
|
||||
setPendingId(k.id);
|
||||
try {
|
||||
await del.mutateAsync(k.id);
|
||||
setToast(t("api_keys.toast_deleted", "Key deleted"));
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
}}
|
||||
icon={pendingId === k.id ? undefined : "trash"}
|
||||
style={{ color: "var(--danger)", padding: 6 }}
|
||||
>
|
||||
{pendingId === k.id ? <Spinner /> : null}
|
||||
</Btn>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Shared bits ----------
|
||||
|
||||
function CreatedKeyAlert({
|
||||
value,
|
||||
t,
|
||||
onDismiss,
|
||||
}: {
|
||||
value: string;
|
||||
t: ReturnType<typeof useT>;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="alert alert-warning" style={{ marginBottom: 16 }}>
|
||||
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, color: "var(--text)", marginBottom: 8 }}>
|
||||
{t("key_shown_once", "Save this key — it's shown only once.")}
|
||||
</div>
|
||||
<CopyField value={value} />
|
||||
</div>
|
||||
<Btn variant="ghost" size="sm" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Btn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateKeyDialog({
|
||||
t,
|
||||
simple = false,
|
||||
sites = [],
|
||||
sitesLoading = false,
|
||||
onCancel,
|
||||
onCreate,
|
||||
}: {
|
||||
t: ReturnType<typeof useT>;
|
||||
simple?: boolean;
|
||||
sites?: { id: string; alias: string; plugin_type: string }[];
|
||||
sitesLoading?: boolean;
|
||||
onCancel: () => void;
|
||||
onCreate: (description: string, scope: string, expiryDays?: number) => Promise<void> | void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
// F.19.2.2: default checks the broadest tier so everything works out of
|
||||
// the box; users explicitly downgrade to a tighter scope.
|
||||
const [scope, setScope] = useState<ScopeTier>("admin");
|
||||
const [siteId, setSiteId] = useState("");
|
||||
const [expiryDays, setExpiryDays] = useState<string>("");
|
||||
|
||||
const selectedTier = SCOPE_TIERS.find((s) => s.value === scope);
|
||||
const canCreate = !!name;
|
||||
|
||||
return (
|
||||
<Card style={{ marginBottom: 16, padding: 20 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div className="field">
|
||||
<label>
|
||||
{simple ? t("api_key_name", "Name") : t("api_keys.description", "Description")}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={
|
||||
simple
|
||||
? "claude-desktop"
|
||||
: t("api_keys.description_placeholder", "What's this key for?")
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!simple ? (
|
||||
<div className="field">
|
||||
<label>{t("scope", "Scope")}</label>
|
||||
<div style={tierGridStyle}>
|
||||
{SCOPE_TIERS.map((tier) => {
|
||||
const active = tier.value === scope;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={tier.value}
|
||||
onClick={() => setScope(tier.value)}
|
||||
style={tierTileStyle(active, tier)}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{t(`tier.${tier.value.replace(":", "_")}`, tier.label)}
|
||||
</span>
|
||||
{tier.danger ? (
|
||||
<Badge variant="danger" className="badge-fixed">
|
||||
{t("badge.admin", "admin")}
|
||||
</Badge>
|
||||
) : tier.warning ? (
|
||||
<Badge variant="warning" className="badge-fixed">
|
||||
{t("badge.sensitive", "sensitive")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 4 }}>
|
||||
{t(`tier.${tier.value.replace(":", "_")}.hint`, tier.hint)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedTier?.danger ? (
|
||||
<div className="alert alert-danger" style={{ marginTop: 10 }}>
|
||||
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, fontSize: 13 }}>
|
||||
{t(
|
||||
"api_keys.admin_warning",
|
||||
"Admin scope grants full system control including destructive operations (deletes, env writes, system tools). Anyone holding this key can act as you across every site. Prefer a narrower tier unless the client genuinely needs it.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedTier?.warning ? (
|
||||
<div className="alert alert-warning" style={{ marginTop: 10 }}>
|
||||
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, fontSize: 13 }}>
|
||||
{t(
|
||||
"api_keys.sensitive_warning",
|
||||
"Reads backup files and env vars, which often contain secrets. Treat the key as a credential and avoid sharing it in unencrypted channels.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="field">
|
||||
<label>{t("table.service", "Service")}</label>
|
||||
<select
|
||||
className="input"
|
||||
value={siteId}
|
||||
onChange={(e) => setSiteId(e.target.value)}
|
||||
disabled={sitesLoading}
|
||||
>
|
||||
<option value="">
|
||||
{sitesLoading
|
||||
? t("loading", "Loading…")
|
||||
: t("all_sites", "All sites")}
|
||||
</option>
|
||||
{sites.map((site) => (
|
||||
<option key={site.id} value={site.id}>
|
||||
{site.alias} · {site.plugin_type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="caption">
|
||||
{t(
|
||||
"api_keys.service_hint",
|
||||
"The key is limited to the selected service. Tool tiers are managed on that service's Tool Access page.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{simple && (
|
||||
<div className="field">
|
||||
<label>{t("api_keys.expiry_label", "Expires in (days, optional)")}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={expiryDays}
|
||||
onChange={(e) => setExpiryDays(e.target.value)}
|
||||
placeholder={t("api_keys.expiry_placeholder", "Leave blank for no expiry")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<Btn variant="ghost" onClick={onCancel}>
|
||||
{t("cancel", "Cancel")}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
disabled={!canCreate}
|
||||
onClick={() =>
|
||||
canCreate && onCreate(name, simple ? siteId : scope, expiryDays ? Number(expiryDays) : undefined)
|
||||
}
|
||||
>
|
||||
{t("create", "Create")}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRows({ cols }: { cols: number }) {
|
||||
return (
|
||||
<>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: cols }).map((_, j) => (
|
||||
<td key={j}>
|
||||
<div className="shimmer" style={{ height: 16, borderRadius: 4 }} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<span
|
||||
aria-label="loading"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 14,
|
||||
height: 14,
|
||||
border: "2px solid var(--border)",
|
||||
borderTopColor: "var(--brand-400)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 0.8s linear infinite",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tierGridStyle: CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
function tierTileStyle(active: boolean, tier: TierInfo): CSSProperties {
|
||||
const accent = tier.danger
|
||||
? "var(--danger)"
|
||||
: tier.warning
|
||||
? "var(--warning, #d97706)"
|
||||
: "var(--brand-400)";
|
||||
return {
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
background: active ? "var(--surface)" : "var(--bg-sunken)",
|
||||
border: `1px solid ${active ? accent : "var(--border)"}`,
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
color: "var(--text)",
|
||||
fontSize: 13,
|
||||
transition: "background 120ms, border-color 120ms",
|
||||
};
|
||||
}
|
||||
343
web/src/pages/AuditLogs.tsx
Normal file
343
web/src/pages/AuditLogs.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import { Topbar } from "../components/Topbar";
|
||||
import { Card, Badge, Btn, Avatar, Seg } from "../components/primitives";
|
||||
import { Icons } from "../components/icons";
|
||||
import { useAuditLogs } from "../lib/queries";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useUiStore } from "../lib/store";
|
||||
import { fmtDateTime, fmtNumber } from "../lib/format";
|
||||
|
||||
type LevelFilter = "all" | "info" | "warn" | "error";
|
||||
|
||||
const PAGE_SIZES = [25, 50, 100, 200] as const;
|
||||
|
||||
function levelBadge(level: string | undefined) {
|
||||
if (level === "error")
|
||||
return (
|
||||
<Badge variant="danger" dot>
|
||||
error
|
||||
</Badge>
|
||||
);
|
||||
if (level === "warn")
|
||||
return (
|
||||
<Badge variant="warning" dot>
|
||||
warn
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Badge variant="success" dot>
|
||||
{level ?? "info"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function resultBadge(r: string | undefined) {
|
||||
if (!r) return null;
|
||||
if (r === "ok")
|
||||
return (
|
||||
<Badge variant="success" className="badge-fixed">
|
||||
ok
|
||||
</Badge>
|
||||
);
|
||||
if (r === "denied")
|
||||
return (
|
||||
<Badge variant="warning" className="badge-fixed">
|
||||
denied
|
||||
</Badge>
|
||||
);
|
||||
if (r === "error")
|
||||
return (
|
||||
<Badge variant="danger" className="badge-fixed">
|
||||
error
|
||||
</Badge>
|
||||
);
|
||||
return <Badge className="badge-fixed">{r}</Badge>;
|
||||
}
|
||||
|
||||
export function AuditLogsPage() {
|
||||
const t = useT();
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState<number>(50);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState(""); // debounced value used in query
|
||||
const [level, setLevel] = useState<LevelFilter>("all");
|
||||
const [date, setDate] = useState<string>("");
|
||||
const [eventType, setEventType] = useState<string>("");
|
||||
|
||||
// Debounce search so each keystroke doesn't refetch.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [searchInput]);
|
||||
|
||||
// Reset to page 1 whenever a filter changes so the user doesn't land on
|
||||
// an empty page.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, level, date, eventType, pageSize]);
|
||||
|
||||
const logs = useAuditLogs({
|
||||
page,
|
||||
limit: pageSize,
|
||||
level: level === "all" ? undefined : level,
|
||||
search: search || undefined,
|
||||
date: date || undefined,
|
||||
eventType: eventType || undefined,
|
||||
});
|
||||
|
||||
const entries = logs.data?.entries ?? [];
|
||||
const total = logs.data?.total ?? 0;
|
||||
const totalPages = logs.data?.pages ?? 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
crumbs={[t("nav.observability", "Observability"), t("audit_logs", "Audit logs")]}
|
||||
actions={
|
||||
<Btn variant="secondary" size="sm" icon="refresh" onClick={() => logs.refetch()}>
|
||||
{t("refresh", "Refresh")}
|
||||
</Btn>
|
||||
}
|
||||
/>
|
||||
<div className="page-pad">
|
||||
<div className="page-head">
|
||||
<h1 className="h-1" style={{ margin: 0 }}>
|
||||
{t("audit_logs", "Audit logs")}
|
||||
</h1>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginTop: 6 }}>
|
||||
{t(
|
||||
"audit.intro",
|
||||
"Every authentication, tool call, and config change. GDPR-compliant. Filters apply server-side.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audit-filter" style={filterRowStyle}>
|
||||
<div style={searchBoxStyle}>
|
||||
<Icons.search style={{ width: 14, height: 14, color: "var(--text-subtle)" }} />
|
||||
<input
|
||||
placeholder={t("audit.search_placeholder", "Search actor / event / target / message…")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={inputResetStyle}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("audit.event_type_placeholder", "event_type (e.g. tool_call)")}
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
className="input"
|
||||
style={{ width: 220, fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="input"
|
||||
style={{ width: 170 }}
|
||||
title={t("audit.date_filter_title", "Filter by single day (YYYY-MM-DD)")}
|
||||
/>
|
||||
<Seg
|
||||
value={level}
|
||||
onChange={setLevel}
|
||||
options={[
|
||||
{ value: "all", label: t("all", "All") },
|
||||
{ value: "info", label: t("audit.level.info", "Info") },
|
||||
{ value: "warn", label: t("audit.level.warn", "Warn") },
|
||||
{ value: "error", label: t("error", "Error") },
|
||||
]}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
style={{ width: 90, fontFamily: "var(--font-mono)" }}
|
||||
value={String(pageSize)}
|
||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||
title={t("audit.page_size", "Page size")}
|
||||
>
|
||||
{PAGE_SIZES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t("audit.per_page", "{n}/page").replace("{n}", String(s))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Card style={{ padding: 0 }}>
|
||||
<table className="table mobile-stack">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 150 }}>{t("audit.col.time", "Time")}</th>
|
||||
<th>{t("audit.col.actor", "Actor")}</th>
|
||||
<th>{t("audit.col.event", "Event")}</th>
|
||||
<th>{t("audit.col.target", "Target")}</th>
|
||||
<th>{t("audit.col.message", "Message")}</th>
|
||||
<th>{t("audit.col.result", "Result")}</th>
|
||||
<th>{t("audit.col.level", "Level")}</th>
|
||||
<th>{t("audit.col.duration", "Duration")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.isLoading ? (
|
||||
<SkeletonRows cols={8} />
|
||||
) : entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="caption" style={{ padding: 24, textAlign: "center" }}>
|
||||
{t("audit.no_entries", "No matching entries.")}
|
||||
{search || level !== "all" || date || eventType ? (
|
||||
<>
|
||||
{" "}
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSearchInput("");
|
||||
setSearch("");
|
||||
setLevel("all");
|
||||
setDate("");
|
||||
setEventType("");
|
||||
}}
|
||||
style={{ color: "var(--brand-400)" }}
|
||||
>
|
||||
{t("audit.clear_filters", "Clear filters")}
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
entries.map((r, i) => (
|
||||
<tr key={r.id ?? i}>
|
||||
<td
|
||||
className="row-head mono"
|
||||
data-label={t("audit.col.time", "Time")}
|
||||
style={{ color: "var(--text-muted)", fontSize: 11.5 }}
|
||||
>
|
||||
{fmtDateTime(r.timestamp, lang)}
|
||||
</td>
|
||||
<td data-label={t("audit.col.actor", "Actor")}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<Avatar name={r.actor || "system"} size={20} />
|
||||
{r.actor ?? "system"}
|
||||
{r.ip ? (
|
||||
<span className="mono caption" title={`from ${r.ip}`}>
|
||||
({r.ip})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="mono"
|
||||
data-label={t("audit.col.event", "Event")}
|
||||
style={{ color: "var(--brand-400)" }}
|
||||
>
|
||||
{r.event_type}
|
||||
</td>
|
||||
<td data-label={t("audit.col.target", "Target")} style={{ color: "var(--text-muted)" }}>
|
||||
{r.target ?? "—"}
|
||||
</td>
|
||||
<td
|
||||
className="caption"
|
||||
data-label={t("audit.col.message", "Message")}
|
||||
style={{ maxWidth: 320, wordBreak: "break-word" }}
|
||||
>
|
||||
{r.message ?? "—"}
|
||||
</td>
|
||||
<td data-label={t("audit.col.result", "Result")}>{resultBadge(r.result)}</td>
|
||||
<td data-label={t("audit.col.level", "Level")}>{levelBadge(r.level)}</td>
|
||||
<td className="mono caption" data-label={t("audit.col.duration", "Duration")}>
|
||||
{r.duration_ms != null ? `${fmtNumber(r.duration_ms, lang)} ms` : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
<div style={paginationStyle}>
|
||||
<span className="caption">
|
||||
{total > 0
|
||||
? t("audit.range_of", "{from}–{to} of {total}")
|
||||
.replace("{from}", fmtNumber((page - 1) * pageSize + 1, lang))
|
||||
.replace("{to}", fmtNumber(Math.min(page * pageSize, total), lang))
|
||||
.replace("{total}", fmtNumber(total, lang))
|
||||
: t("audit.zero_entries", "0 entries")}
|
||||
</span>
|
||||
<Btn variant="secondary" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{lang === "fa" ? "→ " : "← "}
|
||||
{t("previous", "Previous")}
|
||||
</Btn>
|
||||
<span className="caption" style={{ alignSelf: "center" }}>
|
||||
{t("audit.page_label", "Page")}{" "}
|
||||
<strong style={{ color: "var(--text)" }}>{fmtNumber(page, lang)}</strong> /{" "}
|
||||
{fmtNumber(totalPages, lang)}
|
||||
</span>
|
||||
<Btn
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t("next", "Next")}
|
||||
{lang === "fa" ? " ←" : " →"}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRows({ cols }: { cols: number }) {
|
||||
return (
|
||||
<>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: cols }).map((_, j) => (
|
||||
<td key={j}>
|
||||
<div className="shimmer" style={{ height: 14, borderRadius: 4 }} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const filterRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
const searchBoxStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 10px",
|
||||
flex: 1,
|
||||
minWidth: 280,
|
||||
background: "var(--bg-sunken)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const inputResetStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text)",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
const paginationStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
marginTop: 16,
|
||||
};
|
||||
790
web/src/pages/Connect.tsx
Normal file
790
web/src/pages/Connect.tsx
Normal file
@@ -0,0 +1,790 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Topbar } from "../components/Topbar";
|
||||
import { Card, CardHead, Badge, CopyField, Step } from "../components/primitives";
|
||||
import { Icons, type IconName } from "../components/icons";
|
||||
import { useSession, useSiteTools, useSites, useUpdateSiteToolScope } from "../lib/queries";
|
||||
import { useUiStore } from "../lib/store";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { Site } from "../lib/types";
|
||||
|
||||
type Client = {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
icon: IconName;
|
||||
guide: "claude-ai" | "claude-desktop" | "cli" | "json" | "codex" | "oauth";
|
||||
};
|
||||
|
||||
const CLIENTS: Client[] = [
|
||||
{
|
||||
id: "claude-ai",
|
||||
name: "Claude.ai Connectors",
|
||||
desc: "Browser · URL only",
|
||||
icon: "sparkles",
|
||||
guide: "claude-ai",
|
||||
},
|
||||
{ id: "claude-desktop", name: "Claude Desktop", desc: "Desktop app · JSON config", icon: "command", guide: "claude-desktop" },
|
||||
{ id: "claude-code", name: "Claude Code", desc: "CLI · Developer", icon: "terminal", guide: "cli" },
|
||||
{ id: "github-codex", name: "Codex", desc: "config.toml · Remote HTTP", icon: "github", guide: "codex" },
|
||||
{ id: "cursor", name: "Cursor", desc: "JSON config", icon: "command", guide: "json" },
|
||||
{ id: "chatgpt", name: "ChatGPT", desc: "OAuth · Apps SDK", icon: "spark", guide: "oauth" },
|
||||
{ id: "gemini", name: "Gemini CLI", desc: "CLI · Token", icon: "terminal", guide: "cli" },
|
||||
{ id: "vscode", name: "VS Code", desc: "Extension · Preview", icon: "edit", guide: "json" },
|
||||
{ id: "custom", name: "Custom client", desc: "Any MCP client", icon: "wrench", guide: "json" },
|
||||
];
|
||||
|
||||
// Tile descriptions go through i18n; brand names stay verbatim.
|
||||
function clientDesc(t: ReturnType<typeof useT>, id: string, fallback: string): string {
|
||||
return t(`connect.client.${id}.desc`, fallback);
|
||||
}
|
||||
function clientName(t: ReturnType<typeof useT>, id: string, fallback: string): string {
|
||||
// Only the "Custom client" entry has a translatable name; the rest are brands.
|
||||
if (id === "custom") return t("connect.client.custom.name", fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function ConnectPage() {
|
||||
const t = useT();
|
||||
const [selected, setSelected] = useState<string>("claude-ai");
|
||||
const [siteAlias, setSiteAlias] = useState<string | undefined>();
|
||||
const location = useLocation();
|
||||
const sites = useSites();
|
||||
const session = useSession();
|
||||
// Real per-user URL needs the actual user_id; falls back to "me" so the
|
||||
// snippet still reads sensibly while the session is loading.
|
||||
const userId = session.data?.user_id ?? "me";
|
||||
|
||||
const current = CLIENTS.find((c) => c.id === selected)!;
|
||||
|
||||
// Pick the first site as default once loaded.
|
||||
useEffect(() => {
|
||||
if (!sites.data || sites.data.length === 0) return;
|
||||
const requested = new URLSearchParams(location.search).get("site") ?? undefined;
|
||||
if (requested && sites.data.some((s) => s.alias === requested)) {
|
||||
setSiteAlias(requested);
|
||||
return;
|
||||
}
|
||||
if (!siteAlias) {
|
||||
setSiteAlias(sites.data[0].alias);
|
||||
}
|
||||
}, [sites.data, siteAlias, location.search]);
|
||||
|
||||
const selectedSite = sites.data?.find((s) => s.alias === siteAlias);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar crumbs={[t("workspace", "Workspace"), t("nav.connect", "Connect")]} />
|
||||
<div className="page-pad">
|
||||
<div className="page-head">
|
||||
<h1 className="h-1" style={{ margin: 0 }}>
|
||||
{t("connect_client", "Connect an AI client")}
|
||||
</h1>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginTop: 6, maxWidth: 640 }}>
|
||||
{t(
|
||||
"connect.intro",
|
||||
"Choose a client below. You'll get a one-time link or config snippet. No tokens to paste by hand.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sites.isLoading && (sites.data ?? []).length === 0 ? (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ padding: 20, display: "flex", gap: 12, alignItems: "flex-start" }}>
|
||||
<Icons.sites style={{ width: 18, height: 18, color: "var(--brand-400)", flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t("connect.no_services_title", "Add a service before connecting clients")}
|
||||
</div>
|
||||
<div className="caption" style={{ marginBottom: 12 }}>
|
||||
{t(
|
||||
"connect.no_services_body",
|
||||
"MCP clients need at least one WordPress, WooCommerce, Coolify, or other service to route tools to. Add your first service from Sites, then return here for the client URL and setup steps.",
|
||||
)}
|
||||
</div>
|
||||
<Link to="/sites?create=1" className="btn btn-primary btn-sm">
|
||||
<Icons.plus style={{ width: 12, height: 12 }} />
|
||||
{t("add_site_short", "Add site")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="connect-grid">
|
||||
<div className="stack connect-list" style={{ gap: 8 }}>
|
||||
{CLIENTS.map((c) => {
|
||||
const Ic = Icons[c.icon];
|
||||
const active = selected === c.id;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={`tile ${active ? "is-active" : ""}`}
|
||||
onClick={() => setSelected(c.id)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: "var(--surface)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<Ic
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: active ? "var(--brand-400)" : "var(--text-muted)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500 }}>
|
||||
{clientName(t, c.id, c.name)}
|
||||
</div>
|
||||
<div className="caption">{clientDesc(t, c.id, c.desc)}</div>
|
||||
</div>
|
||||
{active ? (
|
||||
<Icons.check style={{ width: 14, height: 14, color: "var(--brand-400)" }} />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Card>
|
||||
<div
|
||||
style={{
|
||||
padding: 24,
|
||||
borderBottom: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
gap: 14,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 10,
|
||||
background: "oklch(from var(--brand-500) l c h / 0.1)",
|
||||
border: "1px solid oklch(from var(--brand-500) l c h / 0.25)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const Ic = Icons[current.icon];
|
||||
return <Ic style={{ width: 22, height: 22, color: "var(--brand-400)" }} />;
|
||||
})()}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="h-2" style={{ margin: 0 }}>
|
||||
{t("connect.connect_x", "Connect {name}").replace(
|
||||
"{name}",
|
||||
clientName(t, current.id, current.name),
|
||||
)}
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 2 }}>
|
||||
{clientDesc(t, current.id, current.desc)}
|
||||
</div>
|
||||
</div>
|
||||
{sites.data && sites.data.length > 0 && (
|
||||
<label className="field connect-site-select">
|
||||
<span>{t("connect.service_select_label", "Service")}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={siteAlias ?? ""}
|
||||
onChange={(e) => setSiteAlias(e.target.value || undefined)}
|
||||
>
|
||||
{sites.data.map((s) => (
|
||||
<option key={s.id} value={s.alias}>
|
||||
{s.alias} · {s.plugin_type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{current.guide === "claude-ai" ? <ClaudeAiFlow alias={siteAlias} userId={userId} /> : null}
|
||||
{current.guide === "claude-desktop" ? <ClaudeDesktopFlow alias={siteAlias} userId={userId} /> : null}
|
||||
{current.guide === "cli" ? <CLIFlow alias={siteAlias} userId={userId} /> : null}
|
||||
{current.guide === "json" ? (
|
||||
<JsonFlow alias={siteAlias} userId={userId} name={current.name} />
|
||||
) : null}
|
||||
{current.guide === "codex" ? <CodexFlow alias={siteAlias} userId={userId} /> : null}
|
||||
{current.guide === "oauth" ? <ChatGptFlow alias={siteAlias} userId={userId} /> : null}
|
||||
</Card>
|
||||
|
||||
<SiteScopeCard site={selectedSite} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Per-site Tool Access tier picker (replaces dummy ScopeGroup) ----------
|
||||
// Tiers mirror _VALID_TOOL_SCOPES in core/dashboard/routes.py. Destructive
|
||||
// treatment per F.19.2.3: install + read:sensitive = amber, admin = red.
|
||||
|
||||
type TierInfo = { value: string; label: string; hint: string; danger?: boolean; warning?: boolean };
|
||||
|
||||
const SITE_TOOL_TIERS: TierInfo[] = [
|
||||
{ value: "read", label: "Read", hint: "Listing and inspection only." },
|
||||
{
|
||||
value: "read:sensitive",
|
||||
label: "Read sensitive",
|
||||
hint: "Adds backups, env vars, and other privacy-bearing reads.",
|
||||
warning: true,
|
||||
},
|
||||
{ value: "deploy", label: "Deploy", hint: "Trigger deployments and lifecycle, no edits." },
|
||||
{ value: "editor", label: "Editor", hint: "Pages, posts, content edits (wordpress_specialist F.19.5)." },
|
||||
{
|
||||
value: "settings",
|
||||
label: "Settings",
|
||||
hint: "Options, permalinks, identity, cron (wordpress_specialist F.19.6).",
|
||||
},
|
||||
{
|
||||
value: "install",
|
||||
label: "Installer",
|
||||
hint: "Install plugins / themes from the directory. Treat as elevated.",
|
||||
warning: true,
|
||||
},
|
||||
{ value: "write", label: "Write", hint: "Create / update / delete resources and configuration." },
|
||||
{
|
||||
value: "admin",
|
||||
label: "Admin",
|
||||
hint: "Full system control including destructive operations.",
|
||||
danger: true,
|
||||
},
|
||||
{ value: "custom", label: "Custom", hint: "Toggle individual tools manually after picking this." },
|
||||
];
|
||||
|
||||
function SiteScopeCard({ site }: { site: Site | undefined }) {
|
||||
const t = useT();
|
||||
const update = useUpdateSiteToolScope();
|
||||
const setToast = useUiStore((s) => s.setToast);
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
const siteTools = useSiteTools(site?.id);
|
||||
const current = site?.tool_scope ?? "admin";
|
||||
const hasPluginPresets = !!siteTools.data?.scope_presets?.length;
|
||||
const tiers: TierInfo[] =
|
||||
siteTools.data?.scope_presets?.map((preset) => ({
|
||||
value: preset.value,
|
||||
label: lang === "fa" && preset.label_fa ? preset.label_fa : preset.label,
|
||||
hint: lang === "fa" && preset.hint_fa ? preset.hint_fa : preset.hint,
|
||||
danger: preset.value === "admin",
|
||||
warning: preset.value === "install" || preset.value === "read:sensitive",
|
||||
})) ?? SITE_TOOL_TIERS;
|
||||
|
||||
const onPick = async (scope: string) => {
|
||||
if (!site || scope === current) return;
|
||||
const ok = window.confirm(
|
||||
t(
|
||||
"connect.confirm_scope_change",
|
||||
'Change tool access for "{site}" from "{from}" to "{to}"?',
|
||||
)
|
||||
.replace("{site}", site.alias)
|
||||
.replace("{from}", current)
|
||||
.replace("{to}", scope),
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await update.mutateAsync({ siteId: site.id, scope });
|
||||
setToast(
|
||||
t("connect.toast.scope_updated", "Tool access updated to {scope}").replace(
|
||||
"{scope}",
|
||||
scope,
|
||||
),
|
||||
);
|
||||
} catch (e: any) {
|
||||
setToast(t("connect.toast.scope_failed", "Update failed: {error}").replace("{error}", e.message));
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTier = tiers.find((s) => s.value === current);
|
||||
|
||||
return (
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<CardHead
|
||||
icon="shield"
|
||||
title={t("connect.tool_access", "Tool Access")}
|
||||
subtitle={
|
||||
site
|
||||
? t(
|
||||
"connect.tool_access_service_subtitle",
|
||||
"Pick the access options this service actually supports.",
|
||||
) + ` (${site.alias} · ${site.plugin_type})`
|
||||
: t("connect.tool_access_pick_site", "Select a site above to manage tool access.")
|
||||
}
|
||||
/>
|
||||
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={tierGridStyle}>
|
||||
{tiers.map((tier) => {
|
||||
const active = tier.value === current;
|
||||
const pending = update.isPending && update.variables?.scope === tier.value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={tier.value}
|
||||
disabled={!site || update.isPending}
|
||||
onClick={() => onPick(tier.value)}
|
||||
style={tierTileStyle(active, tier)}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{hasPluginPresets ? tier.label : t(`tier.${tier.value.replace(":", "_")}`, tier.label)}
|
||||
</span>
|
||||
<span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
|
||||
{tier.danger ? (
|
||||
<Badge variant="danger" className="badge-fixed">
|
||||
{t("badge.admin", "admin")}
|
||||
</Badge>
|
||||
) : tier.warning ? (
|
||||
<Badge variant="warning" className="badge-fixed">
|
||||
{t("badge.elevated", "elevated")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{active ? (
|
||||
<Icons.check style={{ width: 14, height: 14, color: "var(--brand-400)" }} />
|
||||
) : null}
|
||||
{pending ? <span className="caption">…</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 4 }}>
|
||||
{hasPluginPresets
|
||||
? tier.hint
|
||||
: t(`tier.${tier.value.replace(":", "_")}.hint`, tier.hint)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedTier?.danger ? (
|
||||
<div className="alert alert-danger">
|
||||
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, fontSize: 13 }}>
|
||||
{t(
|
||||
"connect.tier.admin_warning",
|
||||
"This service's highest tier exposes destructive or administrative operations. Anyone holding a token for this service can act with full privilege. Prefer a narrower option unless the agent needs every tool.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedTier?.warning ? (
|
||||
<div className="alert alert-warning">
|
||||
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, fontSize: 13 }}>
|
||||
{selectedTier.value === "install"
|
||||
? t(
|
||||
"connect.tier.install_warning",
|
||||
"Installer is an elevated tier — installs run code from the WordPress / theme repository on your site. Use it only when the client needs that access.",
|
||||
)
|
||||
: t(
|
||||
"connect.tier.sensitive_warning",
|
||||
"Sensitive reads is an elevated tier — sensitive reads include backups and env vars. Use it only when the client needs that access.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const tierGridStyle: CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
function tierTileStyle(active: boolean, tier: TierInfo): CSSProperties {
|
||||
const accent = tier.danger
|
||||
? "var(--danger)"
|
||||
: tier.warning
|
||||
? "var(--warning, #d97706)"
|
||||
: "var(--brand-400)";
|
||||
return {
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
background: active ? "var(--surface)" : "var(--bg-sunken)",
|
||||
border: `1px solid ${active ? accent : "var(--border)"}`,
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
color: "var(--text)",
|
||||
fontSize: 13,
|
||||
transition: "background 120ms, border-color 120ms",
|
||||
opacity: active ? 1 : 0.92,
|
||||
};
|
||||
}
|
||||
|
||||
function ClaudeAiFlow({ alias, userId }: { alias?: string; userId: string }) {
|
||||
const t = useT();
|
||||
const url = `${typeof window !== "undefined" ? window.location.origin : "https://your-hub"}${alias ? `/u/${userId}/${alias}/mcp` : "/mcp"}`;
|
||||
return (
|
||||
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<Step n={1} title={t("connect.claude_ai.step1", "Use this Claude.ai connector URL")}>
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "stretch" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p className="body-sm" style={{ color: "var(--text-muted)", margin: 0 }}>
|
||||
{t(
|
||||
"connect.claude.step1_body",
|
||||
"Paste this service URL into Claude.ai Connectors in your browser when it asks for your MCP endpoint.",
|
||||
)}
|
||||
</p>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<CopyField value={url} />
|
||||
</div>
|
||||
<div className="alert alert-info" style={{ marginTop: 12 }}>
|
||||
<Icons.info style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, fontSize: 13 }}>
|
||||
{t(
|
||||
"connect.claude.connector_tip",
|
||||
"Tip: You only need the URL above. When connecting, authenticate with an API Key or GitHub/Google.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Step>
|
||||
<Step n={2} title={t("connect.claude.step2", "Approve in Claude.ai")} ghost>
|
||||
<p className="body-sm" style={{ color: "var(--text-muted)", margin: 0 }}>
|
||||
{t("connect.claude.step2_body_prefix", "Claude will show:")}{" "}
|
||||
<span
|
||||
className="mono"
|
||||
style={{ background: "var(--surface)", padding: "2px 6px", borderRadius: 4 }}
|
||||
>
|
||||
{t("connect.claude.prompt_text", "MCP Hub wants to access N tools")}
|
||||
</span>
|
||||
. {t("connect.claude.step2_body_suffix", "Approve to continue.")}
|
||||
</p>
|
||||
</Step>
|
||||
<Step n={3} title={t("connect.claude.step3", "You're connected")} ghost done>
|
||||
<p className="body-sm" style={{ color: "var(--text-muted)", margin: 0 }}>
|
||||
{t(
|
||||
"connect.claude.step3_body",
|
||||
"You'll see the new client appear on your Overview. Try asking Claude to list your sites.",
|
||||
)}
|
||||
</p>
|
||||
</Step>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaudeDesktopFlow({ alias, userId }: { alias?: string; userId: string }) {
|
||||
const t = useT();
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "https://your-hub";
|
||||
const url = `${origin}${alias ? `/u/${userId}/${alias}/mcp` : "/mcp"}`;
|
||||
const desktopUrl = "claude://";
|
||||
const envName = alias
|
||||
? `MCPHUB_${alias.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_TOKEN`
|
||||
: "MCPHUB_TOKEN";
|
||||
const cfg = JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
mcphub: {
|
||||
type: "streamableHttp",
|
||||
url,
|
||||
headers: {
|
||||
Authorization: `Bearer \${${envName}}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<Step n={1} title={t("connect.desktop.open_step", "Open Claude Desktop")}>
|
||||
<p className="body-sm" style={{ color: "var(--text-muted)", margin: 0 }}>
|
||||
{t(
|
||||
"connect.desktop.open_body",
|
||||
"Use this shortcut to switch into Claude Desktop, then return here if your app still needs the local MCP server config.",
|
||||
)}
|
||||
</p>
|
||||
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8, alignItems: "flex-start" }}>
|
||||
<a href={desktopUrl} className="btn btn-primary btn-sm">
|
||||
{t("connect.desktop.open_button", "Open Claude Desktop")}
|
||||
<Icons.arrow style={{ width: 12, height: 12 }} />
|
||||
</a>
|
||||
<div className="caption">
|
||||
{t(
|
||||
"connect.desktop.open_fallback",
|
||||
"If your browser does not open the desktop app, continue with the config steps below.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Step>
|
||||
<Step n={2} title={t("connect.desktop.step1", "Create or select an MCP Hub API key")} ghost>
|
||||
<p className="body-sm" style={{ color: "var(--text-muted)", margin: 0 }}>
|
||||
{t(
|
||||
"connect.desktop.step1_body",
|
||||
"Claude Desktop uses a local config file. Store the mhu_ key in an environment variable instead of pasting it directly into JSON.",
|
||||
)}
|
||||
</p>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<CopyField value={`export ${envName}=mhu_your_key_here`} />
|
||||
</div>
|
||||
</Step>
|
||||
<Step n={3} title={t("connect.desktop.step2", "Add this server to claude_desktop_config.json")} ghost>
|
||||
<CopyField value={cfg} />
|
||||
<div className="caption" style={{ marginTop: 8 }}>
|
||||
{t(
|
||||
"connect.desktop.config_paths",
|
||||
"macOS: ~/Library/Application Support/Claude/claude_desktop_config.json · Windows: %APPDATA%\\Claude\\claude_desktop_config.json",
|
||||
)}
|
||||
</div>
|
||||
</Step>
|
||||
<Step n={4} title={t("connect.desktop.step3", "Restart Claude Desktop and verify tools")} ghost done>
|
||||
<p className="body-sm" style={{ color: "var(--text-muted)", margin: 0 }}>
|
||||
{t(
|
||||
"connect.desktop.step3_body",
|
||||
"Quit Claude Desktop completely, reopen it, then ask Claude to list the available MCP Hub tools for the selected service.",
|
||||
)}
|
||||
</p>
|
||||
</Step>
|
||||
<div className="alert alert-info">
|
||||
<Icons.info style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, fontSize: 13 }}>
|
||||
{t(
|
||||
"connect.desktop.oauth_note",
|
||||
"Claude.ai Connectors are browser-based and only need the URL. Claude Desktop needs this local JSON config plus a bearer token.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CLIFlow({ alias, userId }: { alias?: string; userId: string }) {
|
||||
const t = useT();
|
||||
const url = `${typeof window !== "undefined" ? window.location.origin : "https://your-hub"}${alias ? `/u/${userId}/${alias}/mcp` : "/mcp"}`;
|
||||
return (
|
||||
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<Step n={1} title={t("connect.cli.step1", "Run this in your terminal")}>
|
||||
<div className="term">
|
||||
<div className="term-head">
|
||||
<span className="dot" style={{ background: "#ff5f57" }} />
|
||||
<span className="dot" style={{ background: "#febc2e" }} />
|
||||
<span className="dot" style={{ background: "#28c840" }} />
|
||||
</div>
|
||||
<div className="term-body" dir="ltr">
|
||||
<span className="com"># install + connect</span>
|
||||
{"\n"}
|
||||
<span className="kw">$</span> claude mcp add mcphub \{"\n"}
|
||||
{" "}--transport sse \{"\n"}
|
||||
{" "}<span className="str">{url}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Step>
|
||||
<Step n={2} title={t("connect.cli.step2", "Verify")} ghost done>
|
||||
<div className="term">
|
||||
<div className="term-head">
|
||||
<span className="dot" style={{ background: "#28c840" }} />
|
||||
</div>
|
||||
<div className="term-body" dir="ltr">
|
||||
<span className="kw">$</span> claude mcp list{"\n"}
|
||||
<span className="com">mcphub connected N tools healthy</span>
|
||||
</div>
|
||||
</div>
|
||||
</Step>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonFlow({ alias, userId, name }: { alias?: string; userId: string; name: string }) {
|
||||
const t = useT();
|
||||
const url = `${typeof window !== "undefined" ? window.location.origin : "https://your-hub"}${alias ? `/u/${userId}/${alias}/mcp` : "/mcp"}`;
|
||||
const cfg = JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
mcphub: {
|
||||
url,
|
||||
auth: "bearer",
|
||||
token: "mhu_•••••••",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return (
|
||||
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<div className="alert alert-info">
|
||||
<Icons.info style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, color: "var(--text)" }}>
|
||||
{t("connect.json.paste_into", "Paste this into {name}'s MCP config").replace(
|
||||
"{name}",
|
||||
name,
|
||||
)}
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 2 }}>
|
||||
{t(
|
||||
"connect.json.location_hint",
|
||||
"Settings → MCP Servers · file path differs per client",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CopyField value={cfg} />
|
||||
<div className="alert alert-warning">
|
||||
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, color: "var(--text)" }}>
|
||||
{t("connect.json.create_key_title", "Create an API key first")}
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 2 }}>
|
||||
{t(
|
||||
"connect.json.create_key_body",
|
||||
"Generate an API key from API Keys, copy it when it is shown once, then replace mhu_••••••• in this config. You can delete and recreate keys from API Keys.",
|
||||
)}
|
||||
</div>
|
||||
<Link to="/api-keys" className="btn btn-secondary btn-sm" style={{ marginTop: 10 }}>
|
||||
<Icons.key style={{ width: 12, height: 12 }} />
|
||||
{t("nav.api_keys", "API Keys")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<Badge>{t("connect.json.compatible", "Compatible:")}</Badge>
|
||||
<Badge>Cursor</Badge>
|
||||
<Badge>VS Code</Badge>
|
||||
<Badge>{t("connect.json.custom_mcp", "Custom MCP")}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodexFlow({ alias, userId }: { alias?: string; userId: string }) {
|
||||
const t = useT();
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "https://your-hub";
|
||||
const url = `${origin}${alias ? `/u/${userId}/${alias}/mcp` : "/mcp"}`;
|
||||
const envName = alias
|
||||
? `MCPHUB_${alias.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_TOKEN`
|
||||
: "MCPHUB_TOKEN";
|
||||
const codexToml = `[mcp_servers.mcphub]\nurl = "${url}"\nbearer_token_env_var = "${envName}"`;
|
||||
const claudeJson = JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
mcphub: {
|
||||
type: "streamableHttp",
|
||||
url,
|
||||
headers: {
|
||||
Authorization: `Bearer \${${envName}}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const exportSnippet = `export ${envName}=mhu_your_key_here\ncodex mcp list`;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<div className="alert alert-info">
|
||||
<Icons.info style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, color: "var(--text)" }}>
|
||||
{t("connect.codex.env_var_title", "Codex reads the token from an environment variable")}
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 2 }}>
|
||||
{t(
|
||||
"connect.codex.env_var_body",
|
||||
"Set bearer_token_env_var to the variable name, not the mhu_ token value. Restart Codex after adding new environment variables.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Step n={1} title={t("connect.codex.step1", "Export the token for this service")}>
|
||||
<CopyField value={exportSnippet} />
|
||||
</Step>
|
||||
|
||||
<Step n={2} title={t("connect.codex.step2", "Add this to ~/.codex/config.toml")} ghost>
|
||||
<CopyField value={codexToml} />
|
||||
</Step>
|
||||
|
||||
<Step n={3} title={t("connect.codex.step3", "Claude-style JSON is different")} ghost>
|
||||
<CopyField value={claudeJson} />
|
||||
<div className="caption" style={{ marginTop: 8 }}>
|
||||
{t(
|
||||
"connect.codex.claude_difference",
|
||||
"Use the TOML block for Codex. The JSON block is shown only to clarify how Claude-style headers differ.",
|
||||
)}
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
<div className="alert alert-warning">
|
||||
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, color: "var(--text)" }}>
|
||||
{t("connect.codex.troubleshooting_title", "Troubleshooting online code environments")}
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 2 }}>
|
||||
{t(
|
||||
"connect.codex.troubleshooting_body",
|
||||
"Run codex mcp list, verify bubblewrap/bwrap is available, and restart the Codex session after changing env vars. Missing sandbox dependencies can look like MCP auth failures.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatGptFlow({ alias, userId }: { alias?: string; userId: string }) {
|
||||
const t = useT();
|
||||
const url = `${typeof window !== "undefined" ? window.location.origin : "https://your-hub"}${alias ? `/u/${userId}/${alias}/mcp` : "/mcp"}`;
|
||||
return (
|
||||
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<Step n={1} title={t("connect.chatgpt.step1", "Use this ChatGPT connector URL")}>
|
||||
<p className="body-sm" style={{ color: "var(--text-muted)", margin: 0 }}>
|
||||
{t(
|
||||
"connect.chatgpt.step1_body",
|
||||
"Copy this service URL. ChatGPT only needs the MCP endpoint URL; MCP Hub handles authentication when ChatGPT connects.",
|
||||
)}
|
||||
</p>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<CopyField value={url} />
|
||||
</div>
|
||||
<div className="alert alert-info" style={{ marginTop: 12 }}>
|
||||
<Icons.info style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, fontSize: 13 }}>
|
||||
{t(
|
||||
"connect.chatgpt.connector_tip",
|
||||
"Tip: You only need the URL above. When connecting, authenticate with an API Key or GitHub/Google.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Step>
|
||||
<Step n={2} title={t("connect.chatgpt.step2", "Enable Developer mode in ChatGPT")} ghost>
|
||||
<p className="body-sm" style={{ color: "var(--text-muted)", margin: 0 }}>
|
||||
{t(
|
||||
"connect.chatgpt.step2_body",
|
||||
"In chatgpt.com settings, enable Developer mode first. Then open Apps and choose Create app.",
|
||||
)}
|
||||
</p>
|
||||
</Step>
|
||||
<Step n={3} title={t("connect.chatgpt.step3", "Create the ChatGPT app")} ghost done>
|
||||
<p className="body-sm" style={{ color: "var(--text-muted)", margin: 0 }}>
|
||||
{t(
|
||||
"connect.chatgpt.step3_body",
|
||||
"Set Authorization to OAuth mode, which is the default, then paste the URL above as the MCP server URL and finish the app setup.",
|
||||
)}
|
||||
</p>
|
||||
</Step>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
292
web/src/pages/Health.tsx
Normal file
292
web/src/pages/Health.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Topbar } from "../components/Topbar";
|
||||
import { Card, CardHead, Donut, Badge } from "../components/primitives";
|
||||
import { useDashboardStats, useHealth } from "../lib/queries";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useUiStore } from "../lib/store";
|
||||
import { fmtDateTime } from "../lib/format";
|
||||
|
||||
type StatusVariant = "success" | "warning" | "danger" | "default";
|
||||
|
||||
function statusVariant(s: string | undefined): StatusVariant {
|
||||
if (s === "healthy" || s === "ok" || s === "up") return "success";
|
||||
if (s === "degraded" || s === "warning") return "warning";
|
||||
if (s === "down" || s === "error" || s === "critical") return "danger";
|
||||
return "default";
|
||||
}
|
||||
|
||||
function alertVariant(level: string | undefined): StatusVariant {
|
||||
if (level === "error" || level === "critical") return "danger";
|
||||
if (level === "warning" || level === "warn") return "warning";
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function HealthPage() {
|
||||
const t = useT();
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
const health = useHealth();
|
||||
const stats = useDashboardStats();
|
||||
const data = health.data;
|
||||
const isLoading = health.isLoading;
|
||||
|
||||
const overall = data?.system_status ?? "unknown";
|
||||
const isHealthy = overall === "healthy";
|
||||
const projects = Object.entries(data?.projects_health ?? {});
|
||||
const summary = data?.projects_summary ?? { total: 0, healthy: 0, unhealthy: 0 };
|
||||
const metrics = data?.metrics ?? {};
|
||||
const uptime = data?.uptime ?? {};
|
||||
const alerts = data?.alerts ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar crumbs={[t("nav.observability", "Observability"), t("health", "Health")]} />
|
||||
<div className="page-pad">
|
||||
<div className="page-head">
|
||||
<h1 className="h-1" style={{ margin: 0 }}>
|
||||
{t("health_status", "Health")}
|
||||
</h1>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginTop: 6 }}>
|
||||
{t("health.intro", "Hub and per-site status. Auto-refreshing every 30 seconds.")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Card className="shimmer" style={{ height: 110, marginBottom: 20 }} />
|
||||
) : (
|
||||
<Card style={{ marginBottom: 20, padding: 0, overflow: "hidden" }}>
|
||||
<div className="health-overview">
|
||||
<Donut
|
||||
value={isHealthy ? 100 : overall === "degraded" ? 60 : 25}
|
||||
size={64}
|
||||
color={
|
||||
isHealthy
|
||||
? "var(--success)"
|
||||
: overall === "degraded"
|
||||
? "var(--warning)"
|
||||
: "var(--danger)"
|
||||
}
|
||||
/>
|
||||
<div className="health-overview-text">
|
||||
<div className="health-overview-title">
|
||||
<div className="h-2" style={{ margin: 0 }}>
|
||||
{isHealthy
|
||||
? t("health.all_operational", "All systems operational")
|
||||
: overall === "degraded"
|
||||
? t("health.degraded", "Degraded")
|
||||
: overall === "down"
|
||||
? t("health.down", "Down")
|
||||
: `${t("status", "Status")}: ${overall}`}
|
||||
</div>
|
||||
<Badge variant={statusVariant(overall)} dot>
|
||||
{t(`status_${overall}`, overall)}
|
||||
</Badge>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: isHealthy ? "var(--success)" : "var(--warning)",
|
||||
}}
|
||||
>
|
||||
<span className="live-dot" /> {t("status.live", "live")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 4 }}>
|
||||
{t("health.up", "Up")}:{" "}
|
||||
<strong style={{ color: "var(--text)" }}>{uptime.formatted ?? "—"}</strong>
|
||||
{" · "}
|
||||
{t("health.last_check", "Last check")}:{" "}
|
||||
<strong style={{ color: "var(--text)" }}>
|
||||
{fmtDateTime(uptime.current_time, lang)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="health-overview-stats">
|
||||
<Stat
|
||||
label={t("total_tools", "Tools")}
|
||||
value={stats.data?.tools_count ?? "—"}
|
||||
/>
|
||||
<Stat
|
||||
label={t("health.projects_label", "Projects")}
|
||||
value={`${summary.healthy ?? 0}/${summary.total ?? 0}`}
|
||||
/>
|
||||
<Stat
|
||||
label={t("health.alerts_label", "Alerts")}
|
||||
value={alerts.length}
|
||||
tone={alerts.length > 0 ? "warning" : "default"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="health-metrics-grid">
|
||||
<Card>
|
||||
<CardHead
|
||||
title={t("health.metrics_title", "Request metrics")}
|
||||
subtitle={t("health.metrics_subtitle", "Across the live process")}
|
||||
/>
|
||||
<div className="health-stat-grid">
|
||||
<Stat
|
||||
label={t("health.total_requests", "Total requests")}
|
||||
value={metrics.total_requests ?? "—"}
|
||||
/>
|
||||
<Stat
|
||||
label={t("health.per_minute", "Per minute")}
|
||||
value={metrics.requests_per_minute ?? "—"}
|
||||
/>
|
||||
<Stat
|
||||
label={t("health.avg_response", "Avg response (ms)")}
|
||||
value={metrics.average_response_time_ms ?? "—"}
|
||||
/>
|
||||
<Stat
|
||||
label={t("health.error_rate", "Error rate")}
|
||||
value={
|
||||
metrics.error_rate_percent != null ? `${metrics.error_rate_percent.toFixed(2)}%` : "—"
|
||||
}
|
||||
tone={
|
||||
metrics.error_rate_percent != null && metrics.error_rate_percent > 1
|
||||
? "danger"
|
||||
: "default"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHead
|
||||
title={t("health.recent_alerts", "Recent alerts")}
|
||||
subtitle={`${alerts.length} ${t("active", "active")}`}
|
||||
/>
|
||||
<div style={{ padding: 12 }}>
|
||||
{alerts.length === 0 ? (
|
||||
<div className="caption" style={{ padding: 12 }}>
|
||||
{t("health.no_alerts", "No active alerts.")}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{alerts.slice(0, 6).map((a, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-sunken)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<Badge variant={alertVariant(a.level)} className="badge-fixed">
|
||||
{a.level ?? "info"}
|
||||
</Badge>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{a.message ?? a.source ?? "(no message)"}
|
||||
</div>
|
||||
{a.path || a.status_code != null ? (
|
||||
<div className="mono caption" style={{ marginTop: 2 }}>
|
||||
{a.status_code != null ? `${a.status_code} ` : ""}
|
||||
{a.path ?? ""}
|
||||
</div>
|
||||
) : null}
|
||||
{a.timestamp ? (
|
||||
<div className="caption" style={{ marginTop: 2 }}>
|
||||
{a.timestamp}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHead
|
||||
title={t("health.projects_label", "Projects")}
|
||||
subtitle={`${projects.length} ${t("health.registered", "registered")}`}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="card-body">
|
||||
<div className="shimmer" style={{ height: 28, borderRadius: 4, marginBottom: 6 }} />
|
||||
<div className="shimmer" style={{ height: 28, borderRadius: 4, marginBottom: 6 }} />
|
||||
<div className="shimmer" style={{ height: 28, borderRadius: 4 }} />
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="card-body caption">
|
||||
{t("health.no_projects", "No projects to monitor yet.")}
|
||||
</div>
|
||||
) : (
|
||||
<table className="table mobile-stack">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("table.project", "Project")}</th>
|
||||
<th>{t("status", "Status")}</th>
|
||||
<th>{t("table.latency", "Latency")}</th>
|
||||
<th>{t("table.uptime", "Uptime")}</th>
|
||||
<th>{t("table.tools", "Tools")}</th>
|
||||
<th>{t("table.last_check", "Last check")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map(([id, p]) => (
|
||||
<tr key={id}>
|
||||
<td className="row-head" data-label={t("table.project", "Project")} style={{ fontWeight: 500 }}>
|
||||
{id}
|
||||
</td>
|
||||
<td data-label={t("status", "Status")}>
|
||||
<Badge variant={statusVariant(p.status)} dot>
|
||||
{t(`status_${p.status ?? "unknown"}`, p.status ?? "unknown")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="mono" data-label={t("table.latency", "Latency")}>
|
||||
{p.latency_ms != null ? `${p.latency_ms} ms` : "—"}
|
||||
</td>
|
||||
<td className="mono" data-label={t("table.uptime", "Uptime")}>
|
||||
{p.uptime_percent != null ? `${p.uptime_percent.toFixed(2)}%` : "—"}
|
||||
</td>
|
||||
<td className="mono" data-label={t("table.tools", "Tools")}>
|
||||
{p.tool_count ?? "—"}
|
||||
</td>
|
||||
<td className="caption" data-label={t("table.last_check", "Last check")}>
|
||||
{fmtDateTime(p.last_check, lang)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
tone?: "default" | "warning" | "danger";
|
||||
}) {
|
||||
const color =
|
||||
tone === "danger"
|
||||
? "var(--danger)"
|
||||
: tone === "warning"
|
||||
? "var(--warning, #d97706)"
|
||||
: "var(--text)";
|
||||
return (
|
||||
<div>
|
||||
<div className="caption">{label}</div>
|
||||
<div className="mono" style={{ fontSize: 18, fontWeight: 500, color }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
408
web/src/pages/Landing.tsx
Normal file
408
web/src/pages/Landing.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { Logo } from "../components/Logo";
|
||||
import { Badge } from "../components/primitives";
|
||||
import { Icons, type IconName } from "../components/icons";
|
||||
import { useSession } from "../lib/queries";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { PublicControls } from "../components/PublicControls";
|
||||
|
||||
const GITHUB_URL = "https://github.com/airano-ir/mcphub";
|
||||
const BLOG_URL = "https://blog.palebluedot.live/";
|
||||
const SUPPORT_URL = "https://nowpayments.io/donation/airano";
|
||||
const PUBLIC_PLUGINS = [
|
||||
"WordPress",
|
||||
"WooCommerce",
|
||||
"WordPress Specialist",
|
||||
"Supabase",
|
||||
"OpenPanel",
|
||||
"Gitea",
|
||||
"n8n",
|
||||
"Coolify",
|
||||
];
|
||||
|
||||
export function LandingPage() {
|
||||
const t = useT();
|
||||
const session = useSession();
|
||||
const signedIn = session.data?.authenticated === true;
|
||||
const servedFromPublicRoot =
|
||||
typeof window !== "undefined" && !window.location.pathname.startsWith("/dashboard");
|
||||
const dashboardPath = (path: string) => (servedFromPublicRoot ? `/dashboard${path}` : path);
|
||||
const primaryTo = signedIn ? dashboardPath("/overview") : dashboardPath("/onboarding");
|
||||
const primaryLabel = signedIn
|
||||
? t("landing.continue_dashboard", "Continue to dashboard")
|
||||
: t("landing.start_60", "Start in 60 seconds");
|
||||
const secondaryTo = signedIn ? dashboardPath("/sites") : dashboardPath("/login");
|
||||
const secondaryLabel = signedIn ? t("my_sites", "Sites") : t("login.sign_in", "Sign in");
|
||||
|
||||
return (
|
||||
<div className="landing-page" style={{ background: "var(--bg)", position: "relative", overflow: "hidden", minHeight: "100vh" }}>
|
||||
<nav
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
background: "oklch(from var(--bg) l c h / 0.7)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="container landing-nav-inner" style={{ display: "flex", alignItems: "center", minHeight: 64, gap: 24 }}>
|
||||
<span className="logo-wrap">
|
||||
<Logo size={26} />
|
||||
<span className="logo-text">
|
||||
MCP <span>·</span> Hub
|
||||
</span>
|
||||
</span>
|
||||
<div className="landing-nav-links" style={{ display: "flex", gap: 20, marginLeft: 24 }}>
|
||||
<a href="#features" className="body-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{t("landing.nav.features", "Features")}
|
||||
</a>
|
||||
<a href="#integrations" className="body-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{t("landing.nav.integrations", "Integrations")}
|
||||
</a>
|
||||
<a href={BLOG_URL} className="body-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{t("landing.nav.blog", "Blog")}
|
||||
</a>
|
||||
<a href={GITHUB_URL} className="body-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{t("landing.nav.docs", "Docs")}
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<PublicControls compact />
|
||||
<a href={signedIn ? dashboardPath("/overview") : dashboardPath("/login")} className="btn btn-ghost btn-sm">
|
||||
{signedIn ? t("dashboard", "Dashboard") : t("login.sign_in", "Sign in")}
|
||||
</a>
|
||||
<a href={signedIn ? secondaryTo : primaryTo} className="btn btn-primary btn-sm">
|
||||
{signedIn ? t("nav.sites", "Sites") : t("landing.get_started", "Get started")}{" "}
|
||||
<Icons.arrow style={{ width: 12, height: 12 }} />
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section className="landing-hero" style={{ position: "relative", paddingTop: 80, paddingBottom: 100 }}>
|
||||
<div
|
||||
className="orb"
|
||||
style={{
|
||||
width: 520,
|
||||
height: 520,
|
||||
background: "var(--brand-500)",
|
||||
top: -100,
|
||||
right: -80,
|
||||
animation: "float 18s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="orb"
|
||||
style={{
|
||||
width: 420,
|
||||
height: 420,
|
||||
background: "var(--accent-500)",
|
||||
bottom: -180,
|
||||
left: -60,
|
||||
opacity: 0.25,
|
||||
animation: "float 22s ease-in-out infinite reverse",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="grid-pattern"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0.4,
|
||||
maskImage: "radial-gradient(ellipse at center, black 30%, transparent 75%)",
|
||||
WebkitMaskImage: "radial-gradient(ellipse at center, black 30%, transparent 75%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="container" style={{ position: "relative" }}>
|
||||
<div style={{ maxWidth: 820 }}>
|
||||
<Badge variant="brand" dot style={{ marginBottom: 20 }}>
|
||||
{t("landing.hero_badge", "MCP 1.0 · Claude · ChatGPT · Cursor · Gemini")}
|
||||
</Badge>
|
||||
<h1 className="h-display" style={{ margin: "0 0 24px", color: "var(--text)" }}>
|
||||
{t("landing.hero_title_line1", "One hub for every")}
|
||||
<br />
|
||||
<em>{t("landing.hero_title_em", "AI connection")}</em>{" "}
|
||||
{t("landing.hero_title_line2", "to your sites.")}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 19,
|
||||
lineHeight: 1.55,
|
||||
color: "var(--text-muted)",
|
||||
maxWidth: 640,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"landing.hero_body",
|
||||
"MCP Hub is the control plane between your self-hosted services and the AI tools that work on them. Issue keys, connect Claude.ai, Claude Desktop, ChatGPT, Cursor, or Codex, and review every call from one clean surface.",
|
||||
)}
|
||||
</p>
|
||||
<div className="landing-hero-actions" style={{ display: "flex", gap: 12, marginTop: 32 }}>
|
||||
<a href={primaryTo} className="btn btn-primary btn-lg">
|
||||
{primaryLabel} <Icons.arrow style={{ width: 14, height: 14 }} />
|
||||
</a>
|
||||
<a href={secondaryTo} className="btn btn-secondary btn-lg">
|
||||
<Icons.sparkles style={{ width: 14, height: 14 }} />
|
||||
{secondaryLabel}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="integrations"
|
||||
style={{ padding: "80px 0", borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="container">
|
||||
<div style={{ maxWidth: 620, marginBottom: 36 }}>
|
||||
<div className="eyebrow" style={{ marginBottom: 12 }}>
|
||||
{t("landing.integrations.eyebrow", "Integrations")}
|
||||
</div>
|
||||
<h2 className="h-1" style={{ margin: 0 }}>
|
||||
{t("landing.integrations.title", "Service-specific tools, one MCP surface.")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="landing-integrations-grid" style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14 }}>
|
||||
{PUBLIC_PLUGINS.map((name) => (
|
||||
<div key={name} className="tile" style={{ padding: 18 }}>
|
||||
<div className="h-3" style={{ margin: 0 }}>
|
||||
{name}
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 6 }}>
|
||||
{t("landing.integrations.tile", "Scoped tools, keys, health, and audit logs.")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="features"
|
||||
style={{ padding: "80px 0", borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="container">
|
||||
<div style={{ maxWidth: 600, marginBottom: 48 }}>
|
||||
<div className="eyebrow" style={{ marginBottom: 12 }}>
|
||||
{t("landing.features_eyebrow", "Features")}
|
||||
</div>
|
||||
<h2 className="h-1" style={{ margin: 0 }}>
|
||||
{t("landing.features_title", "Everything your AI agents need, nothing they don't.")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="landing-features-grid" style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 20 }}>
|
||||
{(
|
||||
[
|
||||
{
|
||||
icon: "sites",
|
||||
titleKey: "landing.feature.sites.title",
|
||||
title: "Services as first-class objects",
|
||||
descKey: "landing.feature.sites.desc",
|
||||
desc: "Register WordPress, WooCommerce, WordPress Specialist, Supabase, OpenPanel, Gitea, n8n, and Coolify. Each service becomes a discoverable MCP resource with its own tools and access level.",
|
||||
tagKey: "landing.feature.sites.tag",
|
||||
tag: "Core",
|
||||
},
|
||||
{
|
||||
icon: "key",
|
||||
titleKey: "landing.feature.keys.title",
|
||||
title: "Scoped API keys",
|
||||
descKey: "landing.feature.keys.desc",
|
||||
desc: "Create keys for one service or all sites. Tool tiers stay service-specific and can be tightened later.",
|
||||
tagKey: "landing.feature.keys.tag",
|
||||
tag: "Security",
|
||||
},
|
||||
{
|
||||
icon: "shield",
|
||||
titleKey: "landing.feature.oauth.title",
|
||||
title: "OAuth 2.1 + PKCE",
|
||||
descKey: "landing.feature.oauth.desc",
|
||||
desc: "Connect browser-based clients like Claude.ai Connectors and ChatGPT, while desktop clients can use direct URLs or bearer tokens.",
|
||||
tagKey: "landing.feature.oauth.tag",
|
||||
tag: "Auth",
|
||||
},
|
||||
{
|
||||
icon: "activity",
|
||||
titleKey: "landing.feature.health.title",
|
||||
title: "Service health",
|
||||
descKey: "landing.feature.health.desc",
|
||||
desc: "Track credential checks, latency, and service status so agents know what is available before they act.",
|
||||
tagKey: "landing.feature.health.tag",
|
||||
tag: "Observability",
|
||||
},
|
||||
{
|
||||
icon: "logs",
|
||||
titleKey: "landing.feature.audit.title",
|
||||
title: "Full audit trail",
|
||||
descKey: "landing.feature.audit.desc",
|
||||
desc: "Every tool call, auth event, and settings change is searchable and tied back to the user or key.",
|
||||
tagKey: "landing.feature.audit.tag",
|
||||
tag: "Compliance",
|
||||
},
|
||||
{
|
||||
icon: "zap",
|
||||
titleKey: "landing.feature.protocol.title",
|
||||
title: "MCP-native tools",
|
||||
descKey: "landing.feature.protocol.desc",
|
||||
desc: "Expose plugin tools through MCP without forcing users to learn every service API by hand.",
|
||||
tagKey: "landing.feature.protocol.tag",
|
||||
tag: "Protocol",
|
||||
},
|
||||
] as {
|
||||
icon: IconName;
|
||||
titleKey: string;
|
||||
title: string;
|
||||
descKey: string;
|
||||
desc: string;
|
||||
tagKey: string;
|
||||
tag: string;
|
||||
}[]
|
||||
).map((f) => {
|
||||
const Ic = Icons[f.icon];
|
||||
return (
|
||||
<div key={f.titleKey} className="tile" style={{ padding: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
background: "oklch(from var(--brand-500) l c h / 0.12)",
|
||||
color: "var(--brand-400)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid oklch(from var(--brand-500) l c h / 0.2)",
|
||||
}}
|
||||
>
|
||||
<Ic style={{ width: 18, height: 18 }} />
|
||||
</div>
|
||||
<Badge>{t(f.tagKey, f.tag)}</Badge>
|
||||
</div>
|
||||
<div className="h-3" style={{ marginBottom: 8 }}>
|
||||
{t(f.titleKey, f.title)}
|
||||
</div>
|
||||
<div className="body-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{t(f.descKey, f.desc)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ padding: "80px 0" }}>
|
||||
<div className="container">
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 20,
|
||||
padding: "56px 48px",
|
||||
background:
|
||||
"linear-gradient(135deg, oklch(from var(--brand-500) l c h / 0.15), oklch(from var(--accent-500) l c h / 0.08) 60%, var(--bg-elevated) 100%)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div className="dot-pattern" style={{ position: "absolute", inset: 0, opacity: 0.3 }} />
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 40,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<h2 className="h-1" style={{ margin: "0 0 10px" }}>
|
||||
{t("landing.cta_title", "Spin up your hub, in a minute.")}
|
||||
</h2>
|
||||
<p className="body" style={{ color: "var(--text-muted)", margin: 0 }}>
|
||||
{t(
|
||||
"landing.cta_body",
|
||||
"Deploy on any Coolify instance. Free for personal use. Self-hosted forever.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<a href={primaryTo} className="btn btn-primary btn-lg">
|
||||
{signedIn ? t("dashboard", "Dashboard") : t("landing.create_account", "Create account")}{" "}
|
||||
<Icons.arrow style={{ width: 14, height: 14 }} />
|
||||
</a>
|
||||
<a href={GITHUB_URL} className="btn btn-secondary btn-lg">
|
||||
<Icons.github style={{ width: 14, height: 14 }} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer style={{ borderTop: "1px solid var(--border)", padding: "40px 0 60px" }}>
|
||||
<div
|
||||
className="container"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 40,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<span className="logo-wrap" style={{ marginBottom: 12 }}>
|
||||
<Logo size={22} />
|
||||
<span className="logo-text">
|
||||
MCP <span>·</span> Hub
|
||||
</span>
|
||||
</span>
|
||||
<div className="caption">
|
||||
{t(
|
||||
"landing.footer_tagline",
|
||||
"The self-hosted MCP control plane for WordPress, Coolify, Gitea, and more.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 18, flexWrap: "wrap" }}>
|
||||
<a href={BLOG_URL} className="body-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{t("landing.nav.blog", "Blog")}
|
||||
</a>
|
||||
<a href={GITHUB_URL} className="body-sm" style={{ color: "var(--text-muted)" }}>
|
||||
GitHub
|
||||
</a>
|
||||
<a href={SUPPORT_URL} className="body-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{t("support_mcphub", "Support MCP Hub")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="container"
|
||||
style={{
|
||||
marginTop: 32,
|
||||
paddingTop: 20,
|
||||
borderTop: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 12,
|
||||
color: "var(--text-subtle)",
|
||||
}}
|
||||
>
|
||||
<div>© airano.ir · MCP Hub</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
web/src/pages/Login.tsx
Normal file
187
web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState } from "react";
|
||||
import { LogoWordmark } from "../components/Logo";
|
||||
import { Btn, Avatar } from "../components/primitives";
|
||||
import { Icons } from "../components/icons";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSession } from "../lib/queries";
|
||||
import { getCsrfToken } from "../lib/api";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { PublicControls } from "../components/PublicControls";
|
||||
|
||||
function normalizeClientNext(raw: string | null): string {
|
||||
if (!raw || !raw.startsWith("/")) return "/overview";
|
||||
const dashboardPath = raw.startsWith("/dashboard/") ? raw.slice("/dashboard".length) || "/overview" : raw;
|
||||
if (
|
||||
dashboardPath === "/" ||
|
||||
dashboardPath === "/dashboard" ||
|
||||
dashboardPath === "/dashboard/" ||
|
||||
dashboardPath === "/landing" ||
|
||||
dashboardPath === "/onboarding" ||
|
||||
dashboardPath.startsWith("/login")
|
||||
) {
|
||||
return "/overview";
|
||||
}
|
||||
if (raw.startsWith("/dashboard/")) return dashboardPath;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function toServerNext(nextPath: string): string {
|
||||
return nextPath.startsWith("/dashboard") ? nextPath : `/dashboard${nextPath}`;
|
||||
}
|
||||
|
||||
// Login page. Master-key login posts to /api/dashboard/login (JSON, G.12);
|
||||
// OAuth still uses /auth/{provider}. Server returns JSON so the SPA never has
|
||||
// to hand off to the legacy Jinja form.
|
||||
export function LoginPage() {
|
||||
const t = useT();
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const session = useSession();
|
||||
const nextPath = normalizeClientNext(new URLSearchParams(location.search).get("next"));
|
||||
|
||||
// Hide the master-key form when the server has DISABLE_MASTER_KEY_LOGIN=true
|
||||
// (production posture). Default to false until /api/me lands so the form
|
||||
// never flashes on a host that has it disabled.
|
||||
const masterKeyEnabled = session.data?.master_key_login_enabled === true;
|
||||
|
||||
const onMasterKey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const csrf = getCsrfToken() ?? session.data?.csrf_token ?? "";
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (csrf) headers["X-CSRF-Token"] = csrf;
|
||||
const res = await fetch("/api/dashboard/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ api_key: apiKey, next: toServerNext(nextPath) }),
|
||||
credentials: "include",
|
||||
headers,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && data.ok) {
|
||||
await queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["me"], type: "active" });
|
||||
navigate(normalizeClientNext(typeof data.next === "string" ? data.next : nextPath), {
|
||||
replace: true,
|
||||
});
|
||||
} else if (res.status === 429 || data.error === "rate_limit") {
|
||||
setError(t("login.rate_limit", "Too many attempts. Please try again later."));
|
||||
} else if (res.status === 401 || data.error === "invalid") {
|
||||
setError(t("login.invalid_key", "Invalid API key"));
|
||||
} else {
|
||||
setError(`${t("login.failed", "Sign in failed")} (${res.status})`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || t("login.failed", "Sign in failed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-form-pane">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "auto" }}>
|
||||
<Link to="/landing" className="logo-link">
|
||||
<LogoWordmark size={28} />
|
||||
</Link>
|
||||
<PublicControls compact />
|
||||
</div>
|
||||
<div className="login-form-inner">
|
||||
<h1 className="h-1" style={{ margin: "0 0 8px" }}>
|
||||
{t("login.welcome", "Welcome back")}
|
||||
</h1>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginBottom: 28 }}>
|
||||
{t("login.subtitle", "Sign in to your MCP Hub")}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<a href="/auth/github" className="btn btn-secondary btn-lg">
|
||||
<Icons.github style={{ width: 16, height: 16 }} />
|
||||
{t("login.continue_github", "Continue with GitHub")}
|
||||
</a>
|
||||
<a href="/auth/google" className="btn btn-secondary btn-lg">
|
||||
<Icons.sparkles style={{ width: 16, height: 16 }} />
|
||||
{t("login.continue_google", "Continue with Google")}
|
||||
</a>
|
||||
{masterKeyEnabled && (
|
||||
<>
|
||||
<div className="login-divider">
|
||||
<div className="login-divider-line" />
|
||||
<span>{t("login.or_admin_key", "or admin key")}</span>
|
||||
<div className="login-divider-line" />
|
||||
</div>
|
||||
<form onSubmit={onMasterKey} style={{ display: "contents" }}>
|
||||
<div className="field">
|
||||
<label>{t("login.master_key_label", "Master API key")}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
<Btn variant="primary" size="lg" type="submit" disabled={!apiKey || submitting}>
|
||||
{submitting ? t("login.signing_in", "Signing in…") : t("login.sign_in", "Sign in")}
|
||||
</Btn>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="caption login-footer">
|
||||
{t("login.footer", "© airano.ir · Self-hosted · Open source")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-testimonial-pane">
|
||||
<div
|
||||
className="orb"
|
||||
style={{
|
||||
width: 500,
|
||||
height: 500,
|
||||
background: "var(--brand-500)",
|
||||
top: "-10%",
|
||||
insetInlineStart: "-10%",
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="orb"
|
||||
style={{
|
||||
width: 420,
|
||||
height: 420,
|
||||
background: "var(--accent-500)",
|
||||
bottom: "-15%",
|
||||
insetInlineEnd: "-20%",
|
||||
opacity: 0.25,
|
||||
}}
|
||||
/>
|
||||
<div className="grid-pattern" style={{ position: "absolute", inset: 0, opacity: 0.4 }} />
|
||||
<div className="login-testimonial-inner">
|
||||
<blockquote className="login-quote">
|
||||
{t(
|
||||
"login.testimonial",
|
||||
"“My six AI tools now share one key, one audit log, one revoke button. I shouldn't be this happy about a dashboard.”",
|
||||
)}
|
||||
</blockquote>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center", marginTop: 24 }}>
|
||||
<Avatar name="Lena K." size={36} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t("login.testimonial_author", "Lena K.")}</div>
|
||||
<div className="caption">
|
||||
{t("login.testimonial_role", "Staff eng, self-hosted everything")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
web/src/pages/NotFound.tsx
Normal file
29
web/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Btn } from "../components/primitives";
|
||||
import { LogoWordmark } from "../components/Logo";
|
||||
import { useT } from "../lib/i18n";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const t = useT();
|
||||
const servedFromPublicRoot =
|
||||
typeof window !== "undefined" && !window.location.pathname.startsWith("/dashboard");
|
||||
const target = servedFromPublicRoot ? "/dashboard/overview" : "/overview";
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24 }}>
|
||||
<div style={{ maxWidth: 480, textAlign: "center" }}>
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<LogoWordmark size={32} />
|
||||
</div>
|
||||
<h1 className="h-1" style={{ marginBottom: 12 }}>
|
||||
{t("notfound.title", "Not found")}
|
||||
</h1>
|
||||
<p className="body" style={{ color: "var(--text-muted)", marginBottom: 28 }}>
|
||||
{t("notfound.body", "The page you were looking for doesn't exist or has moved.")}
|
||||
</p>
|
||||
<Link to={target}>
|
||||
<Btn variant="primary">{t("notfound.cta", "Back to dashboard")}</Btn>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
web/src/pages/OAuthClients.tsx
Normal file
274
web/src/pages/OAuthClients.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { Topbar } from "../components/Topbar";
|
||||
import { Card, Badge, Btn } from "../components/primitives";
|
||||
import { Icons } from "../components/icons";
|
||||
import { useCreateOAuthClient, useDeleteOAuthClient, useOAuthClients } from "../lib/queries";
|
||||
import { useUiStore } from "../lib/store";
|
||||
import { useT } from "../lib/i18n";
|
||||
|
||||
const DEFAULT_REDIRECT_URIS = [
|
||||
"https://chatgpt.com/connector/oauth/jl0vrVeOwbY8",
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
].join("\n");
|
||||
|
||||
function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
const u = new URL(value.trim());
|
||||
return u.protocol === "https:" || u.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function OAuthClientsPage() {
|
||||
const t = useT();
|
||||
const clients = useOAuthClients();
|
||||
const create = useCreateOAuthClient();
|
||||
const del = useDeleteOAuthClient();
|
||||
const setToast = useUiStore((s) => s.setToast);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
|
||||
const onDelete = async (id: string, name: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
t(
|
||||
"oauth.confirm_delete",
|
||||
'Delete OAuth client "{name}"?\nUsers signed in via this client will be cut off.',
|
||||
).replace("{name}", name),
|
||||
)
|
||||
)
|
||||
return;
|
||||
setPendingDeleteId(id);
|
||||
try {
|
||||
await del.mutateAsync(id);
|
||||
setToast(t("oauth.toast_deleted", "Client deleted"));
|
||||
} catch (e: any) {
|
||||
setToast(
|
||||
t("oauth.toast_delete_failed", "Delete failed: {error}").replace("{error}", e.message),
|
||||
);
|
||||
} finally {
|
||||
setPendingDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
crumbs={[t("workspace", "Workspace"), t("oauth_clients", "OAuth clients")]}
|
||||
actions={
|
||||
<Btn variant="primary" size="sm" icon="plus" onClick={() => setShowCreate(true)}>
|
||||
{t("create", "New client")}
|
||||
</Btn>
|
||||
}
|
||||
/>
|
||||
<div className="page-pad">
|
||||
<div
|
||||
className="page-head"
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end" }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="h-1" style={{ margin: 0 }}>
|
||||
{t("oauth.title", "OAuth 2.1 clients")}
|
||||
</h1>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginTop: 6, maxWidth: 640 }}>
|
||||
{t(
|
||||
"oauth.intro",
|
||||
"Register third-party apps that authorize users against your hub. Tool access is managed per service.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<CreateClientDialog
|
||||
t={t}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onCreate={async (name, redirects) => {
|
||||
try {
|
||||
await create.mutateAsync({ name, redirect_uris: redirects });
|
||||
setToast(t("oauth.toast_created", "Client created"));
|
||||
setShowCreate(false);
|
||||
} catch (e: any) {
|
||||
setToast(
|
||||
t("oauth.toast_create_failed", "Create failed: {error}").replace(
|
||||
"{error}",
|
||||
e.message,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{clients.isLoading ? (
|
||||
<ClientSkeletons />
|
||||
) : (clients.data ?? []).length === 0 ? (
|
||||
<Card>
|
||||
<div style={{ padding: 32, textAlign: "center" }}>
|
||||
<div className="caption" style={{ marginBottom: 12 }}>
|
||||
{t("oauth.empty", "No OAuth clients yet.")}
|
||||
</div>
|
||||
<Btn variant="primary" icon="plus" onClick={() => setShowCreate(true)}>
|
||||
{t("oauth.register_first", "Register first client")}
|
||||
</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
(clients.data ?? []).map((c) => (
|
||||
<Card key={c.id}>
|
||||
<div style={{ padding: 20, display: "flex", gap: 18, alignItems: "flex-start" }}>
|
||||
<div style={iconBoxStyle}>
|
||||
<Icons.shield style={{ width: 22, height: 22, color: "var(--brand-400)" }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
|
||||
<div className="h-3" style={{ margin: 0 }}>
|
||||
{c.name}
|
||||
</div>
|
||||
<Badge variant="success" dot>
|
||||
{t("status.live", "live")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mono caption" style={{ marginBottom: 12 }}>
|
||||
{c.id}
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: 20 }}>
|
||||
<div>
|
||||
<div className="eyebrow" style={{ marginBottom: 6 }}>
|
||||
{t("oauth.redirect_uris", "Redirect URIs")}
|
||||
</div>
|
||||
{c.redirect_uris.length === 0 ? (
|
||||
<div className="caption">{t("oauth.none", "— none —")}</div>
|
||||
) : (
|
||||
c.redirect_uris.map((r: string) => (
|
||||
<div key={r} className="mono" style={{ fontSize: 11.5, wordBreak: "break-all" }}>
|
||||
{r}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={pendingDeleteId === c.id ? undefined : "trash"}
|
||||
style={{ color: "var(--danger)" }}
|
||||
disabled={pendingDeleteId === c.id}
|
||||
onClick={() => onDelete(c.id, c.name)}
|
||||
>
|
||||
{pendingDeleteId === c.id ? <Spinner /> : t("delete", "Delete")}
|
||||
</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateClientDialog({
|
||||
t,
|
||||
onCancel,
|
||||
onCreate,
|
||||
}: {
|
||||
t: ReturnType<typeof useT>;
|
||||
onCancel: () => void;
|
||||
onCreate: (name: string, redirects: string[]) => Promise<void> | void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [redirectsRaw, setRedirectsRaw] = useState(DEFAULT_REDIRECT_URIS);
|
||||
|
||||
const redirects = redirectsRaw
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const invalid = redirects.filter((r) => !isValidUrl(r));
|
||||
const canSubmit = name.length > 0 && redirects.length > 0 && invalid.length === 0;
|
||||
|
||||
return (
|
||||
<Card style={{ marginBottom: 16, padding: 20 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div className="field">
|
||||
<label>{t("api_key_name", "Name")}</label>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>{t("oauth.redirect_uris_one_per_line", "Redirect URIs (one per line)")}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
value={redirectsRaw}
|
||||
onChange={(e) => setRedirectsRaw(e.target.value)}
|
||||
placeholder="https://example.com/oauth/callback"
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
/>
|
||||
{invalid.length > 0 ? (
|
||||
<div className="caption" style={{ color: "var(--danger)", marginTop: 4 }}>
|
||||
{t("oauth.invalid_uris", "{n} URI(s) not valid http(s) URLs:")
|
||||
.replace("{n}", String(invalid.length))}{" "}
|
||||
{invalid.map((r) => `"${r}"`).join(", ")}
|
||||
</div>
|
||||
) : redirects.length > 0 ? (
|
||||
<div className="caption" style={{ marginTop: 4 }}>
|
||||
{t("oauth.valid_uris", "{n} valid URI(s).").replace("{n}", String(redirects.length))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<Btn variant="ghost" onClick={onCancel}>
|
||||
{t("cancel", "Cancel")}
|
||||
</Btn>
|
||||
<Btn variant="primary" disabled={!canSubmit} onClick={() => onCreate(name, redirects)}>
|
||||
{t("create", "Create")}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientSkeletons() {
|
||||
return (
|
||||
<>
|
||||
{[0, 1].map((i) => (
|
||||
<Card key={i} className="shimmer" style={{ height: 120 }} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<span
|
||||
aria-label="loading"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 14,
|
||||
height: 14,
|
||||
border: "2px solid var(--border)",
|
||||
borderTopColor: "var(--brand-400)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 0.8s linear infinite",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const iconBoxStyle: CSSProperties = {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 10,
|
||||
background: "oklch(from var(--brand-500) l c h / 0.12)",
|
||||
border: "1px solid oklch(from var(--brand-500) l c h / 0.25)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
};
|
||||
155
web/src/pages/Onboarding.tsx
Normal file
155
web/src/pages/Onboarding.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { LogoWordmark } from "../components/Logo";
|
||||
import { Card, Btn } from "../components/primitives";
|
||||
import { Icons } from "../components/icons";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { PublicControls } from "../components/PublicControls";
|
||||
|
||||
// Onboarding is a redesigned welcome flow that bridges to OAuth login and the
|
||||
// native SPA Sites dialog for the actual work.
|
||||
export function OnboardingPage() {
|
||||
const t = useT();
|
||||
const [step, setStep] = useState(0);
|
||||
const steps = [
|
||||
t("onboarding.step_signin", "Sign in"),
|
||||
t("onboarding.step_add_site", "Add a site"),
|
||||
t("onboarding.step_get_key", "Get your key"),
|
||||
];
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--bg)", position: "relative" }}>
|
||||
<div className="onboarding-topbar">
|
||||
<Link to="/landing" className="logo-link">
|
||||
<LogoWordmark size={26} />
|
||||
</Link>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<PublicControls compact />
|
||||
<Link to="/login" className="btn btn-ghost btn-sm">
|
||||
{t("onboarding.have_account", "Already have an account?")}{" "}
|
||||
<span style={{ color: "var(--brand-400)" }}>{t("login.sign_in", "Sign in")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="onboarding-body">
|
||||
<div className="stepper onboarding-stepper">
|
||||
{steps.map((s, i) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`step ${step === i ? "is-active" : ""} ${step > i ? "is-done" : ""}`}
|
||||
>
|
||||
<div className="n">
|
||||
{step > i ? <Icons.check style={{ width: 12, height: 12 }} /> : i + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{s}</div>
|
||||
<div className="caption">
|
||||
{t("onboarding.step_n_of", "Step {n} of {total}")
|
||||
.replace("{n}", String(i + 1))
|
||||
.replace("{total}", String(steps.length))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{step === 0 && (
|
||||
<div style={{ padding: 28 }}>
|
||||
<h2 className="h-2" style={{ marginTop: 0 }}>
|
||||
{t("onboarding.signin_title", "Sign in with your GitHub or Google account")}
|
||||
</h2>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginBottom: 20 }}>
|
||||
{t(
|
||||
"onboarding.signin_body",
|
||||
"We use OAuth — no passwords, no email verification dance. Takes a few seconds.",
|
||||
)}
|
||||
</div>
|
||||
<div className="onboarding-oauth">
|
||||
<a href="/auth/github" className="btn btn-secondary btn-lg">
|
||||
<Icons.github style={{ width: 16, height: 16 }} />
|
||||
{t("login.continue_github", "Continue with GitHub")}
|
||||
</a>
|
||||
<a href="/auth/google" className="btn btn-secondary btn-lg">
|
||||
<Icons.sparkles style={{ width: 16, height: 16 }} />
|
||||
{t("login.continue_google", "Continue with Google")}
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 24 }}>
|
||||
<Btn variant="ghost" onClick={() => setStep(1)} iconRight="arrow">
|
||||
{t("onboarding.skip_signed_in", "Skip — already signed in")}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div style={{ padding: 28 }}>
|
||||
<h2 className="h-2" style={{ marginTop: 0 }}>
|
||||
{t("onboarding.add_site_title", "Add your first site")}
|
||||
</h2>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginBottom: 20 }}>
|
||||
{t(
|
||||
"onboarding.add_site_body",
|
||||
"Pick a Coolify project, WordPress site, Gitea instance, or any other supported plugin. You can add more later.",
|
||||
)}
|
||||
</div>
|
||||
<div className="onboarding-actions">
|
||||
<Link to="/sites?create=1" className="btn btn-primary btn-lg">
|
||||
{t("onboarding.add_site_cta", "Add a site")}{" "}
|
||||
<Icons.arrow style={{ width: 14, height: 14 }} />
|
||||
</Link>
|
||||
<Btn variant="ghost" onClick={() => setStep(0)}>
|
||||
{t("back", "Back")}
|
||||
</Btn>
|
||||
<Btn variant="ghost" onClick={() => setStep(2)} iconRight="arrow">
|
||||
{t("onboarding.skip", "Skip")}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div style={{ padding: 28 }}>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center", marginBottom: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
background: "var(--success)",
|
||||
color: "#000",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Icons.check style={{ width: 14, height: 14 }} />
|
||||
</div>
|
||||
<h2 className="h-2" style={{ margin: 0 }}>
|
||||
{t("onboarding.done_title", "You're set")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginBottom: 24 }}>
|
||||
{t(
|
||||
"onboarding.done_body",
|
||||
"Head over to API keys to create one, or jump to Connect to wire up an AI client.",
|
||||
)}
|
||||
</div>
|
||||
<div className="onboarding-actions">
|
||||
<Btn variant="primary" size="lg" iconRight="arrow" onClick={() => navigate("/connect")}>
|
||||
{t("onboarding.connect_client", "Connect a client")}
|
||||
</Btn>
|
||||
<Btn variant="secondary" size="lg" onClick={() => navigate("/overview")}>
|
||||
{t("onboarding.go_dashboard", "Go to dashboard")}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
319
web/src/pages/Overview.tsx
Normal file
319
web/src/pages/Overview.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Topbar } from "../components/Topbar";
|
||||
import { Card, CardHead, Badge, Btn, EmptyState } from "../components/primitives";
|
||||
import { Icons } from "../components/icons";
|
||||
import { useDashboardStats, useSession, useSites, useUserKeys } from "../lib/queries";
|
||||
import type { DashboardStats } from "../lib/types";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useUiStore } from "../lib/store";
|
||||
import { fmtNumber, fmtInt, fmtDateTime, normalizeSiteStatus } from "../lib/format";
|
||||
|
||||
export function OverviewPage() {
|
||||
const t = useT();
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
const session = useSession();
|
||||
const isAdmin = session.data?.is_admin ?? false;
|
||||
const stats = useDashboardStats();
|
||||
const sites = useSites();
|
||||
const keys = useUserKeys();
|
||||
|
||||
const userSitesCount = sites.data?.length ?? 0;
|
||||
const userKeysCount = keys.data?.length ?? 0;
|
||||
const healthyCount = (sites.data ?? []).filter((s) => normalizeSiteStatus(s.status, s.last_tested_at) === "healthy").length;
|
||||
|
||||
// Persian has no plural inflection; English does. Keep both natural.
|
||||
const summary =
|
||||
lang === "fa"
|
||||
? `${fmtNumber(userSitesCount, lang)} سایت ثبتشده · ${fmtNumber(userKeysCount, lang)} کلید فعال`
|
||||
: `${userSitesCount} site${userSitesCount === 1 ? "" : "s"} registered · ${userKeysCount} key${userKeysCount === 1 ? "" : "s"} active`;
|
||||
|
||||
// /api/dashboard/stats has different shapes for admin vs OAuth user; the
|
||||
// user payload doesn't carry tools_count or uptime_days. Use the live
|
||||
// /api/sites and /api/keys queries as the source of truth for counts so
|
||||
// the cards never disagree with the underlying lists.
|
||||
const cards = [
|
||||
{
|
||||
label: t("card.active_sites_label", "Active sites"),
|
||||
value: fmtNumber(userSitesCount, lang, "0"),
|
||||
delta: t("card.active_sites_caption", "Sites you manage"),
|
||||
icon: "sites" as const,
|
||||
},
|
||||
{
|
||||
label: t("card.api_keys_label", "API keys"),
|
||||
value: fmtNumber(userKeysCount, lang, "0"),
|
||||
delta: t("card.api_keys_caption", "Personal & client keys"),
|
||||
icon: "key" as const,
|
||||
},
|
||||
{
|
||||
label: t("card.healthy_sites_label", "Healthy sites"),
|
||||
value:
|
||||
userSitesCount === 0
|
||||
? "—"
|
||||
: `${fmtNumber(healthyCount, lang)} / ${fmtNumber(userSitesCount, lang)}`,
|
||||
delta: t("card.healthy_sites_caption", "Sites passing connection tests"),
|
||||
icon: "activity" as const,
|
||||
},
|
||||
{
|
||||
label: t("card.uptime_label", "Uptime (days)"),
|
||||
value: fmtInt(stats.data?.uptime_days, lang, "—"),
|
||||
delta: t("card.uptime_caption", "Hub availability"),
|
||||
icon: "spark" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const greeting = `${t("welcome_greeting", "Hello")}${session.data?.name ? `, ${session.data.name}` : ""}.`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
crumbs={[t("workspace", "Workspace"), t("nav.overview", "Overview")]}
|
||||
actions={
|
||||
<Btn
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="refresh"
|
||||
onClick={() => {
|
||||
stats.refetch();
|
||||
sites.refetch();
|
||||
keys.refetch();
|
||||
}}
|
||||
>
|
||||
{t("refresh", "Refresh")}
|
||||
</Btn>
|
||||
}
|
||||
/>
|
||||
<div className="page-pad">
|
||||
<div className="page-head page-head-split">
|
||||
<div className="page-head-text">
|
||||
<div className="eyebrow" style={{ marginBottom: 8 }}>
|
||||
{t("welcome_eyebrow", "Welcome back")}
|
||||
</div>
|
||||
<h1 className="h-1" style={{ margin: 0 }}>
|
||||
{greeting}
|
||||
</h1>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginTop: 6 }}>
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<Link to="/connect">
|
||||
<Btn variant="secondary" icon="plug">
|
||||
{t("connect_client", "Connect client")}
|
||||
</Btn>
|
||||
</Link>
|
||||
<Link to="/sites">
|
||||
<Btn variant="primary" icon="plus">
|
||||
{t("add_site_short", "Add site")}
|
||||
</Btn>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<AdminStatsPanel stats={stats.data} lang={lang} t={t} />
|
||||
)}
|
||||
|
||||
<div className="stat-grid">
|
||||
{cards.map((s) => {
|
||||
const Ic = Icons[s.icon];
|
||||
return (
|
||||
<div key={s.label} className="card card-pad" style={{ padding: 18 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>{s.label}</div>
|
||||
<Ic style={{ width: 16, height: 16, color: "var(--text-subtle)" }} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "-0.02em",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="caption">{s.delta}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHead
|
||||
icon="sites"
|
||||
title={t("your_sites", "Your sites")}
|
||||
subtitle={t("health_connection_status", "Health and connection status")}
|
||||
action={
|
||||
<Link to="/sites">
|
||||
<Btn variant="ghost" size="sm" iconRight="arrow">
|
||||
{t("manage", "Manage")}
|
||||
</Btn>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
{sites.isLoading ? (
|
||||
<div className="card-body">
|
||||
<div className="shimmer" style={{ height: 28, borderRadius: 4, marginBottom: 6 }} />
|
||||
<div className="shimmer" style={{ height: 28, borderRadius: 4, marginBottom: 6 }} />
|
||||
<div className="shimmer" style={{ height: 28, borderRadius: 4 }} />
|
||||
</div>
|
||||
) : !sites.data || sites.data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="sites"
|
||||
title={t("no_sites", "No sites yet")}
|
||||
action={
|
||||
<Link to="/sites">
|
||||
<Btn variant="primary" icon="plus">
|
||||
{t("add_first_site", "Add your first site")}
|
||||
</Btn>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"register_first_site_body",
|
||||
"Register a Coolify project, WordPress site, or other supported plugin to get started.",
|
||||
)}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<table className="table mobile-stack">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("table.site", "Site")}</th>
|
||||
<th>{t("table.type", "Type")}</th>
|
||||
<th>{t("table.status", "Status")}</th>
|
||||
<th>{t("table.last_tested", "Last tested")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sites.data.map((s) => {
|
||||
const status = normalizeSiteStatus(s.status, s.last_tested_at);
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td className="row-head" data-label={t("table.site", "Site")}>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background:
|
||||
status === "healthy"
|
||||
? "var(--success)"
|
||||
: status === "warning"
|
||||
? "var(--warning)"
|
||||
: status === "error"
|
||||
? "var(--danger)"
|
||||
: "var(--text-subtle)",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontWeight: 500 }}>{s.alias}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label={t("table.type", "Type")}>
|
||||
<Badge>{s.plugin_type}</Badge>
|
||||
</td>
|
||||
<td data-label={t("table.status", "Status")}>
|
||||
{status === "healthy" ? (
|
||||
<Badge variant="success" dot>
|
||||
{t("status_healthy", "healthy")}
|
||||
</Badge>
|
||||
) : status === "warning" ? (
|
||||
<Badge variant="warning" dot>
|
||||
{t("status_warning", "warning")}
|
||||
</Badge>
|
||||
) : status === "error" ? (
|
||||
<Badge variant="danger" dot>
|
||||
{t("status_error", "error")}
|
||||
</Badge>
|
||||
) : status === "unknown" ? (
|
||||
<Badge variant="warning">{t("status_unknown", "unknown")}</Badge>
|
||||
) : (
|
||||
<Badge>{t("status_untested", "untested")}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="caption" data-label={t("table.last_tested", "Last tested")}>
|
||||
{fmtDateTime(s.last_tested_at, lang)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Admin-only stats panel ----------
|
||||
|
||||
function AdminStatsPanel({
|
||||
stats,
|
||||
lang,
|
||||
t,
|
||||
}: {
|
||||
stats: DashboardStats | undefined;
|
||||
lang: string;
|
||||
t: (key: string, fallback: string) => string;
|
||||
}) {
|
||||
if (!stats) return null;
|
||||
const adminCards = [
|
||||
{
|
||||
label: t("card.total_users_label", "Registered users"),
|
||||
value: fmtNumber(stats.users_count, lang, "—"),
|
||||
delta: t("card.total_users_caption", "All-time registrations"),
|
||||
icon: "user" as const,
|
||||
},
|
||||
{
|
||||
label: t("card.recent_users_label", "New users (7d)"),
|
||||
value: fmtNumber(stats.recent_users_count, lang, "—"),
|
||||
delta: t("card.recent_users_caption", "Joined in the last 7 days"),
|
||||
icon: "activity" as const,
|
||||
},
|
||||
{
|
||||
label: t("card.user_sites_label", "User sites"),
|
||||
value: fmtNumber(stats.user_sites_count, lang, "—"),
|
||||
delta: t("card.user_sites_caption", "Sites across all accounts"),
|
||||
icon: "sites" as const,
|
||||
},
|
||||
{
|
||||
label: t("card.tools_label", "Available tools"),
|
||||
value: fmtNumber(stats.tools_count, lang, "—"),
|
||||
delta: t("card.tools_caption", "Active MCP tools"),
|
||||
icon: "plug" as const,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="caption" style={{ marginBottom: 8, color: "var(--text-muted)", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<Icons.shield style={{ width: 12, height: 12 }} />
|
||||
{t("badge.admin", "Admin")} · {t("card.platform_stats", "Platform stats")}
|
||||
</div>
|
||||
<div className="stat-grid">
|
||||
{adminCards.map((s) => {
|
||||
const Ic = Icons[s.icon];
|
||||
return (
|
||||
<div key={s.label} className="card card-pad" style={{ padding: 18, borderColor: "oklch(from var(--brand-500) l c h / 0.25)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>{s.label}</div>
|
||||
<Ic style={{ width: 16, height: 16, color: "var(--text-subtle)" }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em", marginBottom: 6 }}>{s.value}</div>
|
||||
<div className="caption">{s.delta}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
552
web/src/pages/Settings.tsx
Normal file
552
web/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import { Topbar } from "../components/Topbar";
|
||||
import { Card, CardHead, Avatar, Btn, Seg, Badge } from "../components/primitives";
|
||||
import { Icons, type IconName } from "../components/icons";
|
||||
import {
|
||||
useManagedSettings,
|
||||
useResetSettings,
|
||||
useSaveSetting,
|
||||
useSession,
|
||||
type ManagedSetting,
|
||||
} from "../lib/queries";
|
||||
import { useUiStore } from "../lib/store";
|
||||
import { useT } from "../lib/i18n";
|
||||
|
||||
type Tab = "profile" | "appearance" | "limits" | "plugins" | "danger";
|
||||
|
||||
const PLUGIN_LIBRARY: { key: string; label: string; descriptionKey: string; description: string }[] = [
|
||||
{
|
||||
key: "wordpress",
|
||||
label: "WordPress",
|
||||
descriptionKey: "settings.plugin.wordpress",
|
||||
description: "Posts, pages, media, comments.",
|
||||
},
|
||||
{
|
||||
key: "woocommerce",
|
||||
label: "WooCommerce",
|
||||
descriptionKey: "settings.plugin.woocommerce",
|
||||
description: "Products, orders, customers, reports.",
|
||||
},
|
||||
{
|
||||
key: "wordpress_specialist",
|
||||
label: "WordPress Specialist",
|
||||
descriptionKey: "settings.plugin.wordpress_specialist",
|
||||
description: "Companion-backed: blocks, theme files, plugins, DB.",
|
||||
},
|
||||
{ key: "supabase", label: "Supabase", descriptionKey: "settings.plugin.supabase", description: "DB, auth, storage, functions." },
|
||||
{ key: "openpanel", label: "OpenPanel", descriptionKey: "settings.plugin.openpanel", description: "Product analytics and event exports." },
|
||||
{ key: "gitea", label: "Gitea", descriptionKey: "settings.plugin.gitea", description: "Repos, issues, PRs, releases." },
|
||||
{ key: "n8n", label: "n8n", descriptionKey: "settings.plugin.n8n", description: "Workflows and executions." },
|
||||
{ key: "coolify", label: "Coolify", descriptionKey: "settings.plugin.coolify", description: "Apps, deployments, servers, services." },
|
||||
];
|
||||
|
||||
export function SettingsPage() {
|
||||
const t = useT();
|
||||
const session = useSession();
|
||||
const isAdmin = session.data?.is_admin ?? false;
|
||||
const [tab, setTab] = useState<Tab>("profile");
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: IconName; admin?: boolean }[] = [
|
||||
{ id: "profile", label: t("settings.tab_profile", "Profile"), icon: "user" },
|
||||
{ id: "appearance", label: t("settings.tab_appearance", "Appearance"), icon: "sparkles" },
|
||||
{ id: "limits", label: t("settings.tab_limits", "Limits"), icon: "shield", admin: true },
|
||||
{ id: "plugins", label: t("settings.tab_plugins", "Public plugin visibility"), icon: "plug", admin: true },
|
||||
{ id: "danger", label: t("settings.tab_danger", "Danger zone"), icon: "warning", admin: true },
|
||||
];
|
||||
const visible = tabs.filter((t) => !t.admin || isAdmin);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar crumbs={[t("nav.account", "Account"), t("settings", "Settings")]} />
|
||||
<div className="page-pad">
|
||||
<div className="page-head">
|
||||
<h1 className="h-1" style={{ margin: 0 }}>
|
||||
{t("settings", "Settings")}
|
||||
</h1>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginTop: 6 }}>
|
||||
{t(
|
||||
"settings.intro",
|
||||
"Your profile, hub preferences, and integrations.",
|
||||
)}
|
||||
{isAdmin
|
||||
? ` ${t("settings.intro_admin_suffix", "Admin-only sections are flagged in the sidebar.")}`
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-grid">
|
||||
<div className="stack settings-tabs" style={{ gap: 2 }}>
|
||||
{visible.map((row) => {
|
||||
const Ic = Icons[row.icon];
|
||||
return (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
className={`nav-item ${tab === row.id ? "is-active" : ""}`}
|
||||
onClick={() => setTab(row.id)}
|
||||
>
|
||||
<Ic />
|
||||
<span>{row.label}</span>
|
||||
{row.admin ? (
|
||||
<Badge variant="warning" className="badge-fixed" style={{ marginLeft: "auto" }}>
|
||||
{t("badge.admin_lc", "admin")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
||||
{tab === "profile" && <ProfileTab />}
|
||||
{tab === "appearance" && <AppearanceTab />}
|
||||
{tab === "limits" && isAdmin && <LimitsTab />}
|
||||
{tab === "plugins" && isAdmin && <PluginsTab />}
|
||||
{tab === "danger" && isAdmin && <DangerTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Profile ----------
|
||||
|
||||
function ProfileTab() {
|
||||
const t = useT();
|
||||
const { data: session } = useSession();
|
||||
return (
|
||||
<Card>
|
||||
<CardHead
|
||||
icon="user"
|
||||
title={t("settings.profile_title", "Profile")}
|
||||
subtitle={t("settings.profile_subtitle", "Used across the hub and for audit attribution")}
|
||||
/>
|
||||
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", gap: 20, alignItems: "center" }}>
|
||||
<Avatar name={session?.name || session?.email || "User"} size={64} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="h-3">{session?.name ?? "—"}</div>
|
||||
<div className="caption">
|
||||
{session?.email ?? "—"}
|
||||
{session?.role ? ` · ${session.role}` : ""}
|
||||
{session?.type ? ` · ${session.type}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{session?.is_admin ? (
|
||||
<Badge variant="warning">{t("badge.admin", "Admin")}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="settings-profile-grid">
|
||||
<ReadOnlyField label={t("settings.field_full_name", "Full name")} value={session?.name} />
|
||||
<ReadOnlyField label={t("settings.field_email", "Email")} value={session?.email} />
|
||||
<ReadOnlyField label={t("settings.field_session_type", "Session type")} value={session?.type} />
|
||||
<ReadOnlyField label={t("settings.field_role", "Role")} value={session?.role} />
|
||||
</div>
|
||||
<div className="caption">
|
||||
{t(
|
||||
"settings.profile_footnote",
|
||||
"Profile details come from your OAuth provider and aren't editable here. Sign out and reconnect to update them.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyField({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label>{label}</label>
|
||||
<input className="input" value={value ?? ""} disabled />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Appearance ----------
|
||||
|
||||
function AppearanceTab() {
|
||||
const t = useT();
|
||||
const { theme, setTheme, lang, setLang, brandHue, setBrandHue, density, setDensity } =
|
||||
useUiStore();
|
||||
const HUES: { value: number; label: string }[] = [
|
||||
{ value: 205, label: "Cyan" },
|
||||
{ value: 165, label: "Green" },
|
||||
{ value: 290, label: "Purple" },
|
||||
{ value: 30, label: "Amber" },
|
||||
{ value: 0, label: "Red" },
|
||||
];
|
||||
return (
|
||||
<Card>
|
||||
<CardHead
|
||||
icon="sparkles"
|
||||
title={t("settings.tab_appearance", "Appearance")}
|
||||
subtitle={t(
|
||||
"settings.appearance_subtitle",
|
||||
"Theme, language, brand hue, density. Changes apply immediately and persist locally.",
|
||||
)}
|
||||
/>
|
||||
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<SettingRow label={t("theme" as string, "Theme")}>
|
||||
<Seg
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
options={[
|
||||
{ value: "light", label: t("theme.light", "Light") },
|
||||
{ value: "dark", label: t("theme.dark", "Dark") },
|
||||
{ value: "system", label: t("theme.system", "System") },
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t("language" as string, "Language")}>
|
||||
<Seg
|
||||
value={lang}
|
||||
onChange={setLang}
|
||||
options={[
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fa", label: "فارسی" },
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t("settings.brand_color", "Brand color")}>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{HUES.map((h) => (
|
||||
<button
|
||||
key={h.value}
|
||||
type="button"
|
||||
onClick={() => setBrandHue(h.value)}
|
||||
title={h.label}
|
||||
aria-label={`Brand color ${h.label}`}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
background: `oklch(0.74 0.14 ${h.value})`,
|
||||
border: brandHue === h.value ? "2px solid var(--text)" : "2px solid transparent",
|
||||
boxShadow: brandHue === h.value ? "0 0 0 2px var(--bg)" : "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label={t("settings.density", "Density")}>
|
||||
<Seg
|
||||
value={String(density) as "0.85" | "1" | "1.1"}
|
||||
onChange={(v) => setDensity(parseFloat(v))}
|
||||
options={[
|
||||
{ value: "0.85", label: "Compact" },
|
||||
{ value: "1", label: "Default" },
|
||||
{ value: "1.1", label: "Comfortable" },
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Limits (admin) ----------
|
||||
|
||||
function LimitsTab() {
|
||||
const t = useT();
|
||||
const settings = useManagedSettings();
|
||||
const limits = (settings.data ?? []).filter((s) =>
|
||||
["MAX_SITES_PER_USER", "USER_RATE_LIMIT_PER_MIN", "USER_RATE_LIMIT_PER_HR"].includes(s.key),
|
||||
);
|
||||
return (
|
||||
<Card>
|
||||
<CardHead
|
||||
icon="shield"
|
||||
title={t("settings.limits_title", "User limits")}
|
||||
subtitle={t(
|
||||
"settings.limits_subtitle",
|
||||
"Maximum sites and rate limits per registered user. Persists in the SQLite settings table.",
|
||||
)}
|
||||
/>
|
||||
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
{settings.isLoading ? (
|
||||
<>
|
||||
<div className="shimmer" style={{ height: 56, borderRadius: 8 }} />
|
||||
<div className="shimmer" style={{ height: 56, borderRadius: 8 }} />
|
||||
<div className="shimmer" style={{ height: 56, borderRadius: 8 }} />
|
||||
</>
|
||||
) : limits.length === 0 ? (
|
||||
<div className="caption">{t("settings.no_managed_limits", "No managed limits found.")}</div>
|
||||
) : (
|
||||
limits.map((s) => <ManagedSettingRow key={s.key} setting={s} type="number" />)
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Plugins (admin) ----------
|
||||
|
||||
function PluginsTab() {
|
||||
const t = useT();
|
||||
const settings = useManagedSettings();
|
||||
const save = useSaveSetting();
|
||||
const setting = (settings.data ?? []).find((s) => s.key === "ENABLED_PLUGINS");
|
||||
const enabled = new Set(
|
||||
(setting?.value ?? "")
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const toggle = async (key: string) => {
|
||||
if (!setting) return;
|
||||
const next = new Set(enabled);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
await save.mutateAsync({ key: "ENABLED_PLUGINS", value: Array.from(next).join(",") });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHead
|
||||
icon="plug"
|
||||
title={t("settings.plugins_title", "Public plugin visibility")}
|
||||
subtitle={t(
|
||||
"settings.plugins_subtitle",
|
||||
"Toggle which plugin types non-admin users can see. Admins always see everything.",
|
||||
)}
|
||||
/>
|
||||
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{settings.isLoading ? (
|
||||
<div className="shimmer" style={{ height: 200, borderRadius: 8 }} />
|
||||
) : !setting ? (
|
||||
<div className="caption">
|
||||
{t("settings.plugins_unavailable", "ENABLED_PLUGINS setting not available.")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
|
||||
<div className="caption">
|
||||
{t("settings.source_label", "Source")}:{" "}
|
||||
<strong style={{ color: "var(--text)" }}>{setting.source}</strong>
|
||||
{" · "}
|
||||
{t("settings.default_label", "default")}:{" "}
|
||||
<span className="mono">{setting.default}</span>
|
||||
</div>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => save.mutate({ key: "ENABLED_PLUGINS", value: setting.default, action: "reset" })}
|
||||
disabled={save.isPending}
|
||||
>
|
||||
{t("settings.reset_default", "Reset to default")}
|
||||
</Btn>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 8 }}>
|
||||
{PLUGIN_LIBRARY.map((p) => {
|
||||
const on = enabled.has(p.key);
|
||||
const pending = save.isPending && save.variables?.value?.includes(p.key) !== on;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={p.key}
|
||||
onClick={() => toggle(p.key)}
|
||||
disabled={save.isPending}
|
||||
style={pluginTileStyle(on)}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 500 }}>{p.label}</span>
|
||||
<Badge variant={on ? "success" : "default"} dot={on}>
|
||||
{on ? t("toggle.on", "on") : t("toggle.off", "off")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 4 }}>
|
||||
{t(p.descriptionKey, p.description)}
|
||||
</div>
|
||||
{pending ? (
|
||||
<div className="caption" style={{ marginTop: 4 }}>
|
||||
{t("status.saving", "saving…")}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Danger ----------
|
||||
|
||||
function DangerTab() {
|
||||
const t = useT();
|
||||
const reset = useResetSettings();
|
||||
const setToast = useUiStore((s) => s.setToast);
|
||||
|
||||
const onReset = async () => {
|
||||
const ok = window.confirm(t("settings.reset_all_confirm", "Reset all managed settings to environment/default values? This affects every user."));
|
||||
if (!ok) return;
|
||||
try {
|
||||
await reset.mutateAsync();
|
||||
setToast(t("settings.reset_all_done", "Managed settings reset"));
|
||||
} catch (e: any) {
|
||||
setToast(t("settings.reset_all_failed", "Reset failed: {error}").replace("{error}", e.message || "unknown"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ borderColor: "oklch(from var(--danger) l c h / 0.4)" }}>
|
||||
<CardHead
|
||||
icon="warning"
|
||||
title={t("settings.danger_title", "Danger zone")}
|
||||
subtitle={t(
|
||||
"settings.danger_subtitle",
|
||||
"Actions in this section affect every user of the hub. Confirm twice before acting.",
|
||||
)}
|
||||
/>
|
||||
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div className="alert alert-warning" style={{ alignItems: "center" }}>
|
||||
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, color: "var(--text)" }}>
|
||||
{t("settings.reset_all_title", "Reset managed settings")}
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 2 }}>
|
||||
{t(
|
||||
"settings.reset_all_body",
|
||||
"Delete database overrides for user limits and public plugin visibility. Environment values still win over defaults.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Btn variant="danger" size="sm" onClick={onReset} disabled={reset.isPending}>
|
||||
{reset.isPending ? "…" : t("settings.reset_all_action", "Reset all managed settings")}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Shared bits ----------
|
||||
|
||||
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="setting-row"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
padding: 12,
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagedSettingRow({ setting, type = "text" }: { setting: ManagedSetting; type?: "text" | "number" }) {
|
||||
const save = useSaveSetting();
|
||||
const t = useT();
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
const [draft, setDraft] = useState(setting.value);
|
||||
const [hint, setHint] = useState<string | null>(null);
|
||||
|
||||
// Reset local draft when the saved value changes (e.g. another tab edits).
|
||||
useEffect(() => {
|
||||
setDraft(setting.value);
|
||||
}, [setting.value]);
|
||||
|
||||
const dirty = draft !== setting.value;
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
await save.mutateAsync({ key: setting.key, value: draft });
|
||||
setHint("Saved");
|
||||
setTimeout(() => setHint(null), 1500);
|
||||
} catch (e: any) {
|
||||
setHint(e.message || "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
try {
|
||||
await save.mutateAsync({ key: setting.key, value: setting.default, action: "reset" });
|
||||
setHint("Reset to default");
|
||||
setTimeout(() => setHint(null), 1500);
|
||||
} catch (e: any) {
|
||||
setHint(e.message || "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>
|
||||
{lang === "fa" && setting.label_fa ? setting.label_fa : setting.label}
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 2 }}>
|
||||
{lang === "fa" && setting.hint_fa ? setting.hint_fa : setting.hint}
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="badge-fixed">{setting.source}</Badge>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
type={type}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
style={{ flex: 1, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<Btn variant="primary" size="sm" disabled={!dirty || save.isPending} onClick={onSave}>
|
||||
{save.isPending ? "…" : t("action.save", "Save")}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
disabled={save.isPending || setting.value === setting.default}
|
||||
>
|
||||
{t("action.reset", "Reset")}
|
||||
</Btn>
|
||||
</div>
|
||||
{hint ? (
|
||||
<div className="caption" style={{ color: "var(--brand-400)" }}>
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="caption">
|
||||
{t("settings.default_label", "default")}: <span className="mono">{setting.default}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pluginTileStyle(active: boolean): CSSProperties {
|
||||
return {
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
background: active ? "var(--surface)" : "var(--bg-sunken)",
|
||||
border: `1px solid ${active ? "var(--brand-400)" : "var(--border)"}`,
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
color: "var(--text)",
|
||||
fontSize: 13,
|
||||
transition: "background 120ms, border-color 120ms",
|
||||
};
|
||||
}
|
||||
951
web/src/pages/SiteTools.tsx
Normal file
951
web/src/pages/SiteTools.tsx
Normal file
@@ -0,0 +1,951 @@
|
||||
import { useMemo, useState, type CSSProperties } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Topbar } from "../components/Topbar";
|
||||
import { Card, CardHead, Badge } from "../components/primitives";
|
||||
import { Icons } from "../components/icons";
|
||||
import {
|
||||
useDeleteSiteProviderKey,
|
||||
useOpenRouterImageModels,
|
||||
useSetSiteProviderKey,
|
||||
useSetSiteProviderDefaultModel,
|
||||
useSite,
|
||||
useSiteCapabilities,
|
||||
useSiteProviderKeys,
|
||||
useSiteTools,
|
||||
useToggleSiteTool,
|
||||
useUpdateSiteToolScope,
|
||||
type SiteCapabilityProbe,
|
||||
type ScopePreset,
|
||||
} from "../lib/queries";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useUiStore } from "../lib/store";
|
||||
|
||||
function scopeKey(scope: string): string {
|
||||
return scope.replace(":", "_");
|
||||
}
|
||||
|
||||
function scopeFallback(scope: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
read: "Read",
|
||||
"read:sensitive": "Read sensitive",
|
||||
deploy: "Deploy",
|
||||
editor: "Editor",
|
||||
settings: "Settings",
|
||||
install: "Installer",
|
||||
write: "Write",
|
||||
admin: "Admin",
|
||||
custom: "Custom",
|
||||
};
|
||||
return labels[scope] ?? scope;
|
||||
}
|
||||
|
||||
function credentialGuide(
|
||||
pluginType: string | undefined,
|
||||
scope: string,
|
||||
t: ReturnType<typeof useT>,
|
||||
): { title: string; body: string } | null {
|
||||
if (!pluginType || scope === "custom") return null;
|
||||
const key = `${pluginType}.${scopeKey(scope)}`;
|
||||
const fallbacks: Record<string, string> = {
|
||||
"wordpress.read":
|
||||
"The Application Password saved in service credentials should belong to a WordPress user with at least Editor role. Basic read tools do not require CRUD capabilities.",
|
||||
"wordpress.admin":
|
||||
"The Application Password saved in service credentials must belong to a WordPress Administrator for full CRUD. SEO and companion-backed tools may also require their corresponding plugins to be active.",
|
||||
"wordpress_specialist.read":
|
||||
"The Application Password must belong to a WordPress user with manage_options (Administrator). Airano MCP Bridge v2.11.0+ must be installed and active for companion-backed tools.",
|
||||
"wordpress_specialist.editor":
|
||||
"Same prerequisites as Read, plus Airano MCP Bridge v2.13.0+ for page editing and v2.14.0+ for theme file CRUD. Tool calls still check edit_posts/edit_themes.",
|
||||
"wordpress_specialist.settings":
|
||||
"Same prerequisites as Editor. Settings, identity, permalink, and cron tools require an Administrator Application Password with manage_options.",
|
||||
"wordpress_specialist.install":
|
||||
"Same prerequisites as Settings, plus Airano MCP Bridge v2.14.0+ for theme install/activate/delete and v2.15.0+ for plugin install/activate/update.",
|
||||
"wordpress_specialist.admin":
|
||||
"Same prerequisites as Installer, plus destructive routes such as delete and URL/zip installs. PHP file edits require DISALLOW_FILE_EDIT to be unset or false.",
|
||||
"woocommerce.read":
|
||||
"The WooCommerce REST API Consumer Key and Secret saved in service credentials must have Read permission. The creating WordPress user should be at least Shop Manager to see orders and customers.",
|
||||
"woocommerce.admin":
|
||||
"The WooCommerce REST API key must have Read/Write permission and belong to an Administrator or Shop Manager. Media and AI image upload tools additionally need WordPress username and Application Password credentials.",
|
||||
};
|
||||
const title = t("tools.credential_requirement_title", "Credential requirement for {scope}")
|
||||
.replace("{scope}", t(`tier.${scopeKey(scope)}`, scopeFallback(scope)));
|
||||
const fallback = fallbacks[key];
|
||||
if (!fallback) return null;
|
||||
const body = t(`tools.credential_guide.${key}`, fallback);
|
||||
return body ? { title, body } : null;
|
||||
}
|
||||
|
||||
function tierWarning(
|
||||
scope: string,
|
||||
t: ReturnType<typeof useT>,
|
||||
): { severity: "warning" | "danger"; title: string; body: string } | null {
|
||||
if (scope === "install") {
|
||||
return {
|
||||
severity: "warning",
|
||||
title: t("tools.tier_warning_title", "Warning"),
|
||||
body: t(
|
||||
"tools.tier_warning.install",
|
||||
"Installer grants the AI agent permission to install and activate plugins or themes from curated repositories. Test on staging first and review installed extensions regularly.",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (scope === "admin") {
|
||||
return {
|
||||
severity: "danger",
|
||||
title: t("tools.tier_warning_title", "Warning"),
|
||||
body: t(
|
||||
"tools.tier_warning.admin",
|
||||
"Admin grants the full destructive surface: arbitrary installs, deletes, user CRUD, and other operations that may not have undo. Use only where mistakes are recoverable from backups.",
|
||||
),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unavailableReasonMeta(reason: string | null | undefined, t: ReturnType<typeof useT>) {
|
||||
switch (reason) {
|
||||
case "provider_key":
|
||||
return {
|
||||
label: t("tools.reason.provider_key", "needs AI provider key"),
|
||||
detail: t("tools.reason.provider_key_detail", "Configure a provider key in AI Image Generation."),
|
||||
variant: "warning" as const,
|
||||
};
|
||||
case "companion_route":
|
||||
return {
|
||||
label: t("tools.reason.companion_route", "needs companion plugin"),
|
||||
detail: t("tools.reason.companion_route_detail", "Install or update Airano MCP Bridge and run a connection test."),
|
||||
variant: "info" as const,
|
||||
};
|
||||
case "feature":
|
||||
return {
|
||||
label: t("tools.reason.feature", "needs SEO plugin"),
|
||||
detail: t("tools.reason.feature_detail", "Install Rank Math or Yoast support before enabling this tool."),
|
||||
variant: "warning" as const,
|
||||
};
|
||||
case "wp_credentials":
|
||||
return {
|
||||
label: t("tools.reason.wp_credentials", "needs WP App Password"),
|
||||
detail: t("tools.reason.wp_credentials_detail", "Add WordPress username and Application Password in service credentials for media uploads."),
|
||||
variant: "warning" as const,
|
||||
};
|
||||
case "probe_unknown":
|
||||
return {
|
||||
label: t("tools.reason.probe_unknown", "needs health probe"),
|
||||
detail: t("tools.reason.probe_unknown_detail", "Run a connection test so MCP Hub can verify service capabilities."),
|
||||
variant: "default" as const,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function SiteToolsPage() {
|
||||
const t = useT();
|
||||
const { id: siteId = "" } = useParams<{ id: string }>();
|
||||
const site = useSite(siteId);
|
||||
const data = useSiteTools(siteId);
|
||||
const supportsProviderKeys =
|
||||
data.data?.plugin_type === "wordpress" || data.data?.plugin_type === "woocommerce";
|
||||
const providerKeys = useSiteProviderKeys(siteId, supportsProviderKeys);
|
||||
const setProviderKey = useSetSiteProviderKey(siteId);
|
||||
const deleteProviderKey = useDeleteSiteProviderKey(siteId);
|
||||
const setProviderDefaultModel = useSetSiteProviderDefaultModel(siteId);
|
||||
const toggle = useToggleSiteTool(siteId);
|
||||
const updateScope = useUpdateSiteToolScope();
|
||||
const setToast = useUiStore((s) => s.setToast);
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
|
||||
const [pendingTool, setPendingTool] = useState<string | null>(null);
|
||||
const [providerInputs, setProviderInputs] = useState<Record<string, string>>({});
|
||||
const [pendingProvider, setPendingProvider] = useState<string | null>(null);
|
||||
const [pendingModel, setPendingModel] = useState<string | null>(null);
|
||||
const [filterScope, setFilterScope] = useState<string>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const tools = useMemo(() => data.data?.tools ?? [], [data.data?.tools]);
|
||||
const currentScope = data.data?.tool_scope ?? site.data?.tool_scope ?? "admin";
|
||||
const capabilities = useSiteCapabilities(siteId, currentScope);
|
||||
const scopePresets = data.data?.scope_presets ?? [];
|
||||
const configuredProviders = new Set(
|
||||
providerKeys.data?.providers ?? data.data?.configured_providers ?? [],
|
||||
);
|
||||
const defaultModels = providerKeys.data?.default_models ?? {};
|
||||
const scopeCounts = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
tools.forEach((tool) => m.set(tool.required_scope, (m.get(tool.required_scope) ?? 0) + 1));
|
||||
return m;
|
||||
}, [tools]);
|
||||
const allScopes = useMemo(
|
||||
() => Array.from(scopeCounts.keys()).sort(),
|
||||
[scopeCounts],
|
||||
);
|
||||
const guide = credentialGuide(data.data?.plugin_type, currentScope, t);
|
||||
const warning = tierWarning(currentScope, t);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return tools.filter((tool) => {
|
||||
if (filterScope !== "all" && tool.required_scope !== filterScope) return false;
|
||||
if (q && !tool.name.toLowerCase().includes(q) && !(tool.description ?? "").toLowerCase().includes(q))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}, [tools, filterScope, search]);
|
||||
|
||||
// Group by required_scope so the page reads as a per-tier audit. Within a
|
||||
// group, sort by name so reorder doesn't shift as toggles flip enabled.
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, typeof filtered>();
|
||||
filtered.forEach((tool) => {
|
||||
const k = tool.required_scope || "other";
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k)!.push(tool);
|
||||
});
|
||||
return Array.from(groups.entries())
|
||||
.map(([scope, arr]) => ({
|
||||
scope,
|
||||
tools: [...arr].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}))
|
||||
.sort((a, b) => a.scope.localeCompare(b.scope));
|
||||
}, [filtered]);
|
||||
|
||||
const onToggle = async (name: string, next: boolean) => {
|
||||
setPendingTool(name);
|
||||
try {
|
||||
await toggle.mutateAsync({ name, enabled: next });
|
||||
} catch (e: any) {
|
||||
setToast(t("tools.toast_failed", "Failed to update tool: {error}").replace("{error}", e.message));
|
||||
} finally {
|
||||
setPendingTool(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onScopePick = async (scope: string) => {
|
||||
if (!siteId || scope === currentScope) return;
|
||||
try {
|
||||
await updateScope.mutateAsync({ siteId, scope });
|
||||
setToast(t("connect.toast.scope_updated", "Tool access updated to {scope}").replace("{scope}", scope));
|
||||
} catch (e: any) {
|
||||
setToast(t("connect.toast.scope_failed", "Update failed: {error}").replace("{error}", e.message));
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveProvider = async (provider: string) => {
|
||||
const apiKey = (providerInputs[provider] ?? "").trim();
|
||||
if (!apiKey) return;
|
||||
setPendingProvider(provider);
|
||||
try {
|
||||
await setProviderKey.mutateAsync({ provider, apiKey });
|
||||
setProviderInputs((prev) => ({ ...prev, [provider]: "" }));
|
||||
setToast(t("providers.toast_saved", "Provider key saved"));
|
||||
} catch (e: any) {
|
||||
setToast(t("providers.toast_save_failed", "Save failed: {error}").replace("{error}", e.message));
|
||||
} finally {
|
||||
setPendingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveProvider = async (provider: string) => {
|
||||
if (!window.confirm(t("providers.confirm_remove", "Remove this provider key?"))) return;
|
||||
setPendingProvider(provider);
|
||||
try {
|
||||
await deleteProviderKey.mutateAsync(provider);
|
||||
setToast(t("providers.toast_removed", "Provider key removed"));
|
||||
} catch (e: any) {
|
||||
setToast(t("providers.toast_remove_failed", "Remove failed: {error}").replace("{error}", e.message));
|
||||
} finally {
|
||||
setPendingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onSetDefaultModel = async (provider: string, model: string | null) => {
|
||||
setPendingModel(model ?? `${provider}:clear`);
|
||||
try {
|
||||
await setProviderDefaultModel.mutateAsync({ provider, model });
|
||||
setToast(
|
||||
model
|
||||
? t("providers.model.toast_saved", "Default image model saved")
|
||||
: t("providers.model.toast_cleared", "Default image model cleared"),
|
||||
);
|
||||
} catch (e: any) {
|
||||
setToast(t("providers.model.toast_failed", "Model update failed: {error}").replace("{error}", e.message));
|
||||
} finally {
|
||||
setPendingModel(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
crumbs={[t("workspace", "Workspace"), t("nav.sites", "Sites"), site.data?.alias ?? "—"]}
|
||||
actions={
|
||||
<Link to="/sites" className="btn btn-secondary btn-sm">
|
||||
<Icons.arrow style={{ width: 12, height: 12, transform: "scaleX(-1)" }} />{" "}
|
||||
{t("tools.back_to_sites", "Back to sites")}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div className="page-pad">
|
||||
<div className="page-head page-head-split">
|
||||
<div className="page-head-text">
|
||||
<div className="eyebrow" style={{ marginBottom: 8 }}>
|
||||
{t("tools.eyebrow", "Tool access")}
|
||||
</div>
|
||||
<h1 className="h-1" style={{ margin: 0 }}>
|
||||
{site.data?.alias ?? siteId}
|
||||
</h1>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginTop: 6, maxWidth: 640 }}>
|
||||
{t(
|
||||
"tools.intro",
|
||||
"Toggle individual MCP tools this site exposes. Scope-tier presets in Connect are easier for the common case — use this page to fine-tune.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<div style={searchBoxStyle}>
|
||||
<Icons.search style={{ width: 14, height: 14, color: "var(--text-subtle)" }} />
|
||||
<input
|
||||
placeholder={t("tools.search_placeholder", "Filter tools…")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={inputResetStyle}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="input"
|
||||
style={{ width: 200 }}
|
||||
value={filterScope}
|
||||
onChange={(e) => setFilterScope(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("tools.scope_filter_all", "All scopes")} ({tools.length})
|
||||
</option>
|
||||
{allScopes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`tier.${s.replace(":", "_")}`, s)} ({scopeCounts.get(s)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.isLoading ? (
|
||||
<Card>
|
||||
<div className="card-body">
|
||||
<div className="shimmer" style={{ height: 28, borderRadius: 4, marginBottom: 8 }} />
|
||||
<div className="shimmer" style={{ height: 28, borderRadius: 4, marginBottom: 8 }} />
|
||||
<div className="shimmer" style={{ height: 28, borderRadius: 4 }} />
|
||||
</div>
|
||||
</Card>
|
||||
) : tools.length === 0 ? (
|
||||
<Card>
|
||||
<div className="card-body" style={{ textAlign: "center", padding: 32 }}>
|
||||
<div className="caption">
|
||||
{t("tools.empty", "This plugin doesn't expose any tools yet.")}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{supportsProviderKeys ? (
|
||||
<ProviderKeysCard
|
||||
siteId={siteId}
|
||||
configuredProviders={configuredProviders}
|
||||
defaultModels={defaultModels}
|
||||
inputs={providerInputs}
|
||||
pendingProvider={pendingProvider}
|
||||
pendingModel={pendingModel}
|
||||
setInput={(provider, value) =>
|
||||
setProviderInputs((prev) => ({ ...prev, [provider]: value }))
|
||||
}
|
||||
onSave={onSaveProvider}
|
||||
onRemove={onRemoveProvider}
|
||||
onSetDefaultModel={onSetDefaultModel}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
{scopePresets.length > 0 ? (
|
||||
<Card>
|
||||
<CardHead
|
||||
icon="shield"
|
||||
title={t("connect.tool_access", "Tool Access")}
|
||||
subtitle={t(
|
||||
"tools.preset_subtitle",
|
||||
"Choose a service preset or Custom, then fine-tune individual tools below.",
|
||||
)}
|
||||
/>
|
||||
<div style={presetGridStyle}>
|
||||
{scopePresets.map((preset) => {
|
||||
const active = preset.value === currentScope;
|
||||
const pending = updateScope.isPending && updateScope.variables?.scope === preset.value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={preset.value}
|
||||
disabled={updateScope.isPending}
|
||||
onClick={() => onScopePick(preset.value)}
|
||||
style={presetTileStyle(active, preset)}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{lang === "fa" && preset.label_fa ? preset.label_fa : preset.label}
|
||||
</span>
|
||||
{active ? (
|
||||
<Icons.check style={{ width: 14, height: 14, color: "var(--brand-400)" }} />
|
||||
) : pending ? (
|
||||
<span className="caption">...</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 4 }}>
|
||||
{lang === "fa" && preset.hint_fa ? preset.hint_fa : preset.hint}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
<ToolReadinessCard
|
||||
warning={warning}
|
||||
guide={guide}
|
||||
capability={capabilities.data}
|
||||
isCapabilityLoading={capabilities.isLoading}
|
||||
t={t}
|
||||
/>
|
||||
{grouped.map((group) => (
|
||||
<Card key={group.scope}>
|
||||
<CardHead
|
||||
icon="shield"
|
||||
title={t(`tier.${group.scope.replace(":", "_")}`, group.scope)}
|
||||
subtitle={
|
||||
t("tools.group_subtitle", "{n} tool(s) in this tier").replace(
|
||||
"{n}",
|
||||
String(group.tools.length),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
{group.tools.map((tool) => {
|
||||
const isPending = pendingTool === tool.name;
|
||||
const disabled = !tool.available || isPending;
|
||||
return (
|
||||
<div key={tool.name} style={rowStyle}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: tool.enabled ? "var(--text)" : "var(--text-muted)",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{tool.name}
|
||||
</div>
|
||||
{tool.description ? (
|
||||
<div
|
||||
className="caption"
|
||||
style={{ marginTop: 4, opacity: tool.enabled ? 1 : 0.6 }}
|
||||
>
|
||||
{tool.description}
|
||||
</div>
|
||||
) : null}
|
||||
{!tool.available && tool.unavailable_reason ? (
|
||||
<div
|
||||
className="caption"
|
||||
style={{ marginTop: 4, color: "var(--warning)" }}
|
||||
>
|
||||
{t("tools.unavailable", "Unavailable")}:{" "}
|
||||
{unavailableReasonMeta(tool.unavailable_reason, t)?.detail ?? tool.unavailable_reason}
|
||||
</div>
|
||||
) : null}
|
||||
{tool.provider_key_required && !tool.provider_key_configured ? (
|
||||
<div
|
||||
className="caption"
|
||||
style={{ marginTop: 4, color: "var(--warning)" }}
|
||||
>
|
||||
{t(
|
||||
"tools.needs_provider_key",
|
||||
"Needs an AI provider key — configure one above.",
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{!tool.available && tool.unavailable_reason ? (
|
||||
<div style={reasonRowStyle}>
|
||||
<ReasonBadge reason={tool.unavailable_reason} t={t} />
|
||||
{tool.unavailable_reason === "provider_key" && supportsProviderKeys ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("site-provider-keys")
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
>
|
||||
{t("tools.configure_provider_key", "Configure key")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||||
{tool.sensitivity ? (
|
||||
<Badge
|
||||
variant={tool.sensitivity === "destructive" ? "danger" : "warning"}
|
||||
className="badge-fixed"
|
||||
title={tool.sensitivity}
|
||||
>
|
||||
{t(`tools.sensitivity.${tool.sensitivity}`, tool.sensitivity)}
|
||||
</Badge>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`switch ${tool.enabled ? "on" : ""}`}
|
||||
disabled={disabled}
|
||||
aria-label={tool.name}
|
||||
aria-pressed={tool.enabled}
|
||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||
onClick={() => onToggle(tool.name, !tool.enabled)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolReadinessCard({
|
||||
warning,
|
||||
guide,
|
||||
capability,
|
||||
isCapabilityLoading,
|
||||
t,
|
||||
}: {
|
||||
warning: ReturnType<typeof tierWarning>;
|
||||
guide: ReturnType<typeof credentialGuide>;
|
||||
capability?: SiteCapabilityProbe;
|
||||
isCapabilityLoading: boolean;
|
||||
t: ReturnType<typeof useT>;
|
||||
}) {
|
||||
const fit = capability?.fit;
|
||||
const fitStatus = fit?.status;
|
||||
const aiProviders = capability?.ai_providers_configured ?? [];
|
||||
return (
|
||||
<Card>
|
||||
<CardHead
|
||||
icon="shield"
|
||||
title={t("tools.readiness_title", "Service readiness")}
|
||||
subtitle={t(
|
||||
"tools.readiness_subtitle",
|
||||
"Credential and health checks determine which tools are exposed to MCP clients.",
|
||||
)}
|
||||
/>
|
||||
<div style={readinessBodyStyle}>
|
||||
{warning ? (
|
||||
<div className={`alert ${warning.severity === "danger" ? "alert-danger" : "alert-warning"}`} style={readinessAlertStyle}>
|
||||
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0, marginTop: 2 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{warning.title}</div>
|
||||
<div className="caption" style={{ color: "inherit", marginTop: 4 }}>{warning.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{guide ? (
|
||||
<div className="alert alert-info" style={readinessAlertStyle}>
|
||||
<Icons.key style={{ width: 16, height: 16, flexShrink: 0, marginTop: 2 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{guide.title}</div>
|
||||
<div className="caption" style={{ color: "inherit", marginTop: 4 }}>{guide.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={capabilitySummaryStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={{ fontWeight: 600 }}>{t("tools.capability_status", "Capability check")}</span>
|
||||
{isCapabilityLoading ? (
|
||||
<Badge className="badge-fixed">{t("loading", "Loading...")}</Badge>
|
||||
) : fitStatus === "ok" ? (
|
||||
<Badge variant="success" dot className="badge-fixed">
|
||||
{t("tools.capability_ok", "credential fits selected tier")}
|
||||
</Badge>
|
||||
) : fitStatus === "warning" ? (
|
||||
<Badge variant="warning" dot className="badge-fixed">
|
||||
{t("tools.capability_warning", "credential below selected tier")}
|
||||
</Badge>
|
||||
) : fitStatus === "probe_unavailable" ? (
|
||||
<Badge variant="default" className="badge-fixed">
|
||||
{t("tools.capability_unavailable", "probe unavailable")}
|
||||
</Badge>
|
||||
) : fitStatus === "unknown_tier" ? (
|
||||
<Badge variant="info" className="badge-fixed">
|
||||
{t("tools.capability_unknown_tier", "tier not probed")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="badge-fixed">{t("status_unknown", "unknown")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!isCapabilityLoading && fitStatus === "warning" && (fit?.missing ?? []).length > 0 ? (
|
||||
<div className="caption" style={{ marginTop: 6 }}>
|
||||
{t("tools.capability_missing", "Missing")}:{" "}
|
||||
<span className="mono">{(fit?.missing ?? []).join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!isCapabilityLoading && fitStatus === "probe_unavailable" ? (
|
||||
<div className="caption" style={{ marginTop: 6 }}>
|
||||
{t("tools.capability_probe_reason", "Reason")}:{" "}
|
||||
<span className="mono">{fit?.reason ?? capability?.reason ?? "probe_unavailable"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!isCapabilityLoading ? (
|
||||
<div className="caption" style={{ marginTop: 6 }}>
|
||||
{t("tools.capability_ai_providers", "Configured AI providers")}:{" "}
|
||||
<span className="mono">
|
||||
{aiProviders.length > 0 ? aiProviders.join(", ") : t("providers.status_unset", "Unset")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasonBadge({
|
||||
reason,
|
||||
t,
|
||||
}: {
|
||||
reason: string | null;
|
||||
t: ReturnType<typeof useT>;
|
||||
}) {
|
||||
const meta = unavailableReasonMeta(reason, t);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<Badge variant={meta.variant} className="badge-fixed" title={meta.detail}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const PROVIDERS = [
|
||||
{ id: "openai", label: "OpenAI" },
|
||||
{ id: "stability", label: "Stability AI" },
|
||||
{ id: "replicate", label: "Replicate" },
|
||||
{ id: "openrouter", label: "OpenRouter" },
|
||||
];
|
||||
|
||||
function ProviderKeysCard({
|
||||
siteId,
|
||||
configuredProviders,
|
||||
defaultModels,
|
||||
inputs,
|
||||
pendingProvider,
|
||||
pendingModel,
|
||||
setInput,
|
||||
onSave,
|
||||
onRemove,
|
||||
onSetDefaultModel,
|
||||
t,
|
||||
}: {
|
||||
siteId: string;
|
||||
configuredProviders: Set<string>;
|
||||
defaultModels: Record<string, string | null>;
|
||||
inputs: Record<string, string>;
|
||||
pendingProvider: string | null;
|
||||
pendingModel: string | null;
|
||||
setInput: (provider: string, value: string) => void;
|
||||
onSave: (provider: string) => void;
|
||||
onRemove: (provider: string) => void;
|
||||
onSetDefaultModel: (provider: string, model: string | null) => void;
|
||||
t: ReturnType<typeof useT>;
|
||||
}) {
|
||||
const openRouterIsSet = configuredProviders.has("openrouter");
|
||||
const openRouterModels = useOpenRouterImageModels(siteId, openRouterIsSet);
|
||||
const currentDefault = defaultModels.openrouter ?? null;
|
||||
|
||||
return (
|
||||
<Card id="site-provider-keys">
|
||||
<CardHead
|
||||
icon="spark"
|
||||
title={t("providers.title", "AI Image Generation")}
|
||||
subtitle={t(
|
||||
"providers.subtitle",
|
||||
"Store provider API keys for this service. Image generation tools stay unavailable until a provider key is set and the service connection is healthy.",
|
||||
)}
|
||||
/>
|
||||
<div style={providerGridStyle}>
|
||||
{PROVIDERS.map((provider) => {
|
||||
const isSet = configuredProviders.has(provider.id);
|
||||
const pending = pendingProvider === provider.id;
|
||||
return (
|
||||
<div key={provider.id} style={providerRowStyle} data-provider-row={provider.id}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontWeight: 500 }}>{provider.label}</span>
|
||||
<Badge variant={isSet ? "success" : "default"} className="badge-fixed">
|
||||
{isSet ? t("providers.status_set", "Set") : t("providers.status_unset", "Unset")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="caption" style={{ marginTop: 4 }}>
|
||||
{t(
|
||||
`providers.hint.${provider.id}`,
|
||||
provider.id === "openrouter"
|
||||
? "Supports image-capable OpenRouter models. Save a key, then choose a default model for this service."
|
||||
: "Save a new key to replace the stored value.",
|
||||
)}
|
||||
</div>
|
||||
{provider.id === "openrouter" && isSet ? (
|
||||
<OpenRouterModelPicker
|
||||
models={openRouterModels.data ?? []}
|
||||
isLoading={openRouterModels.isLoading}
|
||||
isError={openRouterModels.isError}
|
||||
currentDefault={currentDefault}
|
||||
pendingModel={pendingModel}
|
||||
onSetDefault={(model) => onSetDefaultModel("openrouter", model)}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={providerActionsStyle}>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={inputs[provider.id] ?? ""}
|
||||
onChange={(e) => setInput(provider.id, e.target.value)}
|
||||
placeholder={t("providers.new_key_placeholder", "New API key")}
|
||||
aria-label={`${provider.label} ${t("providers.new_key_placeholder", "New API key")}`}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={pending || !(inputs[provider.id] ?? "").trim()}
|
||||
onClick={() => onSave(provider.id)}
|
||||
>
|
||||
{pending ? "..." : t("action.save", "Save")}
|
||||
</button>
|
||||
{isSet ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={pending}
|
||||
onClick={() => onRemove(provider.id)}
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
{t("providers.remove", "Remove")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="caption" style={{ padding: "0 20px 20px", color: "var(--text-subtle)" }}>
|
||||
{t(
|
||||
"providers.encrypted_note",
|
||||
"Keys are encrypted at rest and scoped to this service only.",
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenRouterModelPicker({
|
||||
models,
|
||||
isLoading,
|
||||
isError,
|
||||
currentDefault,
|
||||
pendingModel,
|
||||
onSetDefault,
|
||||
t,
|
||||
}: {
|
||||
models: import("../lib/queries").OpenRouterImageModel[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
currentDefault: string | null;
|
||||
pendingModel: string | null;
|
||||
onSetDefault: (model: string | null) => void;
|
||||
t: ReturnType<typeof useT>;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(currentDefault ?? "");
|
||||
const active = selected || currentDefault || "";
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="caption" style={{ marginTop: 8 }}>{t("providers.model.loading", "Loading image models…")}</div>;
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="caption" style={{ marginTop: 8, color: "var(--warning)" }}>
|
||||
{t("providers.model.failed", "Could not load OpenRouter image models. The tool remains disabled if the provider connection is not healthy.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<div className="caption" style={{ marginTop: 8, color: "var(--warning)" }}>
|
||||
{t("providers.model.empty", "No image-capable OpenRouter models were found for this key.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedModel = models.find((m) => m.id === active);
|
||||
const price =
|
||||
typeof selectedModel?.price_per_image_usd === "number"
|
||||
? `$${selectedModel.price_per_image_usd.toFixed(3)}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div style={modelPickerStyle}>
|
||||
<label className="field" style={{ margin: 0, flex: "1 1 260px" }}>
|
||||
<span>{t("providers.model.default_label", "Default image model")}</span>
|
||||
<select className="input" value={active} onChange={(e) => setSelected(e.target.value)}>
|
||||
<option value="">{t("providers.model.select", "Select a model")}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!selected || selected === currentDefault || pendingModel === selected}
|
||||
onClick={() => onSetDefault(selected)}
|
||||
>
|
||||
{pendingModel === selected ? "..." : t("providers.model.set_default", "Set default")}
|
||||
</button>
|
||||
{currentDefault ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={pendingModel === "openrouter:clear"}
|
||||
onClick={() => onSetDefault(null)}
|
||||
>
|
||||
{pendingModel === "openrouter:clear" ? "..." : t("providers.model.clear", "Clear")}
|
||||
</button>
|
||||
) : null}
|
||||
{currentDefault ? (
|
||||
<div className="caption" style={{ flexBasis: "100%" }}>
|
||||
{t("providers.model.current", "Current default: {model}").replace("{model}", currentDefault)}
|
||||
{price ? ` · ${price}` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const searchBoxStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 10px",
|
||||
flex: 1,
|
||||
minWidth: 220,
|
||||
background: "var(--bg-sunken)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const providerGridStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "4px 20px 12px",
|
||||
};
|
||||
|
||||
const providerRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 14,
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "14px 0",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
};
|
||||
|
||||
const providerActionsStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-end",
|
||||
};
|
||||
|
||||
const modelPickerStyle: CSSProperties = {
|
||||
marginTop: 10,
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
const inputResetStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text)",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
const rowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 14,
|
||||
alignItems: "center",
|
||||
padding: "14px 20px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
};
|
||||
|
||||
const reasonRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
marginTop: 8,
|
||||
};
|
||||
|
||||
const readinessBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
padding: 20,
|
||||
};
|
||||
|
||||
const readinessAlertStyle: CSSProperties = {
|
||||
alignItems: "flex-start",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const capabilitySummaryStyle: CSSProperties = {
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "12px 14px",
|
||||
background: "var(--bg-sunken)",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const presetGridStyle: CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(190px, 1fr))",
|
||||
gap: 8,
|
||||
padding: 20,
|
||||
};
|
||||
|
||||
function presetTileStyle(active: boolean, preset: ScopePreset): CSSProperties {
|
||||
const accent = preset.value === "admin" ? "var(--danger)" : "var(--brand-400)";
|
||||
return {
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
background: active ? "var(--surface)" : "var(--bg-sunken)",
|
||||
border: `1px solid ${active ? accent : "var(--border)"}`,
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
color: "var(--text)",
|
||||
fontSize: 13,
|
||||
};
|
||||
}
|
||||
681
web/src/pages/Sites.tsx
Normal file
681
web/src/pages/Sites.tsx
Normal file
@@ -0,0 +1,681 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Topbar } from "../components/Topbar";
|
||||
import { Card, Badge, Btn, Seg, EmptyState } from "../components/primitives";
|
||||
import { Icons } from "../components/icons";
|
||||
import { SiteFormDialog } from "../components/SiteFormDialog";
|
||||
import { useDeleteSite, useSiteLimit, useSites, useTestSite } from "../lib/queries";
|
||||
import { useUiStore } from "../lib/store";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { fmtDateTime, fmtInt, normalizeSiteStatus } from "../lib/format";
|
||||
import type { Lang } from "../lib/store";
|
||||
import type { Site } from "../lib/types";
|
||||
|
||||
// Map tool_scope tier → Badge variant. Mirrors the F.19.2.3 destructive-tier
|
||||
// treatment (install = amber, admin = red, everything else = default).
|
||||
function tierBadgeVariant(scope: string | undefined): "default" | "warning" | "danger" {
|
||||
if (scope === "admin") return "danger";
|
||||
if (scope === "install" || scope === "read:sensitive") return "warning";
|
||||
return "default";
|
||||
}
|
||||
|
||||
// Friendly display name for each plugin type. Mirrors the lookup the
|
||||
// legacy Jinja list.html does via `plugin_names.get(...)`.
|
||||
const PLUGIN_DISPLAY: Record<string, string> = {
|
||||
wordpress: "WordPress",
|
||||
woocommerce: "WooCommerce",
|
||||
wordpress_specialist: "WordPress (specialist)",
|
||||
gitea: "Gitea",
|
||||
n8n: "n8n",
|
||||
supabase: "Supabase",
|
||||
openpanel: "OpenPanel",
|
||||
appwrite: "Appwrite",
|
||||
directus: "Directus",
|
||||
coolify: "Coolify",
|
||||
};
|
||||
|
||||
function formatTested(
|
||||
iso: string | null | undefined,
|
||||
lang: Lang,
|
||||
t: (k: string, fb?: string) => string,
|
||||
): string {
|
||||
if (!iso) return t("never_tested", "Never tested");
|
||||
return `${t("last_tested", "Last tested")}: ${fmtDateTime(iso, lang)}`;
|
||||
}
|
||||
|
||||
function emptyCopy(
|
||||
filter: "all" | "healthy" | "untested",
|
||||
search: string,
|
||||
t: (k: string, fb?: string) => string,
|
||||
): { title: string; body: string; showAction: boolean } {
|
||||
if (search.trim()) {
|
||||
return {
|
||||
title: t("sites.empty_search_title", "No sites match this search"),
|
||||
body: t("sites.empty_search_body", "Try a different alias or clear the search field."),
|
||||
showAction: false,
|
||||
};
|
||||
}
|
||||
if (filter === "healthy") {
|
||||
return {
|
||||
title: t("sites.empty_healthy_title", "No healthy sites"),
|
||||
body: t("sites.empty_healthy_body", "Run a connection test or clear the filter to see every site."),
|
||||
showAction: false,
|
||||
};
|
||||
}
|
||||
if (filter === "untested") {
|
||||
return {
|
||||
title: t("sites.empty_untested_title", "No untested sites"),
|
||||
body: t("sites.empty_untested_body", "Every site has been tested. Clear the filter to see all sites."),
|
||||
showAction: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: t("no_sites", "No sites yet"),
|
||||
body: t(
|
||||
"sites.empty_body",
|
||||
"Register a Coolify project, WordPress site, or other supported plugin so your AI clients can use it.",
|
||||
),
|
||||
showAction: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function SitesPage() {
|
||||
const t = useT();
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
const [search, setSearch] = useState("");
|
||||
// Track in-flight test + delete per site so each row can render its own
|
||||
// pending state without a global spinner.
|
||||
const [pendingTestId, setPendingTestId] = useState<string | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [dialog, setDialog] = useState<
|
||||
| { mode: "create" }
|
||||
| { mode: "edit"; site: Site }
|
||||
| null
|
||||
>(null);
|
||||
const sites = useSites();
|
||||
const siteLimit = useSiteLimit();
|
||||
const testSite = useTestSite();
|
||||
const deleteSite = useDeleteSite();
|
||||
const setToast = useUiStore((s) => s.setToast);
|
||||
const lang = useUiStore((s) => s.lang);
|
||||
const limit = siteLimit.data?.limit;
|
||||
const remaining = siteLimit.data?.remaining;
|
||||
const atSiteLimit = typeof remaining === "number" && remaining <= 0;
|
||||
const siteLimitMessage =
|
||||
typeof limit === "number"
|
||||
? t(
|
||||
"sites.limit_reached_body",
|
||||
"You have reached the maximum of {limit} services for this account. Delete an existing service or ask an administrator to raise the limit.",
|
||||
).replace("{limit}", fmtInt(limit, lang))
|
||||
: t(
|
||||
"sites.limit_reached_body_unknown",
|
||||
"You have reached the maximum number of services for this account. Delete an existing service or ask an administrator to raise the limit.",
|
||||
);
|
||||
|
||||
// Surface legacy flash params and support `?create=1` deep links from the
|
||||
// onboarding flow, then strip them from the URL so a refresh doesn't replay
|
||||
// the same action.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const msg = params.get("msg");
|
||||
const err = params.get("error");
|
||||
const openCreate = params.get("create") === "1";
|
||||
if (msg || err) {
|
||||
setToast(
|
||||
err === "site_not_found"
|
||||
? t("site_not_found", "Site not found")
|
||||
: err
|
||||
? `${t("error", "Error")}: ${err}`
|
||||
: msg!,
|
||||
);
|
||||
}
|
||||
if (openCreate && !atSiteLimit) {
|
||||
setDialog((current) => current ?? { mode: "create" });
|
||||
}
|
||||
if (msg || err || openCreate) {
|
||||
params.delete("msg");
|
||||
params.delete("error");
|
||||
params.delete("create");
|
||||
const qs = params.toString();
|
||||
const next = window.location.pathname + (qs ? `?${qs}` : "");
|
||||
window.history.replaceState({}, "", next);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [atSiteLimit]);
|
||||
|
||||
const filtered = (sites.data ?? []).filter((s) => {
|
||||
if (search && !s.alias.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
const empty = emptyCopy("all", search, t);
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
setPendingTestId(id);
|
||||
try {
|
||||
const r = await testSite.mutateAsync(id);
|
||||
// Backend signals success via either `ok: true` or `status: "active"`
|
||||
// (sometimes `"healthy"` from older code paths). Trust `ok` first; fall
|
||||
// back to the status string so we stay tolerant if the contract drifts.
|
||||
const ok = r.ok === true || r.status === "active" || r.status === "healthy" || r.status === "ok";
|
||||
setToast(
|
||||
ok
|
||||
? `${t("connection_ok", "Connection OK")}${r.response_time != null ? ` (${r.response_time}ms)` : ""}`
|
||||
: `${t("connection_failed", "Connection failed")}${r.message ? `: ${r.message}` : ""}`,
|
||||
);
|
||||
sites.refetch();
|
||||
} catch (e: any) {
|
||||
setToast(`${t("connection_failed", "Connection failed")}: ${e.message}`);
|
||||
} finally {
|
||||
setPendingTestId(null);
|
||||
}
|
||||
};
|
||||
const onDelete = async (s: Site) => {
|
||||
const confirmMsg = `${t("delete", "Delete")} "${s.alias}"?\n\n${
|
||||
s.url ?? ""
|
||||
}\n\nThis cannot be undone.`;
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
setPendingDeleteId(s.id);
|
||||
try {
|
||||
await deleteSite.mutateAsync(s.id);
|
||||
setToast(t("site_deleted", "Site deleted"));
|
||||
} catch (e: any) {
|
||||
setToast(`${t("delete", "Delete")} ${t("error", "error")}: ${e.message}`);
|
||||
} finally {
|
||||
setPendingDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
crumbs={[t("workspace", "Workspace"), t("my_sites", "Sites")]}
|
||||
actions={
|
||||
<>
|
||||
<Seg
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ value: "grid", label: t("view.grid", "Grid") },
|
||||
{ value: "list", label: t("view.list", "List") },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={atSiteLimit}
|
||||
title={atSiteLimit ? t("max_sites_reached", "Maximum sites reached") : undefined}
|
||||
onClick={() => {
|
||||
if (!atSiteLimit) setDialog({ mode: "create" });
|
||||
}}
|
||||
>
|
||||
<Icons.plus style={{ width: 12, height: 12 }} /> {t("add_site_short", "Add site")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="page-pad">
|
||||
<div className="page-head page-head-split">
|
||||
<div className="page-head-text">
|
||||
<h1 className="h-1" style={{ margin: 0 }}>
|
||||
{t("my_sites", "Sites")}
|
||||
</h1>
|
||||
<div className="body" style={{ color: "var(--text-muted)", marginTop: 6 }}>
|
||||
{t(
|
||||
"sites.intro",
|
||||
"Every site your AI agents can see. Capabilities are scoped per site and per key.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<div style={searchBoxStyle}>
|
||||
<Icons.search style={{ width: 14, height: 14, color: "var(--text-subtle)" }} />
|
||||
<input
|
||||
placeholder={`${t("search", "Search")}…`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ flex: 1, background: "none", border: "none", color: "var(--text)", outline: "none" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{atSiteLimit ? (
|
||||
<div className="alert alert-warning" style={{ marginBottom: 16 }}>
|
||||
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 500, color: "var(--text)", marginBottom: 2 }}>
|
||||
{t("max_sites_reached", "Maximum sites reached")}
|
||||
</div>
|
||||
<div className="caption">{siteLimitMessage}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sites.isLoading ? (
|
||||
<SitesSkeleton view={view} />
|
||||
) : filtered.length === 0 ? (
|
||||
<Card className="sites-list-card">
|
||||
<EmptyState
|
||||
icon="sites"
|
||||
title={empty.title}
|
||||
action={
|
||||
empty.showAction ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={atSiteLimit}
|
||||
onClick={() => {
|
||||
if (!atSiteLimit) setDialog({ mode: "create" });
|
||||
}}
|
||||
>
|
||||
<Icons.plus style={{ width: 14, height: 14 }} />{" "}
|
||||
{t("add_first_site", "Add your first site")}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{empty.body}
|
||||
</EmptyState>
|
||||
</Card>
|
||||
) : view === "grid" ? (
|
||||
<div style={gridStyle}>
|
||||
{filtered.map((s) => (
|
||||
<SiteCard
|
||||
key={s.id}
|
||||
site={s}
|
||||
t={t}
|
||||
lang={lang}
|
||||
pendingTest={pendingTestId === s.id}
|
||||
pendingDelete={pendingDeleteId === s.id}
|
||||
onTest={() => onTest(s.id)}
|
||||
onDelete={() => onDelete(s)}
|
||||
onEdit={() => setDialog({ mode: "edit", site: s })}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="tile"
|
||||
style={addTileStyle}
|
||||
disabled={atSiteLimit}
|
||||
onClick={() => {
|
||||
if (!atSiteLimit) setDialog({ mode: "create" });
|
||||
}}
|
||||
>
|
||||
<div style={addTileIconStyle}>
|
||||
<Icons.plus style={{ width: 18, height: 18 }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: "var(--text)" }}>
|
||||
{t("sites.add_tile_title", "Add a site")}
|
||||
</div>
|
||||
<div className="caption" style={{ textAlign: "center", maxWidth: 180 }}>
|
||||
{t(
|
||||
"sites.add_tile_desc",
|
||||
"Register a Coolify project, WordPress site, or other plugin",
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<table className="table mobile-stack">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("site_alias", "Alias")}</th>
|
||||
<th>{t("plugin_type", "Plugin Type")}</th>
|
||||
<th>{t("site_url", "Site URL")}</th>
|
||||
<th>{t("status", "Status")}</th>
|
||||
<th>{t("table.tier", "Tier")}</th>
|
||||
<th>{t("last_tested", "Last tested")}</th>
|
||||
<th style={{ textAlign: "right" }}>{t("actions", "Actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s) => (
|
||||
<SiteRow
|
||||
key={s.id}
|
||||
site={s}
|
||||
t={t}
|
||||
lang={lang}
|
||||
pendingTest={pendingTestId === s.id}
|
||||
pendingDelete={pendingDeleteId === s.id}
|
||||
onTest={() => onTest(s.id)}
|
||||
onDelete={() => onDelete(s)}
|
||||
onEdit={() => setDialog({ mode: "edit", site: s })}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dialog ? (
|
||||
<SiteFormDialog
|
||||
mode={dialog.mode}
|
||||
site={dialog.mode === "edit" ? dialog.site : undefined}
|
||||
onCancel={() => setDialog(null)}
|
||||
onDone={() => setDialog(null)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Cards & rows ----------
|
||||
|
||||
type CardProps = {
|
||||
site: Site;
|
||||
t: ReturnType<typeof useT>;
|
||||
lang: Lang;
|
||||
onEdit?: () => void;
|
||||
pendingTest: boolean;
|
||||
pendingDelete: boolean;
|
||||
onTest: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function StatusBadge({ site, t }: { site: Site; t: ReturnType<typeof useT> }) {
|
||||
const status = normalizeSiteStatus(site.status, site.last_tested_at);
|
||||
if (status === "healthy")
|
||||
return (
|
||||
<Badge variant="success" dot className="badge-fixed">
|
||||
{t("status_healthy", "healthy")}
|
||||
</Badge>
|
||||
);
|
||||
if (status === "warning")
|
||||
return (
|
||||
<Badge variant="warning" dot className="badge-fixed">
|
||||
{t("status_warning", "warning")}
|
||||
</Badge>
|
||||
);
|
||||
if (status === "error")
|
||||
return (
|
||||
<Badge variant="danger" dot className="badge-fixed">
|
||||
{t("status_error", "error")}
|
||||
</Badge>
|
||||
);
|
||||
if (status === "unknown")
|
||||
return (
|
||||
<Badge variant="warning" className="badge-fixed">
|
||||
{t("status_unknown", "unknown")}
|
||||
</Badge>
|
||||
);
|
||||
return <Badge className="badge-fixed">{t("status_untested", "untested")}</Badge>;
|
||||
}
|
||||
|
||||
function TierBadge({ scope }: { scope: string | undefined }) {
|
||||
if (!scope) return null;
|
||||
return (
|
||||
<Badge variant={tierBadgeVariant(scope)} className="badge-fixed" title={`tool_scope: ${scope}`}>
|
||||
{scope}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteCard({ site: s, t, lang, pendingTest, pendingDelete, onTest, onDelete, onEdit }: CardProps) {
|
||||
const pluginLabel = PLUGIN_DISPLAY[s.plugin_type] ?? s.plugin_type;
|
||||
return (
|
||||
<div className="tile" style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 14, gap: 8 }}>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center", minWidth: 0 }}>
|
||||
<div style={iconBoxStyle}>
|
||||
<Icons.server style={{ width: 16, height: 16, color: "var(--brand-400)" }} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{s.alias}
|
||||
</div>
|
||||
<div className="caption">{pluginLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4 }}>
|
||||
<StatusBadge site={s} t={t} />
|
||||
<TierBadge scope={s.tool_scope} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="caption" style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{s.url || "—"}
|
||||
</div>
|
||||
{s.status_msg ? (
|
||||
<div
|
||||
className="caption"
|
||||
style={{ marginTop: 6, color: s.status === "error" ? "var(--danger)" : "var(--text-muted)" }}
|
||||
>
|
||||
{s.status_msg.slice(0, 80)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="caption" style={{ marginTop: 6, color: "var(--text-subtle)" }}>
|
||||
{formatTested(s.last_tested_at, lang, t)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 14 }}>
|
||||
<Link
|
||||
to={`/connect?site=${encodeURIComponent(s.alias)}`}
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ flex: 1, textAlign: "center" }}
|
||||
>
|
||||
{t("connect", "Connect")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/sites/${s.id}/tools`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: 6 }}
|
||||
title={t("sites.manage_tools", "Manage tools")}
|
||||
>
|
||||
<Icons.shield style={{ width: 14, height: 14 }} />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: 6 }}
|
||||
onClick={onEdit}
|
||||
title={t("edit", "Edit")}
|
||||
>
|
||||
<Icons.edit style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: 6 }}
|
||||
onClick={onTest}
|
||||
disabled={pendingTest}
|
||||
title={t("test_connection", "Test connection")}
|
||||
>
|
||||
{pendingTest ? <Spinner /> : <Icons.refresh style={{ width: 14, height: 14 }} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: 6, color: "var(--danger)" }}
|
||||
onClick={onDelete}
|
||||
disabled={pendingDelete}
|
||||
title={t("delete", "Delete")}
|
||||
>
|
||||
{pendingDelete ? <Spinner /> : <Icons.trash style={{ width: 14, height: 14 }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteRow({ site: s, t, lang, pendingTest, pendingDelete, onTest, onDelete, onEdit }: CardProps) {
|
||||
const pluginLabel = PLUGIN_DISPLAY[s.plugin_type] ?? s.plugin_type;
|
||||
return (
|
||||
<tr>
|
||||
<td className="row-head" data-label={t("site_alias", "Alias")} style={{ fontWeight: 500 }}>
|
||||
{s.alias}
|
||||
</td>
|
||||
<td data-label={t("plugin_type", "Plugin Type")}>
|
||||
<Badge>{pluginLabel}</Badge>
|
||||
</td>
|
||||
<td className="mono caption sites-url-cell" data-label={t("site_url", "Site URL")}>
|
||||
{s.url}
|
||||
</td>
|
||||
<td data-label={t("status", "Status")}>
|
||||
<StatusBadge site={s} t={t} />
|
||||
</td>
|
||||
<td data-label={t("table.tier", "Tier")}>
|
||||
<TierBadge scope={s.tool_scope} />
|
||||
</td>
|
||||
<td className="caption" data-label={t("last_tested", "Last tested")}>
|
||||
{fmtDateTime(s.last_tested_at, lang)}
|
||||
</td>
|
||||
<td className="cell-actions" style={{ textAlign: "right" }}>
|
||||
<div style={{ display: "inline-flex", gap: 4 }}>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onTest}
|
||||
disabled={pendingTest}
|
||||
title={t("test_connection", "Test")}
|
||||
style={{ padding: 6 }}
|
||||
>
|
||||
{pendingTest ? <Spinner /> : <Icons.refresh style={{ width: 14, height: 14 }} />}
|
||||
</Btn>
|
||||
<Link
|
||||
to={`/connect?site=${encodeURIComponent(s.alias)}`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: "6px 10px" }}
|
||||
title={t("connect", "Connect")}
|
||||
>
|
||||
{t("connect", "Connect")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/sites/${s.id}/tools`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: 6 }}
|
||||
title={t("sites.manage_tools", "Manage tools")}
|
||||
>
|
||||
<Icons.shield style={{ width: 14, height: 14 }} />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: 6 }}
|
||||
onClick={onEdit}
|
||||
title={t("edit", "Edit")}
|
||||
>
|
||||
<Icons.edit style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
disabled={pendingDelete}
|
||||
title={t("delete", "Delete")}
|
||||
style={{ padding: 6, color: "var(--danger)" }}
|
||||
>
|
||||
{pendingDelete ? <Spinner /> : <Icons.trash style={{ width: 14, height: 14 }} />}
|
||||
</Btn>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function SitesSkeleton({ view }: { view: "grid" | "list" }) {
|
||||
// Minimal animated placeholder so the empty space carries system status while
|
||||
// the request is in flight (replaces the bare "Loading sites…" caption).
|
||||
if (view === "list") {
|
||||
return (
|
||||
<Card>
|
||||
<div role="status" aria-live="polite" style={{ padding: 16 }}>
|
||||
<div className="caption" style={{ marginBottom: 12 }}>
|
||||
{/* visually hidden text for screen readers; keep visible text minimal */}
|
||||
Loading…
|
||||
</div>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="shimmer" style={skeletonRowStyle} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={gridStyle} role="status" aria-live="polite">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="tile shimmer" style={skeletonTileStyle} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<span
|
||||
aria-label="loading"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 14,
|
||||
height: 14,
|
||||
border: "2px solid var(--border)",
|
||||
borderTopColor: "var(--brand-400)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 0.8s linear infinite",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Style constants (lifted out of JSX so the markup is scannable) ----------
|
||||
|
||||
const searchBoxStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 10px",
|
||||
minWidth: 260,
|
||||
background: "var(--bg-sunken)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const gridStyle: CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
gap: 16,
|
||||
alignItems: "stretch",
|
||||
};
|
||||
|
||||
const iconBoxStyle: CSSProperties = {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
background: "var(--surface)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const addTileStyle: CSSProperties = {
|
||||
borderStyle: "dashed",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
color: "var(--text-muted)",
|
||||
};
|
||||
|
||||
const addTileIconStyle: CSSProperties = {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: "50%",
|
||||
background: "oklch(from var(--brand-500) l c h / 0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--brand-400)",
|
||||
};
|
||||
|
||||
const skeletonTileStyle: CSSProperties = {
|
||||
height: 160,
|
||||
};
|
||||
|
||||
const skeletonRowStyle: CSSProperties = {
|
||||
height: 32,
|
||||
marginBottom: 8,
|
||||
borderRadius: 6,
|
||||
};
|
||||
1020
web/src/styles/globals.css
Normal file
1020
web/src/styles/globals.css
Normal file
File diff suppressed because it is too large
Load Diff
70
web/src/styles/tokens.css
Normal file
70
web/src/styles/tokens.css
Normal file
@@ -0,0 +1,70 @@
|
||||
/* Design tokens — ported from claude.ai/design prototype.
|
||||
Dark-first; brand cyan-teal with warm amber accent.
|
||||
The hue is dynamic via --brand-hue so the (admin-only) Tweaks panel can change it. */
|
||||
|
||||
:root {
|
||||
--brand-hue: 205;
|
||||
|
||||
--brand-500: oklch(0.74 0.14 var(--brand-hue));
|
||||
--brand-400: oklch(0.80 0.13 var(--brand-hue));
|
||||
--brand-300: oklch(0.86 0.11 var(--brand-hue));
|
||||
--brand-600: oklch(0.66 0.15 var(--brand-hue));
|
||||
--brand-700: oklch(0.42 0.13 calc(var(--brand-hue) + 5));
|
||||
--brand-glow: oklch(0.74 0.14 var(--brand-hue) / 0.35);
|
||||
|
||||
--accent-500: oklch(0.78 0.15 75);
|
||||
--accent-600: oklch(0.70 0.16 65);
|
||||
|
||||
--success: oklch(0.74 0.15 155);
|
||||
--warning: oklch(0.80 0.15 85);
|
||||
--danger: oklch(0.66 0.19 25);
|
||||
--info: oklch(0.72 0.13 235);
|
||||
|
||||
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, "JetBrains Mono", monospace;
|
||||
--font-serif: "Instrument Serif", "Times New Roman", serif;
|
||||
--font-fa: "Vazirmatn", var(--font-sans);
|
||||
|
||||
--r-xs: 4px;
|
||||
--r-sm: 6px;
|
||||
--r-md: 10px;
|
||||
--r-lg: 14px;
|
||||
--r-xl: 20px;
|
||||
--r-2xl: 28px;
|
||||
|
||||
--density: 1;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--bg: oklch(0.14 0.012 250);
|
||||
--bg-elevated: oklch(0.17 0.014 250);
|
||||
--bg-sunken: oklch(0.11 0.012 250);
|
||||
--surface: oklch(0.20 0.014 250);
|
||||
--surface-2: oklch(0.24 0.015 250);
|
||||
--border: oklch(0.28 0.015 250);
|
||||
--border-strong: oklch(0.36 0.020 250);
|
||||
--text: oklch(0.97 0.005 250);
|
||||
--text-muted: oklch(0.72 0.012 250);
|
||||
--text-subtle: oklch(0.55 0.012 250);
|
||||
--shadow-1: 0 1px 0 oklch(1 0 0 / 0.04) inset, 0 1px 2px oklch(0 0 0 / 0.4);
|
||||
--shadow-2: 0 1px 0 oklch(1 0 0 / 0.05) inset, 0 8px 24px oklch(0 0 0 / 0.45);
|
||||
--shadow-pop: 0 1px 0 oklch(1 0 0 / 0.08) inset, 0 24px 60px oklch(0 0 0 / 0.55);
|
||||
--dot: oklch(0.22 0.014 250);
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--bg: oklch(0.985 0.003 250);
|
||||
--bg-elevated: oklch(1 0 0);
|
||||
--bg-sunken: oklch(0.965 0.004 250);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.97 0.004 250);
|
||||
--border: oklch(0.90 0.005 250);
|
||||
--border-strong: oklch(0.82 0.008 250);
|
||||
--text: oklch(0.18 0.012 250);
|
||||
--text-muted: oklch(0.42 0.012 250);
|
||||
--text-subtle: oklch(0.60 0.010 250);
|
||||
--shadow-1: 0 1px 0 oklch(1 0 0) inset, 0 1px 2px oklch(0.5 0 0 / 0.08);
|
||||
--shadow-2: 0 1px 0 oklch(1 0 0) inset, 0 8px 24px oklch(0.4 0 0 / 0.08);
|
||||
--shadow-pop: 0 1px 0 oklch(1 0 0) inset, 0 24px 60px oklch(0.3 0 0 / 0.15);
|
||||
--dot: oklch(0.93 0.005 250);
|
||||
}
|
||||
35
web/src/test/Logo.test.tsx
Normal file
35
web/src/test/Logo.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { Logo, LogoWordmark } from "../components/Logo";
|
||||
|
||||
describe("Logo", () => {
|
||||
it("renders the brand mark with default size", () => {
|
||||
const { container } = render(<Logo />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg).toHaveAttribute("width", "28");
|
||||
expect(svg).toHaveAttribute("aria-label", "MCP Hub");
|
||||
});
|
||||
|
||||
it("uses CSS vars for fills by default (theme-aware)", () => {
|
||||
const { container } = render(<Logo />);
|
||||
const paths = container.querySelectorAll("path");
|
||||
expect(paths).toHaveLength(2);
|
||||
expect(paths[0].getAttribute("fill")).toContain("var(--brand-500)");
|
||||
expect(paths[1].getAttribute("fill")).toContain("var(--accent-500)");
|
||||
});
|
||||
|
||||
it("falls back to original colors when original=true", () => {
|
||||
const { container } = render(<Logo original />);
|
||||
const paths = container.querySelectorAll("path");
|
||||
expect(paths[0].getAttribute("fill")).toBe("#51b9f4");
|
||||
expect(paths[1].getAttribute("fill")).toBe("#fec13d");
|
||||
});
|
||||
|
||||
it("LogoWordmark renders text and SVG", () => {
|
||||
const { getByText, container } = render(<LogoWordmark />);
|
||||
expect(getByText(/MCP/)).toBeInTheDocument();
|
||||
expect(getByText(/Hub/)).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
83
web/src/test/primitives.test.tsx
Normal file
83
web/src/test/primitives.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { Badge, Btn, Switch, Seg, Toast } from "../components/primitives";
|
||||
|
||||
describe("Badge", () => {
|
||||
it("renders children with variant class", () => {
|
||||
const { container } = render(<Badge variant="success">healthy</Badge>);
|
||||
const span = container.firstChild as HTMLElement;
|
||||
expect(span.className).toContain("badge");
|
||||
expect(span.className).toContain("badge-success");
|
||||
expect(span.textContent).toBe("healthy");
|
||||
});
|
||||
|
||||
it("adds badge-dot class when dot=true", () => {
|
||||
const { container } = render(<Badge dot>x</Badge>);
|
||||
expect((container.firstChild as HTMLElement).className).toContain("badge-dot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Btn", () => {
|
||||
it("invokes onClick", () => {
|
||||
const handler = vi.fn();
|
||||
render(<Btn onClick={handler}>click</Btn>);
|
||||
fireEvent.click(screen.getByText("click"));
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects disabled", () => {
|
||||
const handler = vi.fn();
|
||||
render(
|
||||
<Btn onClick={handler} disabled>
|
||||
x
|
||||
</Btn>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("x"));
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Switch", () => {
|
||||
it("toggles via click", () => {
|
||||
const handler = vi.fn();
|
||||
const { container } = render(<Switch on={false} onChange={handler} />);
|
||||
fireEvent.click(container.firstChild as HTMLElement);
|
||||
expect(handler).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("reflects on state via class", () => {
|
||||
const { container } = render(<Switch on={true} onChange={() => {}} />);
|
||||
expect((container.firstChild as HTMLElement).className).toContain("on");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Seg", () => {
|
||||
it("highlights active option and switches on click", () => {
|
||||
const handler = vi.fn();
|
||||
render(
|
||||
<Seg
|
||||
value="a"
|
||||
onChange={handler}
|
||||
options={[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("A").className).toContain("is-active");
|
||||
fireEvent.click(screen.getByText("B"));
|
||||
expect(handler).toHaveBeenCalledWith("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toast", () => {
|
||||
it("renders msg", () => {
|
||||
render(<Toast msg="saved" onClose={() => {}} />);
|
||||
expect(screen.getByText("saved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing without msg", () => {
|
||||
const { container } = render(<Toast msg="" onClose={() => {}} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
89
web/src/test/role-visibility.test.tsx
Normal file
89
web/src/test/role-visibility.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sidebar } from "../components/Sidebar";
|
||||
import { SettingsPage } from "../pages/Settings";
|
||||
import type { Session } from "../lib/types";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
session: {
|
||||
authenticated: true,
|
||||
user_id: "admin",
|
||||
email: "admin@example.com",
|
||||
name: "Admin",
|
||||
role: "admin",
|
||||
type: "master",
|
||||
is_admin: true,
|
||||
lang: "en",
|
||||
} as Session,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/queries", () => ({
|
||||
useAdminApiKeys: () => ({ data: [] }),
|
||||
useManagedSettings: () => ({ data: [], isLoading: false }),
|
||||
useOAuthClients: () => ({ data: [] }),
|
||||
useResetSettings: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useSaveSetting: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useSession: () => ({ data: state.session }),
|
||||
useSites: () => ({ data: [] }),
|
||||
useTranslations: () => ({ data: undefined }),
|
||||
useUserKeys: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
function userSession(): Session {
|
||||
return {
|
||||
authenticated: true,
|
||||
user_id: "user",
|
||||
email: "user@example.com",
|
||||
name: "User",
|
||||
role: "user",
|
||||
type: "oauth_user",
|
||||
is_admin: false,
|
||||
lang: "en",
|
||||
};
|
||||
}
|
||||
|
||||
function renderSidebar(session: Session) {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Sidebar session={session} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("role-based dashboard visibility", () => {
|
||||
it("shows admin-only navigation for admins", () => {
|
||||
renderSidebar(state.session);
|
||||
|
||||
expect(screen.queryByText("OAuth Clients")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Health")).toBeInTheDocument();
|
||||
expect(screen.getByText("Audit Logs")).toBeInTheDocument();
|
||||
expect(screen.getByText("Support MCP Hub")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides admin-only navigation for normal users", () => {
|
||||
renderSidebar(userSession());
|
||||
|
||||
expect(screen.queryByText("OAuth Clients")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Health")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Audit Logs")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Support MCP Hub")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows settings limits, plugin visibility, and danger tabs only for admins", () => {
|
||||
state.session = { ...state.session, is_admin: true, role: "admin" };
|
||||
const admin = render(<SettingsPage />);
|
||||
|
||||
expect(screen.getByText("Limits")).toBeInTheDocument();
|
||||
expect(screen.getByText("Public plugin visibility")).toBeInTheDocument();
|
||||
expect(screen.getByText("Danger zone")).toBeInTheDocument();
|
||||
|
||||
admin.unmount();
|
||||
state.session = userSession();
|
||||
render(<SettingsPage />);
|
||||
|
||||
expect(screen.queryByText("Limits")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Public plugin visibility")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Danger zone")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
1
web/src/test/setup.ts
Normal file
1
web/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
62
web/src/test/site-form-ai.test.tsx
Normal file
62
web/src/test/site-form-ai.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SiteFormDialog } from "../components/SiteFormDialog";
|
||||
import type { Site } from "../lib/types";
|
||||
|
||||
vi.mock("../lib/queries", () => ({
|
||||
useCreateSite: () => ({ mutateAsync: vi.fn() }),
|
||||
usePluginCatalog: () => ({
|
||||
isLoading: false,
|
||||
data: [
|
||||
{
|
||||
type: "wordpress",
|
||||
name: "WordPress",
|
||||
fields: [
|
||||
{ name: "username", label: "Username", type: "text", required: true },
|
||||
{ name: "app_password", label: "Application Password", type: "password", required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "woocommerce",
|
||||
name: "WooCommerce",
|
||||
fields: [
|
||||
{ name: "consumer_key", label: "Consumer Key", type: "text", required: true },
|
||||
{ name: "consumer_secret", label: "Consumer Secret", type: "password", required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "coolify",
|
||||
name: "Coolify",
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
useTranslations: () => ({ data: undefined }),
|
||||
useUpdateSite: () => ({ mutateAsync: vi.fn() }),
|
||||
}));
|
||||
|
||||
const noop = vi.fn();
|
||||
|
||||
describe("SiteFormDialog AI image placement", () => {
|
||||
it("shows AI Image Generation guidance while adding a WordPress site", () => {
|
||||
render(<SiteFormDialog mode="create" onCancel={noop} onDone={noop} />);
|
||||
|
||||
expect(screen.getByText("AI Image Generation")).toBeInTheDocument();
|
||||
expect(screen.getByText(/After creating this service/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the tools-page link while editing a WooCommerce site", () => {
|
||||
const site = {
|
||||
id: "site-1",
|
||||
alias: "shop",
|
||||
url: "https://shop.example.com",
|
||||
plugin_type: "woocommerce",
|
||||
status: "active",
|
||||
} as Site;
|
||||
|
||||
render(<SiteFormDialog mode="edit" site={site} onCancel={noop} onDone={noop} />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "Open AI Image Generation settings" });
|
||||
expect(link).toHaveAttribute("href", "/dashboard/sites/site-1/tools");
|
||||
});
|
||||
});
|
||||
138
web/src/test/site-tools-ai.test.tsx
Normal file
138
web/src/test/site-tools-ai.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { SiteToolsPage } from "../pages/SiteTools";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pluginType: "woocommerce",
|
||||
providers: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../lib/queries", () => ({
|
||||
useDeleteSiteProviderKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useOpenRouterImageModels: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "google/gemini-2.5-flash-image",
|
||||
name: "Gemini 2.5 Flash Image",
|
||||
price_per_image_usd: 0.039,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
useSetSiteProviderDefaultModel: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useSetSiteProviderKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useSiteCapabilities: () => ({
|
||||
isLoading: false,
|
||||
data: {
|
||||
ok: true,
|
||||
plugin_type: state.pluginType,
|
||||
probe_available: true,
|
||||
granted: ["read_write"],
|
||||
ai_providers_configured: state.providers,
|
||||
tier: "admin",
|
||||
fit: { status: "ok", required: ["write_products"], missing: [] },
|
||||
},
|
||||
}),
|
||||
useSite: () => ({
|
||||
data: {
|
||||
id: "site-1",
|
||||
alias: "shop",
|
||||
plugin_type: state.pluginType,
|
||||
tool_scope: "admin",
|
||||
},
|
||||
}),
|
||||
useSiteProviderKeys: () => ({
|
||||
data: { ok: true, providers: state.providers, default_models: { openrouter: "google/gemini-2.5-flash-image" } },
|
||||
}),
|
||||
useSiteTools: () => ({
|
||||
isLoading: false,
|
||||
data: {
|
||||
site_id: "site-1",
|
||||
plugin_type: state.pluginType,
|
||||
tool_scope: "admin",
|
||||
scope_presets: [],
|
||||
configured_providers: state.providers,
|
||||
tools: [
|
||||
{
|
||||
name: "woocommerce_generate_and_upload_image",
|
||||
description: "Generate an image and upload it to WooCommerce media.",
|
||||
plugin_type: "woocommerce",
|
||||
category: "media",
|
||||
sensitivity: null,
|
||||
required_scope: "write",
|
||||
enabled: true,
|
||||
provider_key_required: true,
|
||||
provider_key_configured: state.providers.length > 0,
|
||||
available: state.providers.length > 0,
|
||||
unavailable_reason: state.providers.length > 0 ? null : "provider_key",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
useToggleSiteTool: () => ({ mutateAsync: vi.fn() }),
|
||||
useTranslations: () => ({ data: undefined }),
|
||||
useUpdateSiteToolScope: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/sites/site-1/tools"]}>
|
||||
<Routes>
|
||||
<Route path="/sites/:id/tools" element={<SiteToolsPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SiteTools AI image provider UI", () => {
|
||||
it("shows provider-key management for WooCommerce image tools", () => {
|
||||
state.pluginType = "woocommerce";
|
||||
state.providers = [];
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText("AI Image Generation")).toBeInTheDocument();
|
||||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
|
||||
expect(screen.getByText("Needs an AI provider key — configure one above.")).toBeInTheDocument();
|
||||
expect(screen.getByText("needs AI provider key")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Configure key" })).toBeInTheDocument();
|
||||
expect(screen.getByText("woocommerce_generate_and_upload_image")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows service readiness guidance for the current tool scope", () => {
|
||||
state.pluginType = "woocommerce";
|
||||
state.providers = [];
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText("Service readiness")).toBeInTheDocument();
|
||||
expect(screen.getByText("Credential requirement for Admin")).toBeInTheDocument();
|
||||
expect(screen.getByText("credential fits selected tier")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Configured AI providers/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Media and AI image upload tools additionally need/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Admin grants the full destructive surface/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows OpenRouter default image model selection when the key is set", () => {
|
||||
state.pluginType = "woocommerce";
|
||||
state.providers = ["openrouter"];
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText("Default image model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Gemini 2.5 Flash Image")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Current default:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides provider-key management for non-WordPress services", () => {
|
||||
state.pluginType = "coolify";
|
||||
state.providers = [];
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.queryByText("AI Image Generation")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user