llm providers | app config | admin routes

This commit is contained in:
lovebird 2026-04-13 14:43:26 +02:00
parent b639b83b87
commit 0e634546b1
25 changed files with 410 additions and 455 deletions

View File

@ -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

View File

@ -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;

View File

@ -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,

View File

@ -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<BanList>({
export const BansManager = () => {
const [banList, setBanList] = useState<AdminBanList>({
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"), {

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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<ViolationStats>({
export const ViolationsMonitor = () => {
const [stats, setStats] = useState<AdminViolationStats>({
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"), {

View File

@ -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<string, any> | null;
is_active?: boolean | null;
settings?: Record<string, any> | 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<ProviderConfig[]>(`/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<ProviderEditDialogProps> = ({
}
// 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<ProviderEditDialogProps> = ({
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');
}

View File

@ -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<ProviderSelectorProps> = ({
setLoading(true);
try {
const userProviders = await apiClient<any[]>(`/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,

View File

@ -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<any[]>(`/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;

View File

@ -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<string, any>;
is_active?: boolean;
settings?: Record<string, any>;
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<ProviderConfig[]>(`/api/provider-configs?userId=${user.id}&is_active=true`);
const data = await fetchProviderConfigs(user.id, { is_active: true });
setProviders(data || []);

View File

@ -4,10 +4,11 @@ import { apiClient, serverUrl } from '@/lib/db.js';
interface SystemInfo {
env: Record<string, string> & { appConfig?: AppConfig };
/** Product names with `enabled: true` in server config/products.json */
enabledProducts: string[];
}
export const useSystemInfo = () => {
console.log(`useSystemInfo : ${serverUrl}`);
return useQuery<SystemInfo>({
queryKey: ['system-info'],
queryFn: () => apiClient<SystemInfo>('/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;
};

View File

@ -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 <T>(
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<string | undefined> => {
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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
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<T>(endpoint: string, options: RequestInit = {}):
export async function apiClientOr404<T>(endpoint: string, options: RequestInit = {}): Promise<T | null> {
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 },

View File

@ -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<ProviderConfig[]> => {
/** Pass `userId` (app UUID) to load saved providers; without it, defaults are returned. */
export const getAvailableProviders = async (userId?: string): Promise<ProviderConfig[]> => {
if (!userId) {
return Object.values(DEFAULT_PROVIDERS).filter(provider => provider.isActive);
}
try {
const data = await apiClient<any[]>('/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));
}

View File

@ -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<unknown> {
return apiClient('/api/admin/system');
}
// --- Bans ---
export type AdminBanList = {
bannedIPs: string[];
bannedUserIds: string[];
bannedTokens: string[];
};
export async function getAdminBanList(): Promise<AdminBanList> {
return apiClient<AdminBanList>('/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<AdminViolationStats> {
return apiClient<AdminViolationStats>('/api/admin/bans/violations');
}
// --- User management (admin) — `/api/admin/users` ---
export const fetchAdminUsersAPI = async (): Promise<unknown[]> => {
return apiClient<unknown[]>('/api/admin/users');
};
export const createAdminUserAPI = async (
email: string,
password: string,
copySettings: boolean,
): Promise<unknown> => {
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<unknown> => {
return apiClient(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: 'PATCH',
body: JSON.stringify(profile),
});
};
export const deleteAdminUserAPI = async (userId: string): Promise<void> => {
await apiClient(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: 'DELETE',
});
};

View File

@ -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';

View File

@ -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<HeadersInit> {
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<Campaign[]>(`/api/campaigns?${params}`);
};
export const getCampaign = async (id: string): Promise<Campaign> => {
return apiFetch(`/api/campaigns/${id}`, { headers: await authHeaders() });
return apiClient<Campaign>(`/api/campaigns/${id}`);
};
export const createCampaign = async (data: Partial<Campaign>): Promise<Campaign> => {
return apiFetch('/api/campaigns', {
return apiClient<Campaign>('/api/campaigns', {
method: 'POST',
headers: await authHeaders('application/json'),
body: JSON.stringify(data),
});
};
export const updateCampaign = async (id: string, data: Partial<Campaign>): Promise<Campaign> => {
return apiFetch(`/api/campaigns/${id}`, {
return apiClient<Campaign>(`/api/campaigns/${id}`, {
method: 'PATCH',
headers: await authHeaders('application/json'),
body: JSON.stringify(data),
});
};
export const deleteCampaign = async (id: string): Promise<void> => {
await apiFetch(`/api/campaigns/${id}`, {
method: 'DELETE',
headers: await authHeaders(),
});
await apiClient<unknown>(`/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 }),
}
);
};

View File

@ -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<HeadersInit> {
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<T>(endpoint: string, init?: RequestInit): Promise<T> {
return apiClient<T>(endpoint, { ...init, credentials: 'omit' });
}
export interface MailboxItem {
@ -45,36 +40,23 @@ export interface MailboxInput {
provider?: 'generic' | 'mailersend' | 'mailersend_api';
}
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
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<MailboxItem[]> {
return apiFetch<MailboxItem[]>(API_BASE);
return mailboxApi<MailboxItem[]>(API_BASE);
}
export async function saveMailbox(input: MailboxInput): Promise<MailboxItem> {
return apiFetch<MailboxItem>(API_BASE, {
return mailboxApi<MailboxItem>(API_BASE, {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function deleteMailbox(id: string): Promise<void> {
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',
});
}

View File

@ -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<string | null> {
const { getAuthToken: getZitadelToken } = await import('@/lib/db');
return (await getZitadelToken()) ?? null;
}
async function authFetch(path: string, options: RequestInit = {}): Promise<Response> {
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<Transaction[]> {
const res = await authFetch('/api/transactions');
if (!res.ok) throw new Error(`Failed to list transactions: ${res.status}`);
return res.json();
return apiClient<Transaction[]>('/api/transactions');
}
/** Get a single transaction by ID */
export async function getTransaction(id: string): Promise<Transaction> {
const res = await authFetch(`/api/transactions/${id}`);
if (!res.ok) throw new Error(`Failed to get transaction: ${res.status}`);
return res.json();
return apiClient<Transaction>(`/api/transactions/${id}`);
}
/** Create a new transaction */
export async function createTransaction(input: CreateTransactionInput): Promise<Transaction> {
const res = await authFetch('/api/transactions', {
return apiClient<Transaction>('/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<Transaction> {
const res = await authFetch(`/api/transactions/${id}`, {
return apiClient<Transaction>(`/api/transactions/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`Failed to update transaction: ${res.status}`);
return res.json();
}

View File

@ -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<string> => {
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<string, unknown> | null;
is_active?: boolean | null;
settings?: Record<string, unknown> | 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<ProviderConfigRow[]> => {
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<ProviderConfigRow[]>(`/api/provider-configs${qs ? `?${qs}` : ''}`);
});
};
export const createProviderConfig = async (
_userId: string,
body: Omit<ProviderConfigRow, 'id' | 'created_at' | 'updated_at'> & { user_id?: string },
): Promise<ProviderConfigRow> => {
return apiClient<ProviderConfigRow>('/api/provider-configs', {
method: 'POST',
body: JSON.stringify(body),
});
};
export const updateProviderConfig = async (
_userId: string,
id: string,
body: Partial<Omit<ProviderConfigRow, 'id' | 'created_at' | 'updated_at'>>,
): Promise<ProviderConfigRow> => {
return apiClient<ProviderConfigRow>(`/api/provider-configs/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(body),
});
};
export const deleteProviderConfig = async (_userId: string, id: string): Promise<void> => {
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();
});
};

View File

@ -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<void> => {
});
};
// =============================================
// Admin User Management API Wrappers
// =============================================
const getAuthToken = async (): Promise<string> => {
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<any[]> => {
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<any> => {
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<any> => {
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<void> => {
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 => {

View File

@ -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 <div className="min-h-screen bg-background flex items-center justify-center">
@ -47,9 +47,9 @@ const AdminPage = () => {
<Route path="/" element={<Navigate to="users" replace />} />
<Route path="users" element={<UserManagerSection />} />
<Route path="dashboard" element={<DashboardSection />} />
<Route path="server" element={<ServerSection session={session} />} />
<Route path="bans" element={<BansSection session={session} />} />
<Route path="violations" element={<ViolationsSection session={session} />} />
<Route path="server" element={<ServerSection />} />
<Route path="bans" element={<BansSection />} />
<Route path="violations" element={<ViolationsSection />} />
<Route path="analytics" element={
<Suspense fallback={<div><T>Loading analytics...</T></div>}>
<AnalyticsDashboard />
@ -95,31 +95,21 @@ const DashboardSection = () => (
</div>
);
const BansSection = ({ session }: { session: any }) => (
<BansManager session={session} />
const BansSection = () => (
<BansManager />
);
const ViolationsSection = ({ session }: { session: any }) => (
<ViolationsMonitor session={session} />
const ViolationsSection = () => (
<ViolationsMonitor />
);
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 }) => {
<div className="bg-card border rounded-lg p-6 max-w-2xl mt-6">
<h2 className="text-lg font-semibold mb-4"><T>System Resources</T></h2>
<SystemStats session={session} />
<SystemStats />
</div>
<div className="bg-card border rounded-lg p-6 max-w-2xl mt-6">
@ -247,7 +225,7 @@ interface WorkerPing {
import { useWebSocket } from '@/contexts/WS_Socket';
const SystemStats = ({ session }: { session: any }) => {
const SystemStats = () => {
const [stats, setStats] = useState<SystemData | null>(null);
const { addListener, removeListener, sendCommand } = useWebSocket();
@ -377,7 +355,7 @@ const SystemStats = ({ session }: { session: any }) => {
</div>
{/* Worker Threads */}
<WorkerThreadStats session={session} />
<WorkerThreadStats />
{/* 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<WorkerPing[]>([]);
const { addListener, removeListener } = useWebSocket();

View File

@ -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 = () => {

View File

@ -1,126 +1,63 @@
/// <reference lib="webworker" />
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<Response> {
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
);
}
}