Initial commit

This commit is contained in:
Tesscorp Server
2026-07-15 19:02:28 -06:00
parent 26d3c0e1e7
commit 3059708aba
26 changed files with 1315 additions and 126 deletions
+7 -12
View File
@@ -1,14 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
.pnp.js
# testing
/coverage
@@ -30,11 +23,13 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# local env files
.env*.local
.env
# vercel
.vercel
# prisma
dev.db
dev.db-journal
# typescript
*.tsbuildinfo
+1
View File
@@ -0,0 +1 @@
23
+1
View File
@@ -0,0 +1 @@
/var/www/vhosts/system/hub.tesscorp.com.mx/etc/php.ini
+1
View File
@@ -0,0 +1 @@
8.3
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: ['hub.tesscorp.com.mx'],
};
export default nextConfig;
+138
View File
@@ -8,11 +8,16 @@
"name": "hub.tesscorp.com.mx",
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"jose": "^6.2.3",
"next": "16.2.10",
"prisma": "^5.22.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -1231,6 +1236,69 @@
"node": ">=12.4.0"
}
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.13"
},
"peerDependencies": {
"prisma": "*"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
}
}
},
"node_modules/@prisma/debug": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1258,6 +1326,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2233,6 +2308,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
@@ -3393,6 +3477,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -4209,6 +4307,27 @@
"node": ">= 0.4"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4877,6 +4996,25 @@
"node": ">= 0.8.0"
}
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
},
"optionalDependencies": {
"fsevents": "2.3.3"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+5
View File
@@ -9,11 +9,16 @@
"lint": "eslint"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"jose": "^6.2.3",
"next": "16.2.10",
"prisma": "^5.22.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+33
View File
@@ -0,0 +1,33 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Company {
id String @id @default(uuid())
name String @unique
users User[]
}
model User {
id String @id @default(uuid())
email String @unique
password String
role String @default("CLIENT") // "ADMIN" or "CLIENT"
companyId String?
company Company? @relation(fields: [companyId], references: [id])
}
model TicketHistory {
id String @id @default(uuid())
title String
description String
urgency String
createdAt DateTime @default(now())
userEmail String
companyName String
}
+27
View File
@@ -0,0 +1,27 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient({});
async function main() {
const adminEmail = 'admin@tesscorp.com.mx';
const existingAdmin = await prisma.user.findUnique({ where: { email: adminEmail } });
if (!existingAdmin) {
const hashedPassword = await bcrypt.hash('SoporteTess2026!', 10);
await prisma.user.create({
data: {
email: adminEmail,
password: hashedPassword,
role: 'ADMIN'
}
});
console.log('Admin user created successfully');
} else {
console.log('Admin user already exists');
}
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect());
+32
View File
@@ -0,0 +1,32 @@
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
// Plesk Phusion Passenger usually passes the port via process.env.PORT
const port = process.env.PORT || 3000;
// Initialize Next.js
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
createServer(async (req, res) => {
try {
const parsedUrl = parse(req.url, true);
await handle(req, res, parsedUrl);
} catch (err) {
console.error('Error occurred handling', req.url, err);
res.statusCode = 500;
res.end('Internal Server Error');
}
})
.once('error', (err) => {
console.error(err);
process.exit(1);
})
.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`);
});
});
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useState, useEffect } from 'react';
import Link from 'next/link';
export default function AdminPage() {
const [companies, setCompanies] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [newCompanyName, setNewCompanyName] = useState('');
const [newUser, setNewUser] = useState({
email: '',
password: '',
role: 'CLIENT',
companyId: ''
});
const fetchData = async () => {
const compRes = await fetch('/api/admin/companies');
const userRes = await fetch('/api/admin/users');
if (compRes.ok) setCompanies((await compRes.json()).companies);
if (userRes.ok) setUsers((await userRes.json()).users);
};
useEffect(() => {
fetchData();
}, []);
const handleCreateCompany = async (e: React.FormEvent) => {
e.preventDefault();
await fetch('/api/admin/companies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newCompanyName })
});
setNewCompanyName('');
fetchData();
};
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser)
});
setNewUser({ email: '', password: '', role: 'CLIENT', companyId: '' });
fetchData();
};
return (
<main className="hub-container">
<Link href="/" style={{ color: 'var(--accent-color)', textDecoration: 'none', marginBottom: '2rem', display: 'inline-block' }}>
&larr; Volver al Hub
</Link>
<div className="header" style={{ marginBottom: '3rem', textAlign: 'left' }}>
<h1 className="title" style={{ fontSize: '2.5rem' }}>Panel de Administración</h1>
<p className="subtitle">Gestión centralizada de Clientes y Accesos Corporativos</p>
</div>
<div className="grid" style={{ gridTemplateColumns: '1fr 1.5fr', alignItems: 'start' }}>
{/* Panel Izquierdo: Creación */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div className="glass-panel" style={{ padding: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ color: '#c084fc' }}>🏢</span> Agregar Empresa
</h2>
<form onSubmit={handleCreateCompany} style={{ display: 'flex', gap: '1rem' }}>
<input
type="text"
className="input-field"
placeholder="Nombre (Ej. Trimape)"
value={newCompanyName}
onChange={e => setNewCompanyName(e.target.value)}
required
/>
<button type="submit" className="btn-primary" style={{ padding: '0.75rem 1.5rem', whiteSpace: 'nowrap' }}>Agregar</button>
</form>
</div>
<div className="glass-panel" style={{ padding: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ color: '#38bdf8' }}>👤</span> Crear Usuario
</h2>
<form onSubmit={handleCreateUser} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div className="input-group">
<input type="email" className="input-field" placeholder="Correo electrónico" value={newUser.email} onChange={e => setNewUser({...newUser, email: e.target.value})} required />
</div>
<div className="input-group">
<input type="password" className="input-field" placeholder="Contraseña segura" value={newUser.password} onChange={e => setNewUser({...newUser, password: e.target.value})} required />
</div>
<div className="input-group">
<select className="input-field" value={newUser.role} onChange={e => setNewUser({...newUser, role: e.target.value})}>
<option value="CLIENT">Rol: Cliente (Limitado)</option>
<option value="ADMIN">Rol: Administrador (Total)</option>
</select>
</div>
{newUser.role === 'CLIENT' && (
<div className="input-group">
<select className="input-field" value={newUser.companyId} onChange={e => setNewUser({...newUser, companyId: e.target.value})} required>
<option value="">Asignar a Empresa...</option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
)}
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>Emitir Cuenta</button>
</form>
</div>
</div>
{/* Panel Derecho: Lista de Usuarios */}
<div className="glass-panel" style={{ padding: '2rem', minHeight: '100%' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ color: '#34d399' }}>📋</span> Directorio Activo
</h2>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', textAlign: 'left', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ padding: '1rem 0.5rem', color: 'var(--text-secondary)', fontWeight: 500, fontSize: '0.9rem', borderBottom: '1px solid var(--border-glass)' }}>Email</th>
<th style={{ padding: '1rem 0.5rem', color: 'var(--text-secondary)', fontWeight: 500, fontSize: '0.9rem', borderBottom: '1px solid var(--border-glass)' }}>Rol</th>
<th style={{ padding: '1rem 0.5rem', color: 'var(--text-secondary)', fontWeight: 500, fontSize: '0.9rem', borderBottom: '1px solid var(--border-glass)' }}>Empresa</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr><td colSpan={3} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>No hay usuarios aún.</td></tr>
) : (
users.map(u => (
<tr key={u.id} style={{ transition: 'background 0.2s' }}>
<td style={{ padding: '1rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ width: '32px', height: '32px', borderRadius: '50%', background: 'rgba(56, 189, 248, 0.2)', color: '#38bdf8', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.8rem', fontWeight: 'bold' }}>
{u.email.charAt(0).toUpperCase()}
</div>
{u.email}
</td>
<td style={{ padding: '1rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span style={{
background: u.role === 'ADMIN' ? 'rgba(192, 132, 252, 0.15)' : 'rgba(56, 189, 248, 0.15)',
color: u.role === 'ADMIN' ? '#c084fc' : '#38bdf8',
padding: '0.25rem 0.5rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 600
}}>
{u.role}
</span>
</td>
<td style={{ padding: '1rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)', color: 'var(--text-secondary)' }}>
{u.company?.name || <span style={{ opacity: 0.5 }}>-</span>}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</main>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { cookies } from 'next/headers';
import { verifyToken } from '@/lib/auth';
export async function POST(request: Request) {
try {
const cookieStore = await cookies();
const token = cookieStore.get('hub_token')?.value;
if (!token) return NextResponse.json({ error: 'No autorizado' }, { status: 401 });
const user = await verifyToken(token) as any;
if (!user || user.role !== 'ADMIN') return NextResponse.json({ error: 'Acceso denegado' }, { status: 403 });
const { name } = await request.json();
if (!name) return NextResponse.json({ error: 'Falta el nombre' }, { status: 400 });
const company = await prisma.company.create({
data: { name }
});
return NextResponse.json({ success: true, company });
} catch (error) {
return NextResponse.json({ error: 'Error al crear empresa' }, { status: 500 });
}
}
export async function GET() {
try {
const companies = await prisma.company.findMany();
return NextResponse.json({ companies });
} catch (error) {
return NextResponse.json({ error: 'Error al obtener empresas' }, { status: 500 });
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
import { verifyToken } from '@/lib/auth';
export async function POST(request: Request) {
try {
const cookieStore = await cookies();
const token = cookieStore.get('hub_token')?.value;
if (!token) return NextResponse.json({ error: 'No autorizado' }, { status: 401 });
const currentUser = await verifyToken(token) as any;
if (!currentUser || currentUser.role !== 'ADMIN') return NextResponse.json({ error: 'Acceso denegado' }, { status: 403 });
const { email, password, role, companyId } = await request.json();
if (!email || !password || !role) return NextResponse.json({ error: 'Faltan campos' }, { status: 400 });
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) return NextResponse.json({ error: 'El correo ya está registrado' }, { status: 400 });
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
role,
companyId: companyId || null
},
select: { id: true, email: true, role: true, company: true }
});
return NextResponse.json({ success: true, user });
} catch (error) {
return NextResponse.json({ error: 'Error al crear usuario' }, { status: 500 });
}
}
export async function GET() {
try {
const users = await prisma.user.findMany({
select: { id: true, email: true, role: true, company: true }
});
return NextResponse.json({ users });
} catch (error) {
return NextResponse.json({ error: 'Error al obtener usuarios' }, { status: 500 });
}
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.delete('hub_token');
return response;
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { signToken } from '@/lib/auth';
export async function POST(request: Request) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json({ error: 'Faltan credenciales' }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { email },
include: { company: true }
});
if (!user) {
return NextResponse.json({ error: 'Credenciales inválidas' }, { status: 401 });
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return NextResponse.json({ error: 'Credenciales inválidas' }, { status: 401 });
}
const payload = {
sub: user.id,
email: user.email,
role: user.role,
companyId: user.company?.id,
companyName: user.company?.name
};
const token = await signToken(payload);
const response = NextResponse.json({ success: true, user: payload });
response.cookies.set({
name: 'hub_token',
value: token,
httpOnly: true,
path: '/',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 // 1 day
});
return response;
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Error interno del servidor' }, { status: 500 });
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { verifyToken } from '@/lib/auth';
export async function POST(request: Request) {
try {
const cookieStore = await cookies();
const token = cookieStore.get('hub_token')?.value;
if (!token) return NextResponse.json({ error: 'No autorizado' }, { status: 401 });
const user = await verifyToken(token) as any;
if (!user) return NextResponse.json({ error: 'Token inválido' }, { status: 401 });
const body = await request.json();
const { title, description, urgency } = body;
const apiKey = process.env.PLANE_API_KEY;
const apiUrl = process.env.PLANE_API_URL;
const workspace = process.env.PLANE_WORKSPACE_SLUG;
const projectId = process.env.PLANE_PROJECT_ID;
if (!apiKey || !apiUrl || !workspace || !projectId) {
return NextResponse.json({ error: 'Faltan variables de entorno para la conexión con Plane' }, { status: 500 });
}
const companyPrefix = user.companyName ? `[${user.companyName.toUpperCase()}] ` : '';
const planeData = {
name: `${companyPrefix}${title}`,
description_html: `<p><strong>Reportado por:</strong> ${user.email}</p><p>${description.replace(/\n/g, '<br/>')}</p>`,
priority: urgency
};
const response = await fetch(`${apiUrl}/workspaces/${workspace}/projects/${projectId}/issues/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
},
body: JSON.stringify(planeData)
});
if (!response.ok) {
const errorText = await response.text();
console.error('Plane API Error:', errorText);
return NextResponse.json({ error: 'Error al crear el ticket en Plane' }, { status: response.status });
}
const result = await response.json();
return NextResponse.json({ success: true, ticket: result });
} catch (error) {
console.error('Error procesando ticket:', error);
return NextResponse.json({ error: 'Error interno del servidor' }, { status: 500 });
}
}
+313 -37
View File
@@ -1,49 +1,325 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html {
height: 100%;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
min-height: 100%;
display: flex;
flex-direction: column;
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--bg-dark: #050814;
--bg-card: rgba(15, 23, 42, 0.4);
--border-glass: rgba(148, 163, 184, 0.1);
--border-glass-hover: rgba(148, 163, 184, 0.3);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--accent-glow: rgba(59, 130, 246, 0.5);
--gradient-primary: linear-gradient(135deg, #38bdf8 0%, #3b82f6 100%);
--font-family: var(--font-outfit), system-ui, sans-serif;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
padding: 0;
}
a {
color: inherit;
body {
background-color: var(--bg-dark);
color: var(--text-primary);
font-family: var(--font-family);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
position: relative;
}
/* Background animated orbs */
body::before, body::after {
content: '';
position: fixed;
width: 50vw;
height: 50vw;
border-radius: 50%;
filter: blur(100px);
z-index: -1;
opacity: 0.15;
animation: orbFloat 20s infinite alternate ease-in-out;
pointer-events: none;
}
body::before {
top: -10%;
left: -10%;
background: #38bdf8;
}
body::after {
bottom: -10%;
right: -10%;
background: #c084fc;
animation-delay: -10s;
}
@keyframes orbFloat {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(5%, 10%) scale(1.1); }
100% { transform: translate(-5%, 5%) scale(0.9); }
}
.hub-container {
max-width: 1200px;
margin: 0 auto;
padding: 3rem 2rem;
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
.glass-panel {
background: var(--bg-card);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glass);
border-top: 1px solid rgba(255,255,255,0.15);
border-radius: 20px;
padding: 2.5rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
}
.header {
text-align: center;
margin-bottom: 4rem;
animation: fadeDown 0.8s ease-out;
}
@keyframes fadeDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
.title {
font-size: 3.5rem;
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(to right, #ffffff, #94a3b8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.75rem;
}
.subtitle {
color: var(--text-secondary);
font-size: 1.2rem;
font-weight: 300;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 2rem;
animation: fadeUp 0.8s ease-out 0.2s both;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
text-decoration: none;
color: var(--text-primary);
position: relative;
overflow: hidden;
padding: 3rem 2rem;
cursor: pointer;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
.card:hover {
transform: translateY(-8px);
border-color: var(--border-glass-hover);
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.5), 0 0 20px rgba(56, 189, 248, 0.1);
}
.card::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: radial-gradient(800px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(255,255,255,0.06), transparent 40%);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.card:hover::after {
opacity: 1;
}
.card-icon {
font-size: 3.5rem;
margin-bottom: 1.5rem;
filter: drop-shadow(0 4px 10px rgba(0,0,0,0.3));
transition: transform 0.3s ease;
}
.card:hover .card-icon {
transform: scale(1.1) rotate(5deg);
}
.card h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.75rem;
letter-spacing: -0.01em;
}
.card p {
color: var(--text-secondary);
font-size: 1rem;
line-height: 1.5;
}
/* Forms and Inputs */
.login-form, .ticket-form {
max-width: 450px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
animation: fadeUp 0.8s ease-out;
}
.input-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: left;
position: relative;
}
.input-group label {
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 500;
margin-left: 0.25rem;
transition: color 0.3s;
}
.input-group:focus-within label {
color: #38bdf8;
}
.input-field {
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-glass);
padding: 1rem 1.25rem;
border-radius: 12px;
color: white;
font-size: 1rem;
font-family: inherit;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%;
}
.input-field::placeholder {
color: rgba(148, 163, 184, 0.4);
}
.input-field:focus {
outline: none;
border-color: #38bdf8;
background: rgba(0, 0, 0, 0.5);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15);
}
textarea.input-field {
resize: vertical;
min-height: 120px;
}
/* Buttons */
.btn-primary {
background: var(--gradient-primary);
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 15px var(--accent-glow);
position: relative;
overflow: hidden;
}
.btn-primary::before {
content: '';
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: linear-gradient(rgba(255,255,255,0.2), transparent);
opacity: 0;
transition: opacity 0.3s;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px var(--accent-glow);
}
.btn-primary:hover::before {
opacity: 1;
}
.btn-primary:active {
transform: translateY(1px);
}
/* Select Tags (For Ticket Urgency) */
.tags-container {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.tag-btn {
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-glass);
padding: 0.75rem 1.25rem;
border-radius: 100px;
color: var(--text-secondary);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.tag-btn:hover {
background: rgba(255,255,255,0.05);
color: white;
}
.tag-btn.active {
background: rgba(56, 189, 248, 0.15);
border-color: #38bdf8;
color: #38bdf8;
}
.tag-btn.active.high {
background: rgba(244, 63, 94, 0.15);
border-color: #f43f5e;
color: #f43f5e;
}
.tag-btn.active.medium {
background: rgba(234, 179, 8, 0.15);
border-color: #eab308;
color: #eab308;
}
.tag-btn.active.low {
background: rgba(34, 197, 94, 0.15);
border-color: #22c55e;
color: #22c55e;
}
+6 -14
View File
@@ -1,20 +1,12 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Outfit } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const outfit = Outfit({ subsets: ["latin"], variable: "--font-outfit" });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Tess Hub | Portal B2B",
description: "Portal interno para el equipo y clientes corporativos",
};
export default function RootLayout({
@@ -23,8 +15,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}>
<body>{children}</body>
<html lang="es" className={outfit.variable}>
<body className="antialiased" suppressHydrationWarning>{children}</body>
</html>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (res.ok) {
window.location.href = '/';
} else {
const data = await res.json();
setError(data.error || 'Credenciales inválidas');
}
} catch (err) {
setError('Error de conexión');
}
};
return (
<main className="hub-container" style={{ alignItems: 'center', justifyContent: 'center' }}>
<div className="glass-panel" style={{ width: '100%', maxWidth: '450px' }}>
<div className="header" style={{ marginBottom: '2.5rem' }}>
<div style={{ fontSize: '3rem', marginBottom: '1rem', display: 'inline-block', filter: 'drop-shadow(0 0 10px rgba(56,189,248,0.3))' }}>🌌</div>
<h1 className="title" style={{ fontSize: '2.5rem' }}>Tess Hub</h1>
<p className="subtitle">Identificación Corporativa</p>
</div>
<form className="login-form" onSubmit={handleLogin}>
{error && (
<div style={{
background: 'rgba(244, 63, 94, 0.1)',
border: '1px solid rgba(244, 63, 94, 0.3)',
color: '#f43f5e',
padding: '0.75rem',
borderRadius: '8px',
textAlign: 'center',
fontSize: '0.9rem'
}}>
{error}
</div>
)}
<div className="input-group">
<label htmlFor="email">Correo Electrónico</label>
<input
type="email"
id="email"
className="input-field"
required
placeholder="tu@empresa.com"
value={email}
onChange={e => setEmail(e.target.value)}
/>
</div>
<div className="input-group">
<label htmlFor="password">Contraseña</label>
<input
type="password"
id="password"
className="input-field"
required
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '1rem' }}>
Acceder al Portal
</button>
</form>
</div>
</main>
);
}
+60 -62
View File
@@ -1,66 +1,64 @@
import Image from "next/image";
import styles from "./page.module.css";
import { cookies } from 'next/headers';
import { verifyToken } from '@/lib/auth';
import Link from 'next/link';
import LogoutButton from '@/components/LogoutButton';
export default async function TessHub() {
const cookieStore = await cookies();
const token = cookieStore.get('hub_token')?.value;
let user: any = null;
if (token) {
user = await verifyToken(token);
}
const isAdmin = user?.role === 'ADMIN';
export default function Home() {
return (
<div className={styles.page}>
<main className={styles.main}>
<Image
className={styles.logo}
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className={styles.intro}>
<h1>To get started, edit the page.tsx file.</h1>
<p>
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className={styles.secondary}
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
<main className="hub-container">
<div className="header">
<h1 className="title">Tess Hub</h1>
<p className="subtitle">
{isAdmin ? "Central de Operaciones de TI y Desarrollo" : `Portal de Soporte B2B - ${user?.companyName || 'Cliente'}`}
</p>
</div>
<div className="grid">
{isAdmin && (
<>
<a href="https://plane.tesscorp.com.mx" className="card glass-panel" target="_blank" rel="noopener noreferrer">
<div className="card-icon">🚀</div>
<h2>Plane</h2>
<p>Gestión ágil de proyectos, sprints y backlog del equipo.</p>
</a>
<a href="https://git.tesscorp.com.mx" className="card glass-panel" target="_blank" rel="noopener noreferrer">
<div className="card-icon">💻</div>
<h2>Gitea</h2>
<p>Repositorios de código, pull requests y revisiones.</p>
</a>
<Link href="/admin" className="card glass-panel">
<div className="card-icon"></div>
<h2>Administración</h2>
<p>Gestionar clientes, empresas y accesos al portal.</p>
</Link>
</>
)}
<Link href="/tickets" className="card glass-panel">
<div className="card-icon">🎫</div>
<h2>Tickets TI</h2>
<p>Levantar incidencias y contactar con soporte técnico.</p>
</Link>
</div>
<div style={{ marginTop: '3rem', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<p style={{ color: 'var(--text-secondary)' }}>
Conectado como: <strong>{user?.email}</strong>
</p>
<LogoutButton />
</div>
</main>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import Link from "next/link";
export default function TicketsPage() {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [urgency, setUrgency] = useState("low");
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [errorMessage, setErrorMessage] = useState("");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setStatus("submitting");
try {
const response = await fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description, urgency })
});
if (response.ok) {
setStatus("success");
setTitle("");
setDescription("");
setUrgency("low");
} else {
setStatus("error");
setErrorMessage("Error de conexión con el sistema central.");
}
} catch (error) {
setStatus("error");
setErrorMessage("Ha ocurrido un error inesperado.");
}
};
if (status === "success") {
return (
<main className="hub-container" style={{ alignItems: 'center', justifyContent: 'center' }}>
<div className="glass-panel" style={{ textAlign: 'center', maxWidth: '500px' }}>
<div style={{ fontSize: '4rem', marginBottom: '1rem', animation: 'fadeUp 0.5s ease-out' }}></div>
<h2 style={{ fontSize: '2rem', marginBottom: '1rem' }}>Ticket Enviado</h2>
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>
Nuestro equipo de soporte ha recibido tu solicitud. Te responderemos a la brevedad.
</p>
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'center' }}>
<button className="btn-primary" onClick={() => setStatus("idle")}>
Nuevo Ticket
</button>
<Link href="/" className="btn-primary" style={{ background: 'rgba(255,255,255,0.1)', boxShadow: 'none' }}>
Volver al Hub
</Link>
</div>
</div>
</main>
);
}
return (
<main className="hub-container">
<Link href="/" style={{ color: 'var(--accent-color)', textDecoration: 'none', marginBottom: '2rem', display: 'inline-block' }}>
&larr; Volver al Hub
</Link>
<div className="header" style={{ marginBottom: '3rem', textAlign: 'left' }}>
<h1 className="title" style={{ fontSize: '2.5rem' }}>Nuevo Ticket</h1>
<p className="subtitle">Detalla tu problema para que podamos ayudarte rápido.</p>
</div>
<form className="glass-panel ticket-form" onSubmit={handleSubmit} style={{ margin: '0', maxWidth: '800px' }}>
{status === "error" && (
<div style={{ background: 'rgba(244, 63, 94, 0.1)', color: '#f43f5e', padding: '1rem', borderRadius: '8px' }}>
{errorMessage}
</div>
)}
<div className="input-group">
<label>¿Cuál es el nivel de urgencia?</label>
<div className="tags-container">
<div
className={`tag-btn ${urgency === 'low' ? 'active low' : ''}`}
onClick={() => setUrgency('low')}
>
🟢 Normal
</div>
<div
className={`tag-btn ${urgency === 'medium' ? 'active medium' : ''}`}
onClick={() => setUrgency('medium')}
>
🟡 Importante
</div>
<div
className={`tag-btn ${urgency === 'high' ? 'active high' : ''}`}
onClick={() => setUrgency('high')}
>
🔴 Crítico
</div>
</div>
</div>
<div className="input-group" style={{ marginTop: '1rem' }}>
<label htmlFor="title">Asunto Principal</label>
<input
type="text"
id="title"
className="input-field"
required
placeholder="Ej. Error al acceder al correo electrónico"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="input-group">
<label htmlFor="description">Detalles del Problema</label>
<textarea
id="description"
className="input-field"
required
placeholder="Describe paso a paso lo que ocurrió..."
value={description}
onChange={(e) => setDescription(e.target.value)}
></textarea>
</div>
<div style={{ marginTop: '1.5rem', display: 'flex', justifyContent: 'flex-end' }}>
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === "submitting" ? "Enviando..." : "Enviar Ticket a Soporte"}
</button>
</div>
</form>
</main>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
export default function LogoutButton() {
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/login';
};
return (
<button
onClick={handleLogout}
style={{
background: 'transparent',
border: '1px solid rgba(255,255,255,0.2)',
color: 'var(--text-secondary)',
padding: '0.5rem 1rem',
borderRadius: '8px',
cursor: 'pointer',
marginTop: '1rem'
}}
>
Cerrar Sesión
</button>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { SignJWT, jwtVerify } from 'jose';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_me';
const secretKey = new TextEncoder().encode(JWT_SECRET);
export async function signToken(payload: any) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('24h')
.sign(secretKey);
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, secretKey);
return payload;
} catch (error) {
return null;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_me';
const secretKey = new TextEncoder().encode(JWT_SECRET);
export async function middleware(request: NextRequest) {
const token = request.cookies.get('hub_token')?.value;
const isLoginPage = request.nextUrl.pathname.startsWith('/login');
if (isLoginPage) {
if (token) {
try {
await jwtVerify(token, secretKey);
return NextResponse.redirect(new URL('/', request.url));
} catch (e) {
return NextResponse.next();
}
}
return NextResponse.next();
}
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
const { payload } = await jwtVerify(token, secretKey);
// Protect /admin routes
if (request.nextUrl.pathname.startsWith('/admin') && payload.role !== 'ADMIN') {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
} catch (error) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
View File