Compare commits
5 Commits
30ef7312b5
...
494206b5b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 494206b5b3 | |||
| 31edea9dd9 | |||
| 9f53193c7c | |||
| 01d347bce3 | |||
| 4e8c1aa849 |
1781
backend/package-lock.json
generated
1781
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@
|
|||||||
"migration:generate": "npm run typeorm -- migration:generate",
|
"migration:generate": "npm run typeorm -- migration:generate",
|
||||||
"migration:run": "npm run typeorm -- migration:run",
|
"migration:run": "npm run typeorm -- migration:run",
|
||||||
"migration:revert": "npm run typeorm -- migration:revert",
|
"migration:revert": "npm run typeorm -- migration:revert",
|
||||||
"seed:videos": "ts-node --transpile-only src/seeds/videos.ts"
|
"seed:videos": "ts-node --transpile-only src/seeds/videos.ts",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
@@ -18,11 +19,13 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
|
"express-rate-limit": "^8.3.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"mysql2": "^3.9.3",
|
"mysql2": "^3.9.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"typeorm": "^0.3.20"
|
"typeorm": "^0.3.20",
|
||||||
|
"winston": "^3.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
@@ -32,8 +35,12 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^20.12.2",
|
"@types/node": "^20.12.2",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
|
"@types/winston": "^2.4.4",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^5.4.3"
|
"typescript": "^5.4.3",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import playlistRoutes from "./routes/playlist.routes";
|
|||||||
import adminRoutes from "./routes/admin.routes";
|
import adminRoutes from "./routes/admin.routes";
|
||||||
import streamRoutes from "./routes/stream.routes";
|
import streamRoutes from "./routes/stream.routes";
|
||||||
import userRoutes from "./routes/user.routes";
|
import userRoutes from "./routes/user.routes";
|
||||||
|
import logger from "./utils/logger";
|
||||||
|
import { loginRateLimiter, adminRateLimiter } from "./middleware/rateLimiter";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", 1);
|
||||||
const PORT = parseInt(process.env.PORT ?? "4000");
|
const PORT = parseInt(process.env.PORT ?? "4000");
|
||||||
|
|
||||||
const allowedOrigins = (process.env.FRONTEND_URL ?? "http://localhost:5173")
|
const allowedOrigins = (process.env.FRONTEND_URL ?? "http://localhost:5173")
|
||||||
@@ -35,21 +38,22 @@ app.get("/api/health", (_req, res) => {
|
|||||||
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.use("/api/auth/login", loginRateLimiter);
|
||||||
app.use("/api/auth", authRoutes);
|
app.use("/api/auth", authRoutes);
|
||||||
app.use("/api/videos", videoRoutes);
|
app.use("/api/videos", videoRoutes);
|
||||||
app.use("/api/playlists", playlistRoutes);
|
app.use("/api/playlists", playlistRoutes);
|
||||||
app.use("/api/admin", adminRoutes);
|
app.use("/api/admin", adminRateLimiter, adminRoutes);
|
||||||
app.use("/api/stream", streamRoutes);
|
app.use("/api/stream", streamRoutes);
|
||||||
app.use("/api/users", userRoutes);
|
app.use("/api/users", userRoutes);
|
||||||
|
|
||||||
AppDataSource.initialize()
|
AppDataSource.initialize()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log("Database connected");
|
logger.info("Database connected");
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server running on port ${PORT}`);
|
logger.info(`Server running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err: unknown) => {
|
||||||
console.error("Database connection failed:", err);
|
logger.error("Database connection failed", { err });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
export interface AuthenticatedUser {
|
export interface AuthenticatedUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,7 +41,7 @@ export const requireAuth = async (
|
|||||||
|
|
||||||
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
|
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
|
||||||
if (!superOAuthUrl) {
|
if (!superOAuthUrl) {
|
||||||
console.error("SUPER_OAUTH_URL not configured");
|
logger.error("SUPER_OAUTH_URL not configured");
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR", message: "Auth service not configured" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR", message: "Auth service not configured" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -65,7 +66,8 @@ export const requireAuth = async (
|
|||||||
|
|
||||||
(req as AuthenticatedRequest).user = data.data.user;
|
(req as AuthenticatedRequest).user = data.data.user;
|
||||||
next();
|
next();
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("requireAuth — auth service unreachable", { err });
|
||||||
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE", message: "Authentication service unreachable" });
|
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE", message: "Authentication service unreachable" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
25
backend/src/middleware/rateLimiter.ts
Normal file
25
backend/src/middleware/rateLimiter.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
|
||||||
|
const rateLimitResponse = { error: "RATE_LIMIT_EXCEEDED" };
|
||||||
|
|
||||||
|
/** POST /api/auth/login — 10 req / 15 min par IP */
|
||||||
|
export const loginRateLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: (_req, res) => {
|
||||||
|
res.status(429).json(rateLimitResponse);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** /api/admin/* — 50 req / min par IP */
|
||||||
|
export const adminRateLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 50,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: (_req, res) => {
|
||||||
|
res.status(429).json(rateLimitResponse);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { UserSubscription } from "../entities/UserSubscription";
|
|||||||
import { SubscriptionPlan } from "../entities/SubscriptionPlan";
|
import { SubscriptionPlan } from "../entities/SubscriptionPlan";
|
||||||
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
|
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||||
import { requireAdmin } from "../middleware/admin.middleware";
|
import { requireAdmin } from "../middleware/admin.middleware";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
|
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
|
||||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||||
@@ -80,14 +81,27 @@ router.post(
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/videos
|
* GET /api/admin/videos
|
||||||
* Liste toutes les vidéos (publiées et non publiées), tous les champs.
|
* Liste toutes les vidéos (publiées et non publiées), tous les champs.
|
||||||
|
* Query: ?page=1&limit=20
|
||||||
*/
|
*/
|
||||||
router.get("/videos", async (_req: Request, res: Response): Promise<void> => {
|
router.get("/videos", async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const rawPage = Number(req.query.page ?? 1);
|
||||||
|
const rawLimit = Number(req.query.limit ?? 20);
|
||||||
|
|
||||||
|
if (!Number.isInteger(rawPage) || rawPage < 1 ||
|
||||||
|
!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) {
|
||||||
|
res.status(400).json({ success: false, error: "INVALID_PAGINATION" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const videos = await AppDataSource.getRepository(Video).find({
|
const [videos, total] = await AppDataSource.getRepository(Video).findAndCount({
|
||||||
order: { createdAt: "DESC" },
|
order: { createdAt: "DESC" },
|
||||||
|
skip: (rawPage - 1) * rawLimit,
|
||||||
|
take: rawLimit,
|
||||||
});
|
});
|
||||||
res.json({ success: true, data: { videos } });
|
res.json({ success: true, data: videos, total, page: rawPage, limit: rawLimit });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("GET /admin/videos — failed to list videos", { err });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -139,7 +153,8 @@ router.post("/videos", async (req: Request, res: Response): Promise<void> => {
|
|||||||
|
|
||||||
await AppDataSource.getRepository(Video).save(video);
|
await AppDataSource.getRepository(Video).save(video);
|
||||||
res.status(201).json({ success: true, data: { video } });
|
res.status(201).json({ success: true, data: { video } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("POST /admin/videos — failed to create video", { err });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -179,7 +194,8 @@ router.patch("/videos/:id", async (req: Request, res: Response): Promise<void> =
|
|||||||
|
|
||||||
await repo.save(video);
|
await repo.save(video);
|
||||||
res.json({ success: true, data: { video } });
|
res.json({ success: true, data: { video } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("PATCH /admin/videos/:id — failed to update video", { err, id: req.params.id });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -200,7 +216,8 @@ router.delete("/videos/:id", async (req: Request, res: Response): Promise<void>
|
|||||||
|
|
||||||
await repo.remove(video);
|
await repo.remove(video);
|
||||||
res.json({ success: true, data: { deleted: req.params.id } });
|
res.json({ success: true, data: { deleted: req.params.id } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("DELETE /admin/videos/:id — failed to delete video", { err, id: req.params.id });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -212,15 +229,27 @@ router.delete("/videos/:id", async (req: Request, res: Response): Promise<void>
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/users
|
* GET /api/admin/users
|
||||||
* Liste tous les utilisateurs avec leurs rôles et abonnement actif.
|
* Liste tous les utilisateurs avec leurs rôles et abonnement actif.
|
||||||
|
* Query: ?page=1&limit=20
|
||||||
*/
|
*/
|
||||||
router.get("/users", async (_req: Request, res: Response): Promise<void> => {
|
router.get("/users", async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const rawPage = Number(req.query.page ?? 1);
|
||||||
|
const rawLimit = Number(req.query.limit ?? 20);
|
||||||
|
|
||||||
|
if (!Number.isInteger(rawPage) || rawPage < 1 ||
|
||||||
|
!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) {
|
||||||
|
res.status(400).json({ success: false, error: "INVALID_PAGINATION" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const users = await AppDataSource.getRepository(User).find({
|
const [users, total] = await AppDataSource.getRepository(User).findAndCount({
|
||||||
relations: ["userRoles", "userRoles.role", "subscriptions", "subscriptions.plan"],
|
relations: ["userRoles", "userRoles.role", "subscriptions", "subscriptions.plan"],
|
||||||
order: { createdAt: "DESC" },
|
order: { createdAt: "DESC" },
|
||||||
|
skip: (rawPage - 1) * rawLimit,
|
||||||
|
take: rawLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = users.map((u) => ({
|
const data = users.map((u) => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
nickname: u.nickname,
|
nickname: u.nickname,
|
||||||
@@ -233,8 +262,9 @@ router.get("/users", async (_req: Request, res: Response): Promise<void> => {
|
|||||||
})(),
|
})(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json({ success: true, data: { users: result } });
|
res.json({ success: true, data, total, page: rawPage, limit: rawLimit });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("GET /admin/users — failed to list users", { err });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -296,7 +326,8 @@ router.patch("/users/:id/roles", async (req: Request, res: Response): Promise<vo
|
|||||||
roles: roleEntities.map((r) => ({ id: r.id, slug: r.slug, name: r.name })),
|
roles: roleEntities.map((r) => ({ id: r.id, slug: r.slug, name: r.name })),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("PATCH /admin/users/:id/roles — failed to assign roles", { err, id: req.params.id });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -315,7 +346,8 @@ router.get("/plans", async (_req: Request, res: Response): Promise<void> => {
|
|||||||
order: { level: "ASC" },
|
order: { level: "ASC" },
|
||||||
});
|
});
|
||||||
res.json({ success: true, data: { plans } });
|
res.json({ success: true, data: { plans } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("GET /admin/plans — failed to list plans", { err });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -360,7 +392,8 @@ router.post("/plans", async (req: Request, res: Response): Promise<void> => {
|
|||||||
|
|
||||||
await AppDataSource.getRepository(SubscriptionPlan).save(plan);
|
await AppDataSource.getRepository(SubscriptionPlan).save(plan);
|
||||||
res.status(201).json({ success: true, data: { plan } });
|
res.status(201).json({ success: true, data: { plan } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("POST /admin/plans — failed to create plan", { err });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -390,7 +423,8 @@ router.patch("/plans/:id", async (req: Request, res: Response): Promise<void> =>
|
|||||||
|
|
||||||
await repo.save(plan);
|
await repo.save(plan);
|
||||||
res.json({ success: true, data: { plan } });
|
res.json({ success: true, data: { plan } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("PATCH /admin/plans/:id — failed to update plan", { err, id: req.params.id });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -445,7 +479,8 @@ router.post("/users/:id/subscriptions", async (req: Request, res: Response): Pro
|
|||||||
|
|
||||||
await subRepo.save(sub);
|
await subRepo.save(sub);
|
||||||
res.status(201).json({ success: true, data: { subscription: { ...sub, plan } } });
|
res.status(201).json({ success: true, data: { subscription: { ...sub, plan } } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("POST /admin/users/:id/subscriptions — failed to assign subscription", { err, id: req.params.id });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
import { AppDataSource } from "../config/data-source";
|
import { AppDataSource } from "../config/data-source";
|
||||||
|
import logger from "../utils/logger";
|
||||||
import { User } from "../entities/User";
|
import { User } from "../entities/User";
|
||||||
import { UserSubscription } from "../entities/UserSubscription";
|
import { UserSubscription } from "../entities/UserSubscription";
|
||||||
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
|
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||||
@@ -79,7 +80,8 @@ router.post("/login", async (req: Request, res: Response): Promise<void> => {
|
|||||||
res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
|
res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
|
||||||
}
|
}
|
||||||
res.json({ success: true, data: { user: data.data.user } });
|
res.json({ success: true, data: { user: data.data.user } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("POST /auth/login — auth service unavailable", { err });
|
||||||
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
|
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -125,7 +127,8 @@ router.post("/session", async (req: Request, res: Response): Promise<void> => {
|
|||||||
|
|
||||||
res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS);
|
res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS);
|
||||||
res.json({ success: true, data: { user: data.data.user } });
|
res.json({ success: true, data: { user: data.data.user } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("POST /auth/session — auth service unavailable", { err });
|
||||||
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
|
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -173,7 +176,8 @@ router.post("/refresh", async (req: Request, res: Response): Promise<void> => {
|
|||||||
res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
|
res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
|
||||||
}
|
}
|
||||||
res.json({ success: true, data: { user: data.data.user ?? null } });
|
res.json({ success: true, data: { user: data.data.user ?? null } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("POST /auth/refresh — auth service unavailable", { err });
|
||||||
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
|
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -275,7 +279,8 @@ router.get("/me/optional", async (req: Request, res: Response): Promise<void> =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: { user: data.data.user } });
|
res.json({ success: true, data: { user: data.data.user } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("GET /auth/me/optional — auth service unavailable", { err });
|
||||||
res.json({ success: true, data: { user: null } });
|
res.json({ success: true, data: { user: null } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { PlaylistShare } from "../entities/PlaylistShare";
|
|||||||
import { User } from "../entities/User";
|
import { User } from "../entities/User";
|
||||||
import { Video } from "../entities/Video";
|
import { Video } from "../entities/Video";
|
||||||
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
|
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -42,7 +43,8 @@ router.get("/", requireAuth, async (req: Request, res: Response): Promise<void>
|
|||||||
shared: shared.map((s) => ({ ...s.playlist, permission: s.permission })),
|
shared: shared.map((s) => ({ ...s.playlist, permission: s.permission })),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("GET /playlists — failed to list playlists", { err });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -78,7 +80,8 @@ router.post("/", requireAuth, async (req: Request, res: Response): Promise<void>
|
|||||||
|
|
||||||
await AppDataSource.getRepository(Playlist).save(playlist);
|
await AppDataSource.getRepository(Playlist).save(playlist);
|
||||||
res.status(201).json({ success: true, data: { playlist } });
|
res.status(201).json({ success: true, data: { playlist } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("POST /playlists — failed to create playlist", { err });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -96,7 +99,7 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise<voi
|
|||||||
try {
|
try {
|
||||||
const playlist = await AppDataSource.getRepository(Playlist).findOne({
|
const playlist = await AppDataSource.getRepository(Playlist).findOne({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
relations: ["playlistVideos", "playlistVideos.video", "shares"],
|
relations: ["playlistVideos", "playlistVideos.video"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
@@ -105,8 +108,12 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = playlist.ownerId === dbUserId;
|
const isOwner = playlist.ownerId === dbUserId;
|
||||||
const share = playlist.shares.find((s) => s.userId === dbUserId && s.status === "active");
|
|
||||||
const isPublic = playlist.visibility === "public";
|
const isPublic = playlist.visibility === "public";
|
||||||
|
const share = (!isOwner && !isPublic)
|
||||||
|
? await AppDataSource.getRepository(PlaylistShare).findOne({
|
||||||
|
where: { playlistId: playlist.id, userId: dbUserId, status: "active" },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!isOwner && !share && !isPublic) {
|
if (!isOwner && !share && !isPublic) {
|
||||||
res.status(403).json({ success: false, error: "FORBIDDEN" });
|
res.status(403).json({ success: false, error: "FORBIDDEN" });
|
||||||
@@ -125,7 +132,8 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise<voi
|
|||||||
permission: isOwner ? "owner" : share?.permission ?? "view",
|
permission: isOwner ? "owner" : share?.permission ?? "view",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("GET /playlists/:id — failed to fetch playlist", { err, id: req.params.id });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -158,7 +166,8 @@ router.patch("/:id", requireAuth, async (req: Request, res: Response): Promise<v
|
|||||||
|
|
||||||
await repo.save(playlist);
|
await repo.save(playlist);
|
||||||
res.json({ success: true, data: { playlist } });
|
res.json({ success: true, data: { playlist } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("PATCH /playlists/:id — failed to update playlist", { err, id: req.params.id });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -181,7 +190,8 @@ router.delete("/:id", requireAuth, async (req: Request, res: Response): Promise<
|
|||||||
|
|
||||||
await repo.remove(playlist);
|
await repo.remove(playlist);
|
||||||
res.json({ success: true, data: { deleted: req.params.id } });
|
res.json({ success: true, data: { deleted: req.params.id } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("DELETE /playlists/:id — failed to delete playlist", { err, id: req.params.id });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -200,15 +210,14 @@ router.post("/:id/videos", requireAuth, async (req: Request, res: Response): Pro
|
|||||||
if (!videoId) { res.status(400).json({ success: false, error: "MISSING_VIDEO_ID" }); return; }
|
if (!videoId) { res.status(400).json({ success: false, error: "MISSING_VIDEO_ID" }); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const playlist = await AppDataSource.getRepository(Playlist).findOne({
|
const playlist = await AppDataSource.getRepository(Playlist).findOneBy({ id: req.params.id });
|
||||||
where: { id: req.params.id },
|
|
||||||
relations: ["shares"],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
|
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
|
||||||
|
|
||||||
const isOwner = playlist.ownerId === dbUserId;
|
const isOwner = playlist.ownerId === dbUserId;
|
||||||
const canEdit = isOwner || playlist.shares.some((s) => s.userId === dbUserId && s.status === "active" && s.permission === "edit");
|
const canEdit = isOwner || !!(await AppDataSource.getRepository(PlaylistShare).findOne({
|
||||||
|
where: { playlistId: playlist.id, userId: dbUserId, status: "active", permission: "edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
if (!canEdit) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
|
if (!canEdit) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
|
||||||
|
|
||||||
@@ -226,7 +235,8 @@ router.post("/:id/videos", requireAuth, async (req: Request, res: Response): Pro
|
|||||||
|
|
||||||
await AppDataSource.getRepository(PlaylistVideo).save(pv);
|
await AppDataSource.getRepository(PlaylistVideo).save(pv);
|
||||||
res.status(201).json({ success: true, data: { playlistVideo: pv } });
|
res.status(201).json({ success: true, data: { playlistVideo: pv } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("POST /playlists/:id/videos — failed to add video", { err, id: req.params.id });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -241,15 +251,14 @@ router.delete("/:id/videos/:videoId", requireAuth, async (req: Request, res: Res
|
|||||||
if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; }
|
if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const playlist = await AppDataSource.getRepository(Playlist).findOne({
|
const playlist = await AppDataSource.getRepository(Playlist).findOneBy({ id: req.params.id });
|
||||||
where: { id: req.params.id },
|
|
||||||
relations: ["shares"],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
|
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
|
||||||
|
|
||||||
const isOwner = playlist.ownerId === dbUserId;
|
const isOwner = playlist.ownerId === dbUserId;
|
||||||
const canEdit = isOwner || playlist.shares.some((s) => s.userId === dbUserId && s.status === "active" && s.permission === "edit");
|
const canEdit = isOwner || !!(await AppDataSource.getRepository(PlaylistShare).findOne({
|
||||||
|
where: { playlistId: playlist.id, userId: dbUserId, status: "active", permission: "edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
if (!canEdit) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
|
if (!canEdit) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
|
||||||
|
|
||||||
@@ -262,7 +271,8 @@ router.delete("/:id/videos/:videoId", requireAuth, async (req: Request, res: Res
|
|||||||
|
|
||||||
await AppDataSource.getRepository(PlaylistVideo).remove(pv);
|
await AppDataSource.getRepository(PlaylistVideo).remove(pv);
|
||||||
res.json({ success: true, data: { deleted: req.params.videoId } });
|
res.json({ success: true, data: { deleted: req.params.videoId } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("DELETE /playlists/:id/videos/:videoId — failed to remove video", { err, id: req.params.id, videoId: req.params.videoId });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -305,7 +315,8 @@ router.post("/:id/share", requireAuth, async (req: Request, res: Response): Prom
|
|||||||
|
|
||||||
await AppDataSource.getRepository(PlaylistShare).save(share);
|
await AppDataSource.getRepository(PlaylistShare).save(share);
|
||||||
res.status(201).json({ success: true, data: { share } });
|
res.status(201).json({ success: true, data: { share } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("POST /playlists/:id/share — failed to create share", { err, id: req.params.id });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -343,7 +354,8 @@ router.patch("/:id/share/:shareId", requireAuth, async (req: Request, res: Respo
|
|||||||
|
|
||||||
await AppDataSource.getRepository(PlaylistShare).save(share);
|
await AppDataSource.getRepository(PlaylistShare).save(share);
|
||||||
res.json({ success: true, data: { share } });
|
res.json({ success: true, data: { share } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("PATCH /playlists/:id/share/:shareId — failed to update share", { err, id: req.params.id, shareId: req.params.shareId });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
import { AppDataSource } from "../config/data-source";
|
import { AppDataSource } from "../config/data-source";
|
||||||
|
import logger from "../utils/logger";
|
||||||
import { Video } from "../entities/Video";
|
import { Video } from "../entities/Video";
|
||||||
import { User } from "../entities/User";
|
import { User } from "../entities/User";
|
||||||
import { requireAuth, AuthenticatedRequest, AuthenticatedUser } from "../middleware/auth.middleware";
|
import { requireAuth, AuthenticatedRequest, AuthenticatedUser } from "../middleware/auth.middleware";
|
||||||
@@ -51,7 +52,8 @@ router.get("/", async (req: Request, res: Response): Promise<void> => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
res.json({ success: true, data: { videos: result } });
|
res.json({ success: true, data: { videos: result } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("GET /videos — failed to list videos", { err });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -84,7 +86,8 @@ router.get("/:id", async (req: Request, res: Response): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: { video } });
|
res.json({ success: true, data: { video } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error("GET /videos/:id — failed to fetch video", { err, id: req.params.id });
|
||||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
25
backend/src/utils/logger.ts
Normal file
25
backend/src/utils/logger.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import winston from "winston";
|
||||||
|
|
||||||
|
const { combine, timestamp, json, colorize, printf } = winston.format;
|
||||||
|
|
||||||
|
const devFormat = combine(
|
||||||
|
colorize(),
|
||||||
|
timestamp({ format: "HH:mm:ss" }),
|
||||||
|
printf(({ level, message, timestamp: ts, ...meta }) => {
|
||||||
|
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : "";
|
||||||
|
return `${ts} [${level}] ${message}${metaStr}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const prodFormat = combine(
|
||||||
|
timestamp(),
|
||||||
|
json()
|
||||||
|
);
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL ?? "info",
|
||||||
|
format: process.env.NODE_ENV === "production" ? prodFormat : devFormat,
|
||||||
|
transports: [new winston.transports.Console()],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default logger;
|
||||||
54
backend/tests/auth.middleware.test.ts
Normal file
54
backend/tests/auth.middleware.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import express, { Request, Response } from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import { requireAuth } from "../src/middleware/auth.middleware";
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.get("/protected", requireAuth, (_req: Request, res: Response) => {
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("requireAuth middleware", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.SUPER_OAUTH_URL = "http://fake-oauth";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete process.env.SUPER_OAUTH_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 401 quand le token est invalide (SuperOAuth répond valid: false)", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, data: { valid: false } }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(buildApp())
|
||||||
|
.get("/protected")
|
||||||
|
.set("Authorization", "Bearer invalid-token");
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 401 quand aucun cookie ni header Authorization", async () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const res = await request(buildApp()).get("/protected");
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.message).toBe("Access token required");
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
8
backend/vitest.config.ts
Normal file
8
backend/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import VideoPage from './pages/VideoPage';
|
|||||||
import PlaylistsPage from './pages/PlaylistsPage';
|
import PlaylistsPage from './pages/PlaylistsPage';
|
||||||
import PlaylistPage from './pages/PlaylistPage';
|
import PlaylistPage from './pages/PlaylistPage';
|
||||||
import AdminPage from './pages/AdminPage';
|
import AdminPage from './pages/AdminPage';
|
||||||
|
import ProfilePage from './pages/ProfilePage';
|
||||||
|
|
||||||
type Theme = 'dark' | 'light';
|
type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ function App() {
|
|||||||
<Route path="/playlists" element={<PlaylistsPage />} />
|
<Route path="/playlists" element={<PlaylistsPage />} />
|
||||||
<Route path="/playlists/:id" element={<PlaylistPage />} />
|
<Route path="/playlists/:id" element={<PlaylistPage />} />
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
18
frontend/src/components/UserBadge.tsx
Normal file
18
frontend/src/components/UserBadge.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { User } from '../context/AuthContext';
|
||||||
|
|
||||||
|
interface UserBadgeProps {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserBadge({ user }: UserBadgeProps) {
|
||||||
|
const planLabel = user.plan?.slug ?? 'free';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-od-accent">{user.nickname}</span>
|
||||||
|
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-od-border text-od-muted">
|
||||||
|
{planLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import type { User } from '../../context/AuthContext';
|
import type { User } from '../../context/AuthContext';
|
||||||
|
import UserBadge from '../UserBadge';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
theme: 'dark' | 'light';
|
theme: 'dark' | 'light';
|
||||||
@@ -10,8 +12,23 @@ interface HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderProps) {
|
export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await apiFetch('/auth/logout', { method: 'POST' }).catch(() => {});
|
await apiFetch('/auth/logout', { method: 'POST' }).catch(() => {});
|
||||||
|
setOpen(false);
|
||||||
onLogout();
|
onLogout();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +56,7 @@ export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderP
|
|||||||
Playlists
|
Playlists
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{user?.roles?.includes('admin') && (
|
{user?.roles?.some((r) => r === 'admin' || r === 'super_admin') && (
|
||||||
<Link to="/admin" className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors">
|
<Link to="/admin" className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors">
|
||||||
admin
|
admin
|
||||||
</Link>
|
</Link>
|
||||||
@@ -57,15 +74,32 @@ export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderP
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="relative" ref={dropdownRef}>
|
||||||
<span className="font-mono text-xs text-od-accent">{user.nickname}</span>
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<UserBadge user={user} />
|
||||||
|
<span className="font-mono text-xs text-od-muted">▾</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 w-36 rounded border border-od-border bg-od-surface shadow-lg z-50">
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="block px-3 py-2 text-xs text-od-muted hover:text-od-text transition-colors"
|
||||||
|
>
|
||||||
|
Profil
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors"
|
className="w-full text-left px-3 py-2 font-mono text-xs text-od-muted hover:text-od-crit transition-colors"
|
||||||
>
|
>
|
||||||
↩
|
Déconnexion
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface User {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
|
plan?: { slug: string; name: string; level: number } | null;
|
||||||
subscriptionLevel?: number;
|
subscriptionLevel?: number;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
// Réexporte depuis AuthContext — source unique de vérité auth.
|
||||||
import { apiFetch } from '../lib/api';
|
// Ne pas dupliquer User ou la logique de fetch ici.
|
||||||
|
export type { User } from '../context/AuthContext';
|
||||||
export interface User {
|
export { useAuthContext as useAuth } from '../context/AuthContext';
|
||||||
id: string;
|
|
||||||
email: string | null;
|
|
||||||
nickname: string;
|
|
||||||
subscriptionLevel?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthState {
|
|
||||||
user: User | null;
|
|
||||||
loading: boolean;
|
|
||||||
setUser: (u: User | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MeResponse {
|
|
||||||
success: boolean;
|
|
||||||
data: { user: User };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth(): AuthState {
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
apiFetch<MeResponse>('/auth/me')
|
|
||||||
.then((res) => { if (!cancelled) setUser(res.data.user); })
|
|
||||||
.catch(() => { if (!cancelled) setUser(null); })
|
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { user, loading, setUser };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
// En prod : VITE_API_URL=https://origins.tetardtek.com/api
|
// En prod : VITE_API_URL=https://origins.tetardtek.com/api
|
||||||
const BASE = import.meta.env.VITE_API_URL || '/api';
|
const BASE = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(public readonly status: number, path: string) {
|
||||||
|
super(`API ${status}: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
credentials: 'include', // transmet le cookie httpOnly automatiquement
|
credentials: 'include', // transmet le cookie httpOnly automatiquement
|
||||||
@@ -13,7 +19,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`API ${res.status}: ${path}`);
|
throw new ApiError(res.status, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function AdminPage() {
|
|||||||
const [tab, setTab] = useState<Tab>('videos');
|
const [tab, setTab] = useState<Tab>('videos');
|
||||||
|
|
||||||
if (authLoading) return null;
|
if (authLoading) return null;
|
||||||
if (!user?.roles?.includes('admin')) return <Navigate to="/" replace />;
|
if (!user?.roles?.some((r) => r === 'admin' || r === 'super_admin')) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch, ApiError } from '../lib/api';
|
||||||
|
|
||||||
interface Video {
|
interface Video {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,8 +35,8 @@ export default function PlaylistPage() {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
apiFetch<PlaylistResponse>(`/playlists/${id}`)
|
apiFetch<PlaylistResponse>(`/playlists/${id}`)
|
||||||
.then((res) => setData(res.data))
|
.then((res) => setData(res.data))
|
||||||
.catch((err: Error) => {
|
.catch((err: unknown) => {
|
||||||
if (err.message.includes('403')) setError('forbidden');
|
if (err instanceof ApiError && err.status === 403) setError('forbidden');
|
||||||
else setError('not_found');
|
else setError('not_found');
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ export default function PlaylistsPage() {
|
|||||||
const [owned, setOwned] = useState<Playlist[]>([]);
|
const [owned, setOwned] = useState<Playlist[]>([]);
|
||||||
const [shared, setShared] = useState<(Playlist & { permission: 'view' | 'edit' })[]>([]);
|
const [shared, setShared] = useState<(Playlist & { permission: 'view' | 'edit' })[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
const [createTitle, setCreateTitle] = useState('');
|
const [createTitle, setCreateTitle] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<PlaylistsResponse>('/playlists')
|
apiFetch<PlaylistsResponse>('/playlists')
|
||||||
@@ -30,7 +32,7 @@ export default function PlaylistsPage() {
|
|||||||
setOwned(res.data.owned);
|
setOwned(res.data.owned);
|
||||||
setShared(res.data.shared);
|
setShared(res.data.shared);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => setFetchError('Impossible de charger les playlists.'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ export default function PlaylistsPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!createTitle.trim() || creating) return;
|
if (!createTitle.trim() || creating) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
|
setCreateError(null);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ success: boolean; data: { playlist: Playlist } }>(
|
const res = await apiFetch<{ success: boolean; data: { playlist: Playlist } }>(
|
||||||
'/playlists',
|
'/playlists',
|
||||||
@@ -45,7 +48,9 @@ export default function PlaylistsPage() {
|
|||||||
);
|
);
|
||||||
setOwned((prev) => [res.data.playlist, ...prev]);
|
setOwned((prev) => [res.data.playlist, ...prev]);
|
||||||
setCreateTitle('');
|
setCreateTitle('');
|
||||||
} catch {}
|
} catch {
|
||||||
|
setCreateError('Impossible de créer la playlist.');
|
||||||
|
}
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +64,10 @@ export default function PlaylistsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
return <p className="text-sm text-od-crit">{fetchError}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex flex-col gap-10">
|
||||||
|
|
||||||
@@ -67,6 +76,7 @@ export default function PlaylistsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Créer */}
|
{/* Créer */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
<form onSubmit={handleCreate} className="flex gap-2">
|
<form onSubmit={handleCreate} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -83,6 +93,8 @@ export default function PlaylistsPage() {
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{createError && <p className="font-mono text-xs text-od-crit">{createError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mes playlists */}
|
{/* Mes playlists */}
|
||||||
{owned.length > 0 && (
|
{owned.length > 0 && (
|
||||||
|
|||||||
144
frontend/src/pages/ProfilePage.tsx
Normal file
144
frontend/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { apiFetch, ApiError } from '../lib/api';
|
||||||
|
import { useAuthContext } from '../context/AuthContext';
|
||||||
|
import type { User } from '../context/AuthContext';
|
||||||
|
|
||||||
|
interface MeResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: { user: User };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { user, setUser } = useAuthContext();
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(user?.nickname ?? '');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
function handleEdit() {
|
||||||
|
setDraft(user!.nickname);
|
||||||
|
setError(null);
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
setEditing(false);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed || trimmed === user!.nickname) {
|
||||||
|
setEditing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiFetch('/users/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ nickname: trimmed }),
|
||||||
|
});
|
||||||
|
const res = await apiFetch<MeResponse>('/auth/me');
|
||||||
|
setUser(res.data.user);
|
||||||
|
setEditing(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiError ? `Erreur ${e.status}` : 'Erreur réseau');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const planLabel = user.plan?.name ?? 'Free';
|
||||||
|
const planSlug = user.plan?.slug ?? 'free';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg space-y-8">
|
||||||
|
<h1 className="font-mono text-sm text-od-accent">Profil</h1>
|
||||||
|
|
||||||
|
{/* Infos compte */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest">
|
||||||
|
Compte
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="rounded border border-od-border bg-od-surface divide-y divide-od-border">
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<span className="text-xs text-od-muted">Email</span>
|
||||||
|
<span className="font-mono text-xs text-od-text">
|
||||||
|
{user.email ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nickname */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<span className="text-xs text-od-muted">Pseudo</span>
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') handleCancel(); }}
|
||||||
|
maxLength={32}
|
||||||
|
disabled={saving}
|
||||||
|
className="w-36 rounded border border-od-border bg-od-bg px-2 py-0.5 font-mono text-xs text-od-text focus:border-od-accent focus:outline-none disabled:opacity-50"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="font-mono text-xs text-od-accent hover:opacity-80 transition-opacity disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? '…' : '✓'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={saving}
|
||||||
|
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<span className="font-mono text-[10px] text-od-crit">{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono text-xs text-od-text">{user.nickname}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors"
|
||||||
|
>
|
||||||
|
modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Plan */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest">
|
||||||
|
Abonnement
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="rounded border border-od-border bg-od-surface px-4 py-3 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-od-text">{planLabel}</p>
|
||||||
|
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-od-border text-od-muted">
|
||||||
|
{planSlug}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch, ApiError } from '../lib/api';
|
||||||
import VideoPlayer from '../components/VideoPlayer';
|
import VideoPlayer from '../components/VideoPlayer';
|
||||||
|
|
||||||
interface Video {
|
interface Video {
|
||||||
@@ -30,9 +30,9 @@ export default function VideoPage() {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
apiFetch<VideoResponse>(`/videos/${id}`)
|
apiFetch<VideoResponse>(`/videos/${id}`)
|
||||||
.then((res) => setVideo(res.data.video))
|
.then((res) => setVideo(res.data.video))
|
||||||
.catch((err: Error) => {
|
.catch((err: unknown) => {
|
||||||
if (err.message.includes('403')) setError('forbidden');
|
if (err instanceof ApiError && err.status === 403) setError('forbidden');
|
||||||
else if (err.message.includes('404')) setError('not_found');
|
else if (err instanceof ApiError && err.status === 404) setError('not_found');
|
||||||
else setError('unknown');
|
else setError('unknown');
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|||||||
Reference in New Issue
Block a user