feat(ui): 🎉 More ui work
* Implement EventLogsPage for viewing system activity logs with search and filter capabilities. * Create HooksPage for managing webhook configurations with create, edit, and delete functionalities. * Develop LoginPage for user authentication with error handling and loading states. * Add UsersPage for managing system users, including role assignment and status toggling. * Introduce authStore for managing user authentication state and actions. * Define TypeScript types for User, Hook, EventLog, and other entities. * Set up TypeScript configuration for the project. * Configure Vite for development with proxy settings for API calls. * Update dependencies for improved functionality and security.
This commit is contained in:
59
web/src/App.tsx
Normal file
59
web/src/App.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardLayout from './components/DashboardLayout';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import HooksPage from './pages/HooksPage';
|
||||
import AccountsPage from './pages/AccountsPage';
|
||||
import EventLogsPage from './pages/EventLogsPage';
|
||||
|
||||
// Import Mantine styles
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, checkAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
return (
|
||||
<MantineProvider defaultColorScheme="light">
|
||||
<Notifications position="top-right" />
|
||||
<ModalsProvider>
|
||||
<BrowserRouter basename="/ui">
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={
|
||||
isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />
|
||||
} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route path="/" element={
|
||||
isAuthenticated ? <DashboardLayout /> : <Navigate to="/login" replace />
|
||||
}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="hooks" element={<HooksPage />} />
|
||||
<Route path="accounts" element={<AccountsPage />} />
|
||||
<Route path="event-logs" element={<EventLogsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
web/src/assets/react.svg
Normal file
1
web/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
109
web/src/components/DashboardLayout.tsx
Normal file
109
web/src/components/DashboardLayout.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { AppShell, Burger, Group, Text, NavLink, Button, Avatar, Stack, Badge } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconDashboard,
|
||||
IconUsers,
|
||||
IconWebhook,
|
||||
IconBrandWhatsapp,
|
||||
IconFileText,
|
||||
IconLogout
|
||||
} from '@tabler/icons-react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
export default function DashboardLayout() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [opened, { toggle }] = useDisclosure();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: IconDashboard },
|
||||
{ path: '/users', label: 'Users', icon: IconUsers },
|
||||
{ path: '/hooks', label: 'Hooks', icon: IconWebhook },
|
||||
{ path: '/accounts', label: 'WhatsApp Accounts', icon: IconBrandWhatsapp },
|
||||
{ path: '/event-logs', label: 'Event Logs', icon: IconFileText },
|
||||
];
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{
|
||||
width: 280,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !opened },
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Group>
|
||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||
<Text size="xl" fw={700}>WhatsHooked</Text>
|
||||
<Badge color="blue" variant="light">Admin</Badge>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed">{user?.username || 'User'}</Text>
|
||||
<Avatar color="blue" radius="xl" size="sm">
|
||||
{user?.username?.[0]?.toUpperCase() || 'U'}
|
||||
</Avatar>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="md">
|
||||
<AppShell.Section grow>
|
||||
<Stack gap="xs">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
label={item.label}
|
||||
leftSection={<item.icon size={20} stroke={1.5} />}
|
||||
active={isActive(item.path)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(item.path);
|
||||
if (opened) toggle();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
|
||||
<AppShell.Section>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" px="sm">
|
||||
<div>
|
||||
<Text size="sm" fw={500}>{user?.username || 'User'}</Text>
|
||||
<Text size="xs" c="dimmed">{user?.role || 'user'}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconLogout size={16} />}
|
||||
variant="light"
|
||||
color="red"
|
||||
fullWidth
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Outlet />
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
12
web/src/index.css
Normal file
12
web/src/index.css
Normal file
@@ -0,0 +1,12 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
202
web/src/lib/api.ts
Normal file
202
web/src/lib/api.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import axios, { type AxiosInstance, AxiosError } from 'axios';
|
||||
import type {
|
||||
User, Hook, WhatsAppAccount, EventLog, APIKey,
|
||||
LoginRequest, LoginResponse
|
||||
} from '../types';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
this.client.interceptors.request.use((config) => {
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor for error handling
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token expired or invalid, clear auth and redirect
|
||||
this.clearAuth();
|
||||
window.location.href = '/ui/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Token management
|
||||
private getToken(): string | null {
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
|
||||
private setToken(token: string): void {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
|
||||
private clearAuth(): void {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
const { data } = await this.client.post<LoginResponse>('/api/v1/auth/login', credentials);
|
||||
if (data.token) {
|
||||
this.setToken(data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await this.client.post('/api/v1/auth/logout');
|
||||
} finally {
|
||||
this.clearAuth();
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentUser(): User | null {
|
||||
const userStr = localStorage.getItem('user');
|
||||
return userStr ? JSON.parse(userStr) : null;
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.getToken();
|
||||
}
|
||||
|
||||
// Unified query endpoint
|
||||
async query(request: {
|
||||
action: 'list' | 'get' | 'create' | 'update' | 'delete';
|
||||
table: string;
|
||||
id?: string;
|
||||
data?: Record<string, any>;
|
||||
filters?: Record<string, any>;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<any> {
|
||||
const { data } = await this.client.post('/api/v1/query', request);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Users API
|
||||
async getUsers(): Promise<User[]> {
|
||||
const { data } = await this.client.get<User[]>('/api/v1/users');
|
||||
return data;
|
||||
}
|
||||
|
||||
async getUser(id: string): Promise<User> {
|
||||
const { data } = await this.client.get<User>(`/api/v1/users/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createUser(user: Partial<User>): Promise<User> {
|
||||
const { data } = await this.client.post<User>('/api/v1/users', user);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateUser(id: string, user: Partial<User>): Promise<User> {
|
||||
const { data } = await this.client.put<User>(`/api/v1/users/${id}`, user);
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteUser(id: string): Promise<void> {
|
||||
await this.client.delete(`/api/v1/users/${id}`);
|
||||
}
|
||||
|
||||
// Hooks API
|
||||
async getHooks(): Promise<Hook[]> {
|
||||
const { data } = await this.client.get<Hook[]>('/api/v1/hooks');
|
||||
return data;
|
||||
}
|
||||
|
||||
async getHook(id: string): Promise<Hook> {
|
||||
const { data } = await this.client.get<Hook>(`/api/v1/hooks/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createHook(hook: Partial<Hook>): Promise<Hook> {
|
||||
const { data } = await this.client.post<Hook>('/api/v1/hooks', hook);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateHook(id: string, hook: Partial<Hook>): Promise<Hook> {
|
||||
const { data } = await this.client.put<Hook>(`/api/v1/hooks/${id}`, hook);
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteHook(id: string): Promise<void> {
|
||||
await this.client.delete(`/api/v1/hooks/${id}`);
|
||||
}
|
||||
|
||||
// WhatsApp Accounts API
|
||||
async getAccounts(): Promise<WhatsAppAccount[]> {
|
||||
const { data } = await this.client.get<WhatsAppAccount[]>('/api/v1/whatsapp_accounts');
|
||||
return data;
|
||||
}
|
||||
|
||||
async getAccount(id: string): Promise<WhatsAppAccount> {
|
||||
const { data } = await this.client.get<WhatsAppAccount>(`/api/v1/whatsapp_accounts/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createAccount(account: Partial<WhatsAppAccount>): Promise<WhatsAppAccount> {
|
||||
const { data} = await this.client.post<WhatsAppAccount>('/api/v1/whatsapp_accounts', account);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateAccount(id: string, account: Partial<WhatsAppAccount>): Promise<WhatsAppAccount> {
|
||||
const { data } = await this.client.put<WhatsAppAccount>(`/api/v1/whatsapp_accounts/${id}`, account);
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteAccount(id: string): Promise<void> {
|
||||
await this.client.delete(`/api/v1/whatsapp_accounts/${id}`);
|
||||
}
|
||||
|
||||
// Event Logs API
|
||||
async getEventLogs(params?: { limit?: number; offset?: number }): Promise<EventLog[]> {
|
||||
const { data } = await this.client.get<EventLog[]>('/api/v1/event_logs', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
// API Keys API
|
||||
async getAPIKeys(): Promise<APIKey[]> {
|
||||
const { data } = await this.client.get<APIKey[]>('/api/v1/api_keys');
|
||||
return data;
|
||||
}
|
||||
|
||||
async createAPIKey(apiKey: Partial<APIKey>): Promise<APIKey> {
|
||||
const { data } = await this.client.post<APIKey>('/api/v1/api_keys', apiKey);
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteAPIKey(id: string): Promise<void> {
|
||||
await this.client.delete(`/api/v1/api_keys/${id}`);
|
||||
}
|
||||
|
||||
// Health check
|
||||
async healthCheck(): Promise<{ status: string }> {
|
||||
const { data } = await this.client.get<{ status: string }>('/health');
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
export default apiClient;
|
||||
87
web/src/lib/query.ts
Normal file
87
web/src/lib/query.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { apiClient } from './api';
|
||||
|
||||
export interface QueryRequest {
|
||||
action: 'list' | 'get' | 'create' | 'update' | 'delete';
|
||||
table: string;
|
||||
id?: string;
|
||||
data?: Record<string, any>;
|
||||
filters?: Record<string, any>;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface QueryResponse<T = any> {
|
||||
data: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a ResolveSpec query
|
||||
*/
|
||||
export async function executeQuery<T = any>(request: QueryRequest): Promise<T> {
|
||||
const response = await apiClient.query(request);
|
||||
return response as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* List records from a table
|
||||
*/
|
||||
export async function listRecords<T = any>(
|
||||
table: string,
|
||||
filters?: Record<string, any>,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<T[]> {
|
||||
return executeQuery<T[]>({
|
||||
action: 'list',
|
||||
table,
|
||||
filters,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single record by ID
|
||||
*/
|
||||
export async function getRecord<T = any>(table: string, id: string): Promise<T> {
|
||||
return executeQuery<T>({
|
||||
action: 'get',
|
||||
table,
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record
|
||||
*/
|
||||
export async function createRecord<T = any>(table: string, data: Record<string, any>): Promise<T> {
|
||||
return executeQuery<T>({
|
||||
action: 'create',
|
||||
table,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing record
|
||||
*/
|
||||
export async function updateRecord<T = any>(table: string, id: string, data: Record<string, any>): Promise<T> {
|
||||
return executeQuery<T>({
|
||||
action: 'update',
|
||||
table,
|
||||
id,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record
|
||||
*/
|
||||
export async function deleteRecord(table: string, id: string): Promise<void> {
|
||||
await executeQuery({
|
||||
action: 'delete',
|
||||
table,
|
||||
id,
|
||||
});
|
||||
}
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
332
web/src/pages/AccountsPage.tsx
Normal file
332
web/src/pages/AccountsPage.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
Group,
|
||||
Modal,
|
||||
TextInput,
|
||||
Select,
|
||||
Textarea,
|
||||
Checkbox,
|
||||
Stack,
|
||||
Alert,
|
||||
Loader,
|
||||
Center,
|
||||
ActionIcon
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
import type { WhatsAppAccount } from '../types';
|
||||
|
||||
export default function AccountsPage() {
|
||||
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [editingAccount, setEditingAccount] = useState<WhatsAppAccount | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
phone_number: '',
|
||||
display_name: '',
|
||||
account_type: 'whatsmeow' as 'whatsmeow' | 'business-api',
|
||||
config: '',
|
||||
active: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await apiClient.getAccounts();
|
||||
setAccounts(data || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load accounts');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingAccount(null);
|
||||
setFormData({
|
||||
phone_number: '',
|
||||
display_name: '',
|
||||
account_type: 'whatsmeow',
|
||||
config: '',
|
||||
active: true
|
||||
});
|
||||
open();
|
||||
};
|
||||
|
||||
const handleEdit = (account: WhatsAppAccount) => {
|
||||
setEditingAccount(account);
|
||||
setFormData({
|
||||
phone_number: account.phone_number,
|
||||
display_name: account.display_name || '',
|
||||
account_type: account.account_type,
|
||||
config: account.config || '',
|
||||
active: account.active
|
||||
});
|
||||
open();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this account?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiClient.deleteAccount(id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Account deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
await loadAccounts();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete account',
|
||||
color: 'red',
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate config JSON if not empty
|
||||
if (formData.config) {
|
||||
try {
|
||||
JSON.parse(formData.config);
|
||||
} catch {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Config must be valid JSON',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingAccount) {
|
||||
await apiClient.updateAccount(editingAccount.id, formData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Account updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
await apiClient.createAccount(formData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Account created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
close();
|
||||
await loadAccounts();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to ${editingAccount ? 'update' : 'create'} account`,
|
||||
color: 'red',
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected': return 'green';
|
||||
case 'connecting': return 'yellow';
|
||||
case 'disconnected': return 'red';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
<Button onClick={loadAccounts}>Retry</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Group justify="space-between" mb="xl">
|
||||
<div>
|
||||
<Title order={2}>WhatsApp Accounts</Title>
|
||||
<Text c="dimmed" size="sm">Manage your WhatsApp Business and personal accounts</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleCreate}>
|
||||
New Account
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Table highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Phone Number</Table.Th>
|
||||
<Table.Th>Display Name</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Connection</Table.Th>
|
||||
<Table.Th>Last Connected</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{accounts.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Center h={200}>
|
||||
<Stack align="center">
|
||||
<IconBrandWhatsapp size={48} stroke={1.5} color="gray" />
|
||||
<Text c="dimmed">No WhatsApp accounts configured. Add your first account to start sending messages.</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<Table.Tr key={account.id}>
|
||||
<Table.Td fw={500}>{account.phone_number || '-'}</Table.Td>
|
||||
<Table.Td>{account.display_name || '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={account.account_type === 'whatsmeow' ? 'green' : 'blue'} variant="light">
|
||||
{account.account_type === 'whatsmeow' ? 'WhatsApp' : 'Business API'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={getStatusColor(account.status)} variant="light">
|
||||
{account.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{account.last_connected_at
|
||||
? new Date(account.last_connected_at).toLocaleString()
|
||||
: 'Never'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={account.active ? 'green' : 'red'} variant="light">
|
||||
{account.active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => handleEdit(account)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => handleDelete(account.id)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={editingAccount ? 'Edit Account' : 'Create Account'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Phone Number"
|
||||
placeholder="+1234567890"
|
||||
value={formData.phone_number}
|
||||
onChange={(e) => setFormData({ ...formData, phone_number: e.target.value })}
|
||||
required
|
||||
description="Include country code (e.g., +1 for US)"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
placeholder="My Business Account"
|
||||
value={formData.display_name}
|
||||
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Account Type"
|
||||
value={formData.account_type}
|
||||
onChange={(value) => setFormData({ ...formData, account_type: value as 'whatsmeow' | 'business-api' })}
|
||||
data={[
|
||||
{ value: 'whatsmeow', label: 'WhatsApp (WhatsMe)' },
|
||||
{ value: 'business-api', label: 'Business API' }
|
||||
]}
|
||||
required
|
||||
description="WhatsApp: Personal/WhatsApp Business app connection. Business API: Official WhatsApp Business API"
|
||||
/>
|
||||
|
||||
{formData.account_type === 'business-api' && (
|
||||
<Textarea
|
||||
label="Business API Config (JSON)"
|
||||
placeholder={`{
|
||||
"api_key": "your-api-key",
|
||||
"api_url": "https://api.whatsapp.com",
|
||||
"phone_number_id": "123456"
|
||||
}`}
|
||||
value={formData.config}
|
||||
onChange={(e) => setFormData({ ...formData, config: e.target.value })}
|
||||
rows={6}
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: '13px' } }}
|
||||
description="Business API credentials and configuration"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
label="Active"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.currentTarget.checked })}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={close}>Cancel</Button>
|
||||
<Button type="submit">{editingAccount ? 'Update' : 'Create'}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
147
web/src/pages/DashboardPage.tsx
Normal file
147
web/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Paper,
|
||||
Group,
|
||||
ThemeIcon,
|
||||
Loader,
|
||||
Center,
|
||||
Stack
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconUsers,
|
||||
IconWebhook,
|
||||
IconBrandWhatsapp,
|
||||
IconFileText
|
||||
} from '@tabler/icons-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
|
||||
interface Stats {
|
||||
users: number;
|
||||
hooks: number;
|
||||
accounts: number;
|
||||
eventLogs: number;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
color
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: any;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text c="dimmed" tt="uppercase" fw={700} fz="xs">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fw={700} fz="xl" mt="md">
|
||||
{value.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon
|
||||
color={color}
|
||||
variant="light"
|
||||
size={60}
|
||||
radius="md"
|
||||
>
|
||||
<Icon size={32} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
users: 0,
|
||||
hooks: 0,
|
||||
accounts: 0,
|
||||
eventLogs: 0
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [users, hooks, accounts, eventLogs] = await Promise.all([
|
||||
apiClient.getUsers(),
|
||||
apiClient.getHooks(),
|
||||
apiClient.getAccounts(),
|
||||
apiClient.getEventLogs({ limit: 1000, offset: 0 })
|
||||
]);
|
||||
|
||||
setStats({
|
||||
users: users?.length || 0,
|
||||
hooks: hooks?.length || 0,
|
||||
accounts: accounts?.length || 0,
|
||||
eventLogs: eventLogs?.length || 0
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={2}>Dashboard</Title>
|
||||
<Text c="dimmed" size="sm">Welcome to WhatsHooked Admin Panel</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
value={stats.users}
|
||||
icon={IconUsers}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Hooks"
|
||||
value={stats.hooks}
|
||||
icon={IconWebhook}
|
||||
color="teal"
|
||||
/>
|
||||
<StatCard
|
||||
title="WhatsApp Accounts"
|
||||
value={stats.accounts}
|
||||
icon={IconBrandWhatsapp}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Event Logs"
|
||||
value={stats.eventLogs}
|
||||
icon={IconFileText}
|
||||
color="violet"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
233
web/src/pages/EventLogsPage.tsx
Normal file
233
web/src/pages/EventLogsPage.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Table,
|
||||
Badge,
|
||||
Group,
|
||||
Alert,
|
||||
Loader,
|
||||
Center,
|
||||
Stack,
|
||||
TextInput,
|
||||
Select,
|
||||
Pagination,
|
||||
Code,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { IconAlertCircle, IconFileText, IconSearch } from '@tabler/icons-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
import type { EventLog } from '../types';
|
||||
|
||||
export default function EventLogsPage() {
|
||||
const [logs, setLogs] = useState<EventLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, []);
|
||||
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await apiClient.getEventLogs({ limit: 1000, offset: 0 });
|
||||
setLogs(data || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load event logs');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSuccessColor = (success: boolean) => {
|
||||
return success ? 'green' : 'red';
|
||||
};
|
||||
|
||||
// Filter logs
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
log.event_type.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.action?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.entity_type?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesType = !filterType || log.event_type === filterType;
|
||||
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
|
||||
// Paginate
|
||||
const totalPages = Math.ceil(filteredLogs.length / itemsPerPage);
|
||||
const paginatedLogs = filteredLogs.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
// Get unique event types for filter
|
||||
const eventTypes = Array.from(new Set(logs.map(log => log.event_type)));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Group justify="space-between" mb="xl">
|
||||
<div>
|
||||
<Title order={2}>Event Logs</Title>
|
||||
<Text c="dimmed" size="sm">System activity and audit trail</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
placeholder="Search logs..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Filter by type"
|
||||
data={[
|
||||
{ value: '', label: 'All Types' },
|
||||
...eventTypes.map(type => ({ value: type, label: type }))
|
||||
]}
|
||||
value={filterType}
|
||||
onChange={(value) => {
|
||||
setFilterType(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
clearable
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Table highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Timestamp</Table.Th>
|
||||
<Table.Th>Event Type</Table.Th>
|
||||
<Table.Th>Action</Table.Th>
|
||||
<Table.Th>Entity</Table.Th>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Details</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{paginatedLogs.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Center h={200}>
|
||||
<Stack align="center">
|
||||
<IconFileText size={48} stroke={1.5} color="gray" />
|
||||
<Text c="dimmed">
|
||||
{searchQuery || filterType ? 'No matching logs found' : 'No event logs available'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
paginatedLogs.map((log) => {
|
||||
let entityDisplay = log.entity_type || '-';
|
||||
if (log.entity_id) {
|
||||
entityDisplay += ` (${log.entity_id.substring(0, 8)}...)`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.Tr key={log.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">
|
||||
{log.event_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{log.action || '-'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{entityDisplay}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{log.user_id ? `User ${log.user_id.substring(0, 8)}...` : '-'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={getSuccessColor(log.success)} variant="light">
|
||||
{log.success ? 'Success' : 'Failed'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{log.error ? (
|
||||
<Tooltip label={log.error} position="left" multiline w={300}>
|
||||
<Code color="red" style={{ cursor: 'help' }}>Error</Code>
|
||||
</Tooltip>
|
||||
) : log.data ? (
|
||||
<Tooltip label={log.data} position="left" multiline w={300}>
|
||||
<Code style={{ cursor: 'help' }}>View Data</Code>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">-</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center" mt="xl">
|
||||
<Pagination
|
||||
total={totalPages}
|
||||
value={currentPage}
|
||||
onChange={setCurrentPage}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Showing {paginatedLogs.length} of {filteredLogs.length} logs
|
||||
</Text>
|
||||
{(searchQuery || filterType) && (
|
||||
<Text size="sm" c="dimmed">
|
||||
(filtered from {logs.length} total)
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
427
web/src/pages/HooksPage.tsx
Normal file
427
web/src/pages/HooksPage.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
Group,
|
||||
Modal,
|
||||
TextInput,
|
||||
Select,
|
||||
Textarea,
|
||||
NumberInput,
|
||||
Checkbox,
|
||||
Stack,
|
||||
Alert,
|
||||
Loader,
|
||||
Center,
|
||||
ActionIcon,
|
||||
Code,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconWebhook } from '@tabler/icons-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
import type { Hook } from '../types';
|
||||
|
||||
export default function HooksPage() {
|
||||
const [hooks, setHooks] = useState<Hook[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [editingHook, setEditingHook] = useState<Hook | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
url: '',
|
||||
method: 'POST',
|
||||
description: '',
|
||||
secret: '',
|
||||
headers: '',
|
||||
events: '',
|
||||
retry_count: 3,
|
||||
timeout: 30,
|
||||
active: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadHooks();
|
||||
}, []);
|
||||
|
||||
const loadHooks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await apiClient.getHooks();
|
||||
setHooks(data || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load hooks');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingHook(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
method: 'POST',
|
||||
description: '',
|
||||
secret: '',
|
||||
headers: '',
|
||||
events: '',
|
||||
retry_count: 3,
|
||||
timeout: 30,
|
||||
active: true
|
||||
});
|
||||
open();
|
||||
};
|
||||
|
||||
const handleEdit = (hook: Hook) => {
|
||||
setEditingHook(hook);
|
||||
setFormData({
|
||||
name: hook.name,
|
||||
url: hook.url,
|
||||
method: hook.method,
|
||||
description: hook.description || '',
|
||||
secret: hook.secret || '',
|
||||
headers: hook.headers || '',
|
||||
events: hook.events || '',
|
||||
retry_count: hook.retry_count,
|
||||
timeout: hook.timeout,
|
||||
active: hook.active
|
||||
});
|
||||
open();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this hook?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiClient.deleteHook(id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Hook deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
await loadHooks();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete hook',
|
||||
color: 'red',
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate URL
|
||||
try {
|
||||
new URL(formData.url);
|
||||
} catch {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Please enter a valid URL',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate JSON fields if not empty
|
||||
if (formData.headers) {
|
||||
try {
|
||||
JSON.parse(formData.headers);
|
||||
} catch {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Headers must be valid JSON',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (formData.events) {
|
||||
try {
|
||||
JSON.parse(formData.events);
|
||||
} catch {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Events must be valid JSON',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingHook) {
|
||||
await apiClient.updateHook(editingHook.id, formData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Hook updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
await apiClient.createHook(formData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Hook created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
close();
|
||||
await loadHooks();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to ${editingHook ? 'update' : 'create'} hook`,
|
||||
color: 'red',
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
<Button onClick={loadHooks}>Retry</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Group justify="space-between" mb="xl">
|
||||
<div>
|
||||
<Title order={2}>Webhooks</Title>
|
||||
<Text c="dimmed" size="sm">Manage webhook endpoints for WhatsApp events</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleCreate}>
|
||||
New Hook
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Table highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>URL</Table.Th>
|
||||
<Table.Th>Method</Table.Th>
|
||||
<Table.Th>Events</Table.Th>
|
||||
<Table.Th>Retry</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{hooks.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={8}>
|
||||
<Center h={200}>
|
||||
<Stack align="center">
|
||||
<IconWebhook size={48} stroke={1.5} color="gray" />
|
||||
<Text c="dimmed">No hooks configured. Create your first webhook to start receiving WhatsApp events.</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
hooks.map((hook) => {
|
||||
let eventsList = [];
|
||||
try {
|
||||
eventsList = hook.events ? JSON.parse(hook.events) : [];
|
||||
} catch {
|
||||
eventsList = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.Tr key={hook.id}>
|
||||
<Table.Td fw={500}>{hook.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label={hook.url} position="top">
|
||||
<Code>
|
||||
{hook.url.length > 40 ? hook.url.substring(0, 40) + '...' : hook.url}
|
||||
</Code>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={
|
||||
hook.method === 'POST' ? 'blue' :
|
||||
hook.method === 'GET' ? 'green' :
|
||||
hook.method === 'PUT' ? 'yellow' : 'pink'
|
||||
} variant="light">
|
||||
{hook.method}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{eventsList.length > 0 ? (
|
||||
<Tooltip label={eventsList.join(', ')} position="top">
|
||||
<Badge variant="outline">
|
||||
{eventsList.length} event{eventsList.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text c="dimmed" size="sm" fs="italic">All events</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{hook.retry_count}x</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={hook.active ? 'green' : 'red'} variant="light">
|
||||
{hook.active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{new Date(hook.created_at).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => handleEdit(hook)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => handleDelete(hook.id)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={editingHook ? 'Edit Hook' : 'Create Hook'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="My Webhook"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Method"
|
||||
value={formData.method}
|
||||
onChange={(value) => setFormData({ ...formData, method: value || 'POST' })}
|
||||
data={['POST', 'PUT', 'PATCH', 'GET']}
|
||||
required
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="URL"
|
||||
placeholder="https://example.com/webhook"
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Optional description of this webhook"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Secret Key"
|
||||
placeholder="Optional secret for HMAC signature"
|
||||
type="password"
|
||||
value={formData.secret}
|
||||
onChange={(e) => setFormData({ ...formData, secret: e.target.value })}
|
||||
description="Used to sign webhook payloads for verification"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Custom Headers (JSON)"
|
||||
placeholder='{"Authorization": "Bearer token", "X-Custom": "value"}'
|
||||
value={formData.headers}
|
||||
onChange={(e) => setFormData({ ...formData, headers: e.target.value })}
|
||||
rows={3}
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: '13px' } }}
|
||||
description="Optional JSON object with custom HTTP headers"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Event Filter (JSON Array)"
|
||||
placeholder='["message.received", "message.sent", "status.update"]'
|
||||
value={formData.events}
|
||||
onChange={(e) => setFormData({ ...formData, events: e.target.value })}
|
||||
rows={3}
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: '13px' } }}
|
||||
description="Leave empty to receive all events, or specify an array of event types"
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Retry Count"
|
||||
value={formData.retry_count}
|
||||
onChange={(value) => setFormData({ ...formData, retry_count: Number(value) || 0 })}
|
||||
min={0}
|
||||
max={10}
|
||||
required
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Timeout (seconds)"
|
||||
value={formData.timeout}
|
||||
onChange={(value) => setFormData({ ...formData, timeout: Number(value) || 30 })}
|
||||
min={1}
|
||||
max={300}
|
||||
required
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Checkbox
|
||||
label="Active"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.currentTarget.checked })}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={close}>Cancel</Button>
|
||||
<Button type="submit">{editingHook ? 'Update' : 'Create'}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
117
web/src/pages/LoginPage.tsx
Normal file
117
web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Title,
|
||||
Text,
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Button,
|
||||
Stack,
|
||||
Alert,
|
||||
Center,
|
||||
Box
|
||||
} from '@mantine/core';
|
||||
import { IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
// Error is handled in the store
|
||||
console.error('Login failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}
|
||||
>
|
||||
<Container size={420}>
|
||||
<Paper radius="md" p="xl" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Center>
|
||||
<IconBrandWhatsapp size={48} color="#25D366" />
|
||||
</Center>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Title order={2}>WhatsHooked</Title>
|
||||
<Text c="dimmed" size="sm" mt={5}>
|
||||
Sign in to your account
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
title="Authentication Error"
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Enter your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
mt="md"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Alert variant="light" color="blue">
|
||||
<Text size="sm" ta="center">
|
||||
Default credentials: <strong>admin</strong> / <strong>admin123</strong>
|
||||
</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
318
web/src/pages/UsersPage.tsx
Normal file
318
web/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
Group,
|
||||
Modal,
|
||||
TextInput,
|
||||
Select,
|
||||
Checkbox,
|
||||
Stack,
|
||||
Alert,
|
||||
Loader,
|
||||
Center,
|
||||
ActionIcon
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconEdit, IconTrash, IconPlus, IconAlertCircle } from '@tabler/icons-react';
|
||||
import { listRecords, createRecord, updateRecord, deleteRecord } from '../lib/query';
|
||||
import type { User } from '../types';
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'user' as 'admin' | 'user',
|
||||
active: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await listRecords<User>('users');
|
||||
setUsers(data || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load users');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingUser(null);
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'user',
|
||||
active: true
|
||||
});
|
||||
open();
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
full_name: user.full_name || '',
|
||||
role: user.role,
|
||||
active: user.active
|
||||
});
|
||||
open();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this user?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteRecord('users', id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete user',
|
||||
color: 'red',
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingUser) {
|
||||
const updateData: Partial<User> & { password?: string } = {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
full_name: formData.full_name,
|
||||
role: formData.role,
|
||||
active: formData.active
|
||||
};
|
||||
if (formData.password) {
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
await updateRecord('users', editingUser.id, updateData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
if (!formData.password) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Password is required for new users',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
await createRecord('users', formData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
close();
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to ${editingUser ? 'update' : 'create'} user`,
|
||||
color: 'red',
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
<Button onClick={loadUsers}>Retry</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Group justify="space-between" mb="xl">
|
||||
<div>
|
||||
<Title order={2}>Users</Title>
|
||||
<Text c="dimmed" size="sm">Manage system users and permissions</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleCreate}>
|
||||
New User
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Table highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Username</Table.Th>
|
||||
<Table.Th>Email</Table.Th>
|
||||
<Table.Th>Full Name</Table.Th>
|
||||
<Table.Th>Role</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{users.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Center h={200}>
|
||||
<Text c="dimmed">No users found. Create your first user to get started.</Text>
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td fw={500}>{user.username}</Table.Td>
|
||||
<Table.Td>{user.email}</Table.Td>
|
||||
<Table.Td>{user.full_name || '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={user.role === 'admin' ? 'blue' : 'indigo'} variant="light">
|
||||
{user.role}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={user.active ? 'green' : 'red'} variant="light">
|
||||
{user.active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{new Date(user.created_at).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => handleEdit(user)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={editingUser ? 'Edit User' : 'Create User'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="johndoe"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="john@example.com"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Password"
|
||||
placeholder={editingUser ? 'Leave blank to keep current' : 'Enter password'}
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required={!editingUser}
|
||||
description={editingUser ? 'Leave blank to keep current password' : undefined}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="John Doe"
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Role"
|
||||
value={formData.role}
|
||||
onChange={(value) => setFormData({ ...formData, role: value as 'admin' | 'user' })}
|
||||
data={[
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'admin', label: 'Admin' }
|
||||
]}
|
||||
required
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Active"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.currentTarget.checked })}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={close}>Cancel</Button>
|
||||
<Button type="submit">{editingUser ? 'Update' : 'Create'}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
65
web/src/stores/authStore.ts
Normal file
65
web/src/stores/authStore.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { create } from 'zustand';
|
||||
import type { User } from '../types';
|
||||
import { apiClient } from '../lib/api';
|
||||
|
||||
interface AuthStore {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>((set) => ({
|
||||
user: apiClient.getCurrentUser(),
|
||||
isAuthenticated: apiClient.isAuthenticated(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (username: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await apiClient.login({ username, password });
|
||||
set({
|
||||
user: response.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || 'Login failed';
|
||||
set({
|
||||
error: errorMessage,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
user: null
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
await apiClient.logout();
|
||||
} finally {
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
checkAuth: () => {
|
||||
const user = apiClient.getCurrentUser();
|
||||
const isAuthenticated = apiClient.isAuthenticated();
|
||||
set({ user, isAuthenticated });
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
109
web/src/types/index.ts
Normal file
109
web/src/types/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// API Response Types
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
role: 'admin' | 'user';
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Hook {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: string; // JSON string
|
||||
events?: string; // JSON string
|
||||
description?: string;
|
||||
secret?: string;
|
||||
retry_count: number;
|
||||
timeout: number;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WhatsAppAccount {
|
||||
id: string;
|
||||
user_id: string;
|
||||
phone_number: string;
|
||||
display_name?: string;
|
||||
account_type: 'whatsmeow' | 'business-api';
|
||||
status: 'connected' | 'disconnected' | 'connecting';
|
||||
config?: string; // JSON string
|
||||
session_path?: string;
|
||||
last_connected_at?: string;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface EventLog {
|
||||
id: string;
|
||||
user_id?: string;
|
||||
event_type: string;
|
||||
action?: string;
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
data?: string; // JSON string
|
||||
success: boolean;
|
||||
error?: string;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface APIKey {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
key_prefix?: string;
|
||||
permissions?: string; // JSON string
|
||||
expires_at?: string;
|
||||
last_used_at?: string;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
user_id: string;
|
||||
token: string;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Auth Types
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
// API Response wrappers
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
Reference in New Issue
Block a user