From 3059708aba5d390b4c840e1299901ab7fb998f18 Mon Sep 17 00:00:00 2001 From: Tesscorp Server Date: Wed, 15 Jul 2026 19:02:28 -0600 Subject: [PATCH] Initial commit --- .gitignore | 19 +- .node-version | 1 + .php-ini | 1 + .php-version | 1 + next.config.ts | 2 +- package-lock.json | 138 +++++++++++ package.json | 5 + prisma/schema.prisma | 33 +++ scripts/seed.js | 27 +++ server.js | 32 +++ src/app/admin/page.tsx | 164 +++++++++++++ src/app/api/admin/companies/route.ts | 35 +++ src/app/api/admin/users/route.ts | 49 ++++ src/app/api/auth/logout/route.ts | 7 + src/app/api/auth/route.ts | 55 +++++ src/app/api/tickets/route.ts | 55 +++++ src/app/globals.css | 350 ++++++++++++++++++++++++--- src/app/layout.tsx | 20 +- src/app/login/page.tsx | 89 +++++++ src/app/page.tsx | 122 +++++----- src/app/tickets/page.tsx | 137 +++++++++++ src/components/LogoutButton.tsx | 25 ++ src/lib/auth.ts | 21 ++ src/lib/prisma.ts | 11 + src/middleware.ts | 42 ++++ tmp/restart.txt | 0 26 files changed, 1315 insertions(+), 126 deletions(-) create mode 100644 .node-version create mode 100644 .php-ini create mode 100644 .php-version create mode 100644 prisma/schema.prisma create mode 100644 scripts/seed.js create mode 100644 server.js create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/api/admin/companies/route.ts create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/route.ts create mode 100644 src/app/api/tickets/route.ts create mode 100644 src/app/login/page.tsx create mode 100644 src/app/tickets/page.tsx create mode 100644 src/components/LogoutButton.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/lib/prisma.ts create mode 100644 src/middleware.ts create mode 100644 tmp/restart.txt diff --git a/.gitignore b/.gitignore index 5ef6a52..00e32df 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..4099407 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +23 diff --git a/.php-ini b/.php-ini new file mode 100644 index 0000000..6b4fdeb --- /dev/null +++ b/.php-ini @@ -0,0 +1 @@ +/var/www/vhosts/system/hub.tesscorp.com.mx/etc/php.ini \ No newline at end of file diff --git a/.php-version b/.php-version new file mode 100644 index 0000000..223a939 --- /dev/null +++ b/.php-version @@ -0,0 +1 @@ +8.3 \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index e9ffa30..e82cbf6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + allowedDevOrigins: ['hub.tesscorp.com.mx'], }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index ac0eefc..511c64d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 990d6ad..ce68ab3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..eaf6f99 --- /dev/null +++ b/prisma/schema.prisma @@ -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 +} diff --git a/scripts/seed.js b/scripts/seed.js new file mode 100644 index 0000000..0dcc184 --- /dev/null +++ b/scripts/seed.js @@ -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()); diff --git a/server.js b/server.js new file mode 100644 index 0000000..a6a2687 --- /dev/null +++ b/server.js @@ -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}`); + }); +}); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..66af5dc --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; + +export default function AdminPage() { + const [companies, setCompanies] = useState([]); + const [users, setUsers] = useState([]); + 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 ( +
+ + ← Volver al Hub + + +
+

Panel de Administración

+

Gestión centralizada de Clientes y Accesos Corporativos

+
+ +
+ + {/* Panel Izquierdo: Creación */} +
+ +
+

+ 🏢 Agregar Empresa +

+
+ setNewCompanyName(e.target.value)} + required + /> + +
+
+ +
+

+ 👤 Crear Usuario +

+
+
+ setNewUser({...newUser, email: e.target.value})} required /> +
+
+ setNewUser({...newUser, password: e.target.value})} required /> +
+
+ +
+ {newUser.role === 'CLIENT' && ( +
+ +
+ )} + +
+
+
+ + {/* Panel Derecho: Lista de Usuarios */} +
+

+ 📋 Directorio Activo +

+ +
+ + + + + + + + + + {users.length === 0 ? ( + + ) : ( + users.map(u => ( + + + + + + )) + )} + +
EmailRolEmpresa
No hay usuarios aún.
+
+ {u.email.charAt(0).toUpperCase()} +
+ {u.email} +
+ + {u.role} + + + {u.company?.name || -} +
+
+
+ +
+
+ ); +} diff --git a/src/app/api/admin/companies/route.ts b/src/app/api/admin/companies/route.ts new file mode 100644 index 0000000..87cf58b --- /dev/null +++ b/src/app/api/admin/companies/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..fd09900 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -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 }); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..1402d35 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -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; +} diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts new file mode 100644 index 0000000..7a4602c --- /dev/null +++ b/src/app/api/auth/route.ts @@ -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 }); + } +} diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts new file mode 100644 index 0000000..1ebbdf3 --- /dev/null +++ b/src/app/api/tickets/route.ts @@ -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: `

Reportado por: ${user.email}

${description.replace(/\n/g, '
')}

`, + 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 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 4c18fb3..1c2b8fd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1da6a2f..c1d0802 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - - {children} + + {children} ); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..001241d --- /dev/null +++ b/src/app/login/page.tsx @@ -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 ( +
+
+
+
🌌
+

Tess Hub

+

Identificación Corporativa

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + /> +
+ +
+ + setPassword(e.target.value)} + /> +
+ + +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 7b947a2..21b579c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
-
- Next.js logo -
-

To get started, edit the page.tsx file.

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
+
+
+

Tess Hub

+

+ {isAdmin ? "Central de Operaciones de TI y Desarrollo" : `Portal de Soporte B2B - ${user?.companyName || 'Cliente'}`} +

+
+ +
+ {isAdmin && ( + <> + +
🚀
+

Plane

+

Gestión ágil de proyectos, sprints y backlog del equipo.

+
+ + +
💻
+

Gitea

+

Repositorios de código, pull requests y revisiones.

+
+ + +
⚙️
+

Administración

+

Gestionar clientes, empresas y accesos al portal.

+ + + )} + + +
🎫
+

Tickets TI

+

Levantar incidencias y contactar con soporte técnico.

+ +
+ +
+

+ Conectado como: {user?.email} +

+ +
+
); } diff --git a/src/app/tickets/page.tsx b/src/app/tickets/page.tsx new file mode 100644 index 0000000..599ee82 --- /dev/null +++ b/src/app/tickets/page.tsx @@ -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) => { + 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 ( +
+
+
+

Ticket Enviado

+

+ Nuestro equipo de soporte ha recibido tu solicitud. Te responderemos a la brevedad. +

+
+ + + Volver al Hub + +
+
+
+ ); + } + + return ( +
+ + ← Volver al Hub + + +
+

Nuevo Ticket

+

Detalla tu problema para que podamos ayudarte rápido.

+
+ +
+ + {status === "error" && ( +
+ {errorMessage} +
+ )} + +
+ +
+
setUrgency('low')} + > + 🟢 Normal +
+
setUrgency('medium')} + > + 🟡 Importante +
+
setUrgency('high')} + > + 🔴 Crítico +
+
+
+ +
+ + setTitle(e.target.value)} + /> +
+ +
+ + +
+ +
+ +
+
+
+ ); +} diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx new file mode 100644 index 0000000..23b6f28 --- /dev/null +++ b/src/components/LogoutButton.tsx @@ -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 ( + + ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..f810546 --- /dev/null +++ b/src/lib/auth.ts @@ -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; + } +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..69de182 --- /dev/null +++ b/src/lib/prisma.ts @@ -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; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..c97c5af --- /dev/null +++ b/src/middleware.ts @@ -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).*)'], +}; diff --git a/tmp/restart.txt b/tmp/restart.txt new file mode 100644 index 0000000..e69de29