Compare commits
3 Commits
324efcaa3d
...
27e6541425
| Author | SHA1 | Date | |
|---|---|---|---|
| 27e6541425 | |||
| c7815aac2f | |||
| aa15dc0f54 |
@@ -14,3 +14,6 @@ SUPER_OAUTH_URL=https://superoauth.tetardtek.com
|
||||
|
||||
# CORS — URL du frontend autorisé
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Dossier de stockage des vidéos uploadées (défaut: ./uploads)
|
||||
UPLOADS_DIR=./uploads
|
||||
|
||||
78
backend/package-lock.json
generated
78
backend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.9.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.20"
|
||||
@@ -24,6 +25,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.12.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
@@ -340,6 +342,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
|
||||
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
@@ -546,6 +558,12 @@
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aproba": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||
@@ -737,9 +755,19 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -877,6 +905,21 @@
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
@@ -2109,6 +2152,25 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.19.1",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.19.1.tgz",
|
||||
@@ -2781,6 +2843,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -3086,6 +3156,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typeorm": {
|
||||
"version": "0.3.28",
|
||||
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.9.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.20"
|
||||
@@ -29,6 +30,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.12.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Response, NextFunction } from "express";
|
||||
import { AppDataSource } from "../config/data-source";
|
||||
import { User } from "../entities/User";
|
||||
import { UserRole } from "../entities/UserRole";
|
||||
import { AuthenticatedRequest } from "./auth.middleware";
|
||||
|
||||
/**
|
||||
* Middleware requireAdmin — s'exécute APRÈS requireAuth.
|
||||
* Charge les rôles de l'utilisateur depuis la DB et vérifie
|
||||
* la présence du slug "admin" ou "super_admin".
|
||||
* Résout l'utilisateur local par superOAuthId (req.user.id est l'ID SuperOAuth),
|
||||
* charge ses rôles et vérifie la présence du slug "admin" ou "super_admin".
|
||||
* Retourne 403 FORBIDDEN si la condition n'est pas remplie.
|
||||
*/
|
||||
export const requireAdmin = async (
|
||||
@@ -15,10 +16,17 @@ export const requireAdmin = async (
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const localUser = await AppDataSource.getRepository(User).findOne({
|
||||
where: { superOAuthId: req.user.id },
|
||||
});
|
||||
|
||||
if (!localUser) {
|
||||
res.status(403).json({ success: false, error: "FORBIDDEN" });
|
||||
return;
|
||||
}
|
||||
|
||||
const userRoles = await AppDataSource.getRepository(UserRole).find({
|
||||
where: { userId },
|
||||
where: { userId: localUser.id },
|
||||
relations: ["role"],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import multer, { FileFilterCallback } from "multer";
|
||||
import { AppDataSource } from "../config/data-source";
|
||||
import { Video } from "../entities/Video";
|
||||
import { User } from "../entities/User";
|
||||
@@ -9,6 +12,26 @@ import { SubscriptionPlan } from "../entities/SubscriptionPlan";
|
||||
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { requireAdmin } from "../middleware/admin.middleware";
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
|
||||
const videoStorage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
|
||||
filename: (_req, _file, cb) => cb(null, `${crypto.randomUUID()}.mp4`),
|
||||
});
|
||||
|
||||
const videoUpload = multer({
|
||||
storage: videoStorage,
|
||||
limits: { fileSize: 4 * 1024 * 1024 * 1024 }, // 4 Go
|
||||
fileFilter: (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||
if (["video/mp4", "video/webm"].includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("INVALID_MIME_TYPE"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Applique requireAuth + requireAdmin sur toutes les routes de ce routeur
|
||||
@@ -19,6 +42,41 @@ router.use(requireAdmin as unknown as (req: Request, res: Response, next: () =>
|
||||
// VIDEOS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* POST /api/admin/videos/upload
|
||||
* Upload un fichier vidéo (mp4 / webm) dans UPLOADS_DIR.
|
||||
* Retourne le storageKey à passer ensuite à POST /api/admin/videos.
|
||||
*/
|
||||
router.post(
|
||||
"/videos/upload",
|
||||
(req: Request, res: Response, next) => {
|
||||
videoUpload.single("file")(req, res, (err) => {
|
||||
if (err) {
|
||||
const message = err instanceof Error ? err.message : "UPLOAD_ERROR";
|
||||
if (message === "INVALID_MIME_TYPE") {
|
||||
res.status(415).json({ success: false, error: "INVALID_MIME_TYPE", message: "Only video/mp4 and video/webm are accepted" });
|
||||
} else if (err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(413).json({ success: false, error: "FILE_TOO_LARGE" });
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
(req: Request, res: Response): void => {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ success: false, error: "NO_FILE" });
|
||||
return;
|
||||
}
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: { storageKey: req.file.filename, storageType: "local" },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/admin/videos
|
||||
* Liste toutes les vidéos (publiées et non publiées), tous les champs.
|
||||
|
||||
@@ -6,11 +6,20 @@ import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware
|
||||
const router = Router();
|
||||
|
||||
const COOKIE_NAME = "od_token";
|
||||
const REFRESH_COOKIE_NAME = "od_refresh";
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict" as const,
|
||||
maxAge: 15 * 60 * 1000,
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||
};
|
||||
|
||||
const REFRESH_COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict" as const,
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 jours
|
||||
};
|
||||
|
||||
/** Upsert user en DB depuis un profil SuperOAuth */
|
||||
@@ -53,7 +62,7 @@ router.post("/login", async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
const data = await response.json() as {
|
||||
success: boolean;
|
||||
data?: { user: { id: string; email: string | null; nickname: string }; tokens: { accessToken: string } };
|
||||
data?: { user: { id: string; email: string | null; nickname: string }; tokens: { accessToken: string; refreshToken?: string } };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
@@ -65,6 +74,9 @@ router.post("/login", async (req: Request, res: Response): Promise<void> => {
|
||||
await upsertUser(data.data.user);
|
||||
|
||||
res.cookie(COOKIE_NAME, data.data.tokens.accessToken, COOKIE_OPTIONS);
|
||||
if (data.data.tokens.refreshToken) {
|
||||
res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
|
||||
}
|
||||
res.json({ success: true, data: { user: data.data.user } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
|
||||
@@ -117,12 +129,61 @@ router.post("/session", async (req: Request, res: Response): Promise<void> => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Échange le refresh token contre un nouvel access token via SuperOAuth.
|
||||
*/
|
||||
router.post("/refresh", async (req: Request, res: Response): Promise<void> => {
|
||||
const refreshToken = (req.cookies as Record<string, string>)?.[REFRESH_COOKIE_NAME];
|
||||
|
||||
if (!refreshToken) {
|
||||
res.status(401).json({ success: false, error: "NO_REFRESH_TOKEN" });
|
||||
return;
|
||||
}
|
||||
|
||||
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
|
||||
if (!superOAuthUrl) {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
const data = await response.json() as {
|
||||
success: boolean;
|
||||
data?: { tokens: { accessToken: string; refreshToken?: string }; user?: { id: string; email: string | null; nickname: string } };
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !data.data?.tokens?.accessToken) {
|
||||
res.clearCookie(COOKIE_NAME);
|
||||
res.clearCookie(REFRESH_COOKIE_NAME);
|
||||
res.status(401).json({ success: false, error: "REFRESH_FAILED" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.cookie(COOKIE_NAME, data.data.tokens.accessToken, COOKIE_OPTIONS);
|
||||
if (data.data.tokens.refreshToken) {
|
||||
res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
|
||||
}
|
||||
res.json({ success: true, data: { user: data.data.user ?? null } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Supprime le cookie de session.
|
||||
*/
|
||||
router.post("/logout", (_req: Request, res: Response): void => {
|
||||
res.clearCookie(COOKIE_NAME);
|
||||
res.clearCookie(REFRESH_COOKIE_NAME);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -135,4 +196,47 @@ router.get("/me", requireAuth, (req: Request, res: Response): void => {
|
||||
res.json({ success: true, data: { user } });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/me/optional
|
||||
* Retourne l'utilisateur courant ou null si non authentifié (pas de 401).
|
||||
*/
|
||||
router.get("/me/optional", async (req: Request, res: Response): Promise<void> => {
|
||||
const token =
|
||||
req.headers.authorization?.split(" ")[1] ??
|
||||
(req.cookies as Record<string, string>)?.od_token;
|
||||
|
||||
if (!token) {
|
||||
res.json({ success: true, data: { user: null } });
|
||||
return;
|
||||
}
|
||||
|
||||
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
|
||||
if (!superOAuthUrl) {
|
||||
res.json({ success: true, data: { user: null } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
const data = await response.json() as {
|
||||
success: boolean;
|
||||
data?: { valid: boolean; user?: object };
|
||||
};
|
||||
|
||||
if (!response.ok || !data.data?.valid || !data.data.user) {
|
||||
res.json({ success: true, data: { user: null } });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { user: data.data.user } });
|
||||
} catch {
|
||||
res.json({ success: true, data: { user: null } });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { AppDataSource } from "../config/data-source";
|
||||
import { Playlist } from "../entities/Playlist";
|
||||
import { PlaylistVideo } from "../entities/PlaylistVideo";
|
||||
import { PlaylistShare } from "../entities/PlaylistShare";
|
||||
import { User } from "../entities/User";
|
||||
import { Video } from "../entities/Video";
|
||||
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
|
||||
const router = Router();
|
||||
@@ -128,6 +130,143 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/playlists/:id
|
||||
* Renomme ou change la visibilité d'une playlist (propriétaire uniquement).
|
||||
*/
|
||||
router.patch("/:id", requireAuth, async (req: Request, res: Response): Promise<void> => {
|
||||
const { user } = req as AuthenticatedRequest;
|
||||
const { title, description, visibility } = req.body as {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
visibility?: "private" | "shared" | "public";
|
||||
};
|
||||
|
||||
const dbUserId = await resolveDbUserId(user.id);
|
||||
if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; }
|
||||
|
||||
try {
|
||||
const repo = AppDataSource.getRepository(Playlist);
|
||||
const playlist = await repo.findOneBy({ id: req.params.id });
|
||||
|
||||
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
|
||||
if (playlist.ownerId !== dbUserId) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
|
||||
|
||||
if (title !== undefined) playlist.title = title.trim();
|
||||
if (description !== undefined) playlist.description = description;
|
||||
if (visibility !== undefined) playlist.visibility = visibility;
|
||||
|
||||
await repo.save(playlist);
|
||||
res.json({ success: true, data: { playlist } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/playlists/:id
|
||||
* Supprime une playlist (propriétaire uniquement).
|
||||
*/
|
||||
router.delete("/:id", requireAuth, async (req: Request, res: Response): Promise<void> => {
|
||||
const { user } = req as AuthenticatedRequest;
|
||||
const dbUserId = await resolveDbUserId(user.id);
|
||||
if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; }
|
||||
|
||||
try {
|
||||
const repo = AppDataSource.getRepository(Playlist);
|
||||
const playlist = await repo.findOneBy({ id: req.params.id });
|
||||
|
||||
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
|
||||
if (playlist.ownerId !== dbUserId) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
|
||||
|
||||
await repo.remove(playlist);
|
||||
res.json({ success: true, data: { deleted: req.params.id } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/playlists/:id/videos
|
||||
* Ajoute une vidéo à une playlist (propriétaire ou éditeur).
|
||||
* Body: { videoId, position? }
|
||||
*/
|
||||
router.post("/:id/videos", requireAuth, async (req: Request, res: Response): Promise<void> => {
|
||||
const { user } = req as AuthenticatedRequest;
|
||||
const { videoId, position } = req.body as { videoId?: string; position?: number };
|
||||
const dbUserId = await resolveDbUserId(user.id);
|
||||
if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; }
|
||||
|
||||
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"],
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
if (!canEdit) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
|
||||
|
||||
const video = await AppDataSource.getRepository(Video).findOneBy({ id: videoId });
|
||||
if (!video) { res.status(404).json({ success: false, error: "VIDEO_NOT_FOUND" }); return; }
|
||||
|
||||
const existing = await AppDataSource.getRepository(PlaylistVideo).findOneBy({ playlistId: playlist.id, videoId });
|
||||
if (existing) { res.status(409).json({ success: false, error: "ALREADY_IN_PLAYLIST" }); return; }
|
||||
|
||||
const pv = AppDataSource.getRepository(PlaylistVideo).create({
|
||||
playlistId: playlist.id,
|
||||
videoId,
|
||||
position: position ?? 0,
|
||||
});
|
||||
|
||||
await AppDataSource.getRepository(PlaylistVideo).save(pv);
|
||||
res.status(201).json({ success: true, data: { playlistVideo: pv } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/playlists/:id/videos/:videoId
|
||||
* Retire une vidéo d'une playlist (propriétaire ou éditeur).
|
||||
*/
|
||||
router.delete("/:id/videos/:videoId", requireAuth, async (req: Request, res: Response): Promise<void> => {
|
||||
const { user } = req as AuthenticatedRequest;
|
||||
const dbUserId = await resolveDbUserId(user.id);
|
||||
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"],
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
if (!canEdit) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
|
||||
|
||||
const pv = await AppDataSource.getRepository(PlaylistVideo).findOneBy({
|
||||
playlistId: playlist.id,
|
||||
videoId: req.params.videoId,
|
||||
});
|
||||
|
||||
if (!pv) { res.status(404).json({ success: false, error: "NOT_IN_PLAYLIST" }); return; }
|
||||
|
||||
await AppDataSource.getRepository(PlaylistVideo).remove(pv);
|
||||
res.json({ success: true, data: { deleted: req.params.videoId } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/playlists/:id/share
|
||||
* Invite un utilisateur à une playlist (propriétaire uniquement).
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import Layout from './components/layout/Layout';
|
||||
import RequireAuth from './components/RequireAuth';
|
||||
import HomePage from './pages/HomePage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import CallbackPage from './pages/CallbackPage';
|
||||
import VideoPage from './pages/VideoPage';
|
||||
import PlaylistsPage from './pages/PlaylistsPage';
|
||||
import PlaylistPage from './pages/PlaylistPage';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
@@ -23,18 +26,23 @@ function App() {
|
||||
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout theme={theme} onToggleTheme={toggleTheme} />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/callback" element={<CallbackPage />} />
|
||||
<Route path="/video/:id" element={<VideoPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/playlists" element={<PlaylistsPage />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistPage />} />
|
||||
<Route path="/callback" element={<CallbackPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
15
frontend/src/components/RequireAuth.tsx
Normal file
15
frontend/src/components/RequireAuth.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuthContext } from '../context/AuthContext';
|
||||
|
||||
export default function RequireAuth() {
|
||||
const { user, loading } = useAuthContext();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -20,10 +20,11 @@ export default function VideoPlayer({ storageType, storageKey }: VideoPlayerProp
|
||||
return <YouTubePlayer videoId={storageKey} />;
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_URL || '/api';
|
||||
const url =
|
||||
storageType === 'external'
|
||||
? storageKey
|
||||
: `${import.meta.env.VITE_API_URL}/stream/${storageKey}`;
|
||||
: `${apiBase}/stream/${storageKey}`;
|
||||
|
||||
return <NativePlayer url={url} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import type { User } from '../../hooks/useAuth';
|
||||
import type { User } from '../../context/AuthContext';
|
||||
|
||||
interface HeaderProps {
|
||||
theme: 'dark' | 'light';
|
||||
@@ -39,6 +39,11 @@ export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderP
|
||||
Playlists
|
||||
</Link>
|
||||
)}
|
||||
{user && (
|
||||
<Link to="/admin" className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors">
|
||||
admin
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Right — thème + auth */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { useAuthContext } from '../../context/AuthContext';
|
||||
|
||||
interface LayoutProps {
|
||||
theme: 'dark' | 'light';
|
||||
@@ -8,7 +8,7 @@ interface LayoutProps {
|
||||
}
|
||||
|
||||
export default function Layout({ theme, onToggleTheme }: LayoutProps) {
|
||||
const { user, loading, setUser } = useAuth();
|
||||
const { user, loading, setUser } = useAuthContext();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-od-bg text-od-text">
|
||||
|
||||
50
frontend/src/context/AuthContext.tsx
Normal file
50
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string | null;
|
||||
nickname: string;
|
||||
subscriptionLevel?: number;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
setUser: (u: User | null) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
interface MeResponse {
|
||||
success: boolean;
|
||||
data: { user: User };
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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 (
|
||||
<AuthContext.Provider value={{ user, loading, setUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthContext(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuthContext must be used inside AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
439
frontend/src/pages/AdminPage.tsx
Normal file
439
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Video {
|
||||
id: string;
|
||||
title: string;
|
||||
storageType: string;
|
||||
storageKey: string;
|
||||
requiredLevel: number;
|
||||
isPublished: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
level: number;
|
||||
priceInCents: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
email: string | null;
|
||||
nickname: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
roles: { id: string; slug: string; name: string }[];
|
||||
activeSubscription: {
|
||||
id: string;
|
||||
status: string;
|
||||
endsAt: string | null;
|
||||
plan: Plan;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = 'videos' | 'users' | 'plans';
|
||||
|
||||
export default function AdminPage() {
|
||||
const [tab, setTab] = useState<Tab>('videos');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-1 border-b border-od-border pb-4">
|
||||
{(['videos', 'users', 'plans'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`rounded px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
tab === t
|
||||
? 'bg-od-surface text-od-accent border border-od-accent'
|
||||
: 'text-od-muted hover:text-od-text'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'videos' && <VideosTab />}
|
||||
{tab === 'users' && <UsersTab />}
|
||||
{tab === 'plans' && <PlansTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Videos tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function VideosTab() {
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [form, setForm] = useState({
|
||||
title: '', storageType: 'youtube', storageKey: '',
|
||||
requiredLevel: 0, isPublished: false,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ success: boolean; data: { videos: Video[] } }>('/admin/videos')
|
||||
.then((r) => setVideos(r.data.videos))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.title || !form.storageKey || saving) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await apiFetch<{ success: boolean; data: { video: Video } }>(
|
||||
'/admin/videos',
|
||||
{ method: 'POST', body: JSON.stringify(form) }
|
||||
);
|
||||
setVideos((v) => [r.data.video, ...v]);
|
||||
setForm({ title: '', storageType: 'youtube', storageKey: '', requiredLevel: 0, isPublished: false });
|
||||
} catch {
|
||||
setError('Erreur lors de la création.');
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function togglePublish(video: Video) {
|
||||
try {
|
||||
const r = await apiFetch<{ success: boolean; data: { video: Video } }>(
|
||||
`/admin/videos/${video.id}`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ isPublished: !video.isPublished }) }
|
||||
);
|
||||
setVideos((v) => v.map((x) => x.id === video.id ? r.data.video : x));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Supprimer cette vidéo ?')) return;
|
||||
try {
|
||||
await apiFetch(`/admin/videos/${id}`, { method: 'DELETE' });
|
||||
setVideos((v) => v.filter((x) => x.id !== id));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Formulaire création */}
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-4">
|
||||
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">Nouvelle vidéo</p>
|
||||
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||
placeholder="Titre"
|
||||
required
|
||||
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={form.storageType}
|
||||
onChange={(e) => setForm((f) => ({ ...f, storageType: e.target.value }))}
|
||||
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text outline-none focus:border-od-accent"
|
||||
>
|
||||
<option value="youtube">YouTube</option>
|
||||
<option value="local">Local</option>
|
||||
<option value="s3">S3</option>
|
||||
<option value="external">External</option>
|
||||
</select>
|
||||
<input
|
||||
value={form.storageKey}
|
||||
onChange={(e) => setForm((f) => ({ ...f, storageKey: e.target.value }))}
|
||||
placeholder={form.storageType === 'youtube' ? 'ID YouTube (ex: dQw4w9WgXcQ)' : 'Chemin / URL'}
|
||||
required
|
||||
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-od-muted">
|
||||
Niveau requis
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.requiredLevel}
|
||||
onChange={(e) => setForm((f) => ({ ...f, requiredLevel: parseInt(e.target.value) || 0 }))}
|
||||
className="w-16 rounded border border-od-border bg-od-bg px-2 py-1 text-sm text-od-text outline-none focus:border-od-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-od-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isPublished}
|
||||
onChange={(e) => setForm((f) => ({ ...f, isPublished: e.target.checked }))}
|
||||
/>
|
||||
Publié
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-od-crit">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="self-start rounded border border-od-accent px-4 py-1.5 font-mono text-xs text-od-accent hover:bg-od-accent hover:text-od-bg transition-colors disabled:opacity-40"
|
||||
>
|
||||
{saving ? '…' : 'Créer'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Liste */}
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{[...Array(3)].map((_, i) => <div key={i} className="h-12 rounded border border-od-border animate-pulse" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{videos.map((v) => (
|
||||
<div key={v.id} className="flex items-center gap-3 rounded border border-od-border bg-od-surface px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-od-text truncate">{v.title}</p>
|
||||
<p className="font-mono text-xs text-od-muted">{v.storageType} · niveau {v.requiredLevel}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => togglePublish(v)}
|
||||
className={`font-mono text-xs px-2 py-0.5 rounded border transition-colors ${
|
||||
v.isPublished
|
||||
? 'border-od-accent text-od-accent hover:bg-od-accent hover:text-od-bg'
|
||||
: 'border-od-border text-od-muted hover:border-od-accent hover:text-od-accent'
|
||||
}`}
|
||||
>
|
||||
{v.isPublished ? 'publié' : 'brouillon'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(v.id)}
|
||||
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{videos.length === 0 && <p className="text-sm text-od-muted">Aucune vidéo.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Users tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function UsersTab() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [assigning, setAssigning] = useState<string | null>(null);
|
||||
const [selectedPlan, setSelectedPlan] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users'),
|
||||
apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans'),
|
||||
])
|
||||
.then(([ur, pr]) => {
|
||||
setUsers(ur.data.users);
|
||||
setPlans(pr.data.plans);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function assignPlan(userId: string) {
|
||||
const planId = selectedPlan[userId];
|
||||
if (!planId || assigning) return;
|
||||
setAssigning(userId);
|
||||
try {
|
||||
await apiFetch(`/admin/users/${userId}/subscriptions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ planId }),
|
||||
});
|
||||
// Rafraîchir la liste users
|
||||
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
|
||||
setUsers(r.data.users);
|
||||
} catch {}
|
||||
setAssigning(null);
|
||||
}
|
||||
|
||||
if (loading) return <div className="h-32 animate-pulse rounded border border-od-border" />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="flex flex-col gap-2 rounded border border-od-border bg-od-surface px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-od-text">{u.nickname}</p>
|
||||
<p className="font-mono text-xs text-od-muted">{u.email ?? '—'}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{u.activeSubscription ? (
|
||||
<span className="font-mono text-xs text-od-accent">
|
||||
{u.activeSubscription.plan.name}
|
||||
{u.activeSubscription.endsAt && ` · ${new Date(u.activeSubscription.endsAt).toLocaleDateString()}`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-od-muted">free</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedPlan[u.id] ?? ''}
|
||||
onChange={(e) => setSelectedPlan((s) => ({ ...s, [u.id]: e.target.value }))}
|
||||
className="flex-1 rounded border border-od-border bg-od-bg px-2 py-1 text-xs text-od-text outline-none focus:border-od-accent"
|
||||
>
|
||||
<option value="">— Assigner un plan —</option>
|
||||
{plans.filter((p) => p.isActive).map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name} (niv. {p.level})</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
disabled={!selectedPlan[u.id] || assigning === u.id}
|
||||
onClick={() => assignPlan(u.id)}
|
||||
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors disabled:opacity-40"
|
||||
>
|
||||
{assigning === u.id ? '…' : 'Assigner'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{users.length === 0 && <p className="text-sm text-od-muted">Aucun utilisateur.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plans tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PlansTab() {
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [form, setForm] = useState({ slug: '', name: '', level: 1, priceInCents: 0 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans')
|
||||
.then((r) => setPlans(r.data.plans))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.slug || !form.name || saving) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await apiFetch<{ success: boolean; data: { plan: Plan } }>(
|
||||
'/admin/plans',
|
||||
{ method: 'POST', body: JSON.stringify(form) }
|
||||
);
|
||||
setPlans((p) => [...p, r.data.plan]);
|
||||
setForm({ slug: '', name: '', level: 1, priceInCents: 0 });
|
||||
} catch {
|
||||
setError('Erreur lors de la création.');
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function toggleActive(plan: Plan) {
|
||||
try {
|
||||
const r = await apiFetch<{ success: boolean; data: { plan: Plan } }>(
|
||||
`/admin/plans/${plan.id}`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ isActive: !plan.isActive }) }
|
||||
);
|
||||
setPlans((p) => p.map((x) => x.id === plan.id ? r.data.plan : x));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (loading) return <div className="h-24 animate-pulse rounded border border-od-border" />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-4">
|
||||
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">Nouveau plan</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
|
||||
placeholder="slug (ex: premium)"
|
||||
required
|
||||
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
|
||||
/>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Nom"
|
||||
required
|
||||
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-od-muted">
|
||||
Niveau
|
||||
<input
|
||||
type="number" min={1}
|
||||
value={form.level}
|
||||
onChange={(e) => setForm((f) => ({ ...f, level: parseInt(e.target.value) || 1 }))}
|
||||
className="w-16 rounded border border-od-border bg-od-bg px-2 py-1 text-sm text-od-text outline-none focus:border-od-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-od-muted">
|
||||
Prix (centimes)
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={form.priceInCents}
|
||||
onChange={(e) => setForm((f) => ({ ...f, priceInCents: parseInt(e.target.value) || 0 }))}
|
||||
className="w-24 rounded border border-od-border bg-od-bg px-2 py-1 text-sm text-od-text outline-none focus:border-od-accent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="text-xs text-od-crit">{error}</p>}
|
||||
<button
|
||||
type="submit" disabled={saving}
|
||||
className="self-start rounded border border-od-accent px-4 py-1.5 font-mono text-xs text-od-accent hover:bg-od-accent hover:text-od-bg transition-colors disabled:opacity-40"
|
||||
>
|
||||
{saving ? '…' : 'Créer'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{plans.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-3 rounded border border-od-border bg-od-surface px-4 py-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-od-text">{p.name}</p>
|
||||
<p className="font-mono text-xs text-od-muted">
|
||||
{p.slug} · niv. {p.level} · {(p.priceInCents / 100).toFixed(2)} €
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleActive(p)}
|
||||
className={`font-mono text-xs px-2 py-0.5 rounded border transition-colors ${
|
||||
p.isActive
|
||||
? 'border-od-accent text-od-accent hover:bg-od-accent hover:text-od-bg'
|
||||
: 'border-od-border text-od-muted hover:border-od-accent hover:text-od-accent'
|
||||
}`}
|
||||
>
|
||||
{p.isActive ? 'actif' : 'inactif'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{plans.length === 0 && <p className="text-sm text-od-muted">Aucun plan.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
const PROVIDERS = [
|
||||
@@ -11,6 +11,8 @@ const PROVIDERS = [
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const from = (location.state as { from?: Location })?.from?.pathname ?? '/';
|
||||
const base = import.meta.env.VITE_SUPEROAUTH_URL;
|
||||
const redirectUrl = encodeURIComponent(window.location.origin + '/callback');
|
||||
|
||||
@@ -29,7 +31,7 @@ export default function LoginPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
navigate('/', { replace: true });
|
||||
navigate(from, { replace: true });
|
||||
} catch {
|
||||
setError('Email ou mot de passe incorrect.');
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user