feat(ui): 🎉 More ui work
Some checks failed
CI / Test (1.23) (push) Failing after -22m35s
CI / Test (1.22) (push) Failing after -22m33s
CI / Build (push) Failing after -23m42s
CI / Lint (push) Failing after -23m17s

* 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:
Hein
2026-02-05 19:41:49 +02:00
parent f9773bd07f
commit 8b1eed6c42
32 changed files with 7293 additions and 38 deletions

59
web/src/App.tsx Normal file
View 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
View 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

View 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
View 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
View 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
View 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
View 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>,
)

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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>
);
}

View 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
View 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;
}