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:
15
COOLIFY.md
15
COOLIFY.md
@@ -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
117
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
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 { 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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
36
src/App.vue
36
src/App.vue
@@ -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 class="flex items-center gap-3">
|
||||||
<div
|
<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"
|
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" />
|
<User class="w-4 h-4" />
|
||||||
<span class="font-medium">Coach Sanae</span>
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
15
src/composables/useAuthFetch.js
Normal file
15
src/composables/useAuthFetch.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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
40
src/stores/auth.js
Normal 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 };
|
||||||
|
});
|
||||||
@@ -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
108
src/views/LoginView.vue
Normal 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
231
src/views/UsersView.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user