From 0e634546b1f6b888065364edfb08b0b382c2bf47 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Mon, 13 Apr 2026 14:43:26 +0200 Subject: [PATCH] llm providers | app config | admin routes --- packages/ui/docs/product-pacbot.md | 17 ++ packages/ui/shared/src/config/config.d.ts | 5 + .../ui/shared/src/config/config.schema.ts | 5 + .../ui/src/components/admin/BansManager.tsx | 50 +----- .../src/components/admin/CreateUserDialog.tsx | 2 +- .../src/components/admin/EditUserDialog.tsx | 3 +- .../ui/src/components/admin/UserManager.tsx | 2 +- .../components/admin/ViolationsMonitor.tsx | 30 +--- .../components/filters/ProviderManagement.tsx | 30 ++-- .../components/filters/ProviderSelector.tsx | 4 +- .../lazy-editors/AIGenerationPlugin.tsx | 4 +- packages/ui/src/hooks/useProviderSettings.ts | 15 +- packages/ui/src/hooks/useSystemInfo.ts | 9 +- packages/ui/src/lib/db.ts | 15 +- packages/ui/src/llm/filters/providers.ts | 10 +- packages/ui/src/modules/admin/client-admin.ts | 111 ++++++++++++ packages/ui/src/modules/ai/useChatEngine.ts | 2 +- .../src/modules/campaigns/client-campaigns.ts | 59 ++---- .../src/modules/contacts/client-mailboxes.ts | 52 ++---- .../src/modules/ecommerce/client-ecommerce.ts | 42 +---- .../src/modules/providers/client-providers.ts | 92 ++++++++++ packages/ui/src/modules/user/client-user.ts | 80 +-------- packages/ui/src/pages/AdminPage.tsx | 54 ++---- packages/ui/src/pages/PlaygroundEditorLLM.tsx | 3 +- packages/ui/src/sw.ts | 169 ++++++------------ 25 files changed, 410 insertions(+), 455 deletions(-) create mode 100644 packages/ui/src/modules/admin/client-admin.ts create mode 100644 packages/ui/src/modules/providers/client-providers.ts diff --git a/packages/ui/docs/product-pacbot.md b/packages/ui/docs/product-pacbot.md index d4308ade..b417f04a 100644 --- a/packages/ui/docs/product-pacbot.md +++ b/packages/ui/docs/product-pacbot.md @@ -18,3 +18,20 @@ Going beyond simple contact extraction, PAC-BOT provides structured, multi-dimen As part of its ongoing product roadmap, [Company Name] confirmed that PAC-BOT will soon evolve from a lead discovery engine into an automated global sales deployment. Upcoming features will enable the system to automatically generate hyper-tailored, localized outreach emails for any niche, in any language—allowing businesses to drop a pin anywhere in the world and instantly dispatch the perfect customized pitch. + +- [ ] gridsearch progress | pause | resume | settings : presets ( lang , overview, nearby, discover ) +- [ ] share => noFilters | columns | filters +- [ ] types => partial / fuzzy match | post filters => import contacts +- [ ] report => email +- [ ] notifications => email +- [ ] summary - business intelligence +- [ ] expand => easy => country | lang match +- [ ] llm filters : places | areas +- [ ] ui : track / trail / done zones +- [ ] sell search +- [ ] Centers: LOD + +### GADM + +- [ ] Geo reverse / local names cache +- [ ] Resolution \ No newline at end of file diff --git a/packages/ui/shared/src/config/config.d.ts b/packages/ui/shared/src/config/config.d.ts index a33351f6..303262a2 100644 --- a/packages/ui/shared/src/config/config.d.ts +++ b/packages/ui/shared/src/config/config.d.ts @@ -5,6 +5,7 @@ export interface AppConfig { core: Core; footer_left: FooterEmail[]; footer_email: FooterEmail[]; + email: Email; footer_right: any[]; settings: Settings; params: Params; @@ -73,6 +74,10 @@ export interface Ecommerce { currencyCode: string; } +export interface Email { + languages: string[]; +} + export interface FooterEmail { href: string; text: string; diff --git a/packages/ui/shared/src/config/config.schema.ts b/packages/ui/shared/src/config/config.schema.ts index ff2c8c9e..d0be7be0 100644 --- a/packages/ui/shared/src/config/config.schema.ts +++ b/packages/ui/shared/src/config/config.schema.ts @@ -36,6 +36,10 @@ export const footerEmailSchema = z.object({ text: z.string(), }); +export const emailSchema = z.object({ + languages: z.array(z.string()), +}); + export const settingsSchema = z.object({ search: z.boolean(), account: z.boolean(), @@ -216,6 +220,7 @@ export const appConfigSchema = z.object({ core: coreSchema, footer_left: z.array(footerEmailSchema), footer_email: z.array(footerEmailSchema), + email: emailSchema, footer_right: z.array(z.any()), settings: settingsSchema, params: paramsSchema, diff --git a/packages/ui/src/components/admin/BansManager.tsx b/packages/ui/src/components/admin/BansManager.tsx index 35c46774..b1a05ba9 100644 --- a/packages/ui/src/components/admin/BansManager.tsx +++ b/packages/ui/src/components/admin/BansManager.tsx @@ -23,16 +23,10 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { serverUrl } from "@/lib/db"; +import { getAdminBanList, postAdminUnbanIp, postAdminUnbanUser, type AdminBanList } from "@/modules/admin/client-admin"; -interface BanList { - bannedIPs: string[]; - bannedUserIds: string[]; - bannedTokens: string[]; -} - -export const BansManager = ({ session }: { session: any }) => { - const [banList, setBanList] = useState({ +export const BansManager = () => { + const [banList, setBanList] = useState({ bannedIPs: [], bannedUserIds: [], bannedTokens: [], @@ -43,17 +37,7 @@ export const BansManager = ({ session }: { session: any }) => { const fetchBanList = async () => { try { setLoading(true); - const res = await fetch(`${serverUrl}/api/admin/bans`, { - headers: { - 'Authorization': `Bearer ${session?.access_token || ''}` - } - }); - - if (!res.ok) { - throw new Error('Failed to fetch ban list'); - } - - const data = await res.json(); + const data = await getAdminBanList(); setBanList(data); } catch (err: any) { toast.error(translate("Failed to fetch ban list"), { @@ -68,28 +52,10 @@ export const BansManager = ({ session }: { session: any }) => { if (!unbanTarget) return; try { - const endpoint = unbanTarget.type === 'ip' - ? '/api/admin/bans/unban-ip' - : '/api/admin/bans/unban-user'; - - const body = unbanTarget.type === 'ip' - ? { ip: unbanTarget.value } - : { userId: unbanTarget.value }; - - const res = await fetch(`${serverUrl}${endpoint}`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session?.access_token || ''}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - }); - - if (!res.ok) { - throw new Error('Failed to unban'); - } - - const data = await res.json(); + const data = + unbanTarget.type === 'ip' + ? await postAdminUnbanIp(unbanTarget.value) + : await postAdminUnbanUser(unbanTarget.value); if (data.success) { toast.success(translate("Unbanned successfully"), { diff --git a/packages/ui/src/components/admin/CreateUserDialog.tsx b/packages/ui/src/components/admin/CreateUserDialog.tsx index 2cc6a3eb..4cd7406b 100644 --- a/packages/ui/src/components/admin/CreateUserDialog.tsx +++ b/packages/ui/src/components/admin/CreateUserDialog.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { T, translate } from '@/i18n'; -import { createAdminUserAPI } from '@/modules/user/client-user'; +import { createAdminUserAPI } from '@/modules/admin/client-admin'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; diff --git a/packages/ui/src/components/admin/EditUserDialog.tsx b/packages/ui/src/components/admin/EditUserDialog.tsx index edbca0eb..f68dc876 100644 --- a/packages/ui/src/components/admin/EditUserDialog.tsx +++ b/packages/ui/src/components/admin/EditUserDialog.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'; import { toast } from 'sonner'; import { T, translate } from '@/i18n'; import { Tables } from '@/integrations/supabase/types'; -import { getUserApiKeys as getUserSecrets, updateUserSecrets, updateAdminUserAPI } from '@/modules/user/client-user'; +import { getUserApiKeys as getUserSecrets, updateUserSecrets } from '@/modules/user/client-user'; +import { updateAdminUserAPI } from '@/modules/admin/client-admin'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; diff --git a/packages/ui/src/components/admin/UserManager.tsx b/packages/ui/src/components/admin/UserManager.tsx index e15679df..7edbe811 100644 --- a/packages/ui/src/components/admin/UserManager.tsx +++ b/packages/ui/src/components/admin/UserManager.tsx @@ -12,7 +12,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { EditUserDialog } from './EditUserDialog'; import { DeleteUserDialog } from './DeleteUserDialog'; import { CreateUserDialog } from './CreateUserDialog'; -import { fetchAdminUsersAPI, deleteAdminUserAPI } from '@/modules/user/client-user'; +import { fetchAdminUsersAPI, deleteAdminUserAPI } from '@/modules/admin/client-admin'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { GroupManager } from './GroupManager'; import { fetchUserGroups, fetchUserEffectiveGroups } from '@/modules/user/client-acl'; diff --git a/packages/ui/src/components/admin/ViolationsMonitor.tsx b/packages/ui/src/components/admin/ViolationsMonitor.tsx index 9d41808a..9a72aac1 100644 --- a/packages/ui/src/components/admin/ViolationsMonitor.tsx +++ b/packages/ui/src/components/admin/ViolationsMonitor.tsx @@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { AlertTriangle, RefreshCw } from "lucide-react"; import { T, translate } from "@/i18n"; -import { serverUrl } from "@/lib/db"; +import { getAdminViolationStats, type AdminViolationStats } from "@/modules/admin/client-admin"; import { Table, TableBody, @@ -15,20 +15,8 @@ import { TableRow, } from "@/components/ui/table"; -interface ViolationRecord { - key: string; - count: number; - firstViolation: number; - lastViolation: number; -} - -interface ViolationStats { - totalViolations: number; - violations: ViolationRecord[]; -} - -export const ViolationsMonitor = ({ session }: { session: any }) => { - const [stats, setStats] = useState({ +export const ViolationsMonitor = () => { + const [stats, setStats] = useState({ totalViolations: 0, violations: [], }); @@ -37,17 +25,7 @@ export const ViolationsMonitor = ({ session }: { session: any }) => { const fetchViolationStats = async () => { try { setLoading(true); - const res = await fetch(`${serverUrl}/api/admin/bans/violations`, { - headers: { - 'Authorization': `Bearer ${session?.access_token || ''}` - } - }); - - if (!res.ok) { - throw new Error('Failed to fetch violation stats'); - } - - const data = await res.json(); + const data = await getAdminViolationStats(); setStats(data); } catch (err: any) { toast.error(translate("Failed to fetch violation stats"), { diff --git a/packages/ui/src/components/filters/ProviderManagement.tsx b/packages/ui/src/components/filters/ProviderManagement.tsx index 4e629c41..07daf6d7 100644 --- a/packages/ui/src/components/filters/ProviderManagement.tsx +++ b/packages/ui/src/components/filters/ProviderManagement.tsx @@ -5,8 +5,14 @@ */ import React, { useState, useEffect } from 'react'; -import { apiClient } from '@/lib/db'; import { useAuth } from '@/hooks/useAuth'; +import { + fetchProviderConfigs, + createProviderConfig, + updateProviderConfig, + deleteProviderConfig, + type ProviderConfigRow, +} from '@/modules/providers/client-providers'; import { DEFAULT_PROVIDERS, fetchProviderModelInfo } from '@/llm/filters/providers'; import { groupModelsByCompany } from '@/llm/filters/providers/openrouter'; import { groupOpenAIModelsByType } from '@/llm/filters/providers/openai'; @@ -69,19 +75,7 @@ import { import { toast } from 'sonner'; import { Alert, AlertDescription } from '@/components/ui/alert'; -type ProviderConfig = { - id: string; - user_id?: string; - name: string; - display_name: string; - base_url?: string | null; - models?: string[] | any; - rate_limits?: Record | null; - is_active?: boolean | null; - settings?: Record | null; - created_at?: string; - updated_at?: string; -}; +type ProviderConfig = ProviderConfigRow; interface ProviderSettings { apiKey?: string; @@ -120,7 +114,7 @@ export const ProviderManagement: React.FC = () => { setLoading(true); try { const [loadedProvidersRaw, userSecrets] = await Promise.all([ - apiClient(`/api/provider-configs?userId=${user.id}`), + fetchProviderConfigs(user.id), getUserSecrets(user.id) ]); @@ -170,7 +164,7 @@ export const ProviderManagement: React.FC = () => { if (!deletingProvider) return; try { - await apiClient(`/api/provider-configs/${deletingProvider.id}`, { method: 'DELETE' }); + await deleteProviderConfig(user.id, deletingProvider.id); toast.success(`Provider "${deletingProvider.display_name}" deleted successfully`); setDeletingProvider(null); @@ -567,7 +561,7 @@ const ProviderEditDialog: React.FC = ({ } // Update existing provider - await apiClient(`/api/provider-configs/${provider.id}`, { method: 'PATCH', body: JSON.stringify(data) }); + await updateProviderConfig(user.id, provider.id, data); toast.success('Provider updated successfully'); } else { // Create new provider with user_id @@ -581,7 +575,7 @@ const ProviderEditDialog: React.FC = ({ console.error('Failed to update user secrets:', secretError); } } - await apiClient('/api/provider-configs', { method: 'POST', body: JSON.stringify({ ...data, user_id: user.id }) }); + await createProviderConfig(user.id, { ...data, user_id: user.id }); toast.success('Provider created successfully'); } diff --git a/packages/ui/src/components/filters/ProviderSelector.tsx b/packages/ui/src/components/filters/ProviderSelector.tsx index a2957c08..14871018 100644 --- a/packages/ui/src/components/filters/ProviderSelector.tsx +++ b/packages/ui/src/components/filters/ProviderSelector.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react'; import { ProviderConfig } from '@/llm/filters/types'; -import { apiClient } from '@/lib/db'; +import { fetchProviderConfigs } from '@/modules/providers/client-providers'; import { useAuth } from '@/hooks/useAuth'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Badge } from '@/components/ui/badge'; @@ -69,7 +69,7 @@ export const ProviderSelector: React.FC = ({ setLoading(true); try { - const userProviders = await apiClient(`/api/provider-configs?userId=${user.id}&is_active=true`); + const userProviders = await fetchProviderConfigs(user.id, { is_active: true }); if (userProviders) { const providers = userProviders.map(dbProvider => ({ name: dbProvider.name, diff --git a/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx b/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx index 84e6c5af..cfd041a9 100644 --- a/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx +++ b/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx @@ -7,7 +7,7 @@ import { AIPromptPopup } from './AIPromptPopup'; import { generateText } from '@/lib/openai'; import { toast } from 'sonner'; import { getUserApiKeys as getUserSecrets } from '@/modules/user/client-user'; -import { apiClient } from '@/lib/db'; +import { fetchProviderConfigs } from '@/modules/providers/client-providers'; import { useAuth } from '@/hooks/useAuth'; import { formatTextGenPrompt } from '@/constants'; @@ -33,7 +33,7 @@ const useProviderApiKey = () => { try { console.log('Fetching API key for user:', user.id, 'provider:', provider); - const configs = await apiClient(`/api/provider-configs?userId=${user.id}&name=${provider}&is_active=true`); + const configs = await fetchProviderConfigs(user.id, { name: provider, is_active: true }); const userProvider = configs?.[0]; if (!userProvider) return null; const settings = userProvider.settings as any; diff --git a/packages/ui/src/hooks/useProviderSettings.ts b/packages/ui/src/hooks/useProviderSettings.ts index 49fd6e7d..d59c3cd4 100644 --- a/packages/ui/src/hooks/useProviderSettings.ts +++ b/packages/ui/src/hooks/useProviderSettings.ts @@ -1,17 +1,8 @@ import { useState, useEffect } from 'react'; import { useAuth } from '@/hooks/useAuth'; -import { apiClient } from '@/lib/db'; +import { fetchProviderConfigs, type ProviderConfigRow } from '@/modules/providers/client-providers'; -export type ProviderConfig = { - name: string; - display_name: string; - base_url?: string; - models?: string[]; - rate_limits?: Record; - is_active?: boolean; - settings?: Record; - user_id?: string; -}; +export type ProviderConfig = ProviderConfigRow; const STORAGE_KEY = 'provider-settings'; @@ -42,7 +33,7 @@ export const useProviderSettings = () => { setLoading(true); try { - const data = await apiClient(`/api/provider-configs?userId=${user.id}&is_active=true`); + const data = await fetchProviderConfigs(user.id, { is_active: true }); setProviders(data || []); diff --git a/packages/ui/src/hooks/useSystemInfo.ts b/packages/ui/src/hooks/useSystemInfo.ts index 6820d55e..0b0aea88 100644 --- a/packages/ui/src/hooks/useSystemInfo.ts +++ b/packages/ui/src/hooks/useSystemInfo.ts @@ -4,10 +4,11 @@ import { apiClient, serverUrl } from '@/lib/db.js'; interface SystemInfo { env: Record & { appConfig?: AppConfig }; + /** Product names with `enabled: true` in server config/products.json */ + enabledProducts: string[]; } export const useSystemInfo = () => { - console.log(`useSystemInfo : ${serverUrl}`); return useQuery({ queryKey: ['system-info'], queryFn: () => apiClient('/api/system-info'), @@ -27,3 +28,9 @@ export const useAppConfig = (): AppConfig | undefined => { const { data } = useSystemInfo(); return data?.env as unknown as AppConfig; }; + +/** Shorthand: product names enabled in server config/products.json */ +export const useEnabledProducts = (): string[] | undefined => { + const { data } = useSystemInfo(); + return data?.enabledProducts; +}; diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index 45a209b7..1a306141 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -32,6 +32,7 @@ const KEY_PREFIXES: [RegExp, (...groups: string[]) => string[]][] = [ [/^google-(.+)$/, (_, id) => ['users', 'google', id]], [/^user-secrets-(.+)$/, (_, id) => ['users', 'secrets', id]], [/^provider-(.+)-(.+)$/, (_, uid, prov) => ['users', 'provider', uid, prov]], + [/^provider-configs-([0-9a-f-]{36})-(.+)$/, (_, uid, rest) => ['users', 'provider-configs', uid, rest]], [/^type-(.+)$/, (_, id) => ['types', id]], [/^types-(.+)$/, (_, rest) => ['types', rest]], [/^i18n-(.+)$/, (_, rest) => ['i18n', rest]], @@ -69,6 +70,14 @@ export const fetchWithDeduplication = async ( export const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; +/** Join `serverUrl` with a path, or pass through absolute `http(s)://` URLs unchanged. */ +export function resolveApiUrl(pathOrUrl: string): string { + if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) return pathOrUrl; + const base = String(serverUrl).replace(/\/$/, ''); + const path = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`; + return `${base}${path}`; +} + export const getAuthToken = async (): Promise => { const user = await userManager.getUser(); return user?.access_token ?? undefined; @@ -164,8 +173,7 @@ async function fetchWithApiRetries(url: string, init: RequestInit, labelForLog: export async function apiClient(endpoint: string, options: RequestInit = {}): Promise { const headers = await getAuthHeaders(); - const isAbsolute = endpoint.startsWith('http://') || endpoint.startsWith('https://'); - const url = isAbsolute ? endpoint : `${serverUrl}${endpoint}`; + const url = resolveApiUrl(endpoint); const response = await fetchWithApiRetries(url, { ...options, headers: { ...headers, ...options.headers }, @@ -175,8 +183,7 @@ export async function apiClient(endpoint: string, options: RequestInit = {}): export async function apiClientOr404(endpoint: string, options: RequestInit = {}): Promise { const headers = await getAuthHeaders(); - const isAbsolute = endpoint.startsWith('http://') || endpoint.startsWith('https://'); - const url = isAbsolute ? endpoint : `${serverUrl}${endpoint}`; + const url = resolveApiUrl(endpoint); const response = await fetchWithApiRetries(url, { ...options, headers: { ...headers, ...options.headers }, diff --git a/packages/ui/src/llm/filters/providers.ts b/packages/ui/src/llm/filters/providers.ts index 41157c7a..1d400333 100644 --- a/packages/ui/src/llm/filters/providers.ts +++ b/packages/ui/src/llm/filters/providers.ts @@ -5,7 +5,7 @@ import { ProviderConfig } from './types'; import { generateText } from '@/lib/openai'; -import { apiClient } from '@/lib/db'; +import { fetchProviderConfigs } from '@/modules/providers/client-providers'; import { fetchOpenRouterModelInfo } from './providers/openrouter'; import { fetchOpenAIModelInfo } from './providers/openai'; @@ -67,9 +67,13 @@ export const getProviderConfig = (providerName: string): ProviderConfig | null = /** * Get all available providers from API or defaults */ -export const getAvailableProviders = async (): Promise => { +/** Pass `userId` (app UUID) to load saved providers; without it, defaults are returned. */ +export const getAvailableProviders = async (userId?: string): Promise => { + if (!userId) { + return Object.values(DEFAULT_PROVIDERS).filter(provider => provider.isActive); + } try { - const data = await apiClient('/api/provider-configs?is_active=true'); + const data = await fetchProviderConfigs(userId, { is_active: true }); if (data && data.length > 0) { return data.map(dbProvider => convertDbToConfig(dbProvider)); } diff --git a/packages/ui/src/modules/admin/client-admin.ts b/packages/ui/src/modules/admin/client-admin.ts new file mode 100644 index 00000000..bd9d111c --- /dev/null +++ b/packages/ui/src/modules/admin/client-admin.ts @@ -0,0 +1,111 @@ +/** + * Authenticated admin API helpers (Bearer via `@/lib/db` `apiClient`). + * Server routes are registered as admin-only (`AdminEndpointRegistry` + `adminMiddleware`). + */ + +import { apiClient } from '@/lib/db'; + +// --- Server / cache --- + +export async function postFlushCache(body?: { mirror?: boolean }): Promise<{ success: boolean; message: string }> { + // `apiClient` sets `Content-Type: application/json`; an empty body is invalid JSON and OpenAPI validation returns 400. + return apiClient<{ success: boolean; message: string }>('/api/flush-cache', { + method: 'POST', + body: JSON.stringify(body ?? {}), + }); +} + +export async function postAdminSystemRestart(): Promise<{ message: string; pid: number }> { + return apiClient<{ message: string; pid: number }>('/api/admin/system/restart', { + method: 'POST', + body: JSON.stringify({}), + }); +} + +export async function getAdminSystem(): Promise { + return apiClient('/api/admin/system'); +} + +// --- Bans --- + +export type AdminBanList = { + bannedIPs: string[]; + bannedUserIds: string[]; + bannedTokens: string[]; +}; + +export async function getAdminBanList(): Promise { + return apiClient('/api/admin/bans'); +} + +export async function postAdminBanIps(ips: string[]): Promise<{ banned: number; skipped: number; message: string }> { + return apiClient<{ banned: number; skipped: number; message: string }>('/api/admin/bans/ban-ip', { + method: 'POST', + body: JSON.stringify({ ips }), + }); +} + +export async function postAdminUnbanIp(ip: string): Promise<{ success: boolean; message: string }> { + return apiClient<{ success: boolean; message: string }>('/api/admin/bans/unban-ip', { + method: 'POST', + body: JSON.stringify({ ip }), + }); +} + +export async function postAdminUnbanUser(userId: string): Promise<{ success: boolean; message: string }> { + return apiClient<{ success: boolean; message: string }>('/api/admin/bans/unban-user', { + method: 'POST', + body: JSON.stringify({ userId }), + }); +} + +export type AdminViolationStats = { + totalViolations: number; + violations: Array<{ + key: string; + count: number; + firstViolation: number; + lastViolation: number; + }>; +}; + +export async function getAdminViolationStats(): Promise { + return apiClient('/api/admin/bans/violations'); +} + +// --- User management (admin) — `/api/admin/users` --- + +export const fetchAdminUsersAPI = async (): Promise => { + return apiClient('/api/admin/users'); +}; + +export const createAdminUserAPI = async ( + email: string, + password: string, + copySettings: boolean, +): Promise => { + return apiClient('/api/admin/users', { + method: 'POST', + body: JSON.stringify({ email, password, copySettings }), + }); +}; + +export const updateAdminUserAPI = async ( + userId: string, + profile: { + username?: string | null; + display_name?: string | null; + bio?: string | null; + }, +): Promise => { + return apiClient(`/api/admin/users/${encodeURIComponent(userId)}`, { + method: 'PATCH', + body: JSON.stringify(profile), + }); +}; + +export const deleteAdminUserAPI = async (userId: string): Promise => { + await apiClient(`/api/admin/users/${encodeURIComponent(userId)}`, { + method: 'DELETE', + }); +}; diff --git a/packages/ui/src/modules/ai/useChatEngine.ts b/packages/ui/src/modules/ai/useChatEngine.ts index fd90e05e..e32065bc 100644 --- a/packages/ui/src/modules/ai/useChatEngine.ts +++ b/packages/ui/src/modules/ai/useChatEngine.ts @@ -11,7 +11,7 @@ import { toast } from 'sonner'; import { useAuth } from '@/hooks/useAuth'; import { usePromptHistory } from '@/hooks/usePromptHistory'; import { useDragDrop } from '@/contexts/DragDropContext'; -import { getProviderConfig } from '@/modules/user/client-user'; +import { getProviderConfig } from '@/modules/providers/client-providers'; import { serverUrl } from '@/lib/db'; import { createOpenAIClient } from '@/lib/openai'; import { createSearchToolPreset, createWebSearchToolPreset } from '@/modules/ai/searchTools'; diff --git a/packages/ui/src/modules/campaigns/client-campaigns.ts b/packages/ui/src/modules/campaigns/client-campaigns.ts index 5ec79a56..5318c866 100644 --- a/packages/ui/src/modules/campaigns/client-campaigns.ts +++ b/packages/ui/src/modules/campaigns/client-campaigns.ts @@ -1,6 +1,6 @@ // ─── Types ──────────────────────────────────────────────────────────────────── -import { serverUrl } from '@/lib/db'; +import { apiClient } from '@/lib/db'; export interface Campaign { id: string; @@ -23,30 +23,6 @@ export interface Campaign { updated_at?: string; } -// ─── Auth helper ────────────────────────────────────────────────────────────── - -async function authHeaders(contentType?: string): Promise { - const { getAuthToken } = await import('@/lib/db'); - const token = await getAuthToken(); - const h: HeadersInit = {}; - if (token) h['Authorization'] = `Bearer ${token}`; - if (contentType) h['Content-Type'] = contentType; - return h; -} - -const SERVER_URL = serverUrl; - -async function apiFetch(path: string, init?: RequestInit) { - const url = SERVER_URL ? `${SERVER_URL}${path}` : path; - const res = await fetch(url, init); - if (!res.ok) { - const err = await res.text().catch(() => res.statusText); - throw new Error(`${path} — ${res.status}: ${err}`); - } - const ct = res.headers.get('content-type') || ''; - return ct.includes('json') ? res.json() : res.text(); -} - // ─── Campaigns CRUD ─────────────────────────────────────────────────────────── export const fetchCampaigns = async (options?: { @@ -60,41 +36,40 @@ export const fetchCampaigns = async (options?: { if (options?.q) params.set('q', options.q); if (options?.limit != null) params.set('limit', String(options.limit)); if (options?.offset != null) params.set('offset', String(options.offset)); - const headers = await authHeaders(); - return apiFetch(`/api/campaigns?${params}`, { headers }); + return apiClient(`/api/campaigns?${params}`); }; export const getCampaign = async (id: string): Promise => { - return apiFetch(`/api/campaigns/${id}`, { headers: await authHeaders() }); + return apiClient(`/api/campaigns/${id}`); }; export const createCampaign = async (data: Partial): Promise => { - return apiFetch('/api/campaigns', { + return apiClient('/api/campaigns', { method: 'POST', - headers: await authHeaders('application/json'), body: JSON.stringify(data), }); }; export const updateCampaign = async (id: string, data: Partial): Promise => { - return apiFetch(`/api/campaigns/${id}`, { + return apiClient(`/api/campaigns/${id}`, { method: 'PATCH', - headers: await authHeaders('application/json'), body: JSON.stringify(data), }); }; export const deleteCampaign = async (id: string): Promise => { - await apiFetch(`/api/campaigns/${id}`, { - method: 'DELETE', - headers: await authHeaders(), - }); + await apiClient(`/api/campaigns/${id}`, { method: 'DELETE' }); }; -export const sendCampaign = async (id: string, intervalSecs: number = 1): Promise<{ total: number; sent: number; failed: number; skipped: number }> => { - return apiFetch(`/api/campaigns/${id}/send`, { - method: 'POST', - headers: await authHeaders('application/json'), - body: JSON.stringify({ interval: intervalSecs }), - }); +export const sendCampaign = async ( + id: string, + intervalSecs: number = 1 +): Promise<{ total: number; sent: number; failed: number; skipped: number }> => { + return apiClient<{ total: number; sent: number; failed: number; skipped: number }>( + `/api/campaigns/${id}/send`, + { + method: 'POST', + body: JSON.stringify({ interval: intervalSecs }), + } + ); }; diff --git a/packages/ui/src/modules/contacts/client-mailboxes.ts b/packages/ui/src/modules/contacts/client-mailboxes.ts index 187fa295..ce83e99f 100644 --- a/packages/ui/src/modules/contacts/client-mailboxes.ts +++ b/packages/ui/src/modules/contacts/client-mailboxes.ts @@ -3,18 +3,13 @@ * Calls server routes at /api/contacts/mailboxes */ -import { serverUrl } from '@/lib/db'; +import { apiClient } from '@/lib/db'; -const SERVER_URL = serverUrl; -const API_BASE = `${serverUrl}/api/contacts/mailboxes`; +const API_BASE = '/api/contacts/mailboxes'; -async function authHeaders(contentType?: string): Promise { - const { getAuthToken } = await import('@/lib/db'); - const token = await getAuthToken(); - const h: HeadersInit = {}; - if (token) h['Authorization'] = `Bearer ${token}`; - if (contentType) h['Content-Type'] = contentType; - return h; +/** Same as apiClient but never sends cookies (cross-origin API host). */ +function mailboxApi(endpoint: string, init?: RequestInit): Promise { + return apiClient(endpoint, { ...init, credentials: 'omit' }); } export interface MailboxItem { @@ -45,36 +40,23 @@ export interface MailboxInput { provider?: 'generic' | 'mailersend' | 'mailersend_api'; } -async function apiFetch(path: string, init?: RequestInit): Promise { - const url = SERVER_URL ? `${SERVER_URL}${path}` : path; - const ct = init?.body && typeof init.body === 'string' ? 'application/json' : undefined; - const headers = { ...(await authHeaders(ct)), ...(init?.headers || {}) }; - - const res = await fetch(url, { ...init, headers, credentials: 'omit' }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `HTTP ${res.status}`); - } - return res.json(); -} - export async function listMailboxes(): Promise { - return apiFetch(API_BASE); + return mailboxApi(API_BASE); } export async function saveMailbox(input: MailboxInput): Promise { - return apiFetch(API_BASE, { + return mailboxApi(API_BASE, { method: 'POST', body: JSON.stringify(input), }); } export async function deleteMailbox(id: string): Promise { - await apiFetch<{ ok: boolean }>(`${API_BASE}/${id}`, { method: 'DELETE' }); + await mailboxApi<{ ok: boolean }>(`${API_BASE}/${id}`, { method: 'DELETE' }); } export async function testMailbox(id: string): Promise<{ ok: boolean; error?: string }> { - return apiFetch<{ ok: boolean; error?: string }>(`${API_BASE}/${id}/test`, { + return mailboxApi<{ ok: boolean; error?: string }>(`${API_BASE}/${id}/test`, { method: 'POST', }); } @@ -83,32 +65,34 @@ export async function testSendMailbox( id: string, input: { to: string; subject?: string; message?: string } ): Promise<{ ok: boolean; messageId?: string; error?: string }> { - return apiFetch<{ ok: boolean; messageId?: string; error?: string }>(`${API_BASE}/${id}/test-send`, { + return mailboxApi<{ ok: boolean; messageId?: string; error?: string }>(`${API_BASE}/${id}/test-send`, { method: 'POST', body: JSON.stringify(input), }); } export async function getGmailAuthUrl(): Promise<{ url: string }> { - return apiFetch<{ url: string }>(`${API_BASE}/gmail/authorize`); + return mailboxApi<{ url: string }>(`${API_BASE}/gmail/authorize`); } -export async function syncMailbox(id: string, options: { groupId?: string; deepScan?: boolean }): Promise<{ status: string }> { - return apiFetch<{ status: string }>(`${API_BASE}/${id}/sync`, { +export async function syncMailbox( + id: string, + options: { groupId?: string; deepScan?: boolean } +): Promise<{ status: string }> { + return mailboxApi<{ status: string }>(`${API_BASE}/${id}/sync`, { method: 'POST', body: JSON.stringify(options), }); } export async function stopSyncMailbox(id: string): Promise<{ ok: boolean }> { - return apiFetch<{ ok: boolean }>(`${API_BASE}/${id}/sync-stop`, { + return mailboxApi<{ ok: boolean }>(`${API_BASE}/${id}/sync-stop`, { method: 'POST', }); } export async function resetSyncMailbox(id: string): Promise<{ ok: boolean }> { - return apiFetch<{ ok: boolean }>(`${API_BASE}/${id}/sync-reset`, { + return mailboxApi<{ ok: boolean }>(`${API_BASE}/${id}/sync-reset`, { method: 'POST', }); } - diff --git a/packages/ui/src/modules/ecommerce/client-ecommerce.ts b/packages/ui/src/modules/ecommerce/client-ecommerce.ts index 99e83dae..107b2423 100644 --- a/packages/ui/src/modules/ecommerce/client-ecommerce.ts +++ b/packages/ui/src/modules/ecommerce/client-ecommerce.ts @@ -1,31 +1,9 @@ /** * Client-side ecommerce module — transaction CRUD via authenticated API calls. - * Mirrors the pattern from client-user.ts: uses getAuthToken() + fetch. + * Uses apiClient (@/lib/db) for auth, URL resolution, and retries. */ -const API_BASE = import.meta.env.VITE_API_URL || ''; - -// --------------------------------------------------------------------------- -// Auth helper (reuse from client-user pattern) -// --------------------------------------------------------------------------- - -async function getAuthToken(): Promise { - const { getAuthToken: getZitadelToken } = await import('@/lib/db'); - return (await getZitadelToken()) ?? null; -} - -async function authFetch(path: string, options: RequestInit = {}): Promise { - const token = await getAuthToken(); - if (!token) throw new Error('Not authenticated'); - - const headers = new Headers(options.headers); - headers.set('Authorization', `Bearer ${token}`); - if (!headers.has('Content-Type') && options.body) { - headers.set('Content-Type', 'application/json'); - } - - return fetch(`${API_BASE}${path}`, { ...options, headers }); -} +import { apiClient } from '@/lib/db'; // --------------------------------------------------------------------------- // Transaction Types @@ -82,34 +60,26 @@ export interface UpdateTransactionInput { /** List all transactions for the current user (admins see all) */ export async function listTransactions(): Promise { - const res = await authFetch('/api/transactions'); - if (!res.ok) throw new Error(`Failed to list transactions: ${res.status}`); - return res.json(); + return apiClient('/api/transactions'); } /** Get a single transaction by ID */ export async function getTransaction(id: string): Promise { - const res = await authFetch(`/api/transactions/${id}`); - if (!res.ok) throw new Error(`Failed to get transaction: ${res.status}`); - return res.json(); + return apiClient(`/api/transactions/${id}`); } /** Create a new transaction */ export async function createTransaction(input: CreateTransactionInput): Promise { - const res = await authFetch('/api/transactions', { + return apiClient('/api/transactions', { method: 'POST', body: JSON.stringify(input), }); - if (!res.ok) throw new Error(`Failed to create transaction: ${res.status}`); - return res.json(); } /** Update a transaction (status, metadata, etc.) */ export async function updateTransaction(id: string, input: UpdateTransactionInput): Promise { - const res = await authFetch(`/api/transactions/${id}`, { + return apiClient(`/api/transactions/${id}`, { method: 'PATCH', body: JSON.stringify(input), }); - if (!res.ok) throw new Error(`Failed to update transaction: ${res.status}`); - return res.json(); } diff --git a/packages/ui/src/modules/providers/client-providers.ts b/packages/ui/src/modules/providers/client-providers.ts new file mode 100644 index 00000000..296a5f51 --- /dev/null +++ b/packages/ui/src/modules/providers/client-providers.ts @@ -0,0 +1,92 @@ +/** + * Client API for `public.provider_configs` (auth-scoped on the server). + * Mirrors the pattern in `@/modules/user/client-user` + `@/lib/db`. + */ + +import { fetchWithDeduplication, apiClient, getAuthToken as getZitadelToken, serverUrl as serverBaseUrl } from '@/lib/db'; + +const serverUrl = (path: string) => { + const baseUrl = serverBaseUrl || window.location.origin; + return `${baseUrl}${path}`; +}; + +const getAuthToken = async (): Promise => { + const token = await getZitadelToken(); + if (!token) throw new Error('Not authenticated'); + return token; +}; + +/** Row shape returned by GET/PATCH/POST `/api/provider-configs` */ +export type ProviderConfigRow = { + id: string; + user_id?: string | null; + name: string; + display_name: string; + base_url?: string | null; + models?: unknown; + rate_limits?: Record | null; + is_active?: boolean | null; + settings?: Record | null; + created_at?: string | null; + updated_at?: string | null; +}; + +const listCacheKey = (userId: string, opts?: { is_active?: boolean; name?: string }) => + `provider-configs-${userId}-${JSON.stringify({ a: opts?.is_active ?? null, n: opts?.name ?? null })}`; + +/** + * List provider_configs for the authenticated user. The server resolves the user from the Bearer token; + * `userId` is only used for React Query / deduplication keys. + */ +export const fetchProviderConfigs = async ( + userId: string, + opts?: { is_active?: boolean; name?: string }, +): Promise => { + return fetchWithDeduplication(listCacheKey(userId, opts), async () => { + const params = new URLSearchParams(); + if (opts?.is_active !== undefined) params.set('is_active', String(opts.is_active)); + if (opts?.name) params.set('name', opts.name); + const qs = params.toString(); + return apiClient(`/api/provider-configs${qs ? `?${qs}` : ''}`); + }); +}; + +export const createProviderConfig = async ( + _userId: string, + body: Omit & { user_id?: string }, +): Promise => { + return apiClient('/api/provider-configs', { + method: 'POST', + body: JSON.stringify(body), + }); +}; + +export const updateProviderConfig = async ( + _userId: string, + id: string, + body: Partial>, +): Promise => { + return apiClient(`/api/provider-configs/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(body), + }); +}; + +export const deleteProviderConfig = async (_userId: string, id: string): Promise => { + await apiClient(`/api/provider-configs/${encodeURIComponent(id)}`, { method: 'DELETE' }); +}; + +/** + * GET /api/me/provider-config/:provider — returns `{ settings }` for that named provider (or 404). + */ +export const getProviderConfig = async (userId: string, provider: string) => { + return fetchWithDeduplication(`provider-${userId}-${provider}`, async () => { + const token = await getAuthToken(); + const res = await fetch(serverUrl(`/api/me/provider-config/${encodeURIComponent(provider)}`), { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`Failed to fetch provider config: ${res.statusText}`); + return await res.json(); + }); +}; diff --git a/packages/ui/src/modules/user/client-user.ts b/packages/ui/src/modules/user/client-user.ts index 6ff94ada..6fe7e2ca 100644 --- a/packages/ui/src/modules/user/client-user.ts +++ b/packages/ui/src/modules/user/client-user.ts @@ -99,20 +99,6 @@ export const getUserSecrets = async (userId: string) => { }); }; -export const getProviderConfig = async (userId: string, provider: string) => { - return fetchWithDeduplication(`provider-${userId}-${provider}`, async () => { - const token = await getAuthToken(); - const res = await fetch(serverUrl(`/api/me/provider-config/${encodeURIComponent(provider)}`), { - headers: { 'Authorization': `Bearer ${token}` } - }); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`Failed to fetch provider config: ${res.statusText}`); - return await res.json(); - }); -} - - - export const fetchUserRoles = async (userId: string) => { return fetchWithDeduplication(`roles-${userId}`, async () => { const token = await getAuthToken(); @@ -347,10 +333,6 @@ export const updateUserEmail = async (newEmail: string): Promise => { }); }; -// ============================================= -// Admin User Management API Wrappers -// ============================================= - const getAuthToken = async (): Promise => { const token = await getZitadelToken(); if (!token) throw new Error('Not authenticated'); @@ -378,62 +360,12 @@ export const fetchProfilesByUserIds = async ( return map; }; -/** Fetch all users (admin) */ -export const fetchAdminUsersAPI = async (): Promise => { - const token = await getAuthToken(); - const res = await fetch(serverUrl('/api/admin/users'), { - headers: { 'Authorization': `Bearer ${token}` } - }); - if (!res.ok) throw new Error(`Failed to fetch users: ${res.statusText}`); - return await res.json(); -}; - -/** Create a new user (admin) */ -export const createAdminUserAPI = async (email: string, password: string, copySettings: boolean): Promise => { - const token = await getAuthToken(); - const res = await fetch(serverUrl('/api/admin/users'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ email, password, copySettings }) - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || `Failed to create user: ${res.statusText}`); - } - return await res.json(); -}; - -/** Update a user's profile (admin) */ -export const updateAdminUserAPI = async (userId: string, profile: { - username?: string | null; - display_name?: string | null; - bio?: string | null; -}): Promise => { - const token = await getAuthToken(); - const res = await fetch(serverUrl(`/api/admin/users/${userId}`), { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(profile) - }); - if (!res.ok) throw new Error(`Failed to update user: ${res.statusText}`); - return await res.json(); -}; - -/** Delete a user (admin) */ -export const deleteAdminUserAPI = async (userId: string): Promise => { - const token = await getAuthToken(); - const res = await fetch(serverUrl(`/api/admin/users/${userId}`), { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${token}` } - }); - if (!res.ok) throw new Error(`Failed to delete user: ${res.statusText}`); -}; +export { + fetchAdminUsersAPI, + createAdminUserAPI, + updateAdminUserAPI, + deleteAdminUserAPI, +} from '@/modules/admin/client-admin'; /** Notify the server to flush its auth cache for the current user (best-effort, never throws) */ export const notifyServerLogout = (token: string): void => { diff --git a/packages/ui/src/pages/AdminPage.tsx b/packages/ui/src/pages/AdminPage.tsx index 732a45df..bcc0644d 100644 --- a/packages/ui/src/pages/AdminPage.tsx +++ b/packages/ui/src/pages/AdminPage.tsx @@ -15,13 +15,13 @@ import { BansManager } from "@/components/admin/BansManager"; import { ViolationsMonitor } from "@/components/admin/ViolationsMonitor"; import React, { Suspense } from "react"; import { Routes, Route, Navigate } from "react-router-dom"; -import { serverUrl } from "@/lib/db"; +import { postFlushCache, postAdminSystemRestart } from "@/modules/admin/client-admin"; // Lazy load AnalyticsDashboard const AnalyticsDashboard = React.lazy(() => import("@/modules/analytics").then(module => ({ default: module.AnalyticsDashboard }))); const AdminPage = () => { - const { user, session, loading, roles } = useAuth(); + const { user, loading, roles } = useAuth(); if (loading) { return
@@ -47,9 +47,9 @@ const AdminPage = () => { } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> Loading analytics...
}> @@ -95,31 +95,21 @@ const DashboardSection = () => ( ); -const BansSection = ({ session }: { session: any }) => ( - +const BansSection = () => ( + ); -const ViolationsSection = ({ session }: { session: any }) => ( - +const ViolationsSection = () => ( + ); -const ServerSection = ({ session }: { session: any }) => { +const ServerSection = () => { const [loading, setLoading] = useState(false); const handleFlushCache = async () => { try { setLoading(true); - const res = await fetch(`${serverUrl}/api/flush-cache`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session?.access_token || ''}` - } - }); - - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || 'Failed to flush cache'); - } + await postFlushCache(); toast.success(translate("Cache flushed successfully"), { description: translate("Access and Content caches have been cleared.") @@ -139,19 +129,7 @@ const ServerSection = ({ session }: { session: any }) => { } try { - const res = await fetch(`${serverUrl}/api/admin/system/restart`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session?.access_token || ''}` - } - }); - - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || 'Failed to restart server'); - } - - const data = await res.json(); + const data = await postAdminSystemRestart(); toast.success(translate("Server restarting"), { description: data.message }); @@ -192,7 +170,7 @@ const ServerSection = ({ session }: { session: any }) => {

System Resources

- +
@@ -247,7 +225,7 @@ interface WorkerPing { import { useWebSocket } from '@/contexts/WS_Socket'; -const SystemStats = ({ session }: { session: any }) => { +const SystemStats = () => { const [stats, setStats] = useState(null); const { addListener, removeListener, sendCommand } = useWebSocket(); @@ -377,7 +355,7 @@ const SystemStats = ({ session }: { session: any }) => {
{/* Worker Threads */} - + {/* Task Runner Stats */} {stats.tasks && stats.tasks.length > 0 && ( @@ -422,7 +400,7 @@ const SystemStats = ({ session }: { session: any }) => { ); }; -const WorkerThreadStats = ({ session }: { session: any }) => { +const WorkerThreadStats = () => { const [pings, setPings] = useState([]); const { addListener, removeListener } = useWebSocket(); diff --git a/packages/ui/src/pages/PlaygroundEditorLLM.tsx b/packages/ui/src/pages/PlaygroundEditorLLM.tsx index d7ea082a..32846355 100644 --- a/packages/ui/src/pages/PlaygroundEditorLLM.tsx +++ b/packages/ui/src/pages/PlaygroundEditorLLM.tsx @@ -14,7 +14,8 @@ import { createMarkdownToolPreset } from '@/lib/markdownImageTools'; import { toast } from 'sonner'; import OpenAI from 'openai'; import SimpleLogViewer from '@/components/SimpleLogViewer'; -import { getUserSettings, updateUserSettings, getProviderConfig } from '@/modules/user/client-user'; +import { getUserSettings, updateUserSettings } from '@/modules/user/client-user'; +import { getProviderConfig } from '@/modules/providers/client-providers'; import { usePromptHistory } from '@/hooks/usePromptHistory'; const PlaygroundEditorLLM: React.FC = () => { diff --git a/packages/ui/src/sw.ts b/packages/ui/src/sw.ts index 75996aa3..2597fcf2 100644 --- a/packages/ui/src/sw.ts +++ b/packages/ui/src/sw.ts @@ -1,126 +1,63 @@ /// -import { NetworkFirst, CacheFirst } from 'workbox-strategies'; -import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching' -import { registerRoute, NavigationRoute } from 'workbox-routing' -import { ExpirationPlugin } from 'workbox-expiration' -import { set } from 'idb-keyval' +/** + * Minimal service worker: Web Share Target only (see manifest.webmanifest share_target). + * No app shell / asset / navigation caching — the network handles everything else. + */ +import { precacheAndRoute } from 'workbox-precaching'; +import { set } from 'idb-keyval'; -const SW_VERSION = '2.0.0'; +declare let self: ServiceWorkerGlobalScope; -console.log(`[SW] Initializing Version: ${SW_VERSION}`); +// Required by vite-plugin-pwa injectManifest; globPatterns in vite.config.ts are empty. +precacheAndRoute(self.__WB_MANIFEST); -declare let self: ServiceWorkerGlobalScope - -self.addEventListener('message', (event) => { - if (event.data && event.data.type === 'SKIP_WAITING') { - self.skipWaiting() - } -}) - -// Only precache the app shell — not all JS chunks -const manifest = self.__WB_MANIFEST; -const shellOnly = manifest.filter((entry) => { - const url = typeof entry === 'string' ? entry : entry.url; - return url === 'index.html' - || url === 'favicon.ico' - || url.endsWith('.png') - || url.endsWith('.svg') - || url === 'manifest.webmanifest'; +self.addEventListener('install', () => { + self.skipWaiting(); }); -precacheAndRoute(shellOnly); -// clean old assets -cleanupOutdatedCaches() - -// Runtime cache: hashed static assets (JS/CSS) — cache-first since Vite hashes them -registerRoute( - ({ url }) => url.pathname.startsWith('/assets/'), - new CacheFirst({ - cacheName: 'assets-v1', - plugins: [ - new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }), - ], - }) -); - -// allow only fallback in dev: we don't want to cache everything -let allowlist: undefined | RegExp[] -if (location.hostname === 'localhost' || location.hostname.includes('127.0.0.1')) - allowlist = [/^\/$/] - -// Handle Share Target POST requests -// MUST be registered before NavigationRoute to take precedence -registerRoute( - ({ request, url }) => request.method === 'POST' && url.pathname === '/upload-share-target', - async ({ event, request }) => { - console.log('SW: Intercepting Share Target request!'); - try { - const formData = await request.formData() - const files = formData.getAll('file') - const title = formData.get('title') - const text = formData.get('text') - const url = formData.get('url') - - console.log('SW: Share data received:', { filesLen: files.length, title, text, url }); - - // Store in IDB - await set('share-target', { files, title, text, url, timestamp: Date.now() }) - console.log('SW: Data stored in IDB'); - - // Redirect to the app with success status - return Response.redirect('/new?shared=true&sw_status=success', 303) - } catch (err) { - console.error('SW: Share Target Error:', err); - // Safe error string - const errMsg = err instanceof Error ? err.message : String(err); - return Response.redirect('/new?shared=true&sw_status=error&sw_error=' + encodeURIComponent(errMsg), 303); - } - }, - 'POST' -); - -// Navigation handler: Prefer network to get server injection, fallback to cached index.html -const navigationHandler = async (params: any) => { - try { - const strategy = new NetworkFirst({ - cacheName: 'pages', - networkTimeoutSeconds: 3, - plugins: [ - { - cacheWillUpdate: async ({ response }) => { - return response && response.status === 200 ? response : null; - } - } - ] - }); - return await strategy.handle(params); - } catch (error) { - // Fallback: serve cached index.html from precache - const cache = await caches.match('index.html'); - if (cache) return cache; - return new Response('Offline', { status: 503 }); - } -}; - -// to allow work offline -registerRoute(new NavigationRoute( - navigationHandler, - { - allowlist, - denylist: [/^\/upload-share-target/] - } -)) - -// On activate, clean up old caches but keep current ones self.addEventListener('activate', (event) => { - const currentCaches = new Set(['assets-v1', 'pages']); event.waitUntil( - caches.keys().then((names) => - Promise.all( - names - .filter((name) => !currentCaches.has(name) && !name.startsWith('workbox-precache')) - .map((name) => caches.delete(name)) - ) - ) + (async () => { + // Drop caches from older SW versions that used Workbox caching. + const keys = await caches.keys(); + await Promise.all(keys.map((name) => caches.delete(name))); + await self.clients.claim(); + })() ); }); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if ( + event.request.method === 'POST' && + url.pathname === '/upload-share-target' + ) { + event.respondWith(handleShareTarget(event.request)); + } +}); + +async function handleShareTarget(request: Request): Promise { + try { + const formData = await request.formData(); + const files = formData.getAll('file'); + const title = formData.get('title'); + const text = formData.get('text'); + const urlField = formData.get('url'); + + await set('share-target', { + files, + title, + text, + url: urlField, + timestamp: Date.now(), + }); + + return Response.redirect('/new?shared=true&sw_status=success', 303); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + return Response.redirect( + '/new?shared=true&sw_status=error&sw_error=' + encodeURIComponent(errMsg), + 303 + ); + } +}