From 898d021ae8f733f9865f1e0b1af4b70eb624f23e Mon Sep 17 00:00:00 2001 From: alexandrump Date: Wed, 22 Apr 2026 01:22:05 +0200 Subject: [PATCH] feat: add authentication system (login, users, auth middleware) - server/auth.js: JWT middleware and auth routes - src/stores/auth.js + useAuthFetch.js: client-side auth state - src/views/LoginView.vue + UsersView.vue: login and user management UI - router, sidebar, App.vue: guard routes behind auth - COOLIFY.md: add real deployment IDs --- COOLIFY.md | 15 ++- package-lock.json | 117 ++++++++++++++++ package.json | 2 + server/auth.js | 22 +++ server/db.js | 22 +++ server/index.js | 99 ++++++++++++-- src/App.vue | 44 ++++-- src/components/AppSidebar.vue | 7 + src/composables/useAuthFetch.js | 15 +++ src/router/index.js | 14 +- src/stores/auth.js | 40 ++++++ src/stores/sports.js | 206 ++++++++++++++-------------- src/views/LoginView.vue | 108 +++++++++++++++ src/views/UsersView.vue | 231 ++++++++++++++++++++++++++++++++ 14 files changed, 819 insertions(+), 123 deletions(-) create mode 100644 server/auth.js create mode 100644 src/composables/useAuthFetch.js create mode 100644 src/stores/auth.js create mode 100644 src/views/LoginView.vue create mode 100644 src/views/UsersView.vue diff --git a/COOLIFY.md b/COOLIFY.md index 33ab167..d316d40 100644 --- a/COOLIFY.md +++ b/COOLIFY.md @@ -113,14 +113,17 @@ curl -sSLk -X GET "https://panel.thax.es/api/v1/deploy?uuid=$COOLIFY_UUID&force= ## IDs clave ```bash -# Coolify service UUID (reemplazar después de crear) -SBSPORTS_COOLIFY_UUID="obtén_de_coolify" +# Coolify application UUID +SBSPORTS_COOLIFY_UUID="ng00g8sskks0ok0okk0cg8kw" -# Gitea registry (ya configurado) -git.thax.es/alexandrump/sbsports +# Coolify project UUID +SBSPORTS_PROJECT_UUID="cwo848sgwgk0gk8kgkwkoskg" -# Credenciales (guardadas en memoria de Claude Code) -# Ver: /Users/alex/.claude/projects/*/memory/ +# Gitea repo ID +GITEA_REPO_ID=39 + +# Woodpecker repo ID +WP_REPO_ID=5 ``` ## Notas diff --git a/package-lock.json b/package-lock.json index a5814fe..b2ca3a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "sbsports", "version": "0.1.0", "dependencies": { + "bcryptjs": "^3.0.3", "better-sqlite3": "^11.8.1", "concurrently": "^9.1.2", "express": "^4.21.2", + "jsonwebtoken": "^9.0.3", "lucide-vue-next": "^0.469.0", "pinia": "^2.2.6", "vue": "^3.5.13", @@ -1219,6 +1221,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/better-sqlite3": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", @@ -1358,6 +1369,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1709,6 +1726,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2318,6 +2344,55 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2338,6 +2413,48 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lucide-vue-next": { "version": "0.469.0", "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.469.0.tgz", diff --git a/package.json b/package.json index 18c7d8e..b33dcc6 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "bcryptjs": "^3.0.3", "better-sqlite3": "^11.8.1", "concurrently": "^9.1.2", "express": "^4.21.2", + "jsonwebtoken": "^9.0.3", "lucide-vue-next": "^0.469.0", "pinia": "^2.2.6", "vue": "^3.5.13", diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..1906ee9 --- /dev/null +++ b/server/auth.js @@ -0,0 +1,22 @@ +import jwt from "jsonwebtoken"; + +const JWT_SECRET = + process.env.JWT_SECRET || "sbsports-dev-secret-change-in-prod"; +const JWT_EXPIRES = "8h"; + +export function signToken(payload) { + return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES }); +} + +export function requireAuth(req, res, next) { + const header = req.headers.authorization || ""; + const token = header.startsWith("Bearer ") ? header.slice(7) : null; + if (!token) return res.status(401).json({ error: "No autenticado" }); + + try { + req.user = jwt.verify(token, JWT_SECRET); + next(); + } catch { + res.status(401).json({ error: "Token inválido o expirado" }); + } +} diff --git a/server/db.js b/server/db.js index 8f6a771..551cb46 100644 --- a/server/db.js +++ b/server/db.js @@ -2,6 +2,7 @@ import Database from "better-sqlite3"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { mkdirSync } from "fs"; +import bcrypt from "bcryptjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const DATA_DIR = process.env.DATA_DIR || join(__dirname, "../data"); @@ -40,6 +41,27 @@ db.exec(` date TEXT, createdAt TEXT DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + passwordHash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + createdAt TEXT DEFAULT (datetime('now')) + ); `); +// Seed default admin (Sanae) if no users exist +const userCount = db.prepare("SELECT COUNT(*) as n FROM users").get().n; +if (userCount === 0) { + const DEFAULT_PASSWORD = process.env.ADMIN_PASSWORD || "sanae1234"; + const hash = bcrypt.hashSync(DEFAULT_PASSWORD, 10); + db.prepare( + "INSERT INTO users (username, passwordHash, role) VALUES (?, ?, ?)", + ).run("sanae", hash, "admin"); + console.log( + `[db] Admin user 'sanae' created (password: ${DEFAULT_PASSWORD})`, + ); +} + export default db; diff --git a/server/index.js b/server/index.js index e7c7046..657ace1 100644 --- a/server/index.js +++ b/server/index.js @@ -1,7 +1,9 @@ import express from "express"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; +import bcrypt from "bcryptjs"; import db from "./db.js"; +import { signToken, requireAuth } from "./auth.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -13,15 +15,96 @@ app.use(express.json()); const distPath = join(__dirname, "../dist"); app.use(express.static(distPath)); +// ── Auth ────────────────────────────────────────────────────────────────── + +app.post("/api/auth/login", (req, res) => { + const { username, password } = req.body; + if (!username || !password) + return res.status(400).json({ error: "Faltan credenciales" }); + + const user = db + .prepare("SELECT * FROM users WHERE username = ?") + .get(username.toLowerCase()); + + if (!user || !bcrypt.compareSync(password, user.passwordHash)) + return res.status(401).json({ error: "Usuario o contraseña incorrectos" }); + + const token = signToken({ + id: user.id, + username: user.username, + role: user.role, + }); + res.json({ + token, + user: { id: user.id, username: user.username, role: user.role }, + }); +}); + +app.get("/api/auth/me", requireAuth, (req, res) => { + res.json({ user: req.user }); +}); + +// ── Users (admin only) ──────────────────────────────────────────────────── + +app.get("/api/users", requireAuth, (_req, res) => { + const users = db + .prepare( + "SELECT id, username, role, createdAt FROM users ORDER BY createdAt", + ) + .all(); + res.json(users); +}); + +app.post("/api/users", requireAuth, (req, res) => { + const { username, password, role = "admin" } = req.body; + if (!username || !password) + return res.status(400).json({ error: "Faltan campos obligatorios" }); + + const existing = db + .prepare("SELECT id FROM users WHERE username = ?") + .get(username.toLowerCase()); + if (existing) return res.status(409).json({ error: "El usuario ya existe" }); + + const passwordHash = bcrypt.hashSync(password, 10); + const info = db + .prepare( + "INSERT INTO users (username, passwordHash, role) VALUES (?, ?, ?)", + ) + .run(username.toLowerCase(), passwordHash, role); + res.status(201).json({ id: info.lastInsertRowid, username, role }); +}); + +app.put("/api/users/:id/password", requireAuth, (req, res) => { + const { password } = req.body; + if (!password) return res.status(400).json({ error: "Falta la contraseña" }); + + const passwordHash = bcrypt.hashSync(password, 10); + const info = db + .prepare("UPDATE users SET passwordHash = ? WHERE id = ?") + .run(passwordHash, req.params.id); + if (info.changes === 0) return res.status(404).json({ error: "Not found" }); + res.json({ ok: true }); +}); + +app.delete("/api/users/:id", requireAuth, (req, res) => { + const remaining = db.prepare("SELECT COUNT(*) as n FROM users").get().n; + if (remaining <= 1) + return res + .status(400) + .json({ error: "No se puede eliminar el último usuario" }); + db.prepare("DELETE FROM users WHERE id = ?").run(req.params.id); + res.json({ ok: true }); +}); + // ── Students ────────────────────────────────────────────────────────────── -app.get("/api/students", (_req, res) => { +app.get("/api/students", requireAuth, (_req, res) => { res.json( db.prepare("SELECT * FROM students ORDER BY lastName, firstName").all(), ); }); -app.post("/api/students", (req, res) => { +app.post("/api/students", requireAuth, (req, res) => { const { id, firstName, lastName, age, sex, weight, height, imc } = req.body; db.prepare( ` @@ -32,7 +115,7 @@ app.post("/api/students", (req, res) => { res.status(201).json({ id }); }); -app.put("/api/students/:id", (req, res) => { +app.put("/api/students/:id", requireAuth, (req, res) => { const { firstName, lastName, age, sex, weight, height, imc } = req.body; const info = db .prepare( @@ -46,7 +129,7 @@ app.put("/api/students/:id", (req, res) => { res.json({ ok: true }); }); -app.delete("/api/students/:id", (req, res) => { +app.delete("/api/students/:id", requireAuth, (req, res) => { // CASCADE deletes linked activities automatically (FK ON DELETE CASCADE) db.prepare("DELETE FROM students WHERE id=?").run(req.params.id); res.json({ ok: true }); @@ -54,13 +137,13 @@ app.delete("/api/students/:id", (req, res) => { // ── Activities ──────────────────────────────────────────────────────────── -app.get("/api/activities", (_req, res) => { +app.get("/api/activities", requireAuth, (_req, res) => { res.json( db.prepare("SELECT * FROM activities ORDER BY createdAt DESC").all(), ); }); -app.post("/api/activities", (req, res) => { +app.post("/api/activities", requireAuth, (req, res) => { const { id, studentId, @@ -98,7 +181,7 @@ app.post("/api/activities", (req, res) => { res.status(201).json({ id }); }); -app.put("/api/activities/:id", (req, res) => { +app.put("/api/activities/:id", requireAuth, (req, res) => { const { type, durationInput, @@ -137,7 +220,7 @@ app.put("/api/activities/:id", (req, res) => { res.json({ ok: true }); }); -app.delete("/api/activities/:id", (req, res) => { +app.delete("/api/activities/:id", requireAuth, (req, res) => { db.prepare("DELETE FROM activities WHERE id=?").run(req.params.id); res.json({ ok: true }); }); diff --git a/src/App.vue b/src/App.vue index f5e91ba..7f6448b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,15 +1,25 @@