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:run": "npm run typeorm -- migration:run",
|
||||
"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": {
|
||||
"bcrypt": "^5.1.1",
|
||||
@@ -18,11 +19,13 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.3",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.9.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.20"
|
||||
"typeorm": "^0.3.20",
|
||||
"winston": "^3.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
@@ -32,8 +35,12 @@
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@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-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 streamRoutes from "./routes/stream.routes";
|
||||
import userRoutes from "./routes/user.routes";
|
||||
import logger from "./utils/logger";
|
||||
import { loginRateLimiter, adminRateLimiter } from "./middleware/rateLimiter";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
const PORT = parseInt(process.env.PORT ?? "4000");
|
||||
|
||||
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() });
|
||||
});
|
||||
|
||||
app.use("/api/auth/login", loginRateLimiter);
|
||||
app.use("/api/auth", authRoutes);
|
||||
app.use("/api/videos", videoRoutes);
|
||||
app.use("/api/playlists", playlistRoutes);
|
||||
app.use("/api/admin", adminRoutes);
|
||||
app.use("/api/admin", adminRateLimiter, adminRoutes);
|
||||
app.use("/api/stream", streamRoutes);
|
||||
app.use("/api/users", userRoutes);
|
||||
|
||||
AppDataSource.initialize()
|
||||
.then(() => {
|
||||
console.log("Database connected");
|
||||
logger.info("Database connected");
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Database connection failed:", err);
|
||||
.catch((err: unknown) => {
|
||||
logger.error("Database connection failed", { err });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
@@ -40,7 +41,7 @@ export const requireAuth = async (
|
||||
|
||||
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
@@ -65,7 +66,8 @@ export const requireAuth = async (
|
||||
|
||||
(req as AuthenticatedRequest).user = data.data.user;
|
||||
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" });
|
||||
}
|
||||
};
|
||||
|
||||
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 { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { requireAdmin } from "../middleware/admin.middleware";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
@@ -80,14 +81,27 @@ router.post(
|
||||
/**
|
||||
* GET /api/admin/videos
|
||||
* 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 {
|
||||
const videos = await AppDataSource.getRepository(Video).find({
|
||||
const [videos, total] = await AppDataSource.getRepository(Video).findAndCount({
|
||||
order: { createdAt: "DESC" },
|
||||
skip: (rawPage - 1) * rawLimit,
|
||||
take: rawLimit,
|
||||
});
|
||||
res.json({ success: true, data: { videos } });
|
||||
} catch {
|
||||
res.json({ success: true, data: videos, total, page: rawPage, limit: rawLimit });
|
||||
} catch (err) {
|
||||
logger.error("GET /admin/videos — failed to list videos", { err });
|
||||
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);
|
||||
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" });
|
||||
}
|
||||
});
|
||||
@@ -179,7 +194,8 @@ router.patch("/videos/:id", async (req: Request, res: Response): Promise<void> =
|
||||
|
||||
await repo.save(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" });
|
||||
}
|
||||
});
|
||||
@@ -200,7 +216,8 @@ router.delete("/videos/:id", async (req: Request, res: Response): Promise<void>
|
||||
|
||||
await repo.remove(video);
|
||||
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" });
|
||||
}
|
||||
});
|
||||
@@ -212,15 +229,27 @@ router.delete("/videos/:id", async (req: Request, res: Response): Promise<void>
|
||||
/**
|
||||
* GET /api/admin/users
|
||||
* 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 {
|
||||
const users = await AppDataSource.getRepository(User).find({
|
||||
const [users, total] = await AppDataSource.getRepository(User).findAndCount({
|
||||
relations: ["userRoles", "userRoles.role", "subscriptions", "subscriptions.plan"],
|
||||
order: { createdAt: "DESC" },
|
||||
skip: (rawPage - 1) * rawLimit,
|
||||
take: rawLimit,
|
||||
});
|
||||
|
||||
const result = users.map((u) => ({
|
||||
const data = users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
nickname: u.nickname,
|
||||
@@ -228,13 +257,14 @@ router.get("/users", async (_req: Request, res: Response): Promise<void> => {
|
||||
createdAt: u.createdAt,
|
||||
roles: u.userRoles.map((ur) => ({ id: ur.role.id, slug: ur.role.slug, name: ur.role.name })),
|
||||
activeSubscription: (() => {
|
||||
const sub = u.subscriptions.find((s) => s.status === "active");
|
||||
return sub ? { id: sub.id, status: sub.status, startsAt: sub.startsAt, endsAt: sub.endsAt, plan: sub.plan } : null;
|
||||
})(),
|
||||
const sub = u.subscriptions.find((s) => s.status === "active");
|
||||
return sub ? { id: sub.id, status: sub.status, startsAt: sub.startsAt, endsAt: sub.endsAt, plan: sub.plan } : null;
|
||||
})(),
|
||||
}));
|
||||
|
||||
res.json({ success: true, data: { users: result } });
|
||||
} catch {
|
||||
res.json({ success: true, data, total, page: rawPage, limit: rawLimit });
|
||||
} catch (err) {
|
||||
logger.error("GET /admin/users — failed to list users", { err });
|
||||
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 })),
|
||||
},
|
||||
});
|
||||
} 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" });
|
||||
}
|
||||
});
|
||||
@@ -315,7 +346,8 @@ router.get("/plans", async (_req: Request, res: Response): Promise<void> => {
|
||||
order: { level: "ASC" },
|
||||
});
|
||||
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" });
|
||||
}
|
||||
});
|
||||
@@ -360,7 +392,8 @@ router.post("/plans", async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
await AppDataSource.getRepository(SubscriptionPlan).save(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" });
|
||||
}
|
||||
});
|
||||
@@ -390,7 +423,8 @@ router.patch("/plans/:id", async (req: Request, res: Response): Promise<void> =>
|
||||
|
||||
await repo.save(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" });
|
||||
}
|
||||
});
|
||||
@@ -445,7 +479,8 @@ router.post("/users/:id/subscriptions", async (req: Request, res: Response): Pro
|
||||
|
||||
await subRepo.save(sub);
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { AppDataSource } from "../config/data-source";
|
||||
import logger from "../utils/logger";
|
||||
import { User } from "../entities/User";
|
||||
import { UserSubscription } from "../entities/UserSubscription";
|
||||
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.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" });
|
||||
}
|
||||
});
|
||||
@@ -125,7 +127,8 @@ router.post("/session", async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS);
|
||||
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" });
|
||||
}
|
||||
});
|
||||
@@ -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.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" });
|
||||
}
|
||||
});
|
||||
@@ -275,7 +279,8 @@ router.get("/me/optional", async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
|
||||
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 } });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PlaylistShare } from "../entities/PlaylistShare";
|
||||
import { User } from "../entities/User";
|
||||
import { Video } from "../entities/Video";
|
||||
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
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 })),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
logger.error("GET /playlists — failed to list playlists", { err });
|
||||
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);
|
||||
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" });
|
||||
}
|
||||
});
|
||||
@@ -96,7 +99,7 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise<voi
|
||||
try {
|
||||
const playlist = await AppDataSource.getRepository(Playlist).findOne({
|
||||
where: { id: req.params.id },
|
||||
relations: ["playlistVideos", "playlistVideos.video", "shares"],
|
||||
relations: ["playlistVideos", "playlistVideos.video"],
|
||||
});
|
||||
|
||||
if (!playlist) {
|
||||
@@ -105,8 +108,12 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
|
||||
const isOwner = playlist.ownerId === dbUserId;
|
||||
const share = playlist.shares.find((s) => s.userId === dbUserId && s.status === "active");
|
||||
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) {
|
||||
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",
|
||||
},
|
||||
});
|
||||
} 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" });
|
||||
}
|
||||
});
|
||||
@@ -158,7 +166,8 @@ router.patch("/:id", requireAuth, async (req: Request, res: Response): Promise<v
|
||||
|
||||
await repo.save(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" });
|
||||
}
|
||||
});
|
||||
@@ -181,7 +190,8 @@ router.delete("/:id", requireAuth, async (req: Request, res: Response): Promise<
|
||||
|
||||
await repo.remove(playlist);
|
||||
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" });
|
||||
}
|
||||
});
|
||||
@@ -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; }
|
||||
|
||||
try {
|
||||
const playlist = await AppDataSource.getRepository(Playlist).findOne({
|
||||
where: { id: req.params.id },
|
||||
relations: ["shares"],
|
||||
});
|
||||
const playlist = await AppDataSource.getRepository(Playlist).findOneBy({ id: req.params.id });
|
||||
|
||||
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
|
||||
|
||||
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; }
|
||||
|
||||
@@ -226,7 +235,8 @@ router.post("/:id/videos", requireAuth, async (req: Request, res: Response): Pro
|
||||
|
||||
await AppDataSource.getRepository(PlaylistVideo).save(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" });
|
||||
}
|
||||
});
|
||||
@@ -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; }
|
||||
|
||||
try {
|
||||
const playlist = await AppDataSource.getRepository(Playlist).findOne({
|
||||
where: { id: req.params.id },
|
||||
relations: ["shares"],
|
||||
});
|
||||
const playlist = await AppDataSource.getRepository(Playlist).findOneBy({ id: req.params.id });
|
||||
|
||||
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
|
||||
|
||||
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; }
|
||||
|
||||
@@ -262,7 +271,8 @@ router.delete("/:id/videos/:videoId", requireAuth, async (req: Request, res: Res
|
||||
|
||||
await AppDataSource.getRepository(PlaylistVideo).remove(pv);
|
||||
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" });
|
||||
}
|
||||
});
|
||||
@@ -305,7 +315,8 @@ router.post("/:id/share", requireAuth, async (req: Request, res: Response): Prom
|
||||
|
||||
await AppDataSource.getRepository(PlaylistShare).save(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" });
|
||||
}
|
||||
});
|
||||
@@ -343,7 +354,8 @@ router.patch("/:id/share/:shareId", requireAuth, async (req: Request, res: Respo
|
||||
|
||||
await AppDataSource.getRepository(PlaylistShare).save(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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { AppDataSource } from "../config/data-source";
|
||||
import logger from "../utils/logger";
|
||||
import { Video } from "../entities/Video";
|
||||
import { User } from "../entities/User";
|
||||
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 } });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
logger.error("GET /videos — failed to list videos", { err });
|
||||
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 } });
|
||||
} 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" });
|
||||
}
|
||||
});
|
||||
|
||||
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 PlaylistPage from './pages/PlaylistPage';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
@@ -38,6 +39,7 @@ function App() {
|
||||
<Route path="/playlists" element={<PlaylistsPage />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</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 { apiFetch } from '../../lib/api';
|
||||
import type { User } from '../../context/AuthContext';
|
||||
import UserBadge from '../UserBadge';
|
||||
|
||||
interface HeaderProps {
|
||||
theme: 'dark' | 'light';
|
||||
@@ -10,8 +12,23 @@ interface 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() {
|
||||
await apiFetch('/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
setOpen(false);
|
||||
onLogout();
|
||||
}
|
||||
|
||||
@@ -39,7 +56,7 @@ export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderP
|
||||
Playlists
|
||||
</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">
|
||||
admin
|
||||
</Link>
|
||||
@@ -57,14 +74,31 @@ export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderP
|
||||
</button>
|
||||
|
||||
{user ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-od-accent">{user.nickname}</span>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors"
|
||||
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
|
||||
onClick={handleLogout}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface User {
|
||||
id: string;
|
||||
email: string | null;
|
||||
nickname: string;
|
||||
plan?: { slug: string; name: string; level: number } | null;
|
||||
subscriptionLevel?: number;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
@@ -1,38 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
export interface User {
|
||||
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 };
|
||||
}
|
||||
// Réexporte depuis AuthContext — source unique de vérité auth.
|
||||
// Ne pas dupliquer User ou la logique de fetch ici.
|
||||
export type { User } from '../context/AuthContext';
|
||||
export { useAuthContext as useAuth } from '../context/AuthContext';
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
// En prod : VITE_API_URL=https://origins.tetardtek.com/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> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
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) {
|
||||
throw new Error(`API ${res.status}: ${path}`);
|
||||
throw new ApiError(res.status, path);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function AdminPage() {
|
||||
const [tab, setTab] = useState<Tab>('videos');
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { apiFetch, ApiError } from '../lib/api';
|
||||
|
||||
interface Video {
|
||||
id: string;
|
||||
@@ -35,8 +35,8 @@ export default function PlaylistPage() {
|
||||
if (!id) return;
|
||||
apiFetch<PlaylistResponse>(`/playlists/${id}`)
|
||||
.then((res) => setData(res.data))
|
||||
.catch((err: Error) => {
|
||||
if (err.message.includes('403')) setError('forbidden');
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 403) setError('forbidden');
|
||||
else setError('not_found');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
@@ -21,8 +21,10 @@ export default function PlaylistsPage() {
|
||||
const [owned, setOwned] = useState<Playlist[]>([]);
|
||||
const [shared, setShared] = useState<(Playlist & { permission: 'view' | 'edit' })[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [createTitle, setCreateTitle] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<PlaylistsResponse>('/playlists')
|
||||
@@ -30,7 +32,7 @@ export default function PlaylistsPage() {
|
||||
setOwned(res.data.owned);
|
||||
setShared(res.data.shared);
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => setFetchError('Impossible de charger les playlists.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -38,6 +40,7 @@ export default function PlaylistsPage() {
|
||||
e.preventDefault();
|
||||
if (!createTitle.trim() || creating) return;
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ success: boolean; data: { playlist: Playlist } }>(
|
||||
'/playlists',
|
||||
@@ -45,7 +48,9 @@ export default function PlaylistsPage() {
|
||||
);
|
||||
setOwned((prev) => [res.data.playlist, ...prev]);
|
||||
setCreateTitle('');
|
||||
} catch {}
|
||||
} catch {
|
||||
setCreateError('Impossible de créer la playlist.');
|
||||
}
|
||||
setCreating(false);
|
||||
}
|
||||
|
||||
@@ -59,6 +64,10 @@ export default function PlaylistsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError) {
|
||||
return <p className="text-sm text-od-crit">{fetchError}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
|
||||
@@ -67,6 +76,7 @@ export default function PlaylistsPage() {
|
||||
</section>
|
||||
|
||||
{/* Créer */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<form onSubmit={handleCreate} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -83,6 +93,8 @@ export default function PlaylistsPage() {
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
{createError && <p className="font-mono text-xs text-od-crit">{createError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Mes playlists */}
|
||||
{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 { useParams, Link } from 'react-router-dom';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { apiFetch, ApiError } from '../lib/api';
|
||||
import VideoPlayer from '../components/VideoPlayer';
|
||||
|
||||
interface Video {
|
||||
@@ -30,9 +30,9 @@ export default function VideoPage() {
|
||||
if (!id) return;
|
||||
apiFetch<VideoResponse>(`/videos/${id}`)
|
||||
.then((res) => setVideo(res.data.video))
|
||||
.catch((err: Error) => {
|
||||
if (err.message.includes('403')) setError('forbidden');
|
||||
else if (err.message.includes('404')) setError('not_found');
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 403) setError('forbidden');
|
||||
else if (err instanceof ApiError && err.status === 404) setError('not_found');
|
||||
else setError('unknown');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
Reference in New Issue
Block a user