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
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.*
|
.pnp.js
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
@@ -30,11 +23,13 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# local env files
|
||||||
.env*
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# vercel
|
# prisma
|
||||||
.vercel
|
dev.db
|
||||||
|
dev.db-journal
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
23
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
8.3
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
allowedDevOrigins: ['hub.tesscorp.com.mx'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+138
@@ -8,11 +8,16 @@
|
|||||||
"name": "hub.tesscorp.com.mx",
|
"name": "hub.tesscorp.com.mx",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -1231,6 +1236,69 @@
|
|||||||
"node": ">=12.4.0"
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -1258,6 +1326,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -2233,6 +2308,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
@@ -3393,6 +3477,20 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -4209,6 +4307,27 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -4877,6 +4996,25 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
|||||||
@@ -9,11 +9,16 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+312
-36
@@ -1,49 +1,325 @@
|
|||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--bg-dark: #050814;
|
||||||
--foreground: #171717;
|
--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);
|
||||||
@media (prefers-color-scheme: dark) {
|
--text-primary: #f8fafc;
|
||||||
:root {
|
--text-secondary: #94a3b8;
|
||||||
--background: #0a0a0a;
|
--accent-color: #3b82f6;
|
||||||
--foreground: #ededed;
|
--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;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
body {
|
||||||
color: inherit;
|
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;
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.card:hover {
|
||||||
html {
|
transform: translateY(-8px);
|
||||||
color-scheme: dark;
|
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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Outfit } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const outfit = Outfit({ subsets: ["latin"], variable: "--font-outfit" });
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Tess Hub | Portal B2B",
|
||||||
description: "Generated by create next app",
|
description: "Portal interno para el equipo y clientes corporativos",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,8 +15,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}>
|
<html lang="es" className={outfit.variable}>
|
||||||
<body>{children}</body>
|
<body className="antialiased" suppressHydrationWarning>{children}</body>
|
||||||
</html>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+54
-56
@@ -1,66 +1,64 @@
|
|||||||
import Image from "next/image";
|
import { cookies } from 'next/headers';
|
||||||
import styles from "./page.module.css";
|
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 (
|
return (
|
||||||
<div className={styles.page}>
|
<main className="hub-container">
|
||||||
<main className={styles.main}>
|
<div className="header">
|
||||||
<Image
|
<h1 className="title">Tess Hub</h1>
|
||||||
className={styles.logo}
|
<p className="subtitle">
|
||||||
src="/next.svg"
|
{isAdmin ? "Central de Operaciones de TI y Desarrollo" : `Portal de Soporte B2B - ${user?.companyName || 'Cliente'}`}
|
||||||
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.ctas}>
|
|
||||||
<a
|
<div className="grid">
|
||||||
className={styles.primary}
|
{isAdmin && (
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
<>
|
||||||
target="_blank"
|
<a href="https://plane.tesscorp.com.mx" className="card glass-panel" target="_blank" rel="noopener noreferrer">
|
||||||
rel="noopener noreferrer"
|
<div className="card-icon">🚀</div>
|
||||||
>
|
<h2>Plane</h2>
|
||||||
<Image
|
<p>Gestión ágil de proyectos, sprints y backlog del equipo.</p>
|
||||||
className={styles.logo}
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
className={styles.secondary}
|
<a href="https://git.tesscorp.com.mx" className="card glass-panel" target="_blank" rel="noopener noreferrer">
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
<div className="card-icon">💻</div>
|
||||||
target="_blank"
|
<h2>Gitea</h2>
|
||||||
rel="noopener noreferrer"
|
<p>Repositorios de código, pull requests y revisiones.</p>
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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