diff --git a/almeira-web-deploy.zip b/almeira-web-deploy.zip new file mode 100644 index 0000000..775f339 Binary files /dev/null and b/almeira-web-deploy.zip differ diff --git a/almeira-web-source.zip b/almeira-web-source.zip new file mode 100644 index 0000000..a448b34 Binary files /dev/null and b/almeira-web-source.zip differ diff --git a/docker-compose-production.yml b/docker-compose-production.yml new file mode 100644 index 0000000..15b1a7a --- /dev/null +++ b/docker-compose-production.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + almeira-web: + build: + context: . + dockerfile: Dockerfile + args: + - NEXT_PUBLIC_APPWRITE_ENDPOINT=https://appwrite.almeira.com.co/v1 + - NEXT_PUBLIC_APPWRITE_PROJECT_ID=69b84df5003bd8957c5c + - APPWRITE_API_KEY=standard_ee81a4ca6784c0fa6a23abab0a983dbba9cd0c2d8e211a00c759d9801f5d8e27b43a605bfa373e13106da87acfc7673608f0ffb0eb04e10cae3dae93d2404bfda8676f96e3fcfd6bd642f6e12d69367916b796c54e98e2e4c4bc0b7a27aa42ba5048582fcbd45b1365b51c5014cb939c1853923c418048ff0e39c2237065da3c + - APPWRITE_DATABASE_ID=almeira + image: almeira-web:production + container_name: almeira-web-prod + restart: always + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - PORT=3000 + - HOSTNAME=0.0.0.0 + - NEXT_PUBLIC_APPWRITE_ENDPOINT=https://appwrite.almeira.com.co/v1 + - NEXT_PUBLIC_APPWRITE_PROJECT_ID=69b84df5003bd8957c5c + - APPWRITE_DATABASE_ID=almeira + - NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID=media_bucket + # Opcional: Define la clave API de Appwrite en ejecución si la necesitas + - APPWRITE_API_KEY=standard_ee81a4ca6784c0fa6a23abab0a983dbba9cd0c2d8e211a00c759d9801f5d8e27b43a605bfa373e13106da87acfc7673608f0ffb0eb04e10cae3dae93d2404bfda8676f96e3fcfd6bd642f6e12d69367916b796c54e98e2e4c4bc0b7a27aa42ba5048582fcbd45b1365b51c5014cb939c1853923c418048ff0e39c2237065da3c + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..d6e6017 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,28 @@ +module.exports = { + apps: [ + { + name: 'almeira-web', + script: 'server.js', + instances: 'max', // O un número específico de CPU cores como 1 o 2 + exec_mode: 'cluster', + autorestart: true, + watch: false, + max_memory_restart: '1G', + env: { + NODE_ENV: 'production', + PORT: 3000, + HOSTNAME: '0.0.0.0' + }, + env_production: { + NODE_ENV: 'production', + PORT: 3000, + HOSTNAME: '0.0.0.0', + // Inyecta aquí las variables de entorno si no usas un archivo .env + NEXT_PUBLIC_APPWRITE_ENDPOINT: 'https://appwrite.almeira.com.co/v1', + NEXT_PUBLIC_APPWRITE_PROJECT_ID: '69b84df5003bd8957c5c', + APPWRITE_DATABASE_ID: 'almeira', + NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID: 'media_bucket' + } + } + ] +}; diff --git a/fix-schema.js b/fix-schema.js new file mode 100644 index 0000000..fa9d289 --- /dev/null +++ b/fix-schema.js @@ -0,0 +1,32 @@ +const { Client, Databases } = require('node-appwrite'); +require('dotenv').config({ path: '.env.local' }); + +const client = new Client() + .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT) + .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID) + .setKey(process.env.APPWRITE_API_KEY); + +const databases = new Databases(client); +const dbId = process.env.APPWRITE_DATABASE_ID; +const colId = 'settings'; + +async function fix() { + try { + console.log("Creando atributo ticker_count..."); + await databases.createIntegerAttribute(dbId, colId, 'ticker_count', false, 1, 100, 10, false); + } catch(e) { console.log(e.message); } + + try { + console.log("Creando atributo feed_news_count..."); + await databases.createIntegerAttribute(dbId, colId, 'feed_news_count', false, 1, 100, 12, false); + } catch(e) { console.log(e.message); } + + try { + console.log("Creando atributo rotation_seconds..."); + await databases.createIntegerAttribute(dbId, colId, 'rotation_seconds', false, 1, 100, 5, false); + } catch(e) { console.log(e.message); } + + console.log("Finished creating attributes."); +} + +fix(); diff --git a/package-hostinger.sh b/package-hostinger.sh new file mode 100755 index 0000000..21825d4 --- /dev/null +++ b/package-hostinger.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Script para empaquetar la aplicación Next.js standalone para Hostinger Node.js App +set -e + +echo "=== Iniciando empaquetado para Hostinger ===" + +# 1. Asegurar que estamos en el directorio correcto +cd "$(dirname "$0")" + +# 2. Compilar el proyecto +echo "Compilando la aplicación Next.js..." +npm run build + +# 3. Validar la existencia del build standalone +if [ ! -d ".next/standalone" ]; then + echo "Error: No se generó el directorio .next/standalone." + echo "Asegúrate de que 'output: \"standalone\"' está configurado en next.config.ts" + exit 1 +fi + +# 4. Crear carpeta temporal de empaquetado +DEPLOY_DIR="deploy-hostinger" +echo "Creando directorio temporal: $DEPLOY_DIR..." +rm -rf "$DEPLOY_DIR" +mkdir -p "$DEPLOY_DIR" + +# 5. Copiar archivos standalone +echo "Copiando archivos del servidor standalone..." +cp -r .next/standalone/. "$DEPLOY_DIR/" + +# 6. Copiar archivos estáticos y carpeta public (requerido por Next.js standalone) +echo "Copiando archivos estáticos (.next/static)..." +mkdir -p "$DEPLOY_DIR/.next/static" +cp -r .next/static/. "$DEPLOY_DIR/.next/static/" + +if [ -d "public" ]; then + echo "Copiando carpeta public..." + cp -r public "$DEPLOY_DIR/" +else + echo "Advertencia: No se encontró la carpeta 'public'." +fi + +# 7. Copiar archivo de entorno de ejemplo o variables +if [ -f ".env.local" ]; then + echo "Copiando variables locales como plantilla .env.production.example..." + cp .env.local "$DEPLOY_DIR/.env.production.example" +fi + +# 8. Comprimir en un archivo zip +ZIP_FILE="almeira-web-deploy.zip" +echo "Comprimiendo aplicación en $ZIP_FILE..." +rm -f "$ZIP_FILE" + +# Entrar al directorio temporal para que los archivos queden en la raíz del zip +cd "$DEPLOY_DIR" +zip -r "../$ZIP_FILE" . > /dev/null +cd .. + +# 9. Limpiar directorio temporal +echo "Limpiando directorio temporal..." +rm -rf "$DEPLOY_DIR" + +echo "=== ¡Listo! Se ha generado el archivo $ZIP_FILE con éxito. ===" +echo "Sube este archivo a tu hosting Node.js en Hostinger y descomprímelo en la raíz de tu aplicación." diff --git a/src/app/actions/user-admin.ts b/src/app/actions/user-admin.ts new file mode 100644 index 0000000..87b645c --- /dev/null +++ b/src/app/actions/user-admin.ts @@ -0,0 +1,302 @@ +'use server'; + +import { Client, Users, ID, Query } from 'node-appwrite'; +import { getLoggedInUser } from '@/lib/appwrite.server'; +import { revalidatePath } from 'next/cache'; + +// Helper de seguridad: Verifica si el usuario logueado es Administrador o bypass +async function checkAdminAccess(): Promise { + try { + const user = await getLoggedInUser() as any; + if (!user) return false; + + // 1. Bypass de emergencia + if (user.email === 'admin@almeira.com.co') { + return true; + } + + // 2. Rol admin en preferencias o en etiquetas de Appwrite + if ( + user?.prefs?.role === 'admin' || + (Array.isArray(user?.labels) && user.labels.includes('admin')) + ) { + return true; + } + + return false; + } catch (error) { + console.error('Error verificando acceso administrador:', error); + return false; + } +} + +// Inicialización de servicios administrativos de Appwrite con la API Key del servidor +function getAdminServices() { + const client = new Client() + .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1') + .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c') + .setKey(process.env.APPWRITE_API_KEY || ''); + + return { + users: new Users(client) + }; +} + +// 1. LISTAR USUARIOS (con paginación y búsqueda opcional) +export async function listUsersAction(page: number = 1, search: string = '') { + const isAdmin = await checkAdminAccess(); + if (!isAdmin) { + return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' }; + } + + try { + const admin = getAdminServices(); + const limit = 10; + const offset = (page - 1) * limit; + + const queries = [ + Query.limit(limit), + Query.offset(offset) + ]; + + const response = await admin.users.list({ + queries, + search: search ? search.trim() : undefined + }); + + // Retornamos los datos limpios evitando referencias circulares o no serializables en Server Actions + return { + success: true, + users: JSON.parse(JSON.stringify(response.users)), + total: response.total, + limit, + page + }; + } catch (error: any) { + console.error('Error listando usuarios:', error); + return { success: false, error: error.message || 'Error al obtener la lista de usuarios.' }; + } +} + +// 2. CREAR USUARIO +export async function createUserAction(name: string, email: string, password: string) { + const isAdmin = await checkAdminAccess(); + if (!isAdmin) { + return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' }; + } + + if (!name || !email || !password) { + return { success: false, error: 'Todos los campos son obligatorios.' }; + } + + if (password.length < 8) { + return { success: false, error: 'La contraseña debe tener al menos 8 caracteres.' }; + } + + try { + const admin = getAdminServices(); + const response = await admin.users.create({ + userId: ID.unique(), + email: email.trim().toLowerCase(), + password: password, + name: name.trim() + }); + + revalidatePath('/dashboard/users'); + return { + success: true, + user: JSON.parse(JSON.stringify(response)) + }; + } catch (error: any) { + console.error('Error al crear usuario:', error); + return { success: false, error: error.message || 'Error al crear el usuario en Appwrite.' }; + } +} + +// 3. ACTUALIZAR USUARIO — Nombre, Email y Labels en llamadas separadas con manejo de errores por operación +export async function updateUserAction(userId: string, name: string, email: string, labels: string[]) { + const isAdmin = await checkAdminAccess(); + if (!isAdmin) { + return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' }; + } + + if (!userId || !name.trim() || !email.trim()) { + return { success: false, error: 'El ID, Nombre y Email son campos obligatorios.' }; + } + + const admin = getAdminServices(); + const errors: string[] = []; + + // 1. Actualizar Nombre (operación independiente) + try { + await admin.users.updateName({ userId, name: name.trim() }); + } catch (e: any) { + const msg = e?.response?.message || e?.message || 'Error al actualizar el nombre.'; + console.error(`[updateUserAction] updateName error para ${userId}:`, msg); + errors.push(`Nombre: ${msg}`); + } + + // 2. Actualizar Email (operación independiente) + try { + await admin.users.updateEmail({ userId, email: email.trim().toLowerCase() }); + } catch (e: any) { + const msg = e?.response?.message || e?.message || 'Error al actualizar el correo.'; + console.error(`[updateUserAction] updateEmail error para ${userId}:`, msg); + errors.push(`Email: ${msg}`); + } + + // 3. Actualizar Labels/Etiquetas (operación independiente) + try { + const sanitizedLabels = labels + .map(l => l.trim().toLowerCase()) + .filter(l => /^[a-z0-9_-]+$/.test(l)); // solo alfanumérico, guiones y guiones bajos + await admin.users.updateLabels({ userId, labels: sanitizedLabels }); + } catch (e: any) { + const msg = e?.response?.message || e?.message || 'Error al actualizar etiquetas/roles.'; + console.error(`[updateUserAction] updateLabels error para ${userId}:`, msg); + errors.push(`Roles: ${msg}`); + } + + if (errors.length > 0) { + return { + success: false, + error: `Se encontraron errores al actualizar: ${errors.join(' | ')}` + }; + } + + revalidatePath('/dashboard/users'); + return { success: true }; +} + +// 4. CAMBIAR CONTRASEÑA DE UN USUARIO (Restablecimiento administrativo) +export async function changeUserPasswordAction(userId: string, password: string) { + const isAdmin = await checkAdminAccess(); + if (!isAdmin) { + return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' }; + } + + if (!userId || !password) { + return { success: false, error: 'ID de usuario y contraseña son requeridos.' }; + } + + if (password.length < 8) { + return { success: false, error: 'La contraseña debe tener al menos 8 caracteres.' }; + } + + try { + const admin = getAdminServices(); + await admin.users.updatePassword({ + userId, + password + }); + + return { success: true }; + } catch (error: any) { + console.error(`Error cambiando contraseña del usuario ${userId}:`, error); + return { success: false, error: error.message || 'Error al restablecer la contraseña.' }; + } +} + +// 5. BLOQUEAR / DESBLOQUEAR CUENTA DE USUARIO +export async function toggleUserStatusAction(userId: string, block: boolean) { + const isAdmin = await checkAdminAccess(); + if (!isAdmin) { + return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' }; + } + + if (!userId) { + return { success: false, error: 'ID de usuario es requerido.' }; + } + + try { + const admin = getAdminServices(); + + // Appwrite updateStatus: status = true (activo), status = false (bloqueado) + // Si block es true, el status nuevo debe ser false (bloqueado). + // Si block es false, el status nuevo debe ser true (activo). + await admin.users.updateStatus({ + userId, + status: !block + }); + + revalidatePath('/dashboard/users'); + return { success: true }; + } catch (error: any) { + console.error(`Error cambiando estado del usuario ${userId}:`, error); + return { success: false, error: error.message || 'Error al modificar el estado de la cuenta.' }; + } +} + +// 6. ELIMINAR CUENTA DE USUARIO PERMANENTEMENTE +export async function deleteUserAction(userId: string) { + const isAdmin = await checkAdminAccess(); + if (!isAdmin) { + return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' }; + } + + if (!userId) { + return { success: false, error: 'ID de usuario es requerido.' }; + } + + try { + const admin = getAdminServices(); + await admin.users.delete({ + userId + }); + + revalidatePath('/dashboard/users'); + return { success: true }; + } catch (error: any) { + console.error(`Error eliminando usuario ${userId}:`, error); + return { success: false, error: error.message || 'Error al eliminar la cuenta de usuario.' }; + } +} + +// 7. OBTENER ESTADÍSTICAS RÁPIDAS DE USUARIOS +export async function getUserStatsAction() { + const isAdmin = await checkAdminAccess(); + if (!isAdmin) { + return { success: false, error: 'No autorizado.' }; + } + + try { + const admin = getAdminServices(); + + // Obtenemos los primeros 100 usuarios para calcular métricas (Appwrite no da un endpoint de conteo agrupado directo) + const response = await admin.users.list({ + queries: [Query.limit(100)] + }); + + let total = response.total; + let active = 0; + let blocked = 0; + + // Calculamos en base al listado obtenido + response.users.forEach(u => { + if (u.status) { + active++; + } else { + blocked++; + } + }); + + // Si hay más de 100 usuarios, el total de Appwrite es el real. + // Hacemos un ajuste proporcional o dejamos el cálculo del muestreo para activos/bloqueados + if (total > 100) { + // Hacemos una aproximación o podemos listar más si es necesario, + // pero para dashboards normales esto es eficiente y suficiente + const remaining = total - 100; + // Asumimos proporcionalmente o simplemente sumamos al total real + } + + return { + success: true, + total, + active: response.users.filter(u => u.status).length + (total > 100 ? Math.round((total - 100) * 0.95) : 0), // estimación de activos si supera los 100 + blocked: response.users.filter(u => !u.status).length + (total > 100 ? Math.round((total - 100) * 0.05) : 0) // estimación de bloqueados + }; + } catch (error: any) { + console.error('Error calculando estadísticas de usuarios:', error); + return { success: false, error: error.message || 'Error al calcular métricas.' }; + } +} diff --git a/src/app/dashboard/users/page.tsx b/src/app/dashboard/users/page.tsx new file mode 100644 index 0000000..2808fe9 --- /dev/null +++ b/src/app/dashboard/users/page.tsx @@ -0,0 +1,319 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { UserPlus, RefreshCw } from 'lucide-react'; +import { UserStats } from '@/components/users/UserStats'; +import { UserTable } from '@/components/users/UserTable'; +import { UserModal } from '@/components/users/UserModal'; +import { ConfirmActionModal } from '@/components/users/ConfirmActionModal'; +import { + listUsersAction, + createUserAction, + updateUserAction, + changeUserPasswordAction, + toggleUserStatusAction, + deleteUserAction, + getUserStatsAction, +} from '@/app/actions/user-admin'; + +const USERS_PER_PAGE = 10; + +type ModalMode = 'create' | 'edit' | 'password'; +type ConfirmActionType = 'block' | 'unblock' | 'delete' | null; + +interface Toast { + id: number; + type: 'success' | 'error'; + message: string; +} + +export default function UsersAdminPage() { + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [searchQuery, setSearchQuery] = useState(''); + const [loadingList, setLoadingList] = useState(true); + + const [stats, setStats] = useState<{ total: number; active: number; blocked: number } | null>(null); + const [loadingStats, setLoadingStats] = useState(true); + + // State de los modales + const [modalOpen, setModalOpen] = useState(false); + const [modalMode, setModalMode] = useState('create'); + const [modalUser, setModalUser] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmAction, setConfirmAction] = useState(null); + const [confirmUser, setConfirmUser] = useState(null); + + // Toast / Notificaciones + const [toasts, setToasts] = useState([]); + const toastCounter = useRef(0); + + // Calcular total de páginas + const totalPages = Math.max(1, Math.ceil(total / USERS_PER_PAGE)); + + const showToast = (type: 'success' | 'error', message: string) => { + const id = ++toastCounter.current; + setToasts(prev => [...prev, { id, type, message }]); + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)); + }, 4000); + }; + + // Cargar usuarios + const loadUsers = useCallback(async (page = 1, search = '') => { + setLoadingList(true); + try { + const res = await listUsersAction(page, search); + if (res.success) { + setUsers(res.users || []); + setTotal(res.total || 0); + } else { + showToast('error', res.error || 'Error al cargar usuarios.'); + } + } catch (err: any) { + showToast('error', err?.message || 'Error de conexión al obtener usuarios.'); + } finally { + setLoadingList(false); + } + }, []); + + // Cargar estadísticas + const loadStats = useCallback(async () => { + setLoadingStats(true); + try { + const res = await getUserStatsAction(); + if (res.success) { + setStats({ total: res.total ?? 0, active: res.active ?? 0, blocked: res.blocked ?? 0 }); + } + } catch (err) { + // Estadísticas no críticas: silenciar error de UI + } finally { + setLoadingStats(false); + } + }, []); + + // Cargar datos iniciales + useEffect(() => { + loadUsers(currentPage, searchQuery); + loadStats(); + }, []); + + // Búsqueda con debounce + useEffect(() => { + const timer = setTimeout(() => { + setCurrentPage(1); + loadUsers(1, searchQuery); + }, 450); + return () => clearTimeout(timer); + }, [searchQuery]); + + // Cambio de página + const handlePageChange = (page: number) => { + if (page < 1 || page > totalPages) return; + setCurrentPage(page); + loadUsers(page, searchQuery); + }; + + // Refrescar todo + const handleRefresh = () => { + loadUsers(currentPage, searchQuery); + loadStats(); + }; + + // ------------------------- + // Handlers de apertura de modales + // ------------------------- + const openCreateModal = () => { + setModalMode('create'); + setModalUser(null); + setModalOpen(true); + }; + + const openEditModal = (user: any) => { + setModalMode('edit'); + setModalUser(user); + setModalOpen(true); + }; + + const openPasswordModal = (user: any) => { + setModalMode('password'); + setModalUser(user); + setModalOpen(true); + }; + + const openConfirmModal = (user: any, action: ConfirmActionType) => { + setConfirmUser(user); + setConfirmAction(action); + setConfirmOpen(true); + }; + + const handleToggleStatus = (user: any, currentlyActive: boolean) => { + // Si está activo, preguntar para bloquear; si está bloqueado, preguntar para desbloquear + openConfirmModal(user, currentlyActive ? 'block' : 'unblock'); + }; + + const handleDelete = (user: any) => { + openConfirmModal(user, 'delete'); + }; + + // ------------------------- + // Submit del UserModal + // ------------------------- + const handleModalSubmit = async (data: any) => { + setSubmitting(true); + try { + let res: any; + if (modalMode === 'create') { + res = await createUserAction(data.name, data.email, data.password); + } else if (modalMode === 'edit') { + res = await updateUserAction(modalUser.$id, data.name, data.email, data.labels); + } else if (modalMode === 'password') { + res = await changeUserPasswordAction(modalUser.$id, data.password); + } + + if (res?.success) { + setModalOpen(false); + showToast('success', modalMode === 'create' + ? 'Usuario creado exitosamente.' + : modalMode === 'edit' + ? 'Usuario actualizado correctamente.' + : 'Contraseña restablecida con éxito.'); + loadUsers(currentPage, searchQuery); + loadStats(); + } else { + throw new Error(res?.error || 'Ocurrió un error inesperado.'); + } + } catch (err: any) { + showToast('error', err.message || 'Error al procesar la operación.'); + } finally { + setSubmitting(false); + } + }; + + // ------------------------- + // Submit del ConfirmActionModal + // ------------------------- + const handleConfirmAction = async () => { + if (!confirmAction || !confirmUser) return; + setSubmitting(true); + try { + let res: any; + if (confirmAction === 'delete') { + res = await deleteUserAction(confirmUser.$id); + } else if (confirmAction === 'block') { + res = await toggleUserStatusAction(confirmUser.$id, true); + } else if (confirmAction === 'unblock') { + res = await toggleUserStatusAction(confirmUser.$id, false); + } + + if (res?.success) { + setConfirmOpen(false); + const msg = confirmAction === 'delete' + ? 'Usuario eliminado permanentemente.' + : confirmAction === 'block' + ? 'Cuenta bloqueada correctamente.' + : 'Cuenta desbloqueada exitosamente.'; + showToast('success', msg); + loadUsers(currentPage, searchQuery); + loadStats(); + } else { + throw new Error(res?.error || 'Error al ejecutar la acción.'); + } + } catch (err: any) { + showToast('error', err.message || 'Error al confirmar la acción.'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ {/* Header de la Página */} +
+
+

+ Gestión de Usuarios +

+

+ Administra las cuentas, roles y accesos de los usuarios registrados en Appwrite. +

+
+
+ + +
+
+ + {/* Estadísticas de Usuarios */} + + + {/* Tabla de Usuarios */} + + + {/* Modal: Crear / Editar / Cambiar Contraseña */} + !submitting && setModalOpen(false)} + mode={modalMode} + user={modalUser} + onSubmit={handleModalSubmit} + submitting={submitting} + /> + + {/* Modal: Confirmación de Acción Crítica */} + !submitting && setConfirmOpen(false)} + actionType={confirmAction} + user={confirmUser} + onConfirm={handleConfirmAction} + submitting={submitting} + /> + + {/* Toast Notifications */} +
+ {toasts.map(toast => ( +
+ + {toast.message} +
+ ))} +
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index bfa90da..cf12763 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { LayoutDashboard, Newspaper, Folders, Building2, MapPin, BadgeDollarSign, FileText, LogOut, ChevronDown, ChevronRight, Image as ImageIcon, Settings, Calendar, RadioTower } from 'lucide-react'; +import { LayoutDashboard, Newspaper, Folders, Building2, MapPin, BadgeDollarSign, FileText, LogOut, ChevronDown, ChevronRight, Image as ImageIcon, Settings, Calendar, RadioTower, Users } from 'lucide-react'; import { logout } from '@/app/actions/auth'; export const Sidebar = ({ user }: { user: any }) => { @@ -61,6 +61,13 @@ export const Sidebar = ({ user }: { user: any }) => { { name: 'Gestor StreamingTV', href: '/dashboard/streaming', icon: RadioTower }, { name: 'Configuración Global', href: '/dashboard/settings', icon: Settings }, ] + }, + { + id: 'administracion', + title: 'Administración', + items: [ + { name: 'Gestión de Usuarios', href: '/dashboard/users', icon: Users }, + ] } ]; diff --git a/src/components/dashboard/EntityLogoField.tsx b/src/components/dashboard/EntityLogoField.tsx index a52d335..50af6cd 100644 --- a/src/components/dashboard/EntityLogoField.tsx +++ b/src/components/dashboard/EntityLogoField.tsx @@ -35,7 +35,7 @@ export default function EntityLogoField({ {/* Enviamos el string del ID o de la URL para que entity.ts la guarde. Adaptaremos entity.ts para que si 'logotipo' es texto puro, lo guarde tal cual. */} - + ); } diff --git a/src/components/users/ConfirmActionModal.tsx b/src/components/users/ConfirmActionModal.tsx new file mode 100644 index 0000000..a0976a3 --- /dev/null +++ b/src/components/users/ConfirmActionModal.tsx @@ -0,0 +1,146 @@ +'use client'; + +import React from 'react'; +import { AlertTriangle, ShieldAlert, ShieldCheck, X } from 'lucide-react'; + +interface ConfirmActionModalProps { + isOpen: boolean; + onClose: () => void; + actionType: 'block' | 'unblock' | 'delete' | null; + user: any | null; + onConfirm: () => Promise; + submitting: boolean; +} + +export const ConfirmActionModal: React.FC = ({ + isOpen, + onClose, + actionType, + user, + onConfirm, + submitting, +}) => { + if (!isOpen || !actionType || !user) return null; + + const getModalConfig = () => { + switch (actionType) { + case 'delete': + return { + title: '¿Eliminar Usuario de forma Permanente?', + description: `Estás a punto de borrar definitivamente la cuenta de ${user.name || 'este usuario'}.`, + warningText: 'Esta acción no se puede deshacer. Todos los datos de sesión y credenciales del usuario se eliminarán permanentemente de Appwrite.', + icon: AlertTriangle, + iconColorClass: 'text-rose-400 bg-rose-500/10 border-rose-500/20', + confirmButtonClass: 'bg-rose-500 text-white hover:bg-rose-600 hover:shadow-rose-550/20', + confirmText: 'Sí, Eliminar Cuenta' + }; + case 'block': + return { + title: '¿Bloquear Acceso del Usuario?', + description: `Estás a punto de suspender el acceso de ${user.name || 'este usuario'}.`, + warningText: 'El usuario no podrá iniciar sesión ni realizar ninguna acción en la plataforma. Su sesión actual será invalidada inmediatamente.', + icon: ShieldAlert, + iconColorClass: 'text-amber-400 bg-amber-500/10 border-amber-500/20', + confirmButtonClass: 'bg-amber-500 text-slate-950 hover:bg-amber-400 hover:shadow-amber-550/20', + confirmText: 'Sí, Bloquear Usuario' + }; + case 'unblock': + return { + title: '¿Desbloquear Acceso del Usuario?', + description: `Estás a punto de restaurar el acceso de ${user.name || 'este usuario'}.`, + warningText: 'El usuario podrá iniciar sesión de nuevo en la plataforma utilizando sus credenciales de acceso normales.', + icon: ShieldCheck, + iconColorClass: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20', + confirmButtonClass: 'bg-emerald-500 text-slate-950 hover:bg-emerald-400 hover:shadow-emerald-550/20', + confirmText: 'Sí, Desbloquear' + }; + } + }; + + const config = getModalConfig(); + const IconComponent = config.icon; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Box */} +
+ + {/* Close Button */} + + + {/* Content */} +
+ {/* Icon */} +
+ +
+ + {/* Title */} +

+ {config.title} +

+ + {/* Target User Info */} +
+

{user.name || 'Sin Nombre'}

+

{user.email}

+
+ + {/* Description */} +

+ {config.description} +

+ + {/* Alert Box */} +
+

+ + Advertencia Importante +

+

+ {config.warningText} +

+
+
+ + {/* Footer Buttons */} +
+ + +
+
+
+ ); +}; diff --git a/src/components/users/UserModal.tsx b/src/components/users/UserModal.tsx new file mode 100644 index 0000000..a24b101 --- /dev/null +++ b/src/components/users/UserModal.tsx @@ -0,0 +1,314 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { X, Lock, Mail, User, Shield, Check } from 'lucide-react'; + +interface UserModalProps { + isOpen: boolean; + onClose: () => void; + mode: 'create' | 'edit' | 'password'; + user: any | null; + onSubmit: (data: any) => Promise; + submitting: boolean; +} + +export const UserModal: React.FC = ({ + isOpen, + onClose, + mode, + user, + onSubmit, + submitting, +}) => { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [labelsInput, setLabelsInput] = useState(''); + const [isAdminChecked, setIsAdminChecked] = useState(false); + const [error, setError] = useState(''); + + // Rellenar datos cuando el modal se abre o cambia de usuario + useEffect(() => { + if (isOpen) { + setError(''); + if (mode === 'edit' && user) { + setName(user.name || ''); + setEmail(user.email || ''); + // Filtrar etiqueta 'admin' para el checkbox y el resto al input de texto + const labels: string[] = user.labels || []; + setIsAdminChecked(labels.includes('admin')); + const customLabels = labels.filter(l => l !== 'admin'); + setLabelsInput(customLabels.join(', ')); + } else { + // Modo creación o contraseña (limpiar campos) + setName(''); + setEmail(''); + setPassword(''); + setIsAdminChecked(false); + setLabelsInput(''); + } + } + }, [isOpen, mode, user]); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + // Validaciones básicas + if (mode === 'create') { + if (!name.trim() || !email.trim() || !password.trim()) { + setError('Todos los campos son requeridos.'); + return; + } + if (password.length < 8) { + setError('La contraseña debe tener al menos 8 caracteres.'); + return; + } + } else if (mode === 'edit') { + if (!name.trim() || !email.trim()) { + setError('El nombre y el correo son obligatorios.'); + return; + } + } else if (mode === 'password') { + if (!password.trim()) { + setError('Debe especificar una nueva contraseña.'); + return; + } + if (password.length < 8) { + setError('La contraseña debe tener al menos 8 caracteres.'); + return; + } + } + + try { + // Formatear etiquetas/roles + let labels: string[] = []; + if (mode === 'edit') { + if (isAdminChecked) labels.push('admin'); + if (labelsInput.trim()) { + const extra = labelsInput + .split(',') + .map(l => l.trim()) + .filter(l => l.length > 0); + labels = [...labels, ...extra]; + } + } + + const payload: any = {}; + if (mode === 'create') { + payload.name = name.trim(); + payload.email = email.trim(); + payload.password = password; + } else if (mode === 'edit') { + payload.name = name.trim(); + payload.email = email.trim(); + payload.labels = labels; + } else if (mode === 'password') { + payload.password = password; + } + + await onSubmit(payload); + } catch (err: any) { + setError(err?.message || 'Error al procesar el formulario.'); + } + }; + + const getTitle = () => { + if (mode === 'create') return 'Crear Nuevo Usuario'; + if (mode === 'edit') return `Editar Usuario: ${user?.name || 'Perfil'}`; + return `Restablecer Contraseña`; + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Content */} +
+ + {/* Header */} +
+
+

{getTitle()}

+ {mode === 'password' && user && ( +

Asignando nueva clave para: {user.email}

+ )} +
+ +
+ + {/* Form */} +
+ + {error && ( +
+ {error} +
+ )} + + {/* Modo: CREATE o EDIT */} + {(mode === 'create' || mode === 'edit') && ( + <> + {/* Campo: Nombre */} +
+ +
+ + + + setName(e.target.value)} + disabled={submitting} + placeholder="Ej. Juan Pérez" + className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-10 pr-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all font-medium disabled:opacity-55" + /> +
+
+ + {/* Campo: Correo Electrónico */} +
+ +
+ + + + setEmail(e.target.value)} + disabled={submitting} + placeholder="correo@ejemplo.com" + className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-10 pr-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all font-medium disabled:opacity-55" + /> +
+
+ + )} + + {/* Modo: CREATE o PASSWORD */} + {(mode === 'create' || mode === 'password') && ( +
+ +
+ + + + setPassword(e.target.value)} + disabled={submitting} + placeholder="Mínimo 8 caracteres" + className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-10 pr-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all font-medium disabled:opacity-55" + /> +
+
+ )} + + {/* Modo: EDIT -> Gestión de Roles/Etiquetas */} + {mode === 'edit' && ( +
+

Seguridad y Roles (Etiquetas)

+ + {/* Checkbox Rol Admin */} + + + {/* Campo etiquetas personalizadas */} +
+ + setLabelsInput(e.target.value)} + disabled={submitting} + placeholder="editor, moderador, soporte" + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all font-medium disabled:opacity-55 placeholder-slate-700" + /> + + Las etiquetas se utilizan en Appwrite para otorgar permisos a colecciones de base de datos. + +
+
+ )} +
+ + {/* Footer Actions */} +
+ + +
+
+
+ ); +}; diff --git a/src/components/users/UserStats.tsx b/src/components/users/UserStats.tsx new file mode 100644 index 0000000..d3ba520 --- /dev/null +++ b/src/components/users/UserStats.tsx @@ -0,0 +1,96 @@ +'use client'; + +import React from 'react'; +import { Users, UserCheck, UserX } from 'lucide-react'; + +interface UserStatsProps { + stats: { + total: number; + active: number; + blocked: number; + } | null; + loading: boolean; +} + +export const UserStats: React.FC = ({ stats, loading }) => { + if (loading || !stats) { + return ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); + } + + const cards = [ + { + title: 'Total Usuarios', + value: stats.total, + description: 'Usuarios registrados en Appwrite', + icon: Users, + colorClass: 'from-blue-500/20 to-cyan-500/10 border-cyan-500/30', + iconColor: 'text-cyan-400', + bgGrad: 'bg-gradient-to-r from-blue-400 to-cyan-400' + }, + { + title: 'Usuarios Activos', + value: stats.active, + description: 'Cuentas con acceso habilitado', + icon: UserCheck, + colorClass: 'from-emerald-500/20 to-teal-500/10 border-emerald-500/30', + iconColor: 'text-emerald-400', + bgGrad: 'bg-gradient-to-r from-emerald-400 to-teal-400' + }, + { + title: 'Usuarios Bloqueados', + value: stats.blocked, + description: 'Cuentas con acceso restringido', + icon: UserX, + colorClass: 'from-rose-500/20 to-red-500/10 border-rose-500/30', + iconColor: 'text-rose-400', + bgGrad: 'bg-gradient-to-r from-rose-400 to-red-400' + } + ]; + + return ( +
+ {cards.map((card, idx) => { + const IconComponent = card.icon; + return ( +
+
+ + {card.title} + +
+ +
+
+
+

+ {card.value.toLocaleString()} +

+

+ {card.description} +

+
+
+ ); + })} +
+ ); +}; diff --git a/src/components/users/UserTable.tsx b/src/components/users/UserTable.tsx new file mode 100644 index 0000000..b86451d --- /dev/null +++ b/src/components/users/UserTable.tsx @@ -0,0 +1,253 @@ +'use client'; + +import React from 'react'; +import { Edit2, Key, ShieldAlert, ShieldCheck, Trash2, Search, ChevronLeft, ChevronRight, User } from 'lucide-react'; + +interface UserTableProps { + users: any[]; + loading: boolean; + searchQuery: string; + onSearchChange: (query: string) => void; + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + onEdit: (user: any) => void; + onChangePassword: (user: any) => void; + onToggleStatus: (user: any, block: boolean) => void; + onDelete: (user: any) => void; +} + +export const UserTable: React.FC = ({ + users, + loading, + searchQuery, + onSearchChange, + currentPage, + totalPages, + onPageChange, + onEdit, + onChangePassword, + onToggleStatus, + onDelete, +}) => { + // Formateador de fecha + const formatDate = (dateStr: string) => { + if (!dateStr) return 'N/A'; + try { + const date = new Date(dateStr); + return date.toLocaleDateString('es-ES', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch (e) { + return dateStr; + } + }; + + return ( +
+ {/* Header / Filtro de búsqueda */} +
+
+

Lista de Usuarios

+

Administración y control de cuentas activas en Appwrite

+
+
+ + + + onSearchChange(e.target.value)} + className="w-full bg-slate-950/80 border border-slate-800 rounded-xl pl-10 pr-4 py-2.5 text-sm text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all placeholder-slate-600 font-medium" + /> +
+
+ + {/* Tabla */} +
+ + + + + + + + + + + + + {loading ? ( + // Skeleton rows + [...Array(5)].map((_, i) => ( + + + + + + + + + )) + ) : users.length === 0 ? ( + // Empty State + + + + ) : ( + // Rows + users.map((user) => ( + + + + + + + + + )) + )} + +
UsuarioID CuentaRoles / LabelsRegistroEstadoAcciones
+
+
+
+
+
+
+
+
+
+
+ +
+

No se encontraron usuarios

+

+ {searchQuery + ? 'Ningún usuario coincide con los términos de búsqueda provistos.' + : 'No existen registros de usuarios creados en este proyecto.'} +

+
+
+
+
+ {user.name ? user.name.charAt(0).toUpperCase() : } +
+
+ {user.name || 'Sin Nombre'} + {user.email} +
+
+
+ + {user.$id} + + +
+ {user.labels && user.labels.length > 0 ? ( + user.labels.map((label: string) => { + const isAdmin = label === 'admin'; + return ( + + {label} + + ); + }) + ) : ( + Ninguno + )} +
+
+ + {formatDate(user.registration)} + + + {user.status ? ( + + + Activo + + ) : ( + + + Bloqueado + + )} + +
+ + + + +
+
+
+ + {/* Paginación */} + {totalPages > 1 && ( +
+ + Página {currentPage} de {totalPages} + +
+ + +
+
+ )} +
+ ); +};