feat: web frontend; middleware; serde (WIP?)
This commit is contained in:
7
frontend/.gitignore
vendored
Normal file
7
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Ignore node_modules and build artifacts
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
|
||||
# Keep the placeholder index.html for development
|
||||
!dist/index.html
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Linspirer Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2780
frontend/package-lock.json
generated
Normal file
2780
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "mylinspirer-admin",
|
||||
"version": "0.1.0",
|
||||
"description": "Admin frontend for My Linspirer MITM Server",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-solid": "^2.10.2"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
96
frontend/src/App.tsx
Normal file
96
frontend/src/App.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Component, createSignal, onMount, Show } from 'solid-js';
|
||||
import RulesList from './components/RulesList';
|
||||
import CommandQueue from './components/CommandQueue';
|
||||
import Login from './components/Login';
|
||||
import ChangePassword from './components/ChangePassword';
|
||||
import { authStore } from './api/auth';
|
||||
|
||||
const App: Component = () => {
|
||||
const [activeTab, setActiveTab] = createSignal<'rules' | 'commands'>('rules');
|
||||
const [isAuthenticated, setIsAuthenticated] = createSignal(false);
|
||||
const [showChangePassword, setShowChangePassword] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
// Check if user is already authenticated
|
||||
setIsAuthenticated(authStore.isAuthenticated());
|
||||
});
|
||||
|
||||
const handleLoginSuccess = (token: string) => {
|
||||
authStore.setToken(token);
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.clearToken();
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={isAuthenticated()} fallback={<Login onLoginSuccess={handleLoginSuccess} />}>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<header class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 py-6 flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
My Linspirer Control Panel
|
||||
</h1>
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowChangePassword(true)}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="bg-white border-b">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('rules')}
|
||||
class={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab() === 'rules'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Interception Rules
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('commands')}
|
||||
class={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab() === 'commands'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Command Queue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||
{activeTab() === 'rules' && <RulesList />}
|
||||
{activeTab() === 'commands' && <CommandQueue />}
|
||||
</main>
|
||||
|
||||
<Show when={showChangePassword()}>
|
||||
<ChangePassword
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
19
frontend/src/api/auth.ts
Normal file
19
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
const TOKEN_KEY = 'admin_token';
|
||||
|
||||
export const authStore = {
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
},
|
||||
|
||||
setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
},
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.getToken();
|
||||
},
|
||||
};
|
||||
105
frontend/src/api/client.ts
Normal file
105
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { InterceptionRule, Command, CreateRuleRequest, UpdateRuleRequest, UpdateCommandRequest } from '../types';
|
||||
import { authStore } from './auth';
|
||||
|
||||
const API_BASE = '/admin/api';
|
||||
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const token = authStore.getToken();
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...options?.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Unauthorized - clear token and trigger re-authentication
|
||||
authStore.clearToken();
|
||||
window.location.reload();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || `API Error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (response.status === 204 || response.status === 200) {
|
||||
// Check if response has content
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
// Check if there's actually content to parse
|
||||
const text = await response.text();
|
||||
if (!text || text.trim() === '') {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Rules API
|
||||
export const rulesApi = {
|
||||
list: () =>
|
||||
request<InterceptionRule[]>('/rules'),
|
||||
|
||||
create: (rule: CreateRuleRequest) =>
|
||||
request<InterceptionRule>('/rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(rule),
|
||||
}),
|
||||
|
||||
update: (id: number, rule: UpdateRuleRequest) =>
|
||||
request<InterceptionRule>(`/rules/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(rule),
|
||||
}),
|
||||
|
||||
delete: (id: number) =>
|
||||
request<void>(`/rules/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// Commands API
|
||||
export const commandsApi = {
|
||||
list: (status?: string) =>
|
||||
request<Command[]>(`/commands${status ? `?status=${status}` : ''}`),
|
||||
|
||||
updateStatus: (id: number, req: UpdateCommandRequest) =>
|
||||
request<Command>(`/commands/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
}),
|
||||
};
|
||||
|
||||
// Config API
|
||||
export const configApi = {
|
||||
get: () => request<Record<string, string>>('/config'),
|
||||
update: (config: Record<string, string>) =>
|
||||
request<void>('/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
};
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
request<void>('/password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
143
frontend/src/components/ChangePassword.tsx
Normal file
143
frontend/src/components/ChangePassword.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Component, createSignal } from 'solid-js';
|
||||
import { authApi } from '../api/client';
|
||||
import { authStore } from '../api/auth';
|
||||
|
||||
interface ChangePasswordProps {
|
||||
onClose: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
const ChangePassword: Component<ChangePasswordProps> = (props) => {
|
||||
const [oldPassword, setOldPassword] = createSignal('');
|
||||
const [newPassword, setNewPassword] = createSignal('');
|
||||
const [confirmPassword, setConfirmPassword] = createSignal('');
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [success, setSuccess] = createSignal(false);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
// Validation
|
||||
if (!oldPassword() || !newPassword() || !confirmPassword()) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword() !== confirmPassword()) {
|
||||
setError('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword().length < 6) {
|
||||
setError('New password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await authApi.changePassword(oldPassword(), newPassword());
|
||||
setSuccess(true);
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
|
||||
// Close modal and logout after 2 seconds
|
||||
setTimeout(() => {
|
||||
props.onClose();
|
||||
authStore.clearToken();
|
||||
props.onLogout();
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to change password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Change Password</h3>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} class="px-6 py-4 space-y-4">
|
||||
{error() && (
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success() && (
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
|
||||
Password changed successfully! You will be logged out...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Old Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword()}
|
||||
onInput={(e) => setOldPassword(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
disabled={loading()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword()}
|
||||
onInput={(e) => setNewPassword(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
disabled={loading()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword()}
|
||||
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
disabled={loading()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
disabled={loading()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading()}
|
||||
>
|
||||
{loading() ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePassword;
|
||||
83
frontend/src/components/CommandQueue.tsx
Normal file
83
frontend/src/components/CommandQueue.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Component, createResource, For, Show } from 'solid-js';
|
||||
import { commandsApi } from '../api/client';
|
||||
|
||||
const CommandQueue: Component = () => {
|
||||
const [commands, { refetch }] = createResource(commandsApi.list);
|
||||
|
||||
const updateCommandStatus = async (id: number, status: string) => {
|
||||
try {
|
||||
await commandsApi.updateStatus(id, { status });
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to update command:', err);
|
||||
alert(`Error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold">Command Queue</h2>
|
||||
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||
<Show when={!commands.loading} fallback={<div class="p-4">Loading...</div>}>
|
||||
<For each={commands()} fallback={
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
No commands in queue
|
||||
</div>
|
||||
}>
|
||||
{(cmd) => (
|
||||
<div class="border-b p-4 hover:bg-gray-50">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class={`px-2 py-1 rounded-full text-xs font-semibold ${
|
||||
cmd.status === 'verified' ? 'bg-green-100 text-green-800' :
|
||||
cmd.status === 'rejected' ? 'bg-red-100 text-red-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{cmd.status}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{formatDate(cmd.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
<pre class="text-sm bg-gray-100 p-3 rounded overflow-x-auto">
|
||||
{JSON.stringify(cmd.command, null, 2)}
|
||||
</pre>
|
||||
<Show when={cmd.notes}>
|
||||
<div class="mt-2 text-sm text-gray-600">
|
||||
<strong>Notes:</strong> {cmd.notes}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={cmd.status === 'unverified'}>
|
||||
<div class="ml-4 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => updateCommandStatus(cmd.id, 'verified')}
|
||||
class="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCommandStatus(cmd.id, 'rejected')}
|
||||
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandQueue;
|
||||
87
frontend/src/components/Login.tsx
Normal file
87
frontend/src/components/Login.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Component, createSignal } from 'solid-js';
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: (token: string) => void;
|
||||
}
|
||||
|
||||
const Login: Component<LoginProps> = (props) => {
|
||||
const [password, setPassword] = createSignal('');
|
||||
const [error, setError] = createSignal('');
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ password: password() }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Login failed' }));
|
||||
throw new Error(errorData.error || 'Invalid password');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
props.onLoginSuccess(data.token);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div class="bg-white p-8 rounded-lg shadow-md w-96">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">
|
||||
My Linspirer Admin Login
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-medium mb-2" for="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Enter admin password"
|
||||
disabled={isLoading()}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error() && (
|
||||
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p class="text-sm text-red-600">{error()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading()}
|
||||
class="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading() ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center text-sm text-gray-500">
|
||||
<p>Default password: admin123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
266
frontend/src/components/RulesList.tsx
Normal file
266
frontend/src/components/RulesList.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { Component, createSignal, createResource, For, Show } from 'solid-js';
|
||||
import { rulesApi } from '../api/client';
|
||||
import type { InterceptionRule } from '../types';
|
||||
|
||||
const RulesList: Component = () => {
|
||||
const [rules, { refetch }] = createResource(rulesApi.list);
|
||||
const [showEditor, setShowEditor] = createSignal(false);
|
||||
const [editingId, setEditingId] = createSignal<number | null>(null);
|
||||
const [editingMethod, setEditingMethod] = createSignal('');
|
||||
const [editingAction, setEditingAction] = createSignal<'passthrough' | 'modify' | 'replace'>('passthrough');
|
||||
const [editingResponse, setEditingResponse] = createSignal('');
|
||||
|
||||
const toggleRule = async (rule: InterceptionRule) => {
|
||||
try {
|
||||
await rulesApi.update(rule.id, {
|
||||
is_enabled: !rule.is_enabled,
|
||||
});
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle rule:', err);
|
||||
alert(`Error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this rule?')) return;
|
||||
|
||||
try {
|
||||
await rulesApi.delete(id);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete rule:', err);
|
||||
alert(`Error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const createNewRule = async () => {
|
||||
if (!editingMethod()) {
|
||||
alert('Please enter a method name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rulesApi.create({
|
||||
method_name: editingMethod(),
|
||||
action: editingAction(),
|
||||
custom_response: editingAction() === 'replace' ? editingResponse() : undefined,
|
||||
});
|
||||
setShowEditor(false);
|
||||
setEditingMethod('');
|
||||
setEditingResponse('');
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to create rule:', err);
|
||||
alert(`Error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (rule: InterceptionRule) => {
|
||||
setEditingId(rule.id);
|
||||
setEditingMethod(rule.method_name);
|
||||
setEditingAction(rule.action);
|
||||
setEditingResponse(rule.custom_response || '');
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditingMethod('');
|
||||
setEditingAction('passthrough');
|
||||
setEditingResponse('');
|
||||
setShowEditor(false);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
const id = editingId();
|
||||
if (id === null) {
|
||||
await createNewRule();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingMethod()) {
|
||||
alert('Please enter a method name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rulesApi.update(id, {
|
||||
method_name: editingMethod(),
|
||||
action: editingAction(),
|
||||
custom_response: editingAction() === 'replace' ? editingResponse() : undefined,
|
||||
});
|
||||
cancelEdit();
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to update rule:', err);
|
||||
alert(`Error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-semibold">Interception Rules</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (showEditor()) {
|
||||
cancelEdit();
|
||||
} else {
|
||||
setShowEditor(true);
|
||||
}
|
||||
}}
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
{showEditor() ? 'Cancel' : '+ New Rule'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={showEditor()}>
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-4">
|
||||
<h3 class="text-lg font-semibold mb-4">
|
||||
{editingId() !== null ? 'Edit Rule' : 'Create New Rule'}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Method Name
|
||||
</label>
|
||||
<Show
|
||||
when={editingId() === null}
|
||||
fallback={
|
||||
<input
|
||||
type="text"
|
||||
value={editingMethod()}
|
||||
onInput={(e) => setEditingMethod(e.currentTarget.value)}
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2"
|
||||
placeholder="com.linspirer.method.name"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<select
|
||||
value={editingMethod()}
|
||||
onChange={(e) => setEditingMethod(e.currentTarget.value)}
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2"
|
||||
>
|
||||
<option value="">-- Select Method --</option>
|
||||
<option value="com.linspirer.tactics.gettactics">Get Tactics</option>
|
||||
<option value="com.linspirer.device.getcommand">Get Command</option>
|
||||
</select>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Action
|
||||
</label>
|
||||
<select
|
||||
value={editingAction()}
|
||||
onChange={(e) => setEditingAction(e.currentTarget.value as any)}
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2"
|
||||
>
|
||||
<option value="passthrough">Passthrough (Forward to server)</option>
|
||||
<option value="replace">Replace (Custom response)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Show when={editingAction() === 'replace'}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Custom Response (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={editingResponse()}
|
||||
onInput={(e) => setEditingResponse(e.currentTarget.value)}
|
||||
rows={10}
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm"
|
||||
placeholder='{"code": 0, "type": "object", "data": {...}}'
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
{editingId() !== null ? 'Update Rule' : 'Create Rule'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Method Name
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Action
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<Show when={!rules.loading} fallback={
|
||||
<tr><td colspan="4" class="text-center py-4">Loading...</td></tr>
|
||||
}>
|
||||
<For each={rules()}>
|
||||
{(rule) => (
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{rule.method_name}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span class={`px-2 py-1 rounded-full text-xs ${
|
||||
rule.action === 'replace' ? 'bg-purple-100 text-purple-800' :
|
||||
rule.action === 'modify' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{rule.action}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<button
|
||||
onClick={() => toggleRule(rule)}
|
||||
class={`px-3 py-1 rounded-md text-sm font-medium ${
|
||||
rule.is_enabled
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{rule.is_enabled ? '✓ Enabled' : '✗ Disabled'}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => startEdit(rule)}
|
||||
class="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteRule(rule.id)}
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RulesList;
|
||||
10
frontend/src/index.tsx
Normal file
10
frontend/src/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/* @refresh reload */
|
||||
import { render } from 'solid-js/web';
|
||||
import App from './App';
|
||||
import './styles/index.css';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
if (!root) throw new Error('Root element not found');
|
||||
|
||||
render(() => <App />, root);
|
||||
21
frontend/src/styles/index.css
Normal file
21
frontend/src/styles/index.css
Normal file
@@ -0,0 +1,21 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-400 rounded;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-500;
|
||||
}
|
||||
36
frontend/src/types/index.ts
Normal file
36
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface InterceptionRule {
|
||||
id: number;
|
||||
method_name: string;
|
||||
action: 'passthrough' | 'modify' | 'replace';
|
||||
custom_response: any | null;
|
||||
is_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: number;
|
||||
command: any;
|
||||
status: 'unverified' | 'verified' | 'rejected';
|
||||
received_at: string;
|
||||
processed_at?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateRuleRequest {
|
||||
method_name: string;
|
||||
action: string;
|
||||
custom_response?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRuleRequest {
|
||||
method_name?: string;
|
||||
action?: string;
|
||||
custom_response?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCommandRequest {
|
||||
status: string;
|
||||
notes?: string;
|
||||
}
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import solid from 'vite-plugin-solid';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solid()],
|
||||
base: '/admin/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
sourcemap: false,
|
||||
minify: 'terser',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/admin/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user