feat: add authentication system (login, users, auth middleware)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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
This commit is contained in:
22
server/auth.js
Normal file
22
server/auth.js
Normal file
@@ -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" });
|
||||
}
|
||||
}
|
||||
22
server/db.js
22
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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user