feat: add authentication system (login, users, auth middleware)
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:
alexandrump
2026-04-22 01:22:05 +02:00
parent 0a97e51dc6
commit 898d021ae8
14 changed files with 819 additions and 123 deletions

View File

@@ -113,14 +113,17 @@ curl -sSLk -X GET "https://panel.thax.es/api/v1/deploy?uuid=$COOLIFY_UUID&force=
## IDs clave ## IDs clave
```bash ```bash
# Coolify service UUID (reemplazar después de crear) # Coolify application UUID
SBSPORTS_COOLIFY_UUID="obtén_de_coolify" SBSPORTS_COOLIFY_UUID="ng00g8sskks0ok0okk0cg8kw"
# Gitea registry (ya configurado) # Coolify project UUID
git.thax.es/alexandrump/sbsports SBSPORTS_PROJECT_UUID="cwo848sgwgk0gk8kgkwkoskg"
# Credenciales (guardadas en memoria de Claude Code) # Gitea repo ID
# Ver: /Users/alex/.claude/projects/*/memory/ GITEA_REPO_ID=39
# Woodpecker repo ID
WP_REPO_ID=5
``` ```
## Notas ## Notas

117
package-lock.json generated
View File

@@ -8,9 +8,11 @@
"name": "sbsports", "name": "sbsports",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"express": "^4.21.2", "express": "^4.21.2",
"jsonwebtoken": "^9.0.3",
"lucide-vue-next": "^0.469.0", "lucide-vue-next": "^0.469.0",
"pinia": "^2.2.6", "pinia": "^2.2.6",
"vue": "^3.5.13", "vue": "^3.5.13",
@@ -1219,6 +1221,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/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "11.10.0", "version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
@@ -1358,6 +1369,12 @@
"ieee754": "^1.1.13" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1709,6 +1726,15 @@
"node": ">= 0.4" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -2318,6 +2344,55 @@
"jiti": "bin/jiti.js" "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": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2338,6 +2413,48 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lucide-vue-next": {
"version": "0.469.0", "version": "0.469.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.469.0.tgz", "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.469.0.tgz",

View File

@@ -10,9 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"express": "^4.21.2", "express": "^4.21.2",
"jsonwebtoken": "^9.0.3",
"lucide-vue-next": "^0.469.0", "lucide-vue-next": "^0.469.0",
"pinia": "^2.2.6", "pinia": "^2.2.6",
"vue": "^3.5.13", "vue": "^3.5.13",

22
server/auth.js Normal file
View 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" });
}
}

View File

@@ -2,6 +2,7 @@ import Database from "better-sqlite3";
import { join, dirname } from "path"; import { join, dirname } from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { mkdirSync } from "fs"; import { mkdirSync } from "fs";
import bcrypt from "bcryptjs";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = process.env.DATA_DIR || join(__dirname, "../data"); const DATA_DIR = process.env.DATA_DIR || join(__dirname, "../data");
@@ -40,6 +41,27 @@ db.exec(`
date TEXT, date TEXT,
createdAt TEXT DEFAULT (datetime('now')) 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; export default db;

View File

@@ -1,7 +1,9 @@
import express from "express"; import express from "express";
import { join, dirname } from "path"; import { join, dirname } from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import bcrypt from "bcryptjs";
import db from "./db.js"; import db from "./db.js";
import { signToken, requireAuth } from "./auth.js";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
@@ -13,15 +15,96 @@ app.use(express.json());
const distPath = join(__dirname, "../dist"); const distPath = join(__dirname, "../dist");
app.use(express.static(distPath)); 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 ────────────────────────────────────────────────────────────── // ── Students ──────────────────────────────────────────────────────────────
app.get("/api/students", (_req, res) => { app.get("/api/students", requireAuth, (_req, res) => {
res.json( res.json(
db.prepare("SELECT * FROM students ORDER BY lastName, firstName").all(), 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; const { id, firstName, lastName, age, sex, weight, height, imc } = req.body;
db.prepare( db.prepare(
` `
@@ -32,7 +115,7 @@ app.post("/api/students", (req, res) => {
res.status(201).json({ id }); 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 { firstName, lastName, age, sex, weight, height, imc } = req.body;
const info = db const info = db
.prepare( .prepare(
@@ -46,7 +129,7 @@ app.put("/api/students/:id", (req, res) => {
res.json({ ok: true }); 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) // CASCADE deletes linked activities automatically (FK ON DELETE CASCADE)
db.prepare("DELETE FROM students WHERE id=?").run(req.params.id); db.prepare("DELETE FROM students WHERE id=?").run(req.params.id);
res.json({ ok: true }); res.json({ ok: true });
@@ -54,13 +137,13 @@ app.delete("/api/students/:id", (req, res) => {
// ── Activities ──────────────────────────────────────────────────────────── // ── Activities ────────────────────────────────────────────────────────────
app.get("/api/activities", (_req, res) => { app.get("/api/activities", requireAuth, (_req, res) => {
res.json( res.json(
db.prepare("SELECT * FROM activities ORDER BY createdAt DESC").all(), db.prepare("SELECT * FROM activities ORDER BY createdAt DESC").all(),
); );
}); });
app.post("/api/activities", (req, res) => { app.post("/api/activities", requireAuth, (req, res) => {
const { const {
id, id,
studentId, studentId,
@@ -98,7 +181,7 @@ app.post("/api/activities", (req, res) => {
res.status(201).json({ id }); res.status(201).json({ id });
}); });
app.put("/api/activities/:id", (req, res) => { app.put("/api/activities/:id", requireAuth, (req, res) => {
const { const {
type, type,
durationInput, durationInput,
@@ -137,7 +220,7 @@ app.put("/api/activities/:id", (req, res) => {
res.json({ ok: true }); 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); db.prepare("DELETE FROM activities WHERE id=?").run(req.params.id);
res.json({ ok: true }); res.json({ ok: true });
}); });

View File

@@ -1,15 +1,25 @@
<script setup> <script setup>
import { useRoute } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { onMounted } from "vue"; import { onMounted } from "vue";
import AppSidebar from "./components/AppSidebar.vue"; import AppSidebar from "./components/AppSidebar.vue";
import AppNotification from "./components/AppNotification.vue"; import AppNotification from "./components/AppNotification.vue";
import { useSportsStore } from "./stores/sports"; import { useSportsStore } from "./stores/sports";
import { User } from "lucide-vue-next"; import { useAuthStore } from "./stores/auth";
import { User, LogOut } from "lucide-vue-next";
const store = useSportsStore(); const store = useSportsStore();
const auth = useAuthStore();
const route = useRoute(); const route = useRoute();
const router = useRouter();
onMounted(() => store.loadData()); onMounted(() => {
if (auth.isAuthenticated) store.loadData();
});
function logout() {
auth.logout();
router.push("/login");
}
const titles = { const titles = {
dashboard: "Tableau de Bord", dashboard: "Tableau de Bord",
@@ -17,11 +27,16 @@ const titles = {
activity: "Suivi Personnel", activity: "Suivi Personnel",
results: "Résultats Académiques", results: "Résultats Académiques",
history: "Historique Global", history: "Historique Global",
users: "Gestion Utilisateurs",
}; };
</script> </script>
<template> <template>
<div class="min-h-screen flex flex-col lg:flex-row"> <!-- Public pages (login) render without chrome -->
<RouterView v-if="route.meta.public" />
<!-- Authenticated shell -->
<div v-else class="min-h-screen flex flex-col lg:flex-row">
<AppSidebar /> <AppSidebar />
<main class="flex-1 p-6 lg:p-10 overflow-y-auto"> <main class="flex-1 p-6 lg:p-10 overflow-y-auto">
@@ -31,11 +46,22 @@ const titles = {
<h2 class="text-3xl font-bold text-blue-900"> <h2 class="text-3xl font-bold text-blue-900">
{{ titles[route.name] ?? "SB Sports" }} {{ titles[route.name] ?? "SB Sports" }}
</h2> </h2>
<div <div class="flex items-center gap-3">
class="flex items-center gap-3 text-blue-900 bg-blue-100 px-4 py-2 rounded-full border border-blue-200 shadow-sm" <div
> class="flex items-center gap-3 text-blue-900 bg-blue-100 px-4 py-2 rounded-full border border-blue-200 shadow-sm"
<User class="w-4 h-4" /> >
<span class="font-medium">Coach Sanae</span> <User class="w-4 h-4" />
<span class="font-medium capitalize">{{
auth.user?.username ?? "Coach"
}}</span>
</div>
<button
@click="logout"
class="flex items-center gap-2 text-sm text-red-600 hover:text-red-800 bg-red-50 hover:bg-red-100 px-3 py-2 rounded-full border border-red-200 transition-colors"
title="Déconnexion"
>
<LogOut class="w-4 h-4" />
</button>
</div> </div>
</header> </header>

View File

@@ -6,6 +6,7 @@ import {
Activity, Activity,
GraduationCap, GraduationCap,
History, History,
UserCog,
} from "lucide-vue-next"; } from "lucide-vue-next";
const route = useRoute(); const route = useRoute();
@@ -41,6 +42,12 @@ const navItems = [
icon: History, icon: History,
label: "Historique Global", label: "Historique Global",
}, },
{
to: "/users",
name: "users",
icon: UserCog,
label: "Utilisateurs",
},
]; ];
const isActive = (name) => route.name === name; const isActive = (name) => route.name === name;

View File

@@ -0,0 +1,15 @@
import { useAuthStore } from "../stores/auth";
export function useAuthFetch() {
function authFetch(url, options = {}) {
const auth = useAuthStore();
const headers = {
"Content-Type": "application/json",
...(options.headers || {}),
};
if (auth.token) headers["Authorization"] = `Bearer ${auth.token}`;
return fetch(url, { ...options, headers });
}
return { authFetch };
}

View File

@@ -4,16 +4,28 @@ import StudentsView from "../views/StudentsView.vue";
import ActivityView from "../views/ActivityView.vue"; import ActivityView from "../views/ActivityView.vue";
import ResultsView from "../views/ResultsView.vue"; import ResultsView from "../views/ResultsView.vue";
import HistoryView from "../views/HistoryView.vue"; import HistoryView from "../views/HistoryView.vue";
import LoginView from "../views/LoginView.vue";
import UsersView from "../views/UsersView.vue";
const routes = [ const routes = [
{ path: "/login", name: "login", component: LoginView, meta: { public: true } },
{ path: "/", name: "dashboard", component: DashboardView }, { path: "/", name: "dashboard", component: DashboardView },
{ path: "/students", name: "students", component: StudentsView }, { path: "/students", name: "students", component: StudentsView },
{ path: "/activity", name: "activity", component: ActivityView }, { path: "/activity", name: "activity", component: ActivityView },
{ path: "/results", name: "results", component: ResultsView }, { path: "/results", name: "results", component: ResultsView },
{ path: "/history", name: "history", component: HistoryView }, { path: "/history", name: "history", component: HistoryView },
{ path: "/users", name: "users", component: UsersView },
]; ];
export default createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes, routes,
}); });
router.beforeEach((to) => {
const token = localStorage.getItem("token");
if (!to.meta.public && !token) return { name: "login" };
if (to.name === "login" && token) return { name: "dashboard" };
});
export default router;

40
src/stores/auth.js Normal file
View File

@@ -0,0 +1,40 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useAuthStore = defineStore("auth", () => {
const token = ref(localStorage.getItem("token") || null);
const user = ref(JSON.parse(localStorage.getItem("user") || "null"));
const isAuthenticated = computed(() => !!token.value);
function setSession(data) {
token.value = data.token;
user.value = data.user;
localStorage.setItem("token", data.token);
localStorage.setItem("user", JSON.stringify(data.user));
}
function clearSession() {
token.value = null;
user.value = null;
localStorage.removeItem("token");
localStorage.removeItem("user");
}
async function login(username, password) {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Error al iniciar sesión");
setSession(data);
}
function logout() {
clearSession();
}
return { token, user, isAuthenticated, login, logout };
});

View File

@@ -1,186 +1,194 @@
import { defineStore } from 'pinia' import { defineStore } from "pinia";
import { ref, computed } from 'vue' import { ref, computed } from "vue";
import { gradeWeights, calcIMC } from '../composables/useHelpers' import { gradeWeights, calcIMC } from "../composables/useHelpers";
import { useAuthStore } from "./auth";
const API = '/api' const API = "/api";
async function apiFetch(path, options = {}) { async function apiFetch(path, options = {}) {
const res = await fetch(`${API}${path}`, { const auth = useAuthStore();
headers: { 'Content-Type': 'application/json' }, const headers = { "Content-Type": "application/json" };
...options, if (auth.token) headers["Authorization"] = `Bearer ${auth.token}`;
}) const res = await fetch(`${API}${path}`, { headers, ...options });
if (!res.ok) throw new Error(`API ${path}${res.status}`) if (!res.ok) throw new Error(`API ${path}${res.status}`);
return res.json() return res.json();
} }
export const useSportsStore = defineStore('sports', () => { export const useSportsStore = defineStore("sports", () => {
const activities = ref([]) const activities = ref([]);
const students = ref([]) const students = ref([]);
const notification = ref('') const notification = ref("");
const loading = ref(false) const loading = ref(false);
// ── Persistence ────────────────────────────────────────────────────────── // ── Persistence ──────────────────────────────────────────────────────────
async function loadData() { async function loadData() {
loading.value = true loading.value = true;
try { try {
const [s, a] = await Promise.all([ const [s, a] = await Promise.all([
apiFetch('/students'), apiFetch("/students"),
apiFetch('/activities'), apiFetch("/activities"),
]) ]);
students.value = s students.value = s;
activities.value = a activities.value = a;
} catch (e) { } catch (e) {
showNotification('Erreur de connexion au serveur') showNotification("Erreur de connexion au serveur");
console.error(e) console.error(e);
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
// ── Notification ───────────────────────────────────────────────────────── // ── Notification ─────────────────────────────────────────────────────────
function showNotification(msg) { function showNotification(msg) {
notification.value = msg notification.value = msg;
setTimeout(() => (notification.value = ''), 3000) setTimeout(() => (notification.value = ""), 3000);
} }
// ── Students ───────────────────────────────────────────────────────────── // ── Students ─────────────────────────────────────────────────────────────
async function saveStudent(form, editingId) { async function saveStudent(form, editingId) {
const imc = calcIMC(form.weight, form.height) const imc = calcIMC(form.weight, form.height);
if (editingId) { if (editingId) {
await apiFetch(`/students/${editingId}`, { await apiFetch(`/students/${editingId}`, {
method: 'PUT', method: "PUT",
body: JSON.stringify({ ...form, imc }), body: JSON.stringify({ ...form, imc }),
}) });
const idx = students.value.findIndex((s) => s.id === editingId) const idx = students.value.findIndex((s) => s.id === editingId);
if (idx !== -1) students.value[idx] = { ...form, id: editingId, imc } if (idx !== -1) students.value[idx] = { ...form, id: editingId, imc };
showNotification('Profil mis à jour') showNotification("Profil mis à jour");
} else { } else {
const id = Date.now().toString() const id = Date.now().toString();
await apiFetch('/students', { await apiFetch("/students", {
method: 'POST', method: "POST",
body: JSON.stringify({ id, ...form, imc }), body: JSON.stringify({ id, ...form, imc }),
}) });
students.value.push({ id, ...form, imc }) students.value.push({ id, ...form, imc });
showNotification('Étudiant inscrit') showNotification("Étudiant inscrit");
} }
} }
async function deleteStudent(id) { async function deleteStudent(id) {
await apiFetch(`/students/${id}`, { method: 'DELETE' }) await apiFetch(`/students/${id}`, { method: "DELETE" });
students.value = students.value.filter((s) => s.id !== id) students.value = students.value.filter((s) => s.id !== id);
activities.value = activities.value.filter((a) => a.studentId !== id) activities.value = activities.value.filter((a) => a.studentId !== id);
showNotification('Étudiant supprimé') showNotification("Étudiant supprimé");
} }
// ── Activities ─────────────────────────────────────────────────────────── // ── Activities ───────────────────────────────────────────────────────────
async function saveActivity(act, editingId) { async function saveActivity(act, editingId) {
let norm = act.durationInput let norm = act.durationInput;
if (act.durationUnit === 'sec') norm /= 60 if (act.durationUnit === "sec") norm /= 60;
if (act.durationUnit === 'h') norm *= 60 if (act.durationUnit === "h") norm *= 60;
const record = { const record = {
...act, ...act,
id: editingId || Date.now().toString(), id: editingId || Date.now().toString(),
duration: norm, duration: norm,
displayDuration: `${act.durationInput} ${act.durationUnit}`, displayDuration: `${act.durationInput} ${act.durationUnit}`,
} };
if (editingId) { if (editingId) {
await apiFetch(`/activities/${editingId}`, { await apiFetch(`/activities/${editingId}`, {
method: 'PUT', method: "PUT",
body: JSON.stringify(record), body: JSON.stringify(record),
}) });
const idx = activities.value.findIndex((a) => a.id === editingId) const idx = activities.value.findIndex((a) => a.id === editingId);
if (idx !== -1) activities.value[idx] = record if (idx !== -1) activities.value[idx] = record;
showNotification('Activité mise à jour') showNotification("Activité mise à jour");
} else { } else {
await apiFetch('/activities', { await apiFetch("/activities", {
method: 'POST', method: "POST",
body: JSON.stringify(record), body: JSON.stringify(record),
}) });
activities.value.unshift(record) activities.value.unshift(record);
showNotification('Activité enregistrée') showNotification("Activité enregistrée");
} }
} }
async function deleteActivity(index) { async function deleteActivity(index) {
const act = activities.value[index] const act = activities.value[index];
await apiFetch(`/activities/${act.id}`, { method: 'DELETE' }) await apiFetch(`/activities/${act.id}`, { method: "DELETE" });
activities.value.splice(index, 1) activities.value.splice(index, 1);
showNotification('Supprimé') showNotification("Supprimé");
} }
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
function getStudentName(id) { function getStudentName(id) {
const s = students.value.find((st) => st.id === id) const s = students.value.find((st) => st.id === id);
return s ? `${s.lastName} ${s.firstName}` : 'Inconnu' return s ? `${s.lastName} ${s.firstName}` : "Inconnu";
} }
function getSelectedStudentIMC(id) { function getSelectedStudentIMC(id) {
return students.value.find((s) => s.id === id)?.imc || 0 return students.value.find((s) => s.id === id)?.imc || 0;
} }
function getStudentAveragePoints(studentId) { function getStudentAveragePoints(studentId) {
const evals = activities.value.filter((a) => a.studentId === studentId && a.grade) const evals = activities.value.filter(
if (!evals.length) return -1 (a) => a.studentId === studentId && a.grade,
return evals.reduce((acc, a) => acc + gradeWeights[a.grade], 0) / evals.length );
if (!evals.length) return -1;
return (
evals.reduce((acc, a) => acc + gradeWeights[a.grade], 0) / evals.length
);
} }
function getStudentAverageLiteral(studentId) { function getStudentAverageLiteral(studentId) {
const avg = getStudentAveragePoints(studentId) const avg = getStudentAveragePoints(studentId);
if (avg === -1) return 'Non noté' if (avg === -1) return "Non noté";
const rounded = Math.round(avg) const rounded = Math.round(avg);
return Object.keys(gradeWeights).find((k) => gradeWeights[k] === rounded) return Object.keys(gradeWeights).find((k) => gradeWeights[k] === rounded);
} }
function getStudentEvaluationsCount(id) { function getStudentEvaluationsCount(id) {
return activities.value.filter((a) => a.studentId === id && a.grade).length return activities.value.filter((a) => a.studentId === id && a.grade).length;
} }
// ── Computed stats ──────────────────────────────────────────────────────── // ── Computed stats ────────────────────────────────────────────────────────
const totalMinutes = computed(() => const totalMinutes = computed(() =>
activities.value.reduce((acc, c) => acc + (c.duration || 0), 0), activities.value.reduce((acc, c) => acc + (c.duration || 0), 0),
) );
const successRate = computed(() => const successRate = computed(() =>
activities.value.length activities.value.length
? Math.round( ? Math.round(
(activities.value.filter((a) => a.grade && a.grade !== 'Redouble').length / (activities.value.filter((a) => a.grade && a.grade !== "Redouble")
activities.value.length) * 100, .length /
activities.value.length) *
100,
) )
: 0, : 0,
) );
const gradeStats = computed(() => { const gradeStats = computed(() => {
const total = students.value.length || 1 const total = students.value.length || 1;
const averages = students.value.map((s) => getStudentAverageLiteral(s.id)) const averages = students.value.map((s) => getStudentAverageLiteral(s.id));
const pct = (g) => Math.round((averages.filter((v) => v === g).length / total) * 100) const pct = (g) =>
Math.round((averages.filter((v) => v === g).length / total) * 100);
return { return {
excellent: pct('Excellent'), excellent: pct("Excellent"),
tresBien: pct('Très bien'), tresBien: pct("Très bien"),
bien: pct('Bien'), bien: pct("Bien"),
assezBien: pct('Assez bien'), assezBien: pct("Assez bien"),
redouble: pct('Redouble'), redouble: pct("Redouble"),
} };
}) });
const activityLifestyleStats = computed(() => { const activityLifestyleStats = computed(() => {
const stats = { const stats = {
Inactif: { count: 0, percentage: 0 }, Inactif: { count: 0, percentage: 0 },
Sédentaire: { count: 0, percentage: 0 }, Sédentaire: { count: 0, percentage: 0 },
Actif: { count: 0, percentage: 0 }, Actif: { count: 0, percentage: 0 },
'Très actif': { count: 0, percentage: 0 }, "Très actif": { count: 0, percentage: 0 },
} };
activities.value.forEach((a) => { activities.value.forEach((a) => {
const key = a.lifestyle || 'Sédentaire' const key = a.lifestyle || "Sédentaire";
if (stats[key]) stats[key].count++ if (stats[key]) stats[key].count++;
}) });
const total = activities.value.length || 1 const total = activities.value.length || 1;
Object.keys(stats).forEach( Object.keys(stats).forEach(
(k) => (stats[k].percentage = Math.round((stats[k].count / total) * 100)), (k) => (stats[k].percentage = Math.round((stats[k].count / total) * 100)),
) );
return stats return stats;
}) });
return { return {
activities, activities,
@@ -202,5 +210,5 @@ export const useSportsStore = defineStore('sports', () => {
successRate, successRate,
gradeStats, gradeStats,
activityLifestyleStats, activityLifestyleStats,
} };
}) });

108
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,108 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
import { LogIn, ShieldCheck } from "lucide-vue-next";
const router = useRouter();
const auth = useAuthStore();
const username = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
async function handleLogin() {
error.value = "";
loading.value = true;
try {
await auth.login(username.value, password.value);
router.push("/");
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="min-h-screen gradient-brand flex items-center justify-center p-6">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md p-10">
<!-- Logo -->
<div class="flex flex-col items-center mb-8">
<div class="bg-blue-900 p-4 rounded-2xl mb-4 shadow-lg">
<svg
width="48"
height="48"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="50" cy="50" r="45" fill="#1e40af" />
<path
d="M30 70 L45 30 L55 50 L70 20"
stroke="white"
stroke-width="8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="70" cy="20" r="5" fill="white" />
</svg>
</div>
<h1 class="text-2xl font-black text-blue-900">SB SPORT</h1>
<p class="text-xs text-gray-400 uppercase tracking-widest mt-1">
Sanae Benkhlifa
</p>
</div>
<!-- Form -->
<form @submit.prevent="handleLogin" class="flex flex-col gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">
Utilisateur
</label>
<input
v-model="username"
type="text"
autocomplete="username"
required
placeholder="sanae"
class="w-full border border-gray-300 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-800"
/>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">
Mot de passe
</label>
<input
v-model="password"
type="password"
autocomplete="current-password"
required
class="w-full border border-gray-300 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-800"
/>
</div>
<p v-if="error" class="text-red-500 text-sm text-center">{{ error }}</p>
<button
type="submit"
:disabled="loading"
class="flex items-center justify-center gap-2 bg-blue-900 hover:bg-blue-800 disabled:opacity-60 text-white font-semibold py-3 rounded-xl transition-colors mt-2"
>
<LogIn class="w-4 h-4" />
{{ loading ? "Connexion…" : "Se connecter" }}
</button>
</form>
<div
class="flex items-center justify-center gap-1 mt-6 text-xs text-gray-400"
>
<ShieldCheck class="w-3 h-3" />
Accès sécurisé
</div>
</div>
</div>
</template>

231
src/views/UsersView.vue Normal file
View File

@@ -0,0 +1,231 @@
<script setup>
import { ref, onMounted } from "vue";
import { UserPlus, Trash2, KeyRound, Users } from "lucide-vue-next";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const users = ref([]);
const loading = ref(false);
const error = ref("");
// New user form
const newUsername = ref("");
const newPassword = ref("");
const newRole = ref("admin");
const addError = ref("");
const addLoading = ref(false);
// Change password
const changingId = ref(null);
const newPwd = ref("");
const pwdError = ref("");
async function authFetch(url, options = {}) {
const headers = { "Content-Type": "application/json" };
if (auth.token) headers["Authorization"] = `Bearer ${auth.token}`;
return fetch(url, { headers, ...options });
}
async function loadUsers() {
loading.value = true;
error.value = "";
try {
const res = await authFetch("/api/users");
users.value = await res.json();
} catch {
error.value = "Error al cargar usuarios";
} finally {
loading.value = false;
}
}
async function addUser() {
addError.value = "";
addLoading.value = true;
try {
const res = await authFetch("/api/users", {
method: "POST",
body: JSON.stringify({
username: newUsername.value,
password: newPassword.value,
role: newRole.value,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
newUsername.value = "";
newPassword.value = "";
await loadUsers();
} catch (e) {
addError.value = e.message;
} finally {
addLoading.value = false;
}
}
async function deleteUser(id) {
if (!confirm("¿Eliminar este usuario?")) return;
await authFetch(`/api/users/${id}`, { method: "DELETE" });
await loadUsers();
}
async function changePassword(id) {
pwdError.value = "";
try {
const res = await authFetch(`/api/users/${id}/password`, {
method: "PUT",
body: JSON.stringify({ password: newPwd.value }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
changingId.value = null;
newPwd.value = "";
} catch (e) {
pwdError.value = e.message;
}
}
onMounted(loadUsers);
</script>
<template>
<div class="space-y-8">
<!-- User list -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div class="flex items-center gap-2 mb-6">
<Users class="w-5 h-5 text-blue-900" />
<h3 class="text-lg font-bold text-blue-900">Utilisateurs</h3>
</div>
<p v-if="loading" class="text-gray-400 text-sm">Chargement</p>
<p v-else-if="error" class="text-red-500 text-sm">{{ error }}</p>
<table v-else class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 border-b">
<th class="pb-2 font-semibold">Utilisateur</th>
<th class="pb-2 font-semibold">Rôle</th>
<th class="pb-2 font-semibold">Créé le</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody>
<tr
v-for="u in users"
:key="u.id"
class="border-b last:border-0 hover:bg-gray-50"
>
<td class="py-3 font-medium capitalize">{{ u.username }}</td>
<td class="py-3">
<span
class="bg-blue-100 text-blue-800 text-xs px-2 py-0.5 rounded-full"
>
{{ u.role }}
</span>
</td>
<td class="py-3 text-gray-400">{{ u.createdAt?.slice(0, 10) }}</td>
<td class="py-3">
<div class="flex items-center gap-2 justify-end">
<!-- Change password inline -->
<template v-if="changingId === u.id">
<input
v-model="newPwd"
type="password"
placeholder="Nouveau mot de passe"
class="border rounded-lg px-2 py-1 text-xs w-44 focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
<button
@click="changePassword(u.id)"
class="text-xs bg-blue-900 text-white px-2 py-1 rounded-lg hover:bg-blue-800"
>
OK
</button>
<button
@click="
changingId = null;
newPwd = '';
"
class="text-xs text-gray-500 hover:text-gray-700"
>
</button>
<p v-if="pwdError" class="text-red-500 text-xs">
{{ pwdError }}
</p>
</template>
<template v-else>
<button
@click="changingId = u.id"
class="text-gray-400 hover:text-blue-700 transition-colors"
title="Changer le mot de passe"
>
<KeyRound class="w-4 h-4" />
</button>
<button
@click="deleteUser(u.id)"
class="text-gray-400 hover:text-red-600 transition-colors"
title="Supprimer"
>
<Trash2 class="w-4 h-4" />
</button>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add user -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div class="flex items-center gap-2 mb-6">
<UserPlus class="w-5 h-5 text-blue-900" />
<h3 class="text-lg font-bold text-blue-900">Ajouter un utilisateur</h3>
</div>
<form @submit.prevent="addUser" class="flex flex-wrap gap-3 items-end">
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-gray-600">Utilisateur</label>
<input
v-model="newUsername"
type="text"
required
class="border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 w-40"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-gray-600"
>Mot de passe</label
>
<input
v-model="newPassword"
type="password"
required
class="border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 w-40"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-gray-600">Rôle</label>
<select
v-model="newRole"
class="border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
>
<option value="admin">admin</option>
</select>
</div>
<button
type="submit"
:disabled="addLoading"
class="flex items-center gap-2 bg-blue-900 hover:bg-blue-800 disabled:opacity-60 text-white text-sm font-semibold px-4 py-2 rounded-xl transition-colors"
>
<UserPlus class="w-4 h-4" />
{{ addLoading ? "Ajout…" : "Ajouter" }}
</button>
<p v-if="addError" class="text-red-500 text-sm w-full">
{{ addError }}
</p>
</form>
</div>
</div>
</template>