feat: implementar pestaña de Gestion de Cuentas y autenticacion global RADIUS con JIT provisioning

This commit is contained in:
Carlos Tello
2026-08-15 10:04:43 -03:00
parent d010abe05c
commit bdce625767
15 changed files with 941 additions and 19 deletions
+7
View File
@@ -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}
+3 -1
View File
@@ -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 },
];
+65 -1
View File
@@ -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}>
+330
View File
@@ -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>
);
};
+42
View File
@@ -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' }),
};