811 lines
35 KiB
JavaScript
811 lines
35 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
||
import { admin, jobs, normalizeArrayResponse } from '../utils/api';
|
||
import { wsManager } from '../utils/websocket';
|
||
import UserJobs from './UserJobs';
|
||
import PasswordChange from './PasswordChange';
|
||
import LoadingSpinner from './LoadingSpinner';
|
||
|
||
export default function AdminPanel() {
|
||
const [activeSection, setActiveSection] = useState('api-keys');
|
||
const [apiKeys, setApiKeys] = useState([]);
|
||
const [runners, setRunners] = useState([]);
|
||
const [users, setUsers] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [newAPIKeyName, setNewAPIKeyName] = useState('');
|
||
const [newAPIKeyDescription, setNewAPIKeyDescription] = useState('');
|
||
const [newAPIKeyScope, setNewAPIKeyScope] = useState('user'); // Default to user scope
|
||
const [newAPIKey, setNewAPIKey] = useState(null);
|
||
const [selectedUser, setSelectedUser] = useState(null);
|
||
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||
const [passwordChangeUser, setPasswordChangeUser] = useState(null);
|
||
const listenerIdRef = useRef(null); // Listener ID for shared WebSocket
|
||
const subscribedChannelsRef = useRef(new Set()); // Track confirmed subscribed channels
|
||
const pendingSubscriptionsRef = useRef(new Set()); // Track pending subscriptions (waiting for confirmation)
|
||
|
||
// Connect to shared WebSocket on mount
|
||
useEffect(() => {
|
||
listenerIdRef.current = wsManager.subscribe('adminpanel', {
|
||
open: () => {
|
||
console.log('AdminPanel: Shared WebSocket connected');
|
||
// Subscribe to runners if already viewing runners section
|
||
if (activeSection === 'runners') {
|
||
subscribeToRunners();
|
||
}
|
||
},
|
||
message: (data) => {
|
||
// Handle subscription responses - update both local refs and wsManager
|
||
if (data.type === 'subscribed' && data.channel) {
|
||
pendingSubscriptionsRef.current.delete(data.channel);
|
||
subscribedChannelsRef.current.add(data.channel);
|
||
wsManager.confirmSubscription(data.channel);
|
||
console.log('Successfully subscribed to channel:', data.channel);
|
||
} else if (data.type === 'subscription_error' && data.channel) {
|
||
pendingSubscriptionsRef.current.delete(data.channel);
|
||
subscribedChannelsRef.current.delete(data.channel);
|
||
wsManager.failSubscription(data.channel);
|
||
console.error('Subscription failed for channel:', data.channel, data.error);
|
||
}
|
||
|
||
// Handle runners channel messages
|
||
if (data.channel === 'runners' && data.type === 'runner_status') {
|
||
// Update runner in list
|
||
setRunners(prev => {
|
||
const index = prev.findIndex(r => r.id === data.runner_id);
|
||
if (index >= 0 && data.data) {
|
||
const updated = [...prev];
|
||
updated[index] = { ...updated[index], ...data.data };
|
||
return updated;
|
||
}
|
||
return prev;
|
||
});
|
||
}
|
||
},
|
||
error: (error) => {
|
||
console.error('AdminPanel: Shared WebSocket error:', error);
|
||
},
|
||
close: (event) => {
|
||
console.log('AdminPanel: Shared WebSocket closed:', event);
|
||
subscribedChannelsRef.current.clear();
|
||
pendingSubscriptionsRef.current.clear();
|
||
}
|
||
});
|
||
|
||
// Ensure connection is established
|
||
wsManager.connect();
|
||
|
||
return () => {
|
||
// Unsubscribe from all channels before unmounting
|
||
unsubscribeFromRunners();
|
||
if (listenerIdRef.current) {
|
||
wsManager.unsubscribe(listenerIdRef.current);
|
||
listenerIdRef.current = null;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const subscribeToRunners = () => {
|
||
const channel = 'runners';
|
||
// Don't subscribe if already subscribed or pending
|
||
if (subscribedChannelsRef.current.has(channel) || pendingSubscriptionsRef.current.has(channel)) {
|
||
return;
|
||
}
|
||
wsManager.subscribeToChannel(channel);
|
||
subscribedChannelsRef.current.add(channel);
|
||
pendingSubscriptionsRef.current.add(channel);
|
||
console.log('Subscribing to runners channel');
|
||
};
|
||
|
||
const unsubscribeFromRunners = () => {
|
||
const channel = 'runners';
|
||
if (!subscribedChannelsRef.current.has(channel)) {
|
||
return; // Not subscribed
|
||
}
|
||
wsManager.unsubscribeFromChannel(channel);
|
||
subscribedChannelsRef.current.delete(channel);
|
||
pendingSubscriptionsRef.current.delete(channel);
|
||
console.log('Unsubscribed from runners channel');
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (activeSection === 'api-keys') {
|
||
loadAPIKeys();
|
||
unsubscribeFromRunners();
|
||
} else if (activeSection === 'runners') {
|
||
loadRunners();
|
||
subscribeToRunners();
|
||
} else if (activeSection === 'users') {
|
||
loadUsers();
|
||
unsubscribeFromRunners();
|
||
} else if (activeSection === 'settings') {
|
||
loadSettings();
|
||
unsubscribeFromRunners();
|
||
}
|
||
}, [activeSection]);
|
||
|
||
const loadAPIKeys = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await admin.listAPIKeys();
|
||
setApiKeys(normalizeArrayResponse(data));
|
||
} catch (error) {
|
||
console.error('Failed to load API keys:', error);
|
||
setApiKeys([]);
|
||
alert('Failed to load API keys');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadRunners = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await admin.listRunners();
|
||
setRunners(normalizeArrayResponse(data));
|
||
} catch (error) {
|
||
console.error('Failed to load runners:', error);
|
||
setRunners([]);
|
||
alert('Failed to load runners');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadUsers = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await admin.listUsers();
|
||
setUsers(normalizeArrayResponse(data));
|
||
} catch (error) {
|
||
console.error('Failed to load users:', error);
|
||
setUsers([]);
|
||
alert('Failed to load users');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadSettings = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await admin.getRegistrationEnabled();
|
||
setRegistrationEnabled(data.enabled);
|
||
} catch (error) {
|
||
console.error('Failed to load settings:', error);
|
||
alert('Failed to load settings');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleToggleRegistration = async () => {
|
||
const newValue = !registrationEnabled;
|
||
setLoading(true);
|
||
try {
|
||
await admin.setRegistrationEnabled(newValue);
|
||
setRegistrationEnabled(newValue);
|
||
alert(`Registration ${newValue ? 'enabled' : 'disabled'}`);
|
||
} catch (error) {
|
||
console.error('Failed to update registration setting:', error);
|
||
alert('Failed to update registration setting');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const generateAPIKey = async () => {
|
||
if (!newAPIKeyName.trim()) {
|
||
alert('API key name is required');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const data = await admin.generateAPIKey(newAPIKeyName.trim(), newAPIKeyDescription.trim() || undefined, newAPIKeyScope);
|
||
setNewAPIKey(data);
|
||
setNewAPIKeyName('');
|
||
setNewAPIKeyDescription('');
|
||
setNewAPIKeyScope('user');
|
||
await loadAPIKeys();
|
||
} catch (error) {
|
||
console.error('Failed to generate API key:', error);
|
||
alert('Failed to generate API key');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const [deletingKeyId, setDeletingKeyId] = useState(null);
|
||
const [deletingRunnerId, setDeletingRunnerId] = useState(null);
|
||
|
||
const revokeAPIKey = async (keyId) => {
|
||
if (!confirm('Are you sure you want to delete this API key? This action cannot be undone.')) {
|
||
return;
|
||
}
|
||
setDeletingKeyId(keyId);
|
||
try {
|
||
await admin.deleteAPIKey(keyId);
|
||
await loadAPIKeys();
|
||
} catch (error) {
|
||
console.error('Failed to delete API key:', error);
|
||
alert('Failed to delete API key');
|
||
} finally {
|
||
setDeletingKeyId(null);
|
||
}
|
||
};
|
||
|
||
|
||
const deleteRunner = async (runnerId) => {
|
||
if (!confirm('Are you sure you want to delete this runner?')) {
|
||
return;
|
||
}
|
||
setDeletingRunnerId(runnerId);
|
||
try {
|
||
await admin.deleteRunner(runnerId);
|
||
await loadRunners();
|
||
} catch (error) {
|
||
console.error('Failed to delete runner:', error);
|
||
alert('Failed to delete runner');
|
||
} finally {
|
||
setDeletingRunnerId(null);
|
||
}
|
||
};
|
||
|
||
const copyToClipboard = (text) => {
|
||
navigator.clipboard.writeText(text);
|
||
alert('Copied to clipboard!');
|
||
};
|
||
|
||
const isAPIKeyActive = (isActive) => {
|
||
return isActive;
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex space-x-4 border-b border-gray-700">
|
||
<button
|
||
onClick={() => {
|
||
setActiveSection('api-keys');
|
||
setSelectedUser(null);
|
||
}}
|
||
className={`py-2 px-4 border-b-2 font-medium ${
|
||
activeSection === 'api-keys'
|
||
? 'border-orange-500 text-orange-500'
|
||
: 'border-transparent text-gray-400 hover:text-gray-300'
|
||
}`}
|
||
>
|
||
API Keys
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setActiveSection('runners');
|
||
setSelectedUser(null);
|
||
}}
|
||
className={`py-2 px-4 border-b-2 font-medium ${
|
||
activeSection === 'runners'
|
||
? 'border-orange-500 text-orange-500'
|
||
: 'border-transparent text-gray-400 hover:text-gray-300'
|
||
}`}
|
||
>
|
||
Runner Management
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setActiveSection('users');
|
||
setSelectedUser(null);
|
||
}}
|
||
className={`py-2 px-4 border-b-2 font-medium ${
|
||
activeSection === 'users'
|
||
? 'border-orange-500 text-orange-500'
|
||
: 'border-transparent text-gray-400 hover:text-gray-300'
|
||
}`}
|
||
>
|
||
Users
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setActiveSection('settings');
|
||
setSelectedUser(null);
|
||
}}
|
||
className={`py-2 px-4 border-b-2 font-medium ${
|
||
activeSection === 'settings'
|
||
? 'border-orange-500 text-orange-500'
|
||
: 'border-transparent text-gray-400 hover:text-gray-300'
|
||
}`}
|
||
>
|
||
Settings
|
||
</button>
|
||
</div>
|
||
|
||
{activeSection === 'api-keys' && (
|
||
<div className="space-y-6">
|
||
<div className="bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
|
||
<h2 className="text-xl font-semibold mb-4 text-gray-100">Generate API Key</h2>
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||
Name *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newAPIKeyName}
|
||
onChange={(e) => setNewAPIKeyName(e.target.value)}
|
||
placeholder="e.g., production-runner-01"
|
||
className="w-full px-3 py-2 bg-gray-900 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||
Description
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newAPIKeyDescription}
|
||
onChange={(e) => setNewAPIKeyDescription(e.target.value)}
|
||
placeholder="Optional description"
|
||
className="w-full px-3 py-2 bg-gray-900 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||
Scope
|
||
</label>
|
||
<select
|
||
value={newAPIKeyScope}
|
||
onChange={(e) => setNewAPIKeyScope(e.target.value)}
|
||
className="w-full px-3 py-2 bg-gray-900 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||
>
|
||
<option value="user">User - Only jobs from API key owner</option>
|
||
<option value="manager">Manager - All jobs from any user</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end">
|
||
<button
|
||
onClick={generateAPIKey}
|
||
disabled={loading || !newAPIKeyName.trim()}
|
||
className="px-6 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
Generate API Key
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{newAPIKey && (
|
||
<div className="mt-4 p-4 bg-green-400/20 border border-green-400/50 rounded-lg">
|
||
<p className="text-sm font-medium text-green-400 mb-2">New API Key Generated:</p>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<code className="flex-1 px-3 py-2 bg-gray-900 border border-green-400/50 rounded text-sm font-mono break-all text-gray-100">
|
||
{newAPIKey.key}
|
||
</code>
|
||
<button
|
||
onClick={() => copyToClipboard(newAPIKey.key)}
|
||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-500 transition-colors text-sm whitespace-nowrap"
|
||
>
|
||
Copy Key
|
||
</button>
|
||
</div>
|
||
<div className="text-xs text-green-400/80">
|
||
<p><strong>Name:</strong> {newAPIKey.name}</p>
|
||
{newAPIKey.description && <p><strong>Description:</strong> {newAPIKey.description}</p>}
|
||
</div>
|
||
<p className="text-xs text-green-400/80 mt-2">
|
||
⚠️ Save this API key securely. It will not be shown again.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
|
||
<h2 className="text-xl font-semibold mb-4 text-gray-100">API Keys</h2>
|
||
{loading ? (
|
||
<LoadingSpinner size="sm" className="py-8" />
|
||
) : !apiKeys || apiKeys.length === 0 ? (
|
||
<p className="text-gray-400 text-center py-8">No API keys generated yet.</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-700">
|
||
<thead className="bg-gray-900">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Name
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Scope
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Key Prefix
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Status
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Created At
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-gray-800 divide-y divide-gray-700">
|
||
{apiKeys.map((key) => {
|
||
return (
|
||
<tr key={key.id}>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div>
|
||
<div className="text-sm font-medium text-gray-100">{key.name}</div>
|
||
{key.description && (
|
||
<div className="text-sm text-gray-400">{key.description}</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||
key.scope === 'manager'
|
||
? 'bg-purple-400/20 text-purple-400'
|
||
: 'bg-blue-400/20 text-blue-400'
|
||
}`}>
|
||
{key.scope === 'manager' ? 'Manager' : 'User'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<code className="text-sm font-mono text-gray-300">
|
||
{key.key_prefix}
|
||
</code>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
{!key.is_active ? (
|
||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-500/20 text-gray-400">
|
||
Revoked
|
||
</span>
|
||
) : (
|
||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-400/20 text-green-400">
|
||
Active
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
|
||
{new Date(key.created_at).toLocaleString()}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm space-x-2">
|
||
<button
|
||
onClick={() => revokeAPIKey(key.id)}
|
||
disabled={deletingKeyId === key.id}
|
||
className="text-red-400 hover:text-red-300 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||
title="Delete API key"
|
||
>
|
||
{deletingKeyId === key.id ? 'Deleting...' : 'Delete'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeSection === 'runners' && (
|
||
<div className="bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
|
||
<h2 className="text-xl font-semibold mb-4 text-gray-100">Runner Management</h2>
|
||
{loading ? (
|
||
<LoadingSpinner size="sm" className="py-8" />
|
||
) : !runners || runners.length === 0 ? (
|
||
<p className="text-gray-400 text-center py-8">No runners registered.</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-700">
|
||
<thead className="bg-gray-900">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Name
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Hostname
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Status
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
API Key
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Priority
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Capabilities
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Last Heartbeat
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-gray-800 divide-y divide-gray-700">
|
||
{runners.map((runner) => {
|
||
const isOnline = new Date(runner.last_heartbeat) > new Date(Date.now() - 60000);
|
||
return (
|
||
<tr key={runner.id}>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-100">
|
||
{runner.name}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
|
||
{runner.hostname}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<span
|
||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||
isOnline
|
||
? 'bg-green-400/20 text-green-400'
|
||
: 'bg-gray-500/20 text-gray-400'
|
||
}`}
|
||
>
|
||
{isOnline ? 'Online' : 'Offline'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
|
||
<code className="text-xs font-mono bg-gray-900 px-2 py-1 rounded">
|
||
jk_r{runner.id % 10}_...
|
||
</code>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
|
||
{runner.priority}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
|
||
{runner.capabilities ? (
|
||
(() => {
|
||
try {
|
||
const caps = JSON.parse(runner.capabilities);
|
||
const enabled = Object.entries(caps)
|
||
.filter(([_, v]) => v)
|
||
.map(([k, _]) => k)
|
||
.join(', ');
|
||
return enabled || 'None';
|
||
} catch {
|
||
return runner.capabilities;
|
||
}
|
||
})()
|
||
) : (
|
||
'None'
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
|
||
{new Date(runner.last_heartbeat).toLocaleString()}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||
<button
|
||
onClick={() => deleteRunner(runner.id)}
|
||
disabled={deletingRunnerId === runner.id}
|
||
className="text-red-400 hover:text-red-300 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{deletingRunnerId === runner.id ? 'Deleting...' : 'Delete'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeSection === 'change-password' && passwordChangeUser && (
|
||
<div className="bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
|
||
<button
|
||
onClick={() => {
|
||
setPasswordChangeUser(null);
|
||
setActiveSection('users');
|
||
}}
|
||
className="text-gray-400 hover:text-gray-300 mb-4 flex items-center gap-2"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||
</svg>
|
||
Back to Users
|
||
</button>
|
||
<PasswordChange
|
||
targetUserId={passwordChangeUser.id}
|
||
targetUserName={passwordChangeUser.name || passwordChangeUser.email}
|
||
onSuccess={() => {
|
||
setPasswordChangeUser(null);
|
||
setActiveSection('users');
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{activeSection === 'users' && (
|
||
<div className="space-y-6">
|
||
{selectedUser ? (
|
||
<UserJobs
|
||
userId={selectedUser.id}
|
||
userName={selectedUser.name || selectedUser.email}
|
||
onBack={() => setSelectedUser(null)}
|
||
/>
|
||
) : (
|
||
<div className="bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
|
||
<h2 className="text-xl font-semibold mb-4 text-gray-100">User Management</h2>
|
||
{loading ? (
|
||
<LoadingSpinner size="sm" className="py-8" />
|
||
) : !users || users.length === 0 ? (
|
||
<p className="text-gray-400 text-center py-8">No users found.</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-700">
|
||
<thead className="bg-gray-900">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Email
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Name
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Provider
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Admin
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Jobs
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Created
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-gray-800 divide-y divide-gray-700">
|
||
{users.map((user) => (
|
||
<tr key={user.id}>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-100">
|
||
{user.email}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||
{user.name}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
|
||
{user.oauth_provider}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="flex items-center gap-2">
|
||
{user.is_admin ? (
|
||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-orange-400/20 text-orange-400">
|
||
Admin
|
||
</span>
|
||
) : (
|
||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-500/20 text-gray-400">
|
||
User
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={async () => {
|
||
if (user.is_first_user && user.is_admin) {
|
||
alert('Cannot remove admin status from the first user');
|
||
return;
|
||
}
|
||
if (!confirm(`Are you sure you want to ${user.is_admin ? 'remove admin privileges from' : 'grant admin privileges to'} ${user.name || user.email}?`)) {
|
||
return;
|
||
}
|
||
try {
|
||
await admin.setUserAdminStatus(user.id, !user.is_admin);
|
||
await loadUsers();
|
||
alert(`Admin status ${user.is_admin ? 'removed' : 'granted'} successfully`);
|
||
} catch (error) {
|
||
console.error('Failed to update admin status:', error);
|
||
const errorMsg = error.message || 'Failed to update admin status';
|
||
if (errorMsg.includes('first user')) {
|
||
alert('Cannot remove admin status from the first user');
|
||
} else {
|
||
alert(errorMsg);
|
||
}
|
||
}
|
||
}}
|
||
disabled={user.is_first_user && user.is_admin}
|
||
className={`text-xs px-2 py-1 rounded ${
|
||
user.is_first_user && user.is_admin
|
||
? 'text-gray-500 bg-gray-500/10 cursor-not-allowed'
|
||
: user.is_admin
|
||
? 'text-red-400 hover:text-red-300 bg-red-400/10 hover:bg-red-400/20'
|
||
: 'text-green-400 hover:text-green-300 bg-green-400/10 hover:bg-green-400/20'
|
||
} transition-colors`}
|
||
title={user.is_first_user && user.is_admin ? 'First user must remain admin' : user.is_admin ? 'Remove admin privileges' : 'Grant admin privileges'}
|
||
>
|
||
{user.is_admin ? 'Remove Admin' : 'Make Admin'}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
|
||
{user.job_count || 0}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
|
||
{new Date(user.created_at).toLocaleString()}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => setSelectedUser(user)}
|
||
className="text-orange-400 hover:text-orange-300 font-medium"
|
||
>
|
||
View Jobs
|
||
</button>
|
||
{user.oauth_provider === 'local' && (
|
||
<button
|
||
onClick={() => {
|
||
const userForPassword = { id: user.id, name: user.name || user.email };
|
||
setPasswordChangeUser(userForPassword);
|
||
setSelectedUser(null);
|
||
setActiveSection('change-password');
|
||
}}
|
||
className="text-blue-400 hover:text-blue-300 font-medium"
|
||
>
|
||
Change Password
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeSection === 'settings' && (
|
||
<div className="space-y-6">
|
||
<PasswordChange />
|
||
<div className="bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
|
||
<h2 className="text-xl font-semibold mb-6 text-gray-100">System Settings</h2>
|
||
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700">
|
||
<div>
|
||
<h3 className="text-lg font-medium text-gray-100 mb-1">User Registration</h3>
|
||
<p className="text-sm text-gray-400">
|
||
{registrationEnabled
|
||
? 'New users can register via OAuth or local login'
|
||
: 'Registration is disabled. Only existing users can log in.'}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<span className={`text-sm font-medium ${registrationEnabled ? 'text-green-400' : 'text-red-400'}`}>
|
||
{registrationEnabled ? 'Enabled' : 'Disabled'}
|
||
</span>
|
||
<button
|
||
onClick={handleToggleRegistration}
|
||
disabled={loading}
|
||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||
registrationEnabled
|
||
? 'bg-red-600 hover:bg-red-500 text-white'
|
||
: 'bg-green-600 hover:bg-green-500 text-white'
|
||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||
>
|
||
{registrationEnabled ? 'Disable' : 'Enable'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|