Initial commit
This commit is contained in:
+7
-12
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
23
|
||||
@@ -0,0 +1 @@
|
||||
8.3
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
allowedDevOrigins: ['hub.tesscorp.com.mx'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+138
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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' }}>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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).*)'],
|
||||
};
|
||||
Reference in New Issue
Block a user