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
|
||||
|
||||
```bash
|
||||
# Coolify service UUID (reemplazar después de crear)
|
||||
SBSPORTS_COOLIFY_UUID="obtén_de_coolify"
|
||||
# Coolify application UUID
|
||||
SBSPORTS_COOLIFY_UUID="ng00g8sskks0ok0okk0cg8kw"
|
||||
|
||||
# Gitea registry (ya configurado)
|
||||
git.thax.es/alexandrump/sbsports
|
||||
# Coolify project UUID
|
||||
SBSPORTS_PROJECT_UUID="cwo848sgwgk0gk8kgkwkoskg"
|
||||
|
||||
# Credenciales (guardadas en memoria de Claude Code)
|
||||
# Ver: /Users/alex/.claude/projects/*/memory/
|
||||
# Gitea repo ID
|
||||
GITEA_REPO_ID=39
|
||||
|
||||
# Woodpecker repo ID
|
||||
WP_REPO_ID=5
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
117
package-lock.json
generated
117
package-lock.json
generated
@@ -8,9 +8,11 @@
|
||||
"name": "sbsports",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"concurrently": "^9.1.2",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-vue-next": "^0.469.0",
|
||||
"pinia": "^2.2.6",
|
||||
"vue": "^3.5.13",
|
||||
@@ -1219,6 +1221,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||
@@ -1358,6 +1369,12 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1709,6 +1726,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -2318,6 +2344,55 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -2338,6 +2413,48 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lucide-vue-next": {
|
||||
"version": "0.469.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.469.0.tgz",
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"concurrently": "^9.1.2",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-vue-next": "^0.469.0",
|
||||
"pinia": "^2.2.6",
|
||||
"vue": "^3.5.13",
|
||||
|
||||
22
server/auth.js
Normal file
22
server/auth.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
const JWT_SECRET =
|
||||
process.env.JWT_SECRET || "sbsports-dev-secret-change-in-prod";
|
||||
const JWT_EXPIRES = "8h";
|
||||
|
||||
export function signToken(payload) {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES });
|
||||
}
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization || "";
|
||||
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
|
||||
if (!token) return res.status(401).json({ error: "No autenticado" });
|
||||
|
||||
try {
|
||||
req.user = jwt.verify(token, JWT_SECRET);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: "Token inválido o expirado" });
|
||||
}
|
||||
}
|
||||
22
server/db.js
22
server/db.js
@@ -2,6 +2,7 @@ import Database from "better-sqlite3";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { mkdirSync } from "fs";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = process.env.DATA_DIR || join(__dirname, "../data");
|
||||
@@ -40,6 +41,27 @@ db.exec(`
|
||||
date TEXT,
|
||||
createdAt TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
passwordHash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
createdAt TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Seed default admin (Sanae) if no users exist
|
||||
const userCount = db.prepare("SELECT COUNT(*) as n FROM users").get().n;
|
||||
if (userCount === 0) {
|
||||
const DEFAULT_PASSWORD = process.env.ADMIN_PASSWORD || "sanae1234";
|
||||
const hash = bcrypt.hashSync(DEFAULT_PASSWORD, 10);
|
||||
db.prepare(
|
||||
"INSERT INTO users (username, passwordHash, role) VALUES (?, ?, ?)",
|
||||
).run("sanae", hash, "admin");
|
||||
console.log(
|
||||
`[db] Admin user 'sanae' created (password: ${DEFAULT_PASSWORD})`,
|
||||
);
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import express from "express";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import bcrypt from "bcryptjs";
|
||||
import db from "./db.js";
|
||||
import { signToken, requireAuth } from "./auth.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
@@ -13,15 +15,96 @@ app.use(express.json());
|
||||
const distPath = join(__dirname, "../dist");
|
||||
app.use(express.static(distPath));
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/api/auth/login", (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password)
|
||||
return res.status(400).json({ error: "Faltan credenciales" });
|
||||
|
||||
const user = db
|
||||
.prepare("SELECT * FROM users WHERE username = ?")
|
||||
.get(username.toLowerCase());
|
||||
|
||||
if (!user || !bcrypt.compareSync(password, user.passwordHash))
|
||||
return res.status(401).json({ error: "Usuario o contraseña incorrectos" });
|
||||
|
||||
const token = signToken({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/auth/me", requireAuth, (req, res) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
// ── Users (admin only) ────────────────────────────────────────────────────
|
||||
|
||||
app.get("/api/users", requireAuth, (_req, res) => {
|
||||
const users = db
|
||||
.prepare(
|
||||
"SELECT id, username, role, createdAt FROM users ORDER BY createdAt",
|
||||
)
|
||||
.all();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
app.post("/api/users", requireAuth, (req, res) => {
|
||||
const { username, password, role = "admin" } = req.body;
|
||||
if (!username || !password)
|
||||
return res.status(400).json({ error: "Faltan campos obligatorios" });
|
||||
|
||||
const existing = db
|
||||
.prepare("SELECT id FROM users WHERE username = ?")
|
||||
.get(username.toLowerCase());
|
||||
if (existing) return res.status(409).json({ error: "El usuario ya existe" });
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, 10);
|
||||
const info = db
|
||||
.prepare(
|
||||
"INSERT INTO users (username, passwordHash, role) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(username.toLowerCase(), passwordHash, role);
|
||||
res.status(201).json({ id: info.lastInsertRowid, username, role });
|
||||
});
|
||||
|
||||
app.put("/api/users/:id/password", requireAuth, (req, res) => {
|
||||
const { password } = req.body;
|
||||
if (!password) return res.status(400).json({ error: "Falta la contraseña" });
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, 10);
|
||||
const info = db
|
||||
.prepare("UPDATE users SET passwordHash = ? WHERE id = ?")
|
||||
.run(passwordHash, req.params.id);
|
||||
if (info.changes === 0) return res.status(404).json({ error: "Not found" });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/api/users/:id", requireAuth, (req, res) => {
|
||||
const remaining = db.prepare("SELECT COUNT(*) as n FROM users").get().n;
|
||||
if (remaining <= 1)
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No se puede eliminar el último usuario" });
|
||||
db.prepare("DELETE FROM users WHERE id = ?").run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Students ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.get("/api/students", (_req, res) => {
|
||||
app.get("/api/students", requireAuth, (_req, res) => {
|
||||
res.json(
|
||||
db.prepare("SELECT * FROM students ORDER BY lastName, firstName").all(),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/students", (req, res) => {
|
||||
app.post("/api/students", requireAuth, (req, res) => {
|
||||
const { id, firstName, lastName, age, sex, weight, height, imc } = req.body;
|
||||
db.prepare(
|
||||
`
|
||||
@@ -32,7 +115,7 @@ app.post("/api/students", (req, res) => {
|
||||
res.status(201).json({ id });
|
||||
});
|
||||
|
||||
app.put("/api/students/:id", (req, res) => {
|
||||
app.put("/api/students/:id", requireAuth, (req, res) => {
|
||||
const { firstName, lastName, age, sex, weight, height, imc } = req.body;
|
||||
const info = db
|
||||
.prepare(
|
||||
@@ -46,7 +129,7 @@ app.put("/api/students/:id", (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/api/students/:id", (req, res) => {
|
||||
app.delete("/api/students/:id", requireAuth, (req, res) => {
|
||||
// CASCADE deletes linked activities automatically (FK ON DELETE CASCADE)
|
||||
db.prepare("DELETE FROM students WHERE id=?").run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
@@ -54,13 +137,13 @@ app.delete("/api/students/:id", (req, res) => {
|
||||
|
||||
// ── Activities ────────────────────────────────────────────────────────────
|
||||
|
||||
app.get("/api/activities", (_req, res) => {
|
||||
app.get("/api/activities", requireAuth, (_req, res) => {
|
||||
res.json(
|
||||
db.prepare("SELECT * FROM activities ORDER BY createdAt DESC").all(),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/activities", (req, res) => {
|
||||
app.post("/api/activities", requireAuth, (req, res) => {
|
||||
const {
|
||||
id,
|
||||
studentId,
|
||||
@@ -98,7 +181,7 @@ app.post("/api/activities", (req, res) => {
|
||||
res.status(201).json({ id });
|
||||
});
|
||||
|
||||
app.put("/api/activities/:id", (req, res) => {
|
||||
app.put("/api/activities/:id", requireAuth, (req, res) => {
|
||||
const {
|
||||
type,
|
||||
durationInput,
|
||||
@@ -137,7 +220,7 @@ app.put("/api/activities/:id", (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/api/activities/:id", (req, res) => {
|
||||
app.delete("/api/activities/:id", requireAuth, (req, res) => {
|
||||
db.prepare("DELETE FROM activities WHERE id=?").run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
44
src/App.vue
44
src/App.vue
@@ -1,15 +1,25 @@
|
||||
<script setup>
|
||||
import { useRoute } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { onMounted } from "vue";
|
||||
import AppSidebar from "./components/AppSidebar.vue";
|
||||
import AppNotification from "./components/AppNotification.vue";
|
||||
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 auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(() => store.loadData());
|
||||
onMounted(() => {
|
||||
if (auth.isAuthenticated) store.loadData();
|
||||
});
|
||||
|
||||
function logout() {
|
||||
auth.logout();
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
const titles = {
|
||||
dashboard: "Tableau de Bord",
|
||||
@@ -17,11 +27,16 @@ const titles = {
|
||||
activity: "Suivi Personnel",
|
||||
results: "Résultats Académiques",
|
||||
history: "Historique Global",
|
||||
users: "Gestion Utilisateurs",
|
||||
};
|
||||
</script>
|
||||
|
||||
<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 />
|
||||
|
||||
<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">
|
||||
{{ titles[route.name] ?? "SB Sports" }}
|
||||
</h2>
|
||||
<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>
|
||||
<div class="flex items-center gap-3">
|
||||
<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 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>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Activity,
|
||||
GraduationCap,
|
||||
History,
|
||||
UserCog,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -41,6 +42,12 @@ const navItems = [
|
||||
icon: History,
|
||||
label: "Historique Global",
|
||||
},
|
||||
{
|
||||
to: "/users",
|
||||
name: "users",
|
||||
icon: UserCog,
|
||||
label: "Utilisateurs",
|
||||
},
|
||||
];
|
||||
|
||||
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 ResultsView from "../views/ResultsView.vue";
|
||||
import HistoryView from "../views/HistoryView.vue";
|
||||
import LoginView from "../views/LoginView.vue";
|
||||
import UsersView from "../views/UsersView.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/login", name: "login", component: LoginView, meta: { public: true } },
|
||||
{ path: "/", name: "dashboard", component: DashboardView },
|
||||
{ path: "/students", name: "students", component: StudentsView },
|
||||
{ path: "/activity", name: "activity", component: ActivityView },
|
||||
{ path: "/results", name: "results", component: ResultsView },
|
||||
{ path: "/history", name: "history", component: HistoryView },
|
||||
{ path: "/users", name: "users", component: UsersView },
|
||||
];
|
||||
|
||||
export default createRouter({
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
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 { ref, computed } from 'vue'
|
||||
import { gradeWeights, calcIMC } from '../composables/useHelpers'
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import { gradeWeights, calcIMC } from "../composables/useHelpers";
|
||||
import { useAuthStore } from "./auth";
|
||||
|
||||
const API = '/api'
|
||||
const API = "/api";
|
||||
|
||||
async function apiFetch(path, options = {}) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) throw new Error(`API ${path} → ${res.status}`)
|
||||
return res.json()
|
||||
const auth = useAuthStore();
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
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}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const useSportsStore = defineStore('sports', () => {
|
||||
const activities = ref([])
|
||||
const students = ref([])
|
||||
const notification = ref('')
|
||||
const loading = ref(false)
|
||||
export const useSportsStore = defineStore("sports", () => {
|
||||
const activities = ref([]);
|
||||
const students = ref([]);
|
||||
const notification = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
loading.value = true;
|
||||
try {
|
||||
const [s, a] = await Promise.all([
|
||||
apiFetch('/students'),
|
||||
apiFetch('/activities'),
|
||||
])
|
||||
students.value = s
|
||||
activities.value = a
|
||||
apiFetch("/students"),
|
||||
apiFetch("/activities"),
|
||||
]);
|
||||
students.value = s;
|
||||
activities.value = a;
|
||||
} catch (e) {
|
||||
showNotification('Erreur de connexion au serveur')
|
||||
console.error(e)
|
||||
showNotification("Erreur de connexion au serveur");
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notification ─────────────────────────────────────────────────────────
|
||||
function showNotification(msg) {
|
||||
notification.value = msg
|
||||
setTimeout(() => (notification.value = ''), 3000)
|
||||
notification.value = msg;
|
||||
setTimeout(() => (notification.value = ""), 3000);
|
||||
}
|
||||
|
||||
// ── Students ─────────────────────────────────────────────────────────────
|
||||
async function saveStudent(form, editingId) {
|
||||
const imc = calcIMC(form.weight, form.height)
|
||||
const imc = calcIMC(form.weight, form.height);
|
||||
if (editingId) {
|
||||
await apiFetch(`/students/${editingId}`, {
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ...form, imc }),
|
||||
})
|
||||
const idx = students.value.findIndex((s) => s.id === editingId)
|
||||
if (idx !== -1) students.value[idx] = { ...form, id: editingId, imc }
|
||||
showNotification('Profil mis à jour')
|
||||
});
|
||||
const idx = students.value.findIndex((s) => s.id === editingId);
|
||||
if (idx !== -1) students.value[idx] = { ...form, id: editingId, imc };
|
||||
showNotification("Profil mis à jour");
|
||||
} else {
|
||||
const id = Date.now().toString()
|
||||
await apiFetch('/students', {
|
||||
method: 'POST',
|
||||
const id = Date.now().toString();
|
||||
await apiFetch("/students", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id, ...form, imc }),
|
||||
})
|
||||
students.value.push({ id, ...form, imc })
|
||||
showNotification('Étudiant inscrit')
|
||||
});
|
||||
students.value.push({ id, ...form, imc });
|
||||
showNotification("Étudiant inscrit");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStudent(id) {
|
||||
await apiFetch(`/students/${id}`, { method: 'DELETE' })
|
||||
students.value = students.value.filter((s) => s.id !== id)
|
||||
activities.value = activities.value.filter((a) => a.studentId !== id)
|
||||
showNotification('Étudiant supprimé')
|
||||
await apiFetch(`/students/${id}`, { method: "DELETE" });
|
||||
students.value = students.value.filter((s) => s.id !== id);
|
||||
activities.value = activities.value.filter((a) => a.studentId !== id);
|
||||
showNotification("Étudiant supprimé");
|
||||
}
|
||||
|
||||
// ── Activities ───────────────────────────────────────────────────────────
|
||||
async function saveActivity(act, editingId) {
|
||||
let norm = act.durationInput
|
||||
if (act.durationUnit === 'sec') norm /= 60
|
||||
if (act.durationUnit === 'h') norm *= 60
|
||||
let norm = act.durationInput;
|
||||
if (act.durationUnit === "sec") norm /= 60;
|
||||
if (act.durationUnit === "h") norm *= 60;
|
||||
|
||||
const record = {
|
||||
...act,
|
||||
id: editingId || Date.now().toString(),
|
||||
duration: norm,
|
||||
displayDuration: `${act.durationInput} ${act.durationUnit}`,
|
||||
}
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
await apiFetch(`/activities/${editingId}`, {
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
body: JSON.stringify(record),
|
||||
})
|
||||
const idx = activities.value.findIndex((a) => a.id === editingId)
|
||||
if (idx !== -1) activities.value[idx] = record
|
||||
showNotification('Activité mise à jour')
|
||||
});
|
||||
const idx = activities.value.findIndex((a) => a.id === editingId);
|
||||
if (idx !== -1) activities.value[idx] = record;
|
||||
showNotification("Activité mise à jour");
|
||||
} else {
|
||||
await apiFetch('/activities', {
|
||||
method: 'POST',
|
||||
await apiFetch("/activities", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(record),
|
||||
})
|
||||
activities.value.unshift(record)
|
||||
showNotification('Activité enregistrée')
|
||||
});
|
||||
activities.value.unshift(record);
|
||||
showNotification("Activité enregistrée");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteActivity(index) {
|
||||
const act = activities.value[index]
|
||||
await apiFetch(`/activities/${act.id}`, { method: 'DELETE' })
|
||||
activities.value.splice(index, 1)
|
||||
showNotification('Supprimé')
|
||||
const act = activities.value[index];
|
||||
await apiFetch(`/activities/${act.id}`, { method: "DELETE" });
|
||||
activities.value.splice(index, 1);
|
||||
showNotification("Supprimé");
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
function getStudentName(id) {
|
||||
const s = students.value.find((st) => st.id === id)
|
||||
return s ? `${s.lastName} ${s.firstName}` : 'Inconnu'
|
||||
const s = students.value.find((st) => st.id === id);
|
||||
return s ? `${s.lastName} ${s.firstName}` : "Inconnu";
|
||||
}
|
||||
|
||||
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) {
|
||||
const evals = activities.value.filter((a) => a.studentId === studentId && a.grade)
|
||||
if (!evals.length) return -1
|
||||
return evals.reduce((acc, a) => acc + gradeWeights[a.grade], 0) / evals.length
|
||||
const evals = activities.value.filter(
|
||||
(a) => a.studentId === studentId && a.grade,
|
||||
);
|
||||
if (!evals.length) return -1;
|
||||
return (
|
||||
evals.reduce((acc, a) => acc + gradeWeights[a.grade], 0) / evals.length
|
||||
);
|
||||
}
|
||||
|
||||
function getStudentAverageLiteral(studentId) {
|
||||
const avg = getStudentAveragePoints(studentId)
|
||||
if (avg === -1) return 'Non noté'
|
||||
const rounded = Math.round(avg)
|
||||
return Object.keys(gradeWeights).find((k) => gradeWeights[k] === rounded)
|
||||
const avg = getStudentAveragePoints(studentId);
|
||||
if (avg === -1) return "Non noté";
|
||||
const rounded = Math.round(avg);
|
||||
return Object.keys(gradeWeights).find((k) => gradeWeights[k] === rounded);
|
||||
}
|
||||
|
||||
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 ────────────────────────────────────────────────────────
|
||||
const totalMinutes = computed(() =>
|
||||
activities.value.reduce((acc, c) => acc + (c.duration || 0), 0),
|
||||
)
|
||||
);
|
||||
|
||||
const successRate = computed(() =>
|
||||
activities.value.length
|
||||
? Math.round(
|
||||
(activities.value.filter((a) => a.grade && a.grade !== 'Redouble').length /
|
||||
activities.value.length) * 100,
|
||||
(activities.value.filter((a) => a.grade && a.grade !== "Redouble")
|
||||
.length /
|
||||
activities.value.length) *
|
||||
100,
|
||||
)
|
||||
: 0,
|
||||
)
|
||||
);
|
||||
|
||||
const gradeStats = computed(() => {
|
||||
const total = students.value.length || 1
|
||||
const averages = students.value.map((s) => getStudentAverageLiteral(s.id))
|
||||
const pct = (g) => Math.round((averages.filter((v) => v === g).length / total) * 100)
|
||||
const total = students.value.length || 1;
|
||||
const averages = students.value.map((s) => getStudentAverageLiteral(s.id));
|
||||
const pct = (g) =>
|
||||
Math.round((averages.filter((v) => v === g).length / total) * 100);
|
||||
return {
|
||||
excellent: pct('Excellent'),
|
||||
tresBien: pct('Très bien'),
|
||||
bien: pct('Bien'),
|
||||
assezBien: pct('Assez bien'),
|
||||
redouble: pct('Redouble'),
|
||||
}
|
||||
})
|
||||
excellent: pct("Excellent"),
|
||||
tresBien: pct("Très bien"),
|
||||
bien: pct("Bien"),
|
||||
assezBien: pct("Assez bien"),
|
||||
redouble: pct("Redouble"),
|
||||
};
|
||||
});
|
||||
|
||||
const activityLifestyleStats = computed(() => {
|
||||
const stats = {
|
||||
Inactif: { count: 0, percentage: 0 },
|
||||
Sédentaire: { count: 0, percentage: 0 },
|
||||
Actif: { count: 0, percentage: 0 },
|
||||
'Très actif': { count: 0, percentage: 0 },
|
||||
}
|
||||
Inactif: { count: 0, percentage: 0 },
|
||||
Sédentaire: { count: 0, percentage: 0 },
|
||||
Actif: { count: 0, percentage: 0 },
|
||||
"Très actif": { count: 0, percentage: 0 },
|
||||
};
|
||||
activities.value.forEach((a) => {
|
||||
const key = a.lifestyle || 'Sédentaire'
|
||||
if (stats[key]) stats[key].count++
|
||||
})
|
||||
const total = activities.value.length || 1
|
||||
const key = a.lifestyle || "Sédentaire";
|
||||
if (stats[key]) stats[key].count++;
|
||||
});
|
||||
const total = activities.value.length || 1;
|
||||
Object.keys(stats).forEach(
|
||||
(k) => (stats[k].percentage = Math.round((stats[k].count / total) * 100)),
|
||||
)
|
||||
return stats
|
||||
})
|
||||
);
|
||||
return stats;
|
||||
});
|
||||
|
||||
return {
|
||||
activities,
|
||||
@@ -202,5 +210,5 @@ export const useSportsStore = defineStore('sports', () => {
|
||||
successRate,
|
||||
gradeStats,
|
||||
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