feat: implementar pestaña de Gestion de Cuentas y autenticacion global RADIUS con JIT provisioning
This commit is contained in:
@@ -8,6 +8,7 @@ import { RestoreView } from './pages/RestoreView';
|
||||
import { EventsView } from './pages/EventsView';
|
||||
import { LoginView } from './pages/LoginView';
|
||||
import { SettingsView } from './pages/SettingsView';
|
||||
import { UsersView } from './pages/UsersView';
|
||||
import { ActiveUpload } from './components/LiveTransferMeter';
|
||||
import {
|
||||
api,
|
||||
@@ -152,6 +153,8 @@ export const App: React.FC = () => {
|
||||
return 'Explorador & Restore';
|
||||
case 'events':
|
||||
return 'Auditoría & Logs';
|
||||
case 'users':
|
||||
return 'Gestión de Cuentas';
|
||||
case 'settings':
|
||||
return 'Configuración Global';
|
||||
default:
|
||||
@@ -214,6 +217,10 @@ export const App: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentTab === 'users' && (
|
||||
<UsersView />
|
||||
)}
|
||||
|
||||
{currentTab === 'settings' && (
|
||||
<SettingsView
|
||||
onRefresh={loadData}
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
FileText,
|
||||
ShieldCheck,
|
||||
Server,
|
||||
Settings
|
||||
Settings,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -22,6 +23,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentTab, setCurrentTab, isW
|
||||
{ id: 'clients', label: 'Clientes Windows', icon: HardDrive },
|
||||
{ id: 'jobs', label: 'Trabajos de Backup', icon: Layers },
|
||||
{ id: 'restore', label: 'Explorador & Restore', icon: RotateCcw },
|
||||
{ id: 'users', label: 'Gestión de Cuentas', icon: Users },
|
||||
{ id: 'events', label: 'Auditoría & Logs', icon: FileText },
|
||||
{ id: 'settings', label: 'Configuración Global', icon: Settings },
|
||||
];
|
||||
|
||||
@@ -34,7 +34,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
|
||||
default_client_quota_gb: 100,
|
||||
default_keep_daily: 7,
|
||||
default_keep_weekly: 4,
|
||||
default_keep_monthly: 12
|
||||
default_keep_monthly: 12,
|
||||
auth_mode: 'local',
|
||||
radius_host: '',
|
||||
radius_port: 1812,
|
||||
radius_secret: ''
|
||||
});
|
||||
|
||||
const fetchSettings = async () => {
|
||||
@@ -213,6 +217,66 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: RADIUS Authentication Settings */}
|
||||
<div className="glass-card" style={{ padding: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||
<ShieldCheck size={20} color="var(--accent-cyan)" />
|
||||
<h4 style={{ fontSize: '1rem', fontWeight: 600 }}>4. Autenticación Global y RADIUS</h4>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '16px', lineHeight: '1.4' }}>
|
||||
Elige el mecanismo de autenticación del panel de control. Si seleccionas RADIUS, los usuarios externos podrán loguearse con sus credenciales corporativas (JIT auto-provisioning).
|
||||
</p>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: '16px' }}>
|
||||
<label>Método de Autenticación Principal:</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.auth_mode || 'local'}
|
||||
onChange={(e) => setFormData({ ...formData, auth_mode: e.target.value })}
|
||||
>
|
||||
<option value="local">Local (Base de datos del Servidor)</option>
|
||||
<option value="radius">RADIUS (Servidor Centralizado)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.auth_mode === 'radius' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 2fr', gap: '16px', borderTop: '1px dashed rgba(255,255,255,0.08)', paddingTop: '16px' }}>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label>Servidor RADIUS (Host / IP):</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="form-input"
|
||||
placeholder="ej: 192.168.10.15"
|
||||
value={formData.radius_host || ''}
|
||||
onChange={(e) => setFormData({ ...formData, radius_host: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label>Puerto RADIUS:</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
className="form-input"
|
||||
value={formData.radius_port || 1812}
|
||||
onChange={(e) => setFormData({ ...formData, radius_port: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label>Secreto Compartido RADIUS:</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="form-input"
|
||||
placeholder="Secreto compartido"
|
||||
value={formData.radius_secret || ''}
|
||||
onChange={(e) => setFormData({ ...formData, radius_secret: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '10px' }}>
|
||||
<button type="submit" className="btn btn-primary" style={{ padding: '10px 24px' }} disabled={saving}>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Edit2,
|
||||
X,
|
||||
Key,
|
||||
User,
|
||||
Check,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { api, UserItem } from '../services/api';
|
||||
|
||||
export const UsersView: React.FC = () => {
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUserId, setEditingUserId] = useState<number | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'OPERATOR',
|
||||
is_active: true,
|
||||
auth_source: 'local'
|
||||
});
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getUsers();
|
||||
setUsers(data);
|
||||
} catch (err: any) {
|
||||
alert(`Error cargando usuarios: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const handleEditClick = (user: UserItem) => {
|
||||
setEditingUserId(user.id);
|
||||
setFormData({
|
||||
email: user.email,
|
||||
password: '', // Leave blank unless changing
|
||||
full_name: user.full_name || '',
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
auth_source: user.auth_source
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCreateClick = () => {
|
||||
setEditingUserId(null);
|
||||
setFormData({
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'OPERATOR',
|
||||
is_active: true,
|
||||
auth_source: 'local'
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
if (editingUserId) {
|
||||
// Edit User
|
||||
const updateData: any = {
|
||||
email: formData.email,
|
||||
full_name: formData.full_name,
|
||||
role: formData.role,
|
||||
is_active: formData.is_active
|
||||
};
|
||||
if (formData.password && formData.password.trim() !== '') {
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
await api.updateUser(editingUserId, updateData);
|
||||
} else {
|
||||
// Create User
|
||||
await api.createUser(formData);
|
||||
}
|
||||
setShowModal(false);
|
||||
loadUsers();
|
||||
} catch (err: any) {
|
||||
alert(`Error al guardar el usuario: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (user: UserItem) => {
|
||||
if (user.email === 'admin@oneverdrive.local') {
|
||||
alert('La cuenta del administrador del sistema no se puede eliminar.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`¿Estás seguro de que deseas eliminar permanentemente al usuario '${user.email}'?`)) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.deleteUser(user.id);
|
||||
loadUsers();
|
||||
} catch (err: any) {
|
||||
alert(`Error al eliminar usuario: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="view-container">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.4rem', fontWeight: 700, margin: 0 }}>Cuentas de Acceso</h2>
|
||||
<span style={{ fontSize: '0.82rem', color: 'var(--text-dim)' }}>
|
||||
Administración centralizada de usuarios del sistema local y RADIUS.
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleCreateClick}>
|
||||
<UserPlus size={16} style={{ marginRight: '8px' }} />
|
||||
Nuevo Usuario
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && users.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>Cargando usuarios...</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email / Nombre</th>
|
||||
<th>Rol</th>
|
||||
<th>Método de Autenticación</th>
|
||||
<th>Estado</th>
|
||||
<th>Fecha de Registro</th>
|
||||
<th style={{ textAlign: 'right' }}>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.88rem' }}>{user.email}</span>
|
||||
<span style={{ fontSize: '0.78rem', color: 'var(--text-dim)' }}>
|
||||
{user.full_name || 'Sin nombre cargado'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge" style={{
|
||||
backgroundColor: user.role === 'ADMIN' ? 'rgba(96, 165, 250, 0.15)' : 'rgba(167, 243, 208, 0.15)',
|
||||
color: user.role === 'ADMIN' ? '#60a5fa' : '#a7f3d0'
|
||||
}}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge" style={{
|
||||
backgroundColor: user.auth_source === 'radius' ? 'rgba(251, 191, 36, 0.15)' : 'rgba(255, 255, 255, 0.06)',
|
||||
color: user.auth_source === 'radius' ? '#fbbf24' : 'var(--text-normal)'
|
||||
}}>
|
||||
{user.auth_source === 'radius' ? 'RADIUS Externo' : 'Base Local'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${user.is_active ? 'badge-online' : 'badge-offline'}`}>
|
||||
{user.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{new Date(user.created_at).toLocaleDateString()}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '6px 10px', fontSize: '0.78rem', color: 'var(--text-muted)' }}
|
||||
onClick={() => handleEditClick(user)}
|
||||
title="Editar parámetros"
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
||||
onClick={() => handleDelete(user)}
|
||||
disabled={user.email === 'admin@oneverdrive.local'}
|
||||
title="Eliminar usuario"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create / Edit User Modal */}
|
||||
{showModal && (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal-content" style={{ maxWidth: '520px' }}>
|
||||
<div className="modal-header">
|
||||
<h4 style={{ fontSize: '1.1rem', fontWeight: 700 }}>
|
||||
{editingUserId ? 'Editar Usuario' : 'Crear Nuevo Usuario'}
|
||||
</h4>
|
||||
<button className="modal-close" onClick={() => setShowModal(false)}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '16px 0' }}>
|
||||
<div className="form-group">
|
||||
<label>Correo Electrónico (Email):</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
disabled={!!editingUserId}
|
||||
className="form-input"
|
||||
placeholder="ej: operador@empresa.local"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Nombre Completo:</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="form-input"
|
||||
placeholder="ej: Juan Pérez"
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingUserId && (
|
||||
<div className="form-group">
|
||||
<label>Método de Autenticación:</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.auth_source}
|
||||
onChange={(e) => setFormData({ ...formData, auth_source: e.target.value })}
|
||||
>
|
||||
<option value="local">Local (Base de datos local)</option>
|
||||
<option value="radius">RADIUS (Autenticación centralizada)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show password field only for local users OR when creating a new user */}
|
||||
{(formData.auth_source === 'local' || !editingUserId) && (
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{editingUserId ? 'Nueva Contraseña (dejar vacío si no cambia):' : 'Contraseña de Acceso:'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required={!editingUserId && formData.auth_source === 'local'}
|
||||
className="form-input"
|
||||
placeholder={editingUserId ? '••••••••' : 'Clave de seguridad'}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
/>
|
||||
{formData.auth_source === 'radius' && (
|
||||
<span style={{ fontSize: '0.74rem', color: 'var(--text-dim)', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<AlertTriangle size={12} color="#fbbf24" />
|
||||
Para RADIUS, la clave local sólo sirve de respaldo si RADIUS falla.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div className="form-group">
|
||||
<label>Rol de Usuario:</label>
|
||||
<select
|
||||
className="form-input"
|
||||
disabled={formData.email === 'admin@oneverdrive.local'}
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
>
|
||||
<option value="ADMIN">ADMINISTRADOR</option>
|
||||
<option value="OPERATOR">OPERADOR</option>
|
||||
<option value="VIEWER">AUDITOR (Solo Lectura)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Estado de Cuenta:</label>
|
||||
<select
|
||||
className="form-input"
|
||||
disabled={formData.email === 'admin@oneverdrive.local'}
|
||||
value={formData.is_active ? 'active' : 'inactive'}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.value === 'active' })}
|
||||
>
|
||||
<option value="active">ACTIVO</option>
|
||||
<option value="inactive">INACTIVO</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Guardando...' : editingUserId ? 'Actualizar Usuario' : 'Crear Usuario'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -109,6 +109,20 @@ export interface SystemSettingsResponse {
|
||||
default_keep_daily: number;
|
||||
default_keep_weekly: number;
|
||||
default_keep_monthly: number;
|
||||
auth_mode?: string;
|
||||
radius_host?: string;
|
||||
radius_port?: number;
|
||||
radius_secret?: string;
|
||||
}
|
||||
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
auth_source: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const getAuthToken = (): string | null => {
|
||||
@@ -287,4 +301,32 @@ export const api = {
|
||||
|
||||
// Events
|
||||
getEvents: (limit: number = 50) => request<EventLogItem[]>(`/events?limit=${limit}`),
|
||||
|
||||
// Users Management
|
||||
getUsers: () => request<UserItem[]>('/users'),
|
||||
createUser: (userData: {
|
||||
email: string;
|
||||
password?: string;
|
||||
full_name?: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
auth_source: string;
|
||||
}) =>
|
||||
request<UserItem>('/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userData),
|
||||
}),
|
||||
updateUser: (userId: number, userData: Partial<{
|
||||
email: string;
|
||||
password?: string;
|
||||
full_name?: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
}>) =>
|
||||
request<UserItem>(`/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(userData),
|
||||
}),
|
||||
deleteUser: (userId: number) =>
|
||||
request<{ message: string }>(`/users/${userId}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user