Compare commits

..

59 Commits

Author SHA1 Message Date
05c39640d0 fix: VITE_API_URL fallback to include /api suffix
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 27s
Secret was missing /api — frontend called /auth/session instead of
/api/auth/session, Apache SPA fallback returned index.html instead
of proxying to Express backend.
2026-03-23 03:09:16 +01:00
2c54257c94 fix: CI/CD add missing VITE_OAUTH vars + pm2 reload via tetardtek-brain
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 29s
VITE_OAUTH_URL and VITE_OAUTH_CLIENT_ID were missing from build env,
causing empty client_id in PKCE flow. pm2 reload via su - tetardtek-brain
(same pattern as SuperOAuth post ssh-hardening).
2026-03-23 02:53:51 +01:00
e04666865d fix: CallbackPage handles verification_pending and merge_pending states
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 38s
2026-03-23 01:14:50 +01:00
8309400466 feat(landing): repositionner plateforme vidéo — supprimer pitch B2B SaaS
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 44s
Landing reécrite : vidéos, playlists, créateurs.
Supprimé : pricing, white-label, mentions SuperOAuth, PricingCard component.
CTA principal → /app (explorer les vidéos).
2026-03-22 16:15:02 +01:00
d68041e2f1 feat(auth): PKCE client refinements + backend refresh token support
- oauth.ts: provider param, TokenResponse typing, exchangeCode returns full response
- LoginPage: fully async handleOAuth with buildAuthUrl
- CallbackPage: dual-mode PKCE (code) + legacy (token), refresh token forwarding
- LoginButton: provider prop support
- auth.routes: POST /auth/session accepts refreshToken, sets od_refresh cookie
2026-03-22 16:14:55 +01:00
7932659a73 feat(auth): PKCE flow preparation + CallbackPage dual-mode
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 29s
- Add oauth.ts — PKCE helpers (code verifier/challenge, token exchange)
- Add LoginButton — "Se connecter avec SuperOAuth" component
- Update CallbackPage — handles both PKCE (?code) and legacy (?token) flows
- Update .env.example — VITE_OAUTH_URL + VITE_OAUTH_CLIENT_ID

PKCE flow ready for when SuperOAuth exposes /oauth/authorize endpoint.
Legacy flow (redirect + token query param) remains active in production.
2026-03-22 12:50:07 +01:00
32b9af7b02 fix(auth): UserMenu sessionStorage → AuthContext — unification auth state
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 24s
2026-03-17 07:43:48 +01:00
d25bfb7d87 feat(sprint3-step1-2): vision B2B + Tailwind tokens + LandingPage + Pricing B2B 2026-03-17 06:36:52 +01:00
e52aa1e79c perf: requireAdmin — 2 queries → 1 (User + userRoles eager join TypeORM) 2026-03-15 18:00:48 +01:00
379a9a115b fix(security): isActive defense-in-depth, MIME magic bytes upload, tenantId=origins OAuth
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 54s
2026-03-15 17:34:19 +01:00
ef4c23d6a2 fix: OAuth — window.location.href direct, no cross-origin fetch (CORS)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 28s
2026-03-15 03:30:49 +01:00
94b607c4d0 fix: OAuth buttons — fetch authUrl then redirect (SuperOAuth JSON flow)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 24s
2026-03-15 03:27:00 +01:00
40938be067 fix: OAuth login path — /api/v1/oauth/:provider (pas auth/oauth)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 24s
2026-03-15 03:23:24 +01:00
3eb791d4a1 feat: VideoPage — ajouter à une playlist (owned + edit-permitted)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 23s
2026-03-15 02:53:34 +01:00
8e78ce50b5 feat: profile avatar, callback setUser fix, admin description/thumbnail, pagination limit=100
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 26s
2026-03-15 02:45:50 +01:00
61d8a5257d feat: admin/superadmin — fix response shape, ban/unban, stats tab, role restriction
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 24s
2026-03-15 02:30:11 +01:00
d69281a2e0 feat: B3 — search vidéos (filtre client-side + param ?q= backend)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s
2026-03-15 02:22:04 +01:00
426cd4bbbd feat: B2 — 401 interceptor + auto-refresh token (fix SuperOAuth path + response shape)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 27s
2026-03-15 02:19:40 +01:00
6877db3227 fix: login — setUser après auth pour maj header immédiate
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 24s
2026-03-15 01:57:41 +01:00
2c3d9d95c6 feat(frontend): playlist B1 — edit, delete, share, invitations
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 36s
- PlaylistPage: bouton Éditer (formulaire inline titre/visibilité), Supprimer (confirm → DELETE → redirect), Partager (modal userId/permission → POST share), Retirer vidéo (✕ → DELETE)
- PlaylistsPage: section invitations reçues avec Accept / Refuser (PATCH share/:shareId)
- tsc --noEmit : 0 erreur, 0 console.log
2026-03-15 01:00:26 +01:00
df8e594d57 fix(frontend): Error Boundary, HomePage error state, HLS catch — quick wins pre-Bloc-B 2026-03-15 00:53:46 +01:00
f80b8cb81c fix: instrument bare catch blocks — logger.error sur stream/admin/user 2026-03-15 00:18:37 +01:00
494206b5b3 feat: observability — Winston logging, pagination admin, N+1 playlists
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 27s
2026-03-14 23:21:42 +01:00
31edea9dd9 feat: rate limiting — login 10req/15min, admin 50req/min, trust proxy 2026-03-14 23:20:20 +01:00
9f53193c7c feat: vitest setup + auth middleware — token invalide et absent → 401 2026-03-14 23:19:45 +01:00
01d347bce3 fix: ApiError typée + error handling pages video/playlists/admin
- api.ts : ApiError class (status: number) — remplace Error générique
- VideoPage/PlaylistPage : instanceof ApiError au lieu de message.includes()
- PlaylistsPage : fetchError + createError — silent catch supprimé
- AdminPage : guard roles.some() aligné Header (super_admin inclus)
2026-03-14 22:37:36 +01:00
4e8c1aa849 feat: sprint 3 — profil utilisateur, badge plan, dropdown Header
- AuthContext.User : plan? { slug, name, level } | null
- UserBadge : nickname + badge plan.slug (fallback free)
- Header : dropdown click (Profil / Déconnexion) + click-outside
- ProfilePage : infos compte, badge plan, edit nickname (PATCH /users/me + re-fetch /auth/me → setUser)
- App : route /profile protégée
- useAuth : réexporte depuis AuthContext, fin de la dérive
2026-03-14 22:33:47 +01:00
30ef7312b5 feat: sprint 3 — profile endpoints + avatar
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
- GET /api/auth/me enrichi : avatar, plan actif, subscriptionDate
- GET /api/users/me/profile : profil complet (local UUID, sub, rôles)
- PATCH /api/users/me : update nickname / avatar (validation URL + longueur)
- User entity : champ avatar VARCHAR(500) nullable
- Migration 1742000000000-AddUserAvatar (appliquée VPS)
2026-03-14 22:25:22 +01:00
24ae8854ce fix: GET /admin/users — find() x5 → variable locale 2026-03-14 19:35:04 +01:00
c25d9ad843 feat: admin page — guard isAdmin, error handling, upload local, role assignment
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
- AuthContext: roles: string[] ajouté au type User
- Header: lien /admin masqué si !roles.includes('admin')
- AdminPage: redirect / si non-admin (Navigate)
- AdminPage: fetchError sur les 3 tabs (load silencieux → message visible)
- AdminPage: actionError sur toutes les mutations (toggle/delete/assign)
- AdminPage: loading UsersTab → skeleton list 3 cartes (aligné Videos/Plans)
- AdminPage: upload local — file input mp4/webm, multipart POST /admin/videos/upload,
  storageKey auto-rempli, Créer bloqué pendant upload
- AdminPage: assignation rôle — PATCH /admin/users/:id/roles, rafraîchit la liste
2026-03-14 15:25:11 +01:00
2a74be2624 feat: GET /api/auth/me retourne roles[]
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
2026-03-14 15:18:51 +01:00
27e6541425 fix: requireAdmin résout le user local par superOAuthId
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 59s
2026-03-14 15:14:03 +01:00
c7815aac2f feat: token refresh, video upload, playlist routes complets
- auth: cookie od_token 7j, refresh token od_refresh 30j, POST /api/auth/refresh, GET /api/auth/me/optional
- admin: POST /api/admin/videos/upload via multer (mp4/webm, 4Go max, UUID filename)
- playlist: PATCH /:id, DELETE /:id, POST /:id/videos, DELETE /:id/videos/:videoId
- env: UPLOADS_DIR documenté dans .env.example
2026-03-14 14:32:18 +01:00
aa15dc0f54 feat: AuthContext, protected routes, admin page, fix VideoPlayer URL 2026-03-14 14:31:08 +01:00
324efcaa3d feat: login email/password + proxy POST /api/auth/login → SuperOAuth
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
- auth.routes : POST /api/auth/login proxie vers SuperOAuth, pose httpOnly cookie
- Factorisation upsertUser() partagé avec /session
- LoginPage : form email/password + séparateur + boutons OAuth provider
2026-03-14 10:26:25 +01:00
7e3ee29b13 fix: login page avec sélection provider → /api/v1/auth/oauth/:provider?redirectUrl
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
SuperOAuth root page ignore ?redirectUrl, les boutons de sa UI pointent vers
ses propres URIs hardcodées. Fix : notre page /login construit les URLs API
directement avec redirectUrl=origins.tetardtek.com/callback.

Aussi : CORS_ORIGINS SuperOAuth mis à jour (origins.tetardtek.com ajouté).
2026-03-14 10:11:30 +01:00
34bab532be fix: login → SuperOAuth root page (redirectUrl), drop LoginPage interne
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
2026-03-14 10:04:20 +01:00
666cf6a435 feat: stream route, admin subscriptions, fix CORS multi-origin
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
- index.ts : CORS supporte plusieurs origines (FRONTEND_URL séparé par virgule)
- stream.routes.ts : GET /api/stream/:key* — sert fichiers locaux avec auth
  optionnelle, contrôle d'accès par level, support Range requests (seekable)
- admin.routes.ts : POST /api/admin/users/:id/subscriptions — assigne un plan,
  expire l'abonnement actif précédent
- Fix .env VPS : FRONTEND_URL=origins.tetardtek.com (domaine correct)
2026-03-14 09:58:01 +01:00
4265d21c8b feat: login provider selection, logout, playlists pages
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s
- LoginPage : sélection Discord/GitHub/Google/Twitch via SuperOAuth
- Header : bouton Connexion → /login, logout ↩ quand connecté, nav Playlists conditionnelle
- useAuth : expose setUser pour logout côté Layout
- PlaylistsPage : liste owned/shared, création inline
- PlaylistPage : détail playlist + liste vidéos ordonnées
- Fix : Video.id number → string (UUID)
- Routes : /login, /playlists, /playlists/:id
2026-03-14 09:32:45 +01:00
fcd9867670 ci: fix pipeline — vps-runner host mode, remove setup-node, add smoke test
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 23s
2026-03-14 09:16:57 +01:00
77e5990078 ci: relance pipeline après ajout secrets
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 54s
2026-03-14 09:03:32 +01:00
5031b31aeb fix: pipeline CI/CD — vps-runner direct deploy (pm2 + rsync)
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Has been cancelled
Remplace le flow Docker (docker compose up) par le vrai modèle :
- Job unique sur vps-runner (runner local sur le VPS)
- Build backend → rsync vers /var/www/originsdigital/backend/ → pm2 restart
- Build frontend avec VITE_API_URL/VITE_SUPEROAUTH_URL → rsync vers /var/www/originsdigital/frontend/dist/
- Suppression appleboy/ssh-action (inutile, le runner est sur le VPS)
2026-03-14 08:59:10 +01:00
df3fe8ebe0 fix(auth): correct SuperOAuth endpoint path — /api/v1/auth not /api/auth
Some checks failed
CI/CD — Build & Deploy / Build (push) Failing after 43s
CI/CD — Build & Deploy / Deploy to VPS (push) Has been skipped
2026-03-14 08:40:32 +01:00
9598cd8715 perf(frontend): drop react-player — YouTube iframe natif, HLS.js lazy seulement si .m3u8
Some checks failed
CI/CD — Build & Deploy / Build (push) Failing after 35s
CI/CD — Build & Deploy / Deploy to VPS (push) Has been skipped
2026-03-14 08:39:01 +01:00
5eb0a43d7f feat: lazy ReactPlayer, seed 11 vidéos YouTube (niveaux 0/1/2)
Some checks failed
CI/CD — Build & Deploy / Build (push) Failing after 41s
CI/CD — Build & Deploy / Deploy to VPS (push) Has been skipped
2026-03-14 08:25:41 +01:00
11d9432218 fix(routes): resolve superOAuthId → DB userId — critical auth bug
Some checks failed
CI/CD — Build & Deploy / Build (push) Failing after 35s
CI/CD — Build & Deploy / Deploy to VPS (push) Has been skipped
req.user.id = SuperOAuth UUID, pas l'UUID TypeORM en DB.
Sans ce fix : getUserPlanLevel retourne toujours 0, ownerId ne matche jamais.

- video.routes: resolveDbUserId avant getUserPlanLevel
- playlist.routes: resolveDbUserId sur toutes les opérations owner/member
2026-03-14 08:12:11 +01:00
87d076313c feat(frontend): VideoPage react-player v3, fix data.videos, route /video/:id
Some checks failed
CI/CD — Build & Deploy / Build (push) Has been cancelled
CI/CD — Build & Deploy / Deploy to VPS (push) Has been cancelled
2026-03-14 08:12:08 +01:00
5d4bab7d99 chore: add assign-first-admin.sql — run once after first login
Some checks failed
CI/CD — Build & Deploy / Build (push) Failing after 39s
CI/CD — Build & Deploy / Deploy to VPS (push) Has been skipped
2026-03-14 08:10:16 +01:00
253af8f402 fix(auth): upsert user in DB on session creation — first login creates user record 2026-03-14 08:07:42 +01:00
0591cd4528 feat(frontend): useAuth /auth/me, videos list + locked flag, VITE_API_URL
Some checks failed
CI/CD — Build & Deploy / Build (push) Failing after 40s
CI/CD — Build & Deploy / Deploy to VPS (push) Has been skipped
2026-03-14 08:06:51 +01:00
5afcad487e docs(backend): add .env.example
Some checks failed
CI/CD — Build & Deploy / Build (push) Failing after 44s
CI/CD — Build & Deploy / Deploy to VPS (push) Has been skipped
2026-03-14 08:01:01 +01:00
7c727aa802 feat(admin): requireAdmin middleware + /api/admin routes
- requireAdmin: charge user_roles en DB, accepte admin/super_admin
- GET/POST/PATCH/DELETE /api/admin/videos (publiées + non publiées)
- GET /api/admin/users avec rôles et abonnement actif
- PATCH /api/admin/users/:id/roles (remplacement atomique par slugs)
- GET/POST/PATCH /api/admin/plans
2026-03-14 07:46:35 +01:00
75aad8968f fix(frontend): SuperOAuth flow — VITE_SUPEROAUTH_URL, redirect par provider, pas de client_id
Some checks failed
CI/CD — Build & Deploy / Build (push) Failing after 29s
CI/CD — Build & Deploy / Deploy to VPS (push) Has been skipped
2026-03-14 07:39:51 +01:00
f1de2bb065 fix(backend): resolve migration path relative to __dirname
Some checks failed
CI/CD — Build & Deploy / Deploy to VPS (push) Has been cancelled
CI/CD — Build & Deploy / Build (push) Has been cancelled
migrations glob "src/migrations/**/*.ts" was CWD-relative — broken when CLI runs
outside the src/ directory. Using __dirname makes it absolute and portable.
2026-03-14 07:32:39 +01:00
25733ee3db feat(frontend): scaffold Tailwind design system + routing + auth callback
- Tailwind v3 + PostCSS + autoprefixer
- BrowserRouter with Layout shell (Header, theme toggle dark/light)
- Pages: HomePage, CallbackPage (SuperOAuth callback handler)
- hooks/useAuth.ts + lib/api.ts (API client base)
- styles/index.css (Tailwind directives)
- Theme persisted in localStorage (od-theme)
2026-03-14 07:15:19 +01:00
f3e392ff1b feat(backend): mount API routes + cookie-parser + CORS with credentials
- index.ts: mount /api/auth, /api/videos, /api/playlists; add cookie-parser; CORS with credentials + FRONTEND_URL env
- auth.middleware: read token from Bearer header OR od_token httpOnly cookie
- routes: auth (session/logout/me), videos (level-gated), playlists (CRUD + share management)
- deps: cookie-parser + @types/cookie-parser
2026-03-14 07:10:47 +01:00
71d90eb133 feat: initial schema migration — 9 tables + seed roles & plans 2026-03-14 07:02:20 +01:00
2f47be1305 feat: TypeORM entities — User, Role, SubscriptionPlan, Video, Playlist + relations 2026-03-14 06:53:02 +01:00
4f3c0e6433 feat: SuperOAuth token introspection middleware + /api/profile route 2026-03-14 06:40:43 +01:00
66 changed files with 8541 additions and 45 deletions

19
.claude/settings.json Normal file
View File

@@ -0,0 +1,19 @@
{
"permissions": {
"allow": [
"Bash(npm *)",
"Bash(git *)",
"Bash(pm2 *)",
"Bash(curl *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(grep *)",
"Bash(mkdir *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(node *)",
"Bash(npx *)",
"Write(*)"
]
}
}

View File

@@ -7,45 +7,55 @@ on:
branches: [main]
jobs:
build:
name: Build
runs-on: ubuntu-latest
build-and-deploy:
name: Build & Deploy
runs-on: vps-runner
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: |
backend/package-lock.json
frontend/package-lock.json
# ── Backend ──────────────────────────────────────────────────────────────
- name: Install & build backend
working-directory: backend
run: |
npm ci
npm run build
- name: Deploy backend
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
mkdir -p /var/www/originsdigital/backend
rsync -a --delete backend/dist/ /var/www/originsdigital/backend/dist/
rsync -a backend/package.json backend/package-lock.json /var/www/originsdigital/backend/
cd /var/www/originsdigital/backend && npm ci --omit=dev
- name: Restart pm2
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
su - tetardtek-brain -c 'pm2 reload originsdigital-backend --update-env'
# ── Frontend ─────────────────────────────────────────────────────────────
- name: Install & build frontend
working-directory: frontend
env:
VITE_API_URL: ${{ secrets.VITE_API_URL || 'https://origins.tetardtek.com/api' }}
VITE_SUPEROAUTH_URL: ${{ secrets.VITE_SUPEROAUTH_URL }}
VITE_OAUTH_URL: ${{ secrets.VITE_SUPEROAUTH_URL }}
VITE_OAUTH_CLIENT_ID: origins
run: |
npm ci
npm run build
deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /home/tetardtek/github/originsdigital
git pull origin main
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
- name: Deploy frontend
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
mkdir -p /var/www/originsdigital/frontend/dist
rsync -a --delete frontend/dist/ /var/www/originsdigital/frontend/dist/
# ── Smoke test ───────────────────────────────────────────────────────────
- name: Smoke test API
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
sleep 3
curl -sf http://localhost:4001/api/health | grep -q '"ok"'
echo "✅ API health OK"

19
backend/.env.example Normal file
View File

@@ -0,0 +1,19 @@
# Server
NODE_ENV=development
PORT=4001
# Database (MySQL)
DB_HOST=localhost
DB_PORT=3306
DB_USER=originsdigital
DB_PASSWORD=
DB_NAME=originsdigital
# SuperOAuth — service d'authentification externe
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

1890
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,26 +9,38 @@
"typeorm": "ts-node -e \"require('typeorm/cli')\"",
"migration:generate": "npm run typeorm -- migration:generate",
"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",
"test": "vitest run"
},
"dependencies": {
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7",
"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",
"@types/cookie-parser": "^1.4.10",
"@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",
"@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"
}
}

View File

@@ -0,0 +1,25 @@
-- Script à lancer UNE SEULE FOIS après le premier login
-- Assigne le rôle super_admin au premier user en DB (toi)
--
-- Usage : depuis le VPS
-- docker exec mysql-prod mysql -u originsdigital -p'<password>' originsdigital < assign-first-admin.sql
INSERT INTO user_roles (userId, roleId)
SELECT
u.id,
r.id
FROM users u
CROSS JOIN roles r
WHERE r.slug = 'super_admin'
AND u.id = (SELECT id FROM users ORDER BY createdAt ASC LIMIT 1)
AND NOT EXISTS (
SELECT 1 FROM user_roles ur
WHERE ur.userId = u.id AND ur.roleId = r.id
);
-- Vérifie le résultat
SELECT u.nickname, u.email, r.slug as role
FROM users u
JOIN user_roles ur ON ur.userId = u.id
JOIN roles r ON r.id = ur.roleId
WHERE r.slug = 'super_admin';

View File

@@ -10,7 +10,17 @@ export const AppDataSource = new DataSource({
database: process.env.DB_NAME ?? "originsdigital",
synchronize: false,
logging: process.env.NODE_ENV === "development",
entities: ["src/entities/**/*.ts"],
migrations: ["src/migrations/**/*.ts"],
entities: [
require("../entities/User").User,
require("../entities/Role").Role,
require("../entities/UserRole").UserRole,
require("../entities/SubscriptionPlan").SubscriptionPlan,
require("../entities/UserSubscription").UserSubscription,
require("../entities/Video").Video,
require("../entities/Playlist").Playlist,
require("../entities/PlaylistVideo").PlaylistVideo,
require("../entities/PlaylistShare").PlaylistShare,
],
migrations: [__dirname + "/../migrations/**/*.ts"],
subscribers: [],
});

View File

@@ -0,0 +1,47 @@
import {
Entity, PrimaryGeneratedColumn, Column,
ManyToOne, OneToMany, JoinColumn,
CreateDateColumn, UpdateDateColumn,
} from "typeorm";
import { User } from "./User";
import { PlaylistVideo } from "./PlaylistVideo";
import { PlaylistShare } from "./PlaylistShare";
export type PlaylistVisibility = "private" | "shared" | "public";
@Entity("playlists")
export class Playlist {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid" })
ownerId!: string;
@Column({ type: "varchar", length: 255 })
title!: string;
@Column({ type: "text", nullable: true })
description!: string | null;
// private → visible uniquement par le propriétaire
// shared → visible par les utilisateurs invités via PlaylistShare
// public → visible par tous
@Column({ type: "enum", enum: ["private", "shared", "public"], default: "private" })
visibility!: PlaylistVisibility;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ManyToOne(() => User, (user) => user.playlists, { onDelete: "CASCADE" })
@JoinColumn({ name: "ownerId" })
owner!: User;
@OneToMany(() => PlaylistVideo, (pv) => pv.playlist)
playlistVideos!: PlaylistVideo[];
@OneToMany(() => PlaylistShare, (share) => share.playlist)
shares!: PlaylistShare[];
}

View File

@@ -0,0 +1,46 @@
import {
Entity, PrimaryGeneratedColumn, Column,
ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn,
} from "typeorm";
import { Playlist } from "./Playlist";
import { User } from "./User";
export type SharePermission = "view" | "edit";
export type ShareStatus = "pending" | "active" | "revoked";
@Entity("playlist_shares")
export class PlaylistShare {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid" })
playlistId!: string;
@Column({ type: "uuid" })
userId!: string;
// Permissions gérées par le propriétaire de la playlist
@Column({ type: "enum", enum: ["view", "edit"], default: "view" })
permission!: SharePermission;
// Statut géré par le propriétaire :
// pending → invitation envoyée, pas encore acceptée
// active → invité a accepté
// revoked → propriétaire a révoqué l'accès
@Column({ type: "enum", enum: ["pending", "active", "revoked"], default: "pending" })
status!: ShareStatus;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ManyToOne(() => Playlist, (playlist) => playlist.shares, { onDelete: "CASCADE" })
@JoinColumn({ name: "playlistId" })
playlist!: Playlist;
@ManyToOne(() => User, (user) => user.playlistShares, { onDelete: "CASCADE" })
@JoinColumn({ name: "userId" })
user!: User;
}

View File

@@ -0,0 +1,24 @@
import { Entity, ManyToOne, JoinColumn, PrimaryColumn, Column } from "typeorm";
import { Playlist } from "./Playlist";
import { Video } from "./Video";
@Entity("playlist_videos")
export class PlaylistVideo {
@PrimaryColumn({ type: "uuid" })
playlistId!: string;
@PrimaryColumn({ type: "uuid" })
videoId!: string;
// Ordre d'affichage dans la playlist — géré par le propriétaire ou les éditeurs
@Column({ type: "int", unsigned: true, default: 0 })
position!: number;
@ManyToOne(() => Playlist, (playlist) => playlist.playlistVideos, { onDelete: "CASCADE" })
@JoinColumn({ name: "playlistId" })
playlist!: Playlist;
@ManyToOne(() => Video, (video) => video.playlistVideos, { onDelete: "CASCADE" })
@JoinColumn({ name: "videoId" })
video!: Video;
}

View File

@@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
import { UserRole } from "./UserRole";
export type RoleSlug = "user" | "moderator" | "admin" | "super_admin";
@Entity("roles")
export class Role {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "varchar", length: 50, unique: true })
slug!: RoleSlug;
@Column({ type: "varchar", length: 100 })
name!: string;
@OneToMany(() => UserRole, (userRole) => userRole.role)
userRoles!: UserRole[];
}

View File

@@ -0,0 +1,35 @@
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
import { UserSubscription } from "./UserSubscription";
export type PlanSlug = "free" | "basic" | "pro" | "enterprise";
@Entity("subscription_plans")
export class SubscriptionPlan {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "varchar", length: 50, unique: true })
slug!: PlanSlug;
@Column({ type: "varchar", length: 100 })
name!: string;
// Niveau d'accès — comparaison entière : userPlanLevel >= video.requiredLevel
// free=0, basic=1, pro=2, enterprise=3
@Column({ type: "tinyint", unsigned: true, default: 0 })
level!: number;
// Prix en centimes (0 = gratuit). Évite les flottants en DB.
@Column({ type: "int", unsigned: true, default: 0 })
priceInCents!: number;
// Features libres — permet la personnalisation marque blanche sans migration
@Column({ type: "json", nullable: true })
features!: Record<string, unknown> | null;
@Column({ type: "boolean", default: true })
isActive!: boolean;
@OneToMany(() => UserSubscription, (sub) => sub.plan)
subscriptions!: UserSubscription[];
}

View File

@@ -0,0 +1,48 @@
import {
Entity, PrimaryGeneratedColumn, Column,
OneToMany, CreateDateColumn, UpdateDateColumn,
} from "typeorm";
import { UserRole } from "./UserRole";
import { UserSubscription } from "./UserSubscription";
import { Playlist } from "./Playlist";
import { PlaylistShare } from "./PlaylistShare";
@Entity("users")
export class User {
@PrimaryGeneratedColumn("uuid")
id!: string;
// Identifiant SuperOAuth — clé de réconciliation avec le service auth
@Column({ type: "varchar", length: 255, unique: true })
superOAuthId!: string;
@Column({ type: "varchar", length: 255, nullable: true })
email!: string | null;
@Column({ type: "varchar", length: 100 })
nickname!: string;
@Column({ type: "varchar", length: 500, nullable: true })
avatar!: string | null;
@Column({ type: "boolean", default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@OneToMany(() => UserRole, (userRole) => userRole.user)
userRoles!: UserRole[];
@OneToMany(() => UserSubscription, (sub) => sub.user)
subscriptions!: UserSubscription[];
@OneToMany(() => Playlist, (playlist) => playlist.owner)
playlists!: Playlist[];
@OneToMany(() => PlaylistShare, (share) => share.user)
playlistShares!: PlaylistShare[];
}

View File

@@ -0,0 +1,20 @@
import { Entity, ManyToOne, JoinColumn, PrimaryColumn } from "typeorm";
import { User } from "./User";
import { Role } from "./Role";
@Entity("user_roles")
export class UserRole {
@PrimaryColumn({ type: "uuid" })
userId!: string;
@PrimaryColumn({ type: "uuid" })
roleId!: string;
@ManyToOne(() => User, (user) => user.userRoles, { onDelete: "CASCADE" })
@JoinColumn({ name: "userId" })
user!: User;
@ManyToOne(() => Role, (role) => role.userRoles, { onDelete: "CASCADE" })
@JoinColumn({ name: "roleId" })
role!: Role;
}

View File

@@ -0,0 +1,41 @@
import {
Entity, PrimaryGeneratedColumn, Column,
ManyToOne, JoinColumn, CreateDateColumn,
} from "typeorm";
import { User } from "./User";
import { SubscriptionPlan } from "./SubscriptionPlan";
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial";
@Entity("user_subscriptions")
export class UserSubscription {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid" })
userId!: string;
@Column({ type: "uuid" })
planId!: string;
@Column({ type: "enum", enum: ["active", "expired", "cancelled", "trial"], default: "active" })
status!: SubscriptionStatus;
@Column({ type: "datetime" })
startsAt!: Date;
// null = pas d'expiration (plan gratuit permanent)
@Column({ type: "datetime", nullable: true })
endsAt!: Date | null;
@CreateDateColumn()
createdAt!: Date;
@ManyToOne(() => User, (user) => user.subscriptions, { onDelete: "CASCADE" })
@JoinColumn({ name: "userId" })
user!: User;
@ManyToOne(() => SubscriptionPlan, (plan) => plan.subscriptions)
@JoinColumn({ name: "planId" })
plan!: SubscriptionPlan;
}

View File

@@ -0,0 +1,58 @@
import {
Entity, PrimaryGeneratedColumn, Column,
OneToMany, CreateDateColumn, UpdateDateColumn,
} from "typeorm";
import { PlaylistVideo } from "./PlaylistVideo";
export type StorageType = "youtube" | "s3" | "local" | "external";
@Entity("videos")
export class Video {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "varchar", length: 255 })
title!: string;
@Column({ type: "text", nullable: true })
description!: string | null;
@Column({ type: "varchar", length: 500, nullable: true })
thumbnailUrl!: string | null;
// Durée en secondes
@Column({ type: "int", unsigned: true, nullable: true })
duration!: number | null;
@Column({ type: "enum", enum: ["youtube", "s3", "local", "external"] })
storageType!: StorageType;
// Référence flexible selon storageType :
// youtube → videoId YouTube (ex: "dQw4w9WgXcQ")
// s3 → clé S3 (ex: "videos/2026/my-video.mp4")
// local → chemin relatif (ex: "uploads/my-video.mp4")
// external → URL complète
@Column({ type: "varchar", length: 500 })
storageKey!: string;
// Niveau de plan minimum requis pour accéder à cette vidéo
// 0 = libre, 1 = basic, 2 = pro, 3 = enterprise
// Comparaison : userSubscription.plan.level >= video.requiredLevel
@Column({ type: "tinyint", unsigned: true, default: 0 })
requiredLevel!: number;
@Column({ type: "boolean", default: false })
isPublished!: boolean;
@Column({ type: "datetime", nullable: true })
publishedAt!: Date | null;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@OneToMany(() => PlaylistVideo, (pv) => pv.video)
playlistVideos!: PlaylistVideo[];
}

View File

@@ -1,29 +1,59 @@
import "reflect-metadata";
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import dotenv from "dotenv";
import { AppDataSource } from "./config/data-source";
import authRoutes from "./routes/auth.routes";
import videoRoutes from "./routes/video.routes";
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");
app.use(cors());
const allowedOrigins = (process.env.FRONTEND_URL ?? "http://localhost:5173")
.split(",")
.map((o) => o.trim());
app.use(cors({
origin: (origin, cb) => {
// Autoriser les requêtes sans origin (curl, Postman, same-origin)
if (!origin || allowedOrigins.includes(origin)) return cb(null, true);
cb(new Error(`CORS: origin ${origin} not allowed`));
},
credentials: true,
}));
app.use(express.json());
app.use(cookieParser());
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", 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);
});

View File

@@ -0,0 +1,42 @@
import { Response, NextFunction } from "express";
import { AppDataSource } from "../config/data-source";
import { User } from "../entities/User";
import { AuthenticatedRequest } from "./auth.middleware";
import logger from "../utils/logger";
/**
* Middleware requireAdmin — s'exécute APRÈS requireAuth.
* 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 (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const localUser = await AppDataSource.getRepository(User).findOne({
where: { superOAuthId: req.user.id },
relations: { userRoles: { role: true } },
});
if (!localUser) {
res.status(403).json({ success: false, error: "FORBIDDEN" });
return;
}
const slugs = localUser.userRoles.map((ur) => ur.role.slug);
const isAdmin = slugs.includes("admin") || slugs.includes("super_admin");
if (!isAdmin) {
res.status(403).json({ success: false, error: "FORBIDDEN" });
return;
}
next();
} catch (err) {
logger.error("requireAdmin — DB error", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
};

View File

@@ -0,0 +1,78 @@
import { Request, Response, NextFunction } from "express";
import logger from "../utils/logger";
export interface AuthenticatedUser {
id: string;
email: string | null;
nickname: string;
isActive: boolean;
linkedProviders: string[];
}
export interface AuthenticatedRequest extends Request {
user: AuthenticatedUser;
}
/**
* Middleware d'authentification — Token Introspection via SuperOAuth
*
* Valide le Bearer token auprès de SuperOAuth (POST /api/auth/token/validate).
* Aucun secret JWT partagé — SuperOAuth garde le contrôle total.
*
* Flow :
* 1. Extraire le Bearer token du header Authorization
* 2. Appeler SuperOAuth /api/auth/token/validate
* 3. Si valid → attacher req.user et continuer
* 4. Sinon → 401
*/
export const requireAuth = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const token =
req.headers.authorization?.split(" ")[1] ??
(req.cookies as Record<string, string>)?.od_token;
if (!token) {
res.status(401).json({ success: false, error: "UNAUTHORIZED", message: "Access token required" });
return;
}
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
if (!superOAuthUrl) {
logger.error("SUPER_OAUTH_URL not configured");
res.status(500).json({ success: false, error: "INTERNAL_ERROR", message: "Auth service not configured" });
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?: AuthenticatedUser };
error?: string;
};
if (!response.ok || !data.data?.valid || !data.data.user) {
res.status(401).json({ success: false, error: data.error ?? "INVALID_TOKEN", message: "Invalid or expired token" });
return;
}
if (!data.data.user.isActive) {
res.status(401).json({ success: false, error: "ACCOUNT_DISABLED", message: "Account is disabled" });
return;
}
(req as AuthenticatedRequest).user = data.data.user;
next();
} catch (err) {
logger.error("requireAuth — auth service unreachable", { err });
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE", message: "Authentication service unreachable" });
}
};

View 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);
},
});

View File

@@ -0,0 +1,168 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitialSchema1710374400000 implements MigrationInterface {
name = "InitialSchema1710374400000";
public async up(queryRunner: QueryRunner): Promise<void> {
// roles
await queryRunner.query(`
CREATE TABLE roles (
id VARCHAR(36) NOT NULL PRIMARY KEY,
slug VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// subscription_plans
await queryRunner.query(`
CREATE TABLE subscription_plans (
id VARCHAR(36) NOT NULL PRIMARY KEY,
slug VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
level TINYINT UNSIGNED NOT NULL DEFAULT 0,
priceInCents INT UNSIGNED NOT NULL DEFAULT 0,
features JSON NULL,
isActive BOOLEAN NOT NULL DEFAULT TRUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// users
await queryRunner.query(`
CREATE TABLE users (
id VARCHAR(36) NOT NULL PRIMARY KEY,
superOAuthId VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NULL,
nickname VARCHAR(100) NOT NULL,
isActive BOOLEAN NOT NULL DEFAULT TRUE,
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_users_superOAuthId (superOAuthId),
INDEX idx_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// user_roles (pivot)
await queryRunner.query(`
CREATE TABLE user_roles (
userId VARCHAR(36) NOT NULL,
roleId VARCHAR(36) NOT NULL,
PRIMARY KEY (userId, roleId),
CONSTRAINT fk_user_roles_user FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (roleId) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// user_subscriptions
await queryRunner.query(`
CREATE TABLE user_subscriptions (
id VARCHAR(36) NOT NULL PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
planId VARCHAR(36) NOT NULL,
status ENUM('active','expired','cancelled','trial') NOT NULL DEFAULT 'active',
startsAt DATETIME NOT NULL,
endsAt DATETIME NULL,
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX idx_user_subscriptions_userId (userId),
INDEX idx_user_subscriptions_status (status),
CONSTRAINT fk_user_subscriptions_user FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_subscriptions_plan FOREIGN KEY (planId) REFERENCES subscription_plans(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// videos
await queryRunner.query(`
CREATE TABLE videos (
id VARCHAR(36) NOT NULL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
thumbnailUrl VARCHAR(500) NULL,
duration INT UNSIGNED NULL,
storageType ENUM('youtube','s3','local','external') NOT NULL,
storageKey VARCHAR(500) NOT NULL,
requiredLevel TINYINT UNSIGNED NOT NULL DEFAULT 0,
isPublished BOOLEAN NOT NULL DEFAULT FALSE,
publishedAt DATETIME NULL,
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_videos_requiredLevel (requiredLevel),
INDEX idx_videos_isPublished (isPublished)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// playlists
await queryRunner.query(`
CREATE TABLE playlists (
id VARCHAR(36) NOT NULL PRIMARY KEY,
ownerId VARCHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
visibility ENUM('private','shared','public') NOT NULL DEFAULT 'private',
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_playlists_ownerId (ownerId),
INDEX idx_playlists_visibility (visibility),
CONSTRAINT fk_playlists_owner FOREIGN KEY (ownerId) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// playlist_videos (pivot ordonné)
await queryRunner.query(`
CREATE TABLE playlist_videos (
playlistId VARCHAR(36) NOT NULL,
videoId VARCHAR(36) NOT NULL,
position INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (playlistId, videoId),
INDEX idx_playlist_videos_position (playlistId, position),
CONSTRAINT fk_playlist_videos_playlist FOREIGN KEY (playlistId) REFERENCES playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_videos_video FOREIGN KEY (videoId) REFERENCES videos(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// playlist_shares
await queryRunner.query(`
CREATE TABLE playlist_shares (
id VARCHAR(36) NOT NULL PRIMARY KEY,
playlistId VARCHAR(36) NOT NULL,
userId VARCHAR(36) NOT NULL,
permission ENUM('view','edit') NOT NULL DEFAULT 'view',
status ENUM('pending','active','revoked') NOT NULL DEFAULT 'pending',
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_playlist_shares (playlistId, userId),
INDEX idx_playlist_shares_userId (userId),
INDEX idx_playlist_shares_status (status),
CONSTRAINT fk_playlist_shares_playlist FOREIGN KEY (playlistId) REFERENCES playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_shares_user FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// Données initiales — plans et rôles
await queryRunner.query(`
INSERT INTO roles (id, slug, name) VALUES
(UUID(), 'user', 'Utilisateur'),
(UUID(), 'moderator', 'Modérateur'),
(UUID(), 'admin', 'Administrateur'),
(UUID(), 'super_admin', 'Super Administrateur')
`);
await queryRunner.query(`
INSERT INTO subscription_plans (id, slug, name, level, priceInCents, features) VALUES
(UUID(), 'free', 'Gratuit', 0, 0, JSON_OBJECT('maxPlaylists', 3)),
(UUID(), 'basic', 'Basic', 1, 499, JSON_OBJECT('maxPlaylists', 20)),
(UUID(), 'pro', 'Pro', 2, 999, JSON_OBJECT('maxPlaylists', 999)),
(UUID(), 'enterprise', 'Enterprise', 3, 4999, JSON_OBJECT('maxPlaylists', 999, 'whiteLabel', true))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS playlist_shares`);
await queryRunner.query(`DROP TABLE IF EXISTS playlist_videos`);
await queryRunner.query(`DROP TABLE IF EXISTS playlists`);
await queryRunner.query(`DROP TABLE IF EXISTS videos`);
await queryRunner.query(`DROP TABLE IF EXISTS user_subscriptions`);
await queryRunner.query(`DROP TABLE IF EXISTS user_roles`);
await queryRunner.query(`DROP TABLE IF EXISTS users`);
await queryRunner.query(`DROP TABLE IF EXISTS subscription_plans`);
await queryRunner.query(`DROP TABLE IF EXISTS roles`);
}
}

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddUserAvatar1742000000000 implements MigrationInterface {
name = "AddUserAvatar1742000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE users
ADD COLUMN avatar VARCHAR(500) NULL AFTER nickname
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE users DROP COLUMN avatar`);
}
}

View File

@@ -0,0 +1,564 @@
import { Router, Request, Response } from "express";
import path from "path";
import fs from "fs";
/**
* Vérifie les magic bytes d'un fichier vidéo déjà écrit sur disque.
* MP4 : bytes 4-7 = 'ftyp' (0x66 0x74 0x79 0x70)
* WebM : bytes 0-3 = 0x1A 0x45 0xDF 0xA3
*/
const isValidVideoMagicBytes = (filePath: string): boolean => {
const fd = fs.openSync(filePath, "r");
const buf = Buffer.alloc(12);
fs.readSync(fd, buf, 0, 12, 0);
fs.closeSync(fd);
const isWebM = buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3;
const isMP4 = buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70;
return isMP4 || isWebM;
};
import multer, { FileFilterCallback } from "multer";
import { AppDataSource } from "../config/data-source";
import { Video } from "../entities/Video";
import { User } from "../entities/User";
import { UserRole } from "../entities/UserRole";
import { Role } from "../entities/Role";
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 });
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
router.use(requireAuth as unknown as (req: Request, res: Response, next: () => void) => void);
router.use(requireAdmin as unknown as (req: Request, res: Response, next: () => void) => void);
// ---------------------------------------------------------------------------
// 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;
}
if (!isValidVideoMagicBytes(req.file.path)) {
fs.unlinkSync(req.file.path);
res.status(415).json({ success: false, error: "INVALID_FILE_CONTENT", message: "File content does not match a valid video format" });
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.
* Query: ?page=1&limit=20
*/
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, total] = await AppDataSource.getRepository(Video).findAndCount({
order: { createdAt: "DESC" },
skip: (rawPage - 1) * rawLimit,
take: rawLimit,
});
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" });
}
});
/**
* POST /api/admin/videos
* Crée une nouvelle vidéo.
* Body: { title, description?, thumbnailUrl?, duration?, storageType, storageKey, requiredLevel?, isPublished? }
*/
router.post("/videos", async (req: Request, res: Response): Promise<void> => {
try {
const {
title,
description = null,
thumbnailUrl = null,
duration = null,
storageType,
storageKey,
requiredLevel = 0,
isPublished = false,
} = req.body as {
title: string;
description?: string | null;
thumbnailUrl?: string | null;
duration?: number | null;
storageType: string;
storageKey: string;
requiredLevel?: number;
isPublished?: boolean;
};
if (!title || !storageType || !storageKey) {
res.status(400).json({ success: false, error: "MISSING_REQUIRED_FIELDS" });
return;
}
const video = AppDataSource.getRepository(Video).create({
id: crypto.randomUUID(),
title,
description,
thumbnailUrl,
duration,
storageType: storageType as Video["storageType"],
storageKey,
requiredLevel,
isPublished,
publishedAt: isPublished ? new Date() : null,
});
await AppDataSource.getRepository(Video).save(video);
res.status(201).json({ success: true, data: { video } });
} catch (err) {
logger.error("POST /admin/videos — failed to create video", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* PATCH /api/admin/videos/:id
* Met à jour une vidéo (champs partiels).
*/
router.patch("/videos/:id", async (req: Request, res: Response): Promise<void> => {
try {
const repo = AppDataSource.getRepository(Video);
const video = await repo.findOne({ where: { id: req.params.id } });
if (!video) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
const allowed = [
"title", "description", "thumbnailUrl", "duration",
"storageType", "storageKey", "requiredLevel", "isPublished",
] as const;
for (const field of allowed) {
if (field in req.body) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(video as any)[field] = (req.body as Record<string, unknown>)[field];
}
}
// Mise à jour de publishedAt si on publie maintenant
if (req.body.isPublished === true && !video.publishedAt) {
video.publishedAt = new Date();
} else if (req.body.isPublished === false) {
video.publishedAt = null;
}
await repo.save(video);
res.json({ success: true, data: { video } });
} 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" });
}
});
/**
* DELETE /api/admin/videos/:id
* Supprime une vidéo.
*/
router.delete("/videos/:id", async (req: Request, res: Response): Promise<void> => {
try {
const repo = AppDataSource.getRepository(Video);
const video = await repo.findOne({ where: { id: req.params.id } });
if (!video) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
await repo.remove(video);
res.json({ success: true, data: { deleted: req.params.id } });
} 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" });
}
});
// ---------------------------------------------------------------------------
// USERS
// ---------------------------------------------------------------------------
/**
* 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> => {
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, total] = await AppDataSource.getRepository(User).findAndCount({
relations: ["userRoles", "userRoles.role", "subscriptions", "subscriptions.plan"],
order: { createdAt: "DESC" },
skip: (rawPage - 1) * rawLimit,
take: rawLimit,
});
const data = users.map((u) => ({
id: u.id,
email: u.email,
nickname: u.nickname,
isActive: u.isActive,
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;
})(),
}));
res.json({ success: true, data: { users: 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" });
}
});
/**
* PATCH /api/admin/users/:id
* Met à jour isActive (ban / unban) d'un utilisateur.
*/
router.patch("/users/:id", async (req: Request, res: Response): Promise<void> => {
const { isActive } = req.body as { isActive?: boolean };
if (typeof isActive !== "boolean") {
res.status(400).json({ success: false, error: "INVALID_BODY" });
return;
}
try {
const repo = AppDataSource.getRepository(User);
const user = await repo.findOne({ where: { id: req.params.id } });
if (!user) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
user.isActive = isActive;
await repo.save(user);
res.json({ success: true, data: { userId: user.id, isActive: user.isActive } });
} catch (err) {
logger.error("PATCH /admin/users/:id — failed to update user", { err, id: req.params.id });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* PATCH /api/admin/users/:id/roles
* Assigne des rôles à un utilisateur (remplace les rôles existants).
* Body: { roles: string[] } — slugs de rôles
*/
router.patch("/users/:id/roles", async (req: Request, res: Response): Promise<void> => {
try {
const { roles } = req.body as { roles: string[] };
if (!Array.isArray(roles)) {
res.status(400).json({ success: false, error: "INVALID_BODY" });
return;
}
const userRepo = AppDataSource.getRepository(User);
const roleRepo = AppDataSource.getRepository(Role);
const userRoleRepo = AppDataSource.getRepository(UserRole);
const user = await userRepo.findOne({ where: { id: req.params.id } });
if (!user) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
// Résoudre les slugs en entités Role
const roleEntities = await roleRepo
.createQueryBuilder("role")
.where("role.slug IN (:...slugs)", { slugs: roles.length > 0 ? roles : ["__none__"] })
.getMany();
if (roleEntities.length !== roles.length) {
res.status(400).json({ success: false, error: "INVALID_ROLE_SLUGS" });
return;
}
// Supprimer tous les rôles existants pour cet user
await userRoleRepo.delete({ userId: req.params.id });
// Insérer les nouveaux rôles
const newUserRoles = roleEntities.map((role) => {
const ur = new UserRole();
ur.userId = req.params.id;
ur.roleId = role.id;
return ur;
});
if (newUserRoles.length > 0) {
await userRoleRepo.save(newUserRoles);
}
res.json({
success: true,
data: {
userId: req.params.id,
roles: roleEntities.map((r) => ({ id: r.id, slug: r.slug, name: r.name })),
},
});
} 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" });
}
});
// ---------------------------------------------------------------------------
// STATS (super_admin)
// ---------------------------------------------------------------------------
/**
* GET /api/admin/stats
* Métriques globales de la plateforme.
*/
router.get("/stats", async (_req: Request, res: Response): Promise<void> => {
try {
const [totalUsers, totalVideos, activeSubscriptions] = await Promise.all([
AppDataSource.getRepository(User).count(),
AppDataSource.getRepository(Video).count({ where: { isPublished: true } }),
AppDataSource.getRepository(UserSubscription).count({ where: { status: "active" } }),
]);
res.json({ success: true, data: { totalUsers, totalVideos, activeSubscriptions } });
} catch (err) {
logger.error("GET /admin/stats — failed", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
// ---------------------------------------------------------------------------
// SUBSCRIPTION PLANS
// ---------------------------------------------------------------------------
/**
* GET /api/admin/plans
* Liste tous les plans d'abonnement.
*/
router.get("/plans", async (_req: Request, res: Response): Promise<void> => {
try {
const plans = await AppDataSource.getRepository(SubscriptionPlan).find({
order: { level: "ASC" },
});
res.json({ success: true, data: { plans } });
} catch (err) {
logger.error("GET /admin/plans — failed to list plans", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* POST /api/admin/plans
* Crée un plan d'abonnement.
* Body: { slug, name, level, priceInCents, features?, isActive? }
*/
router.post("/plans", async (req: Request, res: Response): Promise<void> => {
try {
const {
slug,
name,
level,
priceInCents,
features = null,
isActive = true,
} = req.body as {
slug: string;
name: string;
level: number;
priceInCents: number;
features?: Record<string, unknown> | null;
isActive?: boolean;
};
if (!slug || !name || level === undefined || priceInCents === undefined) {
res.status(400).json({ success: false, error: "MISSING_REQUIRED_FIELDS" });
return;
}
const plan = AppDataSource.getRepository(SubscriptionPlan).create({
id: crypto.randomUUID(),
slug: slug as SubscriptionPlan["slug"],
name,
level,
priceInCents,
features,
isActive,
});
await AppDataSource.getRepository(SubscriptionPlan).save(plan);
res.status(201).json({ success: true, data: { plan } });
} catch (err) {
logger.error("POST /admin/plans — failed to create plan", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* PATCH /api/admin/plans/:id
* Met à jour un plan (isActive, priceInCents, features).
*/
router.patch("/plans/:id", async (req: Request, res: Response): Promise<void> => {
try {
const repo = AppDataSource.getRepository(SubscriptionPlan);
const plan = await repo.findOne({ where: { id: req.params.id } });
if (!plan) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
const allowed = ["isActive", "priceInCents", "features"] as const;
for (const field of allowed) {
if (field in req.body) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(plan as any)[field] = (req.body as Record<string, unknown>)[field];
}
}
await repo.save(plan);
res.json({ success: true, data: { plan } });
} 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" });
}
});
// ---------------------------------------------------------------------------
// USER SUBSCRIPTIONS
// ---------------------------------------------------------------------------
/**
* POST /api/admin/users/:id/subscriptions
* Assigne un plan à un utilisateur.
* Expire l'abonnement actif précédent si existant.
* Body: { planId, endsAt? } — endsAt ISO string, null = permanent
*/
router.post("/users/:id/subscriptions", async (req: Request, res: Response): Promise<void> => {
const { planId, endsAt = null } = req.body as { planId?: string; endsAt?: string | null };
if (!planId) {
res.status(400).json({ success: false, error: "MISSING_PLAN_ID" });
return;
}
try {
const userRepo = AppDataSource.getRepository(User);
const planRepo = AppDataSource.getRepository(SubscriptionPlan);
const subRepo = AppDataSource.getRepository(UserSubscription);
const [user, plan] = await Promise.all([
userRepo.findOne({ where: { id: req.params.id } }),
planRepo.findOne({ where: { id: planId } }),
]);
if (!user) { res.status(404).json({ success: false, error: "USER_NOT_FOUND" }); return; }
if (!plan) { res.status(404).json({ success: false, error: "PLAN_NOT_FOUND" }); return; }
// Expirer l'abonnement actif précédent
await subRepo
.createQueryBuilder()
.update(UserSubscription)
.set({ status: "expired" })
.where("userId = :userId AND status = :status", { userId: user.id, status: "active" })
.execute();
const sub = subRepo.create({
id: crypto.randomUUID(),
userId: user.id,
planId: plan.id,
status: "active",
startsAt: new Date(),
endsAt: endsAt ? new Date(endsAt) : null,
});
await subRepo.save(sub);
res.status(201).json({ success: true, data: { subscription: { ...sub, plan } } });
} 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" });
}
});
export default router;

View File

@@ -0,0 +1,291 @@
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";
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: 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 */
async function upsertUser(oauthUser: { id: string; email: string | null; nickname: string }): Promise<void> {
const userRepo = AppDataSource.getRepository(User);
let dbUser = await userRepo.findOne({ where: { superOAuthId: oauthUser.id } });
if (!dbUser) {
dbUser = userRepo.create({ superOAuthId: oauthUser.id, email: oauthUser.email, nickname: oauthUser.nickname });
} else {
dbUser.email = oauthUser.email;
dbUser.nickname = oauthUser.nickname;
}
await userRepo.save(dbUser);
}
/**
* POST /api/auth/login
* Proxy email/password vers SuperOAuth → pose le cookie httpOnly.
*/
router.post("/login", async (req: Request, res: Response): Promise<void> => {
const { email, password } = req.body as { email?: string; password?: string };
if (!email || !password) {
res.status(400).json({ success: false, error: "MISSING_CREDENTIALS" });
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/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await response.json() as {
success: boolean;
data?: { user: { id: string; email: string | null; nickname: string }; tokens: { accessToken: string; refreshToken?: string } };
message?: string;
};
if (!response.ok || !data.data?.tokens?.accessToken) {
res.status(401).json({ success: false, error: "INVALID_CREDENTIALS" });
return;
}
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 (err) {
logger.error("POST /auth/login — auth service unavailable", { err });
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
}
});
/**
* POST /api/auth/session
* Reçoit le token depuis le callback SuperOAuth,
* le valide, puis le pose en httpOnly cookie.
*/
router.post("/session", async (req: Request, res: Response): Promise<void> => {
const { token, refreshToken } = req.body as { token?: string; refreshToken?: string };
if (!token) {
res.status(400).json({ success: false, error: "MISSING_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/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 };
error?: string;
};
if (!response.ok || !data.data?.valid || !data.data.user) {
res.status(401).json({ success: false, error: "INVALID_TOKEN" });
return;
}
await upsertUser(data.data.user as { id: string; email: string | null; nickname: string });
res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS);
if (refreshToken) {
res.cookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
}
res.json({ success: true, data: { user: data.data.user } });
} catch (err) {
logger.error("POST /auth/session — auth service unavailable", { err });
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
}
});
/**
* 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/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
const data = await response.json() as {
success: boolean;
data?: { accessToken: string; refreshToken?: string };
error?: string;
};
if (!response.ok || !data.data?.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.accessToken, COOKIE_OPTIONS);
if (data.data.refreshToken) {
res.cookie(REFRESH_COOKIE_NAME, data.data.refreshToken, REFRESH_COOKIE_OPTIONS);
}
res.json({ success: true });
} catch (err) {
logger.error("POST /auth/refresh — auth service unavailable", { err });
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 });
});
/**
* GET /api/auth/me
* Retourne l'utilisateur courant + rôles locaux + plan actif + avatar.
*/
router.get("/me", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const localUser = await AppDataSource.getRepository(User).findOne({
where: { superOAuthId: user.id },
relations: ["userRoles", "userRoles.role"],
});
const roles = localUser?.userRoles.map((ur) => ur.role.slug) ?? [];
let plan: { slug: string; name: string; level: number } | null = null;
let subscriptionDate: string | null = null;
if (localUser) {
const now = new Date();
const activeSub = await AppDataSource.getRepository(UserSubscription)
.createQueryBuilder("sub")
.leftJoinAndSelect("sub.plan", "plan")
.where("sub.userId = :userId", { userId: localUser.id })
.andWhere("sub.status IN (:...statuses)", { statuses: ["active", "trial"] })
.andWhere("(sub.endsAt IS NULL OR sub.endsAt > :now)", { now })
.orderBy("plan.level", "DESC")
.addOrderBy("sub.startsAt", "DESC")
.getOne();
if (activeSub) {
plan = { slug: activeSub.plan.slug, name: activeSub.plan.name, level: activeSub.plan.level };
subscriptionDate = activeSub.startsAt.toISOString();
}
}
res.json({
success: true,
data: {
user: {
...user,
avatar: localUser?.avatar ?? null,
roles,
plan,
subscriptionDate,
},
},
});
});
/**
* 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 (err) {
logger.error("GET /auth/me/optional — auth service unavailable", { err });
res.json({ success: true, data: { user: null } });
}
});
export default router;

View File

@@ -0,0 +1,363 @@
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";
import logger from "../utils/logger";
const router = Router();
/** Résout le superOAuthId vers l'UUID DB, crée le user si inexistant */
async function resolveDbUserId(superOAuthId: string): Promise<string | null> {
const user = await AppDataSource.getRepository(User).findOne({ where: { superOAuthId } });
return user?.id ?? null;
}
/**
* GET /api/playlists
* Playlists publiques + playlists partagées avec l'utilisateur connecté.
*/
router.get("/", 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 owned = await AppDataSource.getRepository(Playlist).find({
where: { ownerId: dbUserId },
order: { createdAt: "DESC" },
});
const shared = await AppDataSource.getRepository(PlaylistShare).find({
where: { userId: dbUserId, status: "active" },
relations: ["playlist"],
});
res.json({
success: true,
data: {
owned,
shared: shared.map((s) => ({ ...s.playlist, permission: s.permission })),
},
});
} catch (err) {
logger.error("GET /playlists — failed to list playlists", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* POST /api/playlists
* Crée une playlist.
*/
router.post("/", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const { title, description, visibility } = req.body as {
title?: string;
description?: string;
visibility?: "private" | "shared" | "public";
};
if (!title?.trim()) {
res.status(400).json({ success: false, error: "MISSING_TITLE" });
return;
}
const dbUserId = await resolveDbUserId(user.id);
if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; }
try {
const playlist = AppDataSource.getRepository(Playlist).create({
id: require("crypto").randomUUID(),
ownerId: dbUserId,
title: title.trim(),
description: description ?? null,
visibility: visibility ?? "private",
});
await AppDataSource.getRepository(Playlist).save(playlist);
res.status(201).json({ success: true, data: { playlist } });
} catch (err) {
logger.error("POST /playlists — failed to create playlist", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* GET /api/playlists/:id
* Détail d'une playlist + ses vidéos.
* Accès : propriétaire, invité actif, ou playlist publique.
*/
router.get("/: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 playlist = await AppDataSource.getRepository(Playlist).findOne({
where: { id: req.params.id },
relations: ["playlistVideos", "playlistVideos.video"],
});
if (!playlist) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
const isOwner = playlist.ownerId === dbUserId;
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" });
return;
}
const videos = playlist.playlistVideos
.sort((a, b) => a.position - b.position)
.map((pv) => pv.video);
res.json({
success: true,
data: {
playlist: { ...playlist, shares: undefined },
videos,
permission: isOwner ? "owner" : share?.permission ?? "view",
},
});
} 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" });
}
});
/**
* 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 (err) {
logger.error("PATCH /playlists/:id — failed to update playlist", { err, id: req.params.id });
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 (err) {
logger.error("DELETE /playlists/:id — failed to delete playlist", { err, id: req.params.id });
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).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 || !!(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; }
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 (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" });
}
});
/**
* 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).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 || !!(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; }
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 (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" });
}
});
/**
* POST /api/playlists/:id/share
* Invite un utilisateur à une playlist (propriétaire uniquement).
*/
router.post("/:id/share", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const { userId, permission } = req.body as { userId?: string; permission?: "view" | "edit" };
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).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 (!userId) {
res.status(400).json({ success: false, error: "MISSING_USER_ID" });
return;
}
const share = AppDataSource.getRepository(PlaylistShare).create({
id: require("crypto").randomUUID(),
playlistId: playlist.id,
userId,
permission: permission ?? "view",
status: "pending",
});
await AppDataSource.getRepository(PlaylistShare).save(share);
res.status(201).json({ success: true, data: { share } });
} 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" });
}
});
/**
* PATCH /api/playlists/:id/share/:shareId
* Modifie permission ou status d'un invité (propriétaire uniquement).
*/
router.patch("/:id/share/:shareId", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const { permission, status } = req.body as {
permission?: "view" | "edit";
status?: "active" | "revoked";
};
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).findOneBy({ id: req.params.id });
if (!playlist || playlist.ownerId !== dbUserId) {
res.status(403).json({ success: false, error: "FORBIDDEN" });
return;
}
const share = await AppDataSource.getRepository(PlaylistShare).findOneBy({ id: req.params.shareId });
if (!share || share.playlistId !== playlist.id) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
if (permission) share.permission = permission;
if (status) share.status = status;
await AppDataSource.getRepository(PlaylistShare).save(share);
res.json({ success: true, data: { share } });
} 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" });
}
});
export default router;

View File

@@ -0,0 +1,139 @@
import { Router, Request, Response } from "express";
import path from "path";
import fs from "fs";
import { AppDataSource } from "../config/data-source";
import { Video } from "../entities/Video";
import { User } from "../entities/User";
import { UserSubscription } from "../entities/UserSubscription";
import logger from "../utils/logger";
const router = Router();
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
async function getUserLevel(token: string | undefined): Promise<number> {
if (!token) return 0;
try {
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
if (!superOAuthUrl) return 0;
const res = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await res.json() as {
success: boolean;
data?: { valid: boolean; user?: { id: string } };
};
if (!data.data?.valid || !data.data.user) return 0;
const superOAuthId = data.data.user.id;
const dbUser = await AppDataSource.getRepository(User).findOne({ where: { superOAuthId } });
if (!dbUser) return 0;
const sub = await AppDataSource.getRepository(UserSubscription).findOne({
where: { userId: dbUser.id, status: "active" },
relations: ["plan"],
order: { startsAt: "DESC" },
});
return sub?.plan.level ?? 0;
} catch (err) {
logger.warn("getUserLevel — auth/DB error", { err });
return 0;
}
}
/**
* GET /api/stream/:key
* Sert un fichier local (storageType="local") avec contrôle d'accès.
* :key est le chemin relatif stocké en DB (ex: "uploads/my-video.mp4").
*
* Support Range requests pour la seekabilité vidéo.
*/
router.get("/:key(*)", async (req: Request, res: Response): Promise<void> => {
const key = req.params.key;
// Sécurité : interdire path traversal
const resolved = path.resolve(UPLOADS_DIR, key);
if (!resolved.startsWith(path.resolve(UPLOADS_DIR))) {
res.status(400).json({ success: false, error: "INVALID_KEY" });
return;
}
try {
// Vérifier que la vidéo existe en DB et récupérer son niveau requis
const video = await AppDataSource.getRepository(Video).findOne({
where: { storageKey: key, storageType: "local", isPublished: true },
});
if (!video) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
// Contrôle d'accès
const token =
req.headers.authorization?.split(" ")[1] ??
(req.cookies as Record<string, string>)?.od_token;
const userLevel = await getUserLevel(token);
if (video.requiredLevel > userLevel) {
res.status(403).json({ success: false, error: "INSUFFICIENT_PLAN" });
return;
}
// Vérifier que le fichier existe
if (!fs.existsSync(resolved)) {
res.status(404).json({ success: false, error: "FILE_NOT_FOUND" });
return;
}
const stat = fs.statSync(resolved);
const fileSize = stat.size;
const ext = path.extname(resolved).toLowerCase();
const mimeTypes: Record<string, string> = {
".mp4": "video/mp4",
".webm": "video/webm",
".m3u8": "application/vnd.apple.mpegurl",
".ts": "video/MP2T",
};
const contentType = mimeTypes[ext] ?? "application/octet-stream";
const range = req.headers.range;
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
"Accept-Ranges": "bytes",
"Content-Length": chunkSize,
"Content-Type": contentType,
});
fs.createReadStream(resolved, { start, end }).pipe(res);
} else {
res.writeHead(200, {
"Content-Length": fileSize,
"Content-Type": contentType,
"Accept-Ranges": "bytes",
});
fs.createReadStream(resolved).pipe(res);
}
} catch (err) {
logger.error("GET /stream/:key — unexpected error", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
export default router;

View File

@@ -0,0 +1,138 @@
import { Router, Request, Response } from "express";
import { AppDataSource } from "../config/data-source";
import { User } from "../entities/User";
import { UserSubscription } from "../entities/UserSubscription";
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
import logger from "../utils/logger";
const router = Router();
/** Retourne la souscription active la plus élevée pour un userId local, ou null. */
async function getActiveSub(userId: string) {
const now = new Date();
return AppDataSource.getRepository(UserSubscription)
.createQueryBuilder("sub")
.leftJoinAndSelect("sub.plan", "plan")
.where("sub.userId = :userId", { userId })
.andWhere("sub.status IN (:...statuses)", { statuses: ["active", "trial"] })
.andWhere("(sub.endsAt IS NULL OR sub.endsAt > :now)", { now })
.orderBy("plan.level", "DESC")
.addOrderBy("sub.startsAt", "DESC")
.getOne();
}
/**
* GET /api/users/me/profile
* Profil complet de l'utilisateur connecté (données locales).
*/
router.get("/me/profile", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
try {
const localUser = await AppDataSource.getRepository(User).findOne({
where: { superOAuthId: user.id },
relations: ["userRoles", "userRoles.role"],
});
if (!localUser) {
res.status(404).json({ success: false, error: "USER_NOT_FOUND" });
return;
}
const roles = localUser.userRoles.map((ur) => ur.role.slug);
const activeSub = await getActiveSub(localUser.id);
const plan = activeSub
? { slug: activeSub.plan.slug, name: activeSub.plan.name, level: activeSub.plan.level }
: null;
const subscription = activeSub
? {
status: activeSub.status,
startsAt: activeSub.startsAt.toISOString(),
endsAt: activeSub.endsAt ? activeSub.endsAt.toISOString() : null,
}
: null;
res.json({
success: true,
data: {
id: localUser.id,
superOAuthId: localUser.superOAuthId,
email: localUser.email,
nickname: localUser.nickname,
avatar: localUser.avatar,
roles,
plan,
subscription,
createdAt: localUser.createdAt.toISOString(),
},
});
} catch (err) {
logger.error("GET /users/me/profile — DB error", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* PATCH /api/users/me
* Met à jour nickname et/ou avatar de l'utilisateur connecté.
* Note : le nickname local peut être écrasé au prochain login SuperOAuth.
*/
router.patch("/me", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const { nickname, avatar } = req.body as { nickname?: unknown; avatar?: unknown };
// Validation
if (nickname !== undefined) {
if (typeof nickname !== "string" || nickname.trim().length < 2 || nickname.trim().length > 100) {
res.status(400).json({ success: false, error: "INVALID_NICKNAME", message: "nickname must be 2-100 characters" });
return;
}
}
if (avatar !== undefined && avatar !== null) {
if (typeof avatar !== "string") {
res.status(400).json({ success: false, error: "INVALID_AVATAR" });
return;
}
try {
const parsed = new URL(avatar);
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("invalid protocol");
}
} catch (_err) {
res.status(400).json({ success: false, error: "INVALID_AVATAR", message: "avatar must be a valid http/https URL or null" });
return;
}
}
try {
const userRepo = AppDataSource.getRepository(User);
const localUser = await userRepo.findOne({ where: { superOAuthId: user.id } });
if (!localUser) {
res.status(404).json({ success: false, error: "USER_NOT_FOUND" });
return;
}
if (nickname !== undefined) localUser.nickname = (nickname as string).trim();
if (avatar !== undefined) localUser.avatar = avatar as string | null;
await userRepo.save(localUser);
res.json({
success: true,
data: {
id: localUser.id,
nickname: localUser.nickname,
avatar: localUser.avatar,
},
});
} catch (err) {
logger.error("PATCH /users/me — DB error", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
export default router;

View File

@@ -0,0 +1,102 @@
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";
import { UserSubscription } from "../entities/UserSubscription";
const router = Router();
/** Résout le superOAuthId vers l'UUID DB, retourne null si user inconnu */
async function resolveDbUserId(superOAuthId: string): Promise<string | null> {
const user = await AppDataSource.getRepository(User).findOne({ where: { superOAuthId } });
return user?.id ?? null;
}
/** Récupère le niveau de plan actif d'un user (0 = free si aucun abonnement actif) */
async function getUserPlanLevel(superOAuthId: string): Promise<number> {
const dbUserId = await resolveDbUserId(superOAuthId);
if (!dbUserId) return 0;
const sub = await AppDataSource.getRepository(UserSubscription).findOne({
where: { userId: dbUserId, status: "active" },
relations: ["plan"],
order: { startsAt: "DESC" },
});
return sub?.plan.level ?? 0;
}
/**
* GET /api/videos
* Liste les vidéos publiées. Filtre selon le niveau de plan de l'utilisateur.
* Sans auth → niveau 0 (free uniquement).
*/
router.get("/", async (req: Request, res: Response): Promise<void> => {
try {
const user = (req as AuthenticatedRequest).user as AuthenticatedUser | undefined;
const userLevel = user ? await getUserPlanLevel(user.id) : 0;
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
const qb = AppDataSource.getRepository(Video)
.createQueryBuilder("v")
.where("v.isPublished = :pub", { pub: true })
.select([
"v.id", "v.title", "v.description", "v.thumbnailUrl", "v.duration",
"v.storageType", "v.storageKey", "v.requiredLevel", "v.publishedAt",
])
.orderBy("v.publishedAt", "DESC");
if (q) {
qb.andWhere("(v.title LIKE :q OR v.description LIKE :q)", { q: `%${q}%` });
}
const videos = await qb.getMany();
const result = videos.map((v) => ({
...v,
locked: v.requiredLevel > userLevel,
storageKey: v.requiredLevel > userLevel ? null : v.storageKey,
}));
res.json({ success: true, data: { videos: result } });
} catch (err) {
logger.error("GET /videos — failed to list videos", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* GET /api/videos/:id
* Détail d'une vidéo. Retourne 403 si requiredLevel > userLevel.
*/
router.get("/:id", async (req: Request, res: Response): Promise<void> => {
try {
const user = (req as AuthenticatedRequest).user as AuthenticatedUser | undefined;
const userLevel = user ? await getUserPlanLevel(user.id) : 0;
const video = await AppDataSource.getRepository(Video).findOne({
where: { id: req.params.id, isPublished: true },
});
if (!video) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
if (video.requiredLevel > userLevel) {
res.status(403).json({
success: false,
error: "INSUFFICIENT_PLAN",
data: { requiredLevel: video.requiredLevel, userLevel },
});
return;
}
res.json({ success: true, data: { video } });
} 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" });
}
});
export default router;

166
backend/src/seeds/videos.ts Normal file
View File

@@ -0,0 +1,166 @@
/**
* Seed vidéos YouTube — OriginsDigital
* Usage : npm run seed:videos
*
* Requiert DB_HOST, DB_USER, DB_PASSWORD, DB_NAME dans l'env.
*/
import "reflect-metadata";
import * as dotenv from "dotenv";
dotenv.config();
import { AppDataSource } from "../config/data-source";
import { Video } from "../entities/Video";
const yt = (id: string) => `https://img.youtube.com/vi/${id}/maxresdefault.jpg`;
const VIDEOS: Partial<Video>[] = [
// ── Niveau 0 — libre ──────────────────────────────────────────────────────
{
title: "JavaScript in 100 Seconds",
description: "Tour rapide du JavaScript — syntaxe, event loop, histoire.",
storageType: "youtube",
storageKey: "DHjqpvDnNGE",
thumbnailUrl: yt("DHjqpvDnNGE"),
duration: 100,
requiredLevel: 0,
isPublished: true,
publishedAt: new Date(),
},
{
title: "TypeScript in 100 Seconds",
description: "Pourquoi typer son JavaScript — les bases en 100 secondes.",
storageType: "youtube",
storageKey: "zQnBQ4tB3ZA",
thumbnailUrl: yt("zQnBQ4tB3ZA"),
duration: 100,
requiredLevel: 0,
isPublished: true,
publishedAt: new Date(),
},
{
title: "React in 100 Seconds",
description: "Components, JSX, hooks — React expliqué en 100 secondes.",
storageType: "youtube",
storageKey: "Tn6-PIqc4UM",
thumbnailUrl: yt("Tn6-PIqc4UM"),
duration: 100,
requiredLevel: 0,
isPublished: true,
publishedAt: new Date(),
},
{
title: "CSS in 100 Seconds",
description: "Cascading Style Sheets — sélecteurs, box model, flexbox.",
storageType: "youtube",
storageKey: "OEV8gMkCHXQ",
thumbnailUrl: yt("OEV8gMkCHXQ"),
duration: 100,
requiredLevel: 0,
isPublished: true,
publishedAt: new Date(),
},
{
title: "Git in 100 Seconds",
description: "Versionner son code — commits, branches, merges.",
storageType: "youtube",
storageKey: "hwP7WQkmECE",
thumbnailUrl: yt("hwP7WQkmECE"),
duration: 100,
requiredLevel: 0,
isPublished: true,
publishedAt: new Date(),
},
{
title: "Node.js in 100 Seconds",
description: "JavaScript côté serveur — event loop, npm, streams.",
storageType: "youtube",
storageKey: "ENrzD9HAZK4",
thumbnailUrl: yt("ENrzD9HAZK4"),
duration: 100,
requiredLevel: 0,
isPublished: true,
publishedAt: new Date(),
},
// ── Niveau 1 — basic ──────────────────────────────────────────────────────
{
title: "Docker in 100 Seconds",
description: "Conteneuriser ses applications — images, volumes, compose.",
storageType: "youtube",
storageKey: "gAkwW2tuIqE",
thumbnailUrl: yt("gAkwW2tuIqE"),
duration: 100,
requiredLevel: 1,
isPublished: true,
publishedAt: new Date(),
},
{
title: "Tailwind CSS in 100 Seconds",
description: "Utility-first CSS — pourquoi ça change tout.",
storageType: "youtube",
storageKey: "mr15Xzb1Ook",
thumbnailUrl: yt("mr15Xzb1Ook"),
duration: 100,
requiredLevel: 1,
isPublished: true,
publishedAt: new Date(),
},
{
title: "SQL Explained in 100 Seconds",
description: "Bases de données relationnelles — SELECT, JOIN, index.",
storageType: "youtube",
storageKey: "zsjvFFKnte0",
thumbnailUrl: yt("zsjvFFKnte0"),
duration: 100,
requiredLevel: 1,
isPublished: true,
publishedAt: new Date(),
},
// ── Niveau 2 — pro ────────────────────────────────────────────────────────
{
title: "Linux in 100 Seconds",
description: "Le système d'exploitation qui fait tourner Internet.",
storageType: "youtube",
storageKey: "rrB13utjYV4",
thumbnailUrl: yt("rrB13utjYV4"),
duration: 100,
requiredLevel: 2,
isPublished: true,
publishedAt: new Date(),
},
{
title: "Redis in 100 Seconds",
description: "Cache in-memory — sessions, queues, pub/sub.",
storageType: "youtube",
storageKey: "G1rOthIU-uo",
thumbnailUrl: yt("G1rOthIU-uo"),
duration: 100,
requiredLevel: 2,
isPublished: true,
publishedAt: new Date(),
},
];
async function seed() {
await AppDataSource.initialize();
const repo = AppDataSource.getRepository(Video);
let inserted = 0;
let skipped = 0;
for (const data of VIDEOS) {
const exists = await repo.findOne({
where: { storageType: data.storageType, storageKey: data.storageKey },
});
if (exists) { skipped++; continue; }
await repo.save(repo.create(data));
inserted++;
}
await AppDataSource.destroy();
console.log(`✅ Seed terminé — ${inserted} insérées, ${skipped} ignorées (déjà présentes).`);
}
seed().catch((e) => { console.error("❌ Seed échoué :", e.message); process.exit(1); });

View 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;

View 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
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
globals: true,
},
});

View File

@@ -29,6 +29,7 @@ services:
DB_USER: ${DB_USER:-originsdigital}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
SUPER_OAUTH_URL: ${SUPER_OAUTH_URL:-https://superoauth.tetardtek.com}
depends_on:
mysql:
condition: service_healthy

80
docs/vision-b2b.md Normal file
View File

@@ -0,0 +1,80 @@
# OriginsDigital — Vision B2B
> Sprint 3 — Step 1 output
> Date : 2026-03-17
---
## Segment cible
**Studios indépendants et créateurs professionnels**
- Studios indé : 1-10 personnes, besoin d'une vitrine pro sans ressources design
- Créateurs pro : streamers, YouTubers, artistes digitaux qui monétisent leur audience
- Critère d'exclusion V1 : pas de grandes agences (cycle vente trop long), pas de B2C pur
---
## White-label — Ce qui est personnalisable
| Élément | Personnalisable | Notes |
|---------|----------------|-------|
| Logo | ✅ | Upload SVG/PNG, remplacement complet |
| Couleurs | ✅ | Palette primaire + secondaire via config |
| Domaine | ✅ | CNAME custom (studio.client.com) |
| Emails transactionnels | ✅ | Templates brandés (sender name + domaine) |
| Favicon | ✅ | |
| Nom de la plateforme | ✅ | Affiché dans les headers + emails |
| Code source | ❌ | Pas d'accès au code — SaaS uniquement |
Isolation tenant complète via **SuperOAuth Tier 3** (per-tenant providers, déjà en prod ✅).
---
## Identité visuelle cible
**3 mots** : Sobre. Précis. Autoritaire.
Références : Linear, Vercel, Pika.art
- Socle : Void Dark conservé
- Accent : or inchangé
- Typographie : ajout d'une typo display (Geist / Cal Sans) pour les H1
- Pas de gradients agressifs — micro-détails subtils (bordures fines, shadows légères)
- Motion : transitions rapides (150ms), pas d'animations décoratives
---
## Pricing model B2B
**Abonnement mensuel par tier** — pas de per-seat, pas de commission en V1
| Tier | Prix/mois | Inclus |
|------|-----------|--------|
| Starter | 29€ | 1 projet, domaine custom, white-label basique |
| Studio | 99€ | 5 projets, analytics, intégration SuperOAuth |
| Pro | 249€ | Projets illimités, API access, support prioritaire |
| Enterprise | Sur devis | SLA, déploiement dédié, onboarding |
**Pourquoi abonnement et pas per-seat ?**
Cible studios indé = équipes petites → per-seat pénalise la croissance et complexifie la facturation.
Commission découragerait les cas d'usage à fort volume. Abonnement = prévisibilité pour le client et pour nous.
---
## Différenciateur principal
**SuperOAuth Tier 3 intégré nativement** = auth multi-tenant per-tenant providers, en standard.
Aucun concurrent direct dans la cible (studios indé / créateurs pro) ne propose ça en standard.
C'est notre moat technique visible dès l'onboarding.
---
## Brief refonte visuelle → Step 2
- Palette : fond `#0a0a0a`, surface `#111`, accent `#c9a84c` (or mat)
- Typo display : Cal Sans ou Geist — pour H1 uniquement
- Composants prioritaires : hero landing, pricing card, CTA button, navbar avec login OAuth
- Mobile-first, dark mode natif (pas de toggle — dark only en V1)
- Densité : élevée — pas d'espaces vides décoratifs, chaque pixel justifié

8
frontend/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# URL de base SuperOAuth — pas de client_id, SuperOAuth ne gère pas ce concept
# Le flow : /api/v1/auth/oauth/:provider?redirectUrl=<callback_url>
# Valeur : voir brain/MYSECRETS section originsdigital
VITE_SUPEROAUTH_URL=
# SuperOAuth PKCE (Step 3) — flow authorization_code avec PKCE
VITE_OAUTH_URL=https://oauth.tetardtek.com
VITE_OAUTH_CLIENT_ID=originsdigital

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OriginsDigital</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,14 @@
"react-router-dom": "^6.23.0"
},
"devDependencies": {
"@types/hls.js": "^0.13.3",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.27",
"hls.js": "^1.6.15",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "^5.4.3",
"vite": "^5.2.8"
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,9 +1,38 @@
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 LandingPage from './pages/LandingPage';
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';
import ProfilePage from './pages/ProfilePage';
function App() {
return (
<main>
<h1>OriginsDigital v2</h1>
<p>Refonte en cours.</p>
</main>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<LandingPage />} />
<Route path="/app" 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="/admin" element={<AdminPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}

View File

@@ -0,0 +1,31 @@
import { Component, type ReactNode } from 'react';
interface Props { children: ReactNode; }
interface State { hasError: boolean; }
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div className="flex min-h-screen items-center justify-center bg-od-bg">
<div className="rounded border border-od-crit/40 bg-od-surface p-8 text-center">
<p className="font-mono text-sm text-od-crit">Une erreur inattendue s'est produite.</p>
<button
className="mt-4 rounded border border-od-border px-4 py-2 text-xs text-od-muted hover:border-od-accent/40"
onClick={() => window.location.reload()}
>
Recharger
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View 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 />;
}

View File

@@ -0,0 +1,29 @@
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">
{user.avatar ? (
<img
src={user.avatar}
alt={user.nickname}
className="h-6 w-6 rounded-full object-cover border border-od-border"
/>
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-od-surface border border-od-border font-mono text-[10px] text-od-accent">
{user.nickname[0].toUpperCase()}
</span>
)}
<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>
);
}

View File

@@ -0,0 +1,86 @@
/**
* VideoPlayer — zéro dépendance runtime
*
* youtube → <iframe> embed natif (0KB ajouté au bundle)
* s3/local → <video> natif + HLS.js chargé lazily si .m3u8
* external → <video> natif
*/
import { useEffect, useRef } from 'react';
type StorageType = 'youtube' | 's3' | 'local' | 'external';
interface VideoPlayerProps {
storageType: StorageType;
storageKey: string;
}
export default function VideoPlayer({ storageType, storageKey }: VideoPlayerProps) {
if (storageType === 'youtube') {
return <YouTubePlayer videoId={storageKey} />;
}
const apiBase = import.meta.env.VITE_API_URL || '/api';
const url =
storageType === 'external'
? storageKey
: `${apiBase}/stream/${storageKey}`;
return <NativePlayer url={url} />;
}
// ── YouTube ───────────────────────────────────────────────────────────────────
function YouTubePlayer({ videoId }: { videoId: string }) {
return (
<iframe
src={`https://www.youtube.com/embed/${videoId}?rel=0&color=white`}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title="Lecteur vidéo"
/>
);
}
// ── Native + HLS.js lazy ──────────────────────────────────────────────────────
function NativePlayer({ url }: { url: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (!url.includes('.m3u8')) {
video.src = url;
return;
}
// HLS — import dynamique, ne charge que si nécessaire
let hls: import('hls.js').default | null = null;
if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari supporte HLS nativement
video.src = url;
} else {
import('hls.js').then(({ default: Hls }) => {
if (!Hls.isSupported() || !video) return;
hls = new Hls();
hls.loadSource(url);
hls.attachMedia(video);
}).catch(() => { /* HLS non disponible — dégradation silencieuse */ });
}
return () => { hls?.destroy(); };
}, [url]);
return (
<video
ref={videoRef}
className="h-full w-full"
controls
playsInline
/>
);
}

View File

@@ -0,0 +1,40 @@
import { useState } from 'react';
import { buildAuthUrl, saveVerifier } from '../../lib/oauth';
interface LoginButtonProps {
className?: string;
provider?: string;
}
export default function LoginButton({ className, provider = 'discord' }: LoginButtonProps) {
const [loading, setLoading] = useState(false);
async function handleClick() {
if (loading) return;
setLoading(true);
try {
const redirectUri = `${window.location.origin}/callback`;
const { url, verifier } = await buildAuthUrl(redirectUri, provider);
saveVerifier(verifier);
window.location.href = url;
} catch {
setLoading(false);
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className={[
'rounded border border-[#c9a84c] px-4 py-2 font-mono text-xs text-[#c9a84c]',
'transition-all duration-150',
'hover:bg-[#c9a84c] hover:text-od-bg',
'disabled:opacity-40 disabled:cursor-not-allowed',
className ?? '',
].join(' ')}
>
{loading ? '…' : 'Se connecter avec SuperOAuth'}
</button>
);
}

View File

@@ -0,0 +1,43 @@
import { clearAccessToken } from '../../lib/oauth';
import { useAuthContext } from '../../context/AuthContext';
import LoginButton from './LoginButton';
interface UserMenuProps {
/** Optionnel : si fourni, prend le dessus sur le nickname de l'utilisateur */
displayName?: string;
}
export default function UserMenu({ displayName }: UserMenuProps) {
const { user } = useAuthContext();
function handleLogout() {
clearAccessToken();
window.location.href = '/';
}
if (!user) {
return <LoginButton />;
}
const name = displayName ?? user.nickname ?? 'Mon compte';
return (
<div className="flex items-center gap-3">
{/* Avatar placeholder */}
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-od-surface border border-[#c9a84c] font-mono text-[10px] text-[#c9a84c]">
{name !== 'Mon compte' ? name[0].toUpperCase() : '●'}
</span>
<span className="font-mono text-xs text-[#c9a84c]">
{name}
</span>
<button
onClick={handleLogout}
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors duration-150"
>
Se déconnecter
</button>
</div>
);
}

View File

@@ -0,0 +1,21 @@
export default function Footer() {
return (
<footer className="border-t border-od-border bg-od-bg">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-6">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-bold tracking-[0.2em] text-od-accent">OD</span>
<span className="text-xs text-od-muted">OriginsDigital</span>
</div>
<p className="font-mono text-2xs text-od-muted">
© {new Date().getFullYear()} OriginsDigital Tous droits réservés
</p>
<div className="flex items-center gap-6">
<a href="mailto:contact@originsdigital.com" className="font-mono text-2xs text-od-muted hover:text-od-text transition-colors duration-150">
Contact
</a>
<span className="font-mono text-2xs text-od-muted">Powered by SuperOAuth</span>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,112 @@
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 {
user: User | null;
onLogout: () => void;
}
export default function Header({ 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();
}
return (
<header className="sticky top-0 z-40 border-b border-od-border bg-od-bg/90 backdrop-blur-sm">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
{/* Logo */}
<Link to="/" className="flex items-center gap-2.5 group">
<span className="font-mono text-xs font-bold tracking-[0.2em] text-od-accent group-hover:text-od-accent-dim transition-colors duration-150">
OD
</span>
<span className="text-sm font-semibold text-od-text tracking-tight">
OriginsDigital
</span>
</Link>
{/* Navigation */}
<nav className="flex items-center gap-8">
<Link to="/" className="text-sm text-od-muted hover:text-od-text transition-colors duration-150">
Accueil
</Link>
{user && (
<Link to="/playlists" className="text-sm text-od-muted hover:text-od-text transition-colors duration-150">
Playlists
</Link>
)}
{!user && (
<Link to="/#pricing" className="text-sm text-od-muted hover:text-od-text transition-colors duration-150">
Tarifs
</Link>
)}
{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 duration-150">
admin
</Link>
)}
</nav>
{/* Right — auth */}
<div className="flex items-center gap-3">
{user ? (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity duration-150"
>
<UserBadge user={user} />
<span className="font-mono text-xs text-od-muted"></span>
</button>
{open && (
<div className="absolute right-0 top-full mt-1.5 w-40 rounded border border-od-border bg-od-surface shadow-xl z-50 animate-fade-in">
<Link
to="/profile"
onClick={() => setOpen(false)}
className="block px-4 py-2.5 text-xs text-od-muted hover:text-od-text transition-colors duration-150"
>
Profil
</Link>
<div className="border-t border-od-border" />
<button
onClick={handleLogout}
className="w-full text-left px-4 py-2.5 font-mono text-xs text-od-muted hover:text-od-crit transition-colors duration-150"
>
Déconnexion
</button>
</div>
)}
</div>
) : (
<Link
to="/login"
className="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-all duration-150"
>
Se connecter
</Link>
)}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,21 @@
import { Outlet } from 'react-router-dom';
import Header from './Header';
import Footer from './Footer';
import { useAuthContext } from '../../context/AuthContext';
export default function Layout() {
const { user, loading, setUser } = useAuthContext();
return (
<div className="min-h-screen bg-od-bg text-od-text flex flex-col">
<Header
user={loading ? null : user}
onLogout={() => setUser(null)}
/>
<main className="flex-1 mx-auto w-full max-w-6xl px-6 py-10">
<Outlet />
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { apiFetch } from '../lib/api';
export interface User {
id: string;
email: string | null;
nickname: string;
avatar?: string | null;
plan?: { slug: string; name: string; level: number } | null;
subscriptionLevel?: number;
roles: string[];
}
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; };
}, []);
// Token expiré et refresh impossible → déconnexion silencieuse
useEffect(() => {
const handler = () => setUser(null);
window.addEventListener('auth:expired', handler);
return () => window.removeEventListener('auth:expired', handler);
}, []);
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;
}

View File

@@ -0,0 +1,4 @@
// 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';

46
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,46 @@
// En dev : VITE_API_URL absent → proxy Vite sur /api → localhost:4000
// 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}`);
}
}
function buildInit(init?: RequestInit): RequestInit {
return {
credentials: 'include',
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
};
}
// Déduplique les appels refresh simultanés
let refreshingPromise: Promise<boolean> | null = null;
async function tryRefresh(): Promise<boolean> {
if (refreshingPromise) return refreshingPromise;
refreshingPromise = fetch(`${BASE}/auth/refresh`, { method: 'POST', credentials: 'include' })
.then((r) => r.ok)
.finally(() => { refreshingPromise = null; });
return refreshingPromise;
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, buildInit(init));
if (res.status === 401 && path !== '/auth/refresh') {
const refreshed = await tryRefresh();
if (refreshed) {
const retry = await fetch(`${BASE}${path}`, buildInit(init));
if (!retry.ok) throw new ApiError(retry.status, path);
return retry.json() as Promise<T>;
}
window.dispatchEvent(new Event('auth:expired'));
throw new ApiError(401, path);
}
if (!res.ok) throw new ApiError(res.status, path);
return res.json() as Promise<T>;
}

119
frontend/src/lib/oauth.ts Normal file
View File

@@ -0,0 +1,119 @@
// OAuth 2.0 PKCE client — SuperOAuth Tier 3 multi-tenant
// Token stocké en sessionStorage (pas localStorage — sécurité, scope onglet)
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
const SESSION_KEY_TOKEN = 'od_access_token';
const SESSION_KEY_VERIFIER = 'od_pkce_verifier';
// --- PKCE helpers ---
function base64UrlEncode(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array.buffer);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(digest);
}
// --- Auth URL ---
export async function buildAuthUrl(
redirectUri: string,
provider: string,
scope = 'openid profile email',
clientId = OAUTH_CLIENT_ID,
): Promise<{ url: string; verifier: string }> {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
provider,
code_challenge: challenge,
code_challenge_method: 'S256',
});
return {
url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`,
verifier,
};
}
// --- Token exchange ---
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
scope?: string;
}
export async function exchangeCode(
code: string,
verifier: string,
redirectUri: string,
clientId = OAUTH_CLIENT_ID,
): Promise<TokenResponse> {
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}).toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
}
const data = await response.json() as TokenResponse;
if (!data.access_token) throw new Error('No access_token in OAuth response');
return data;
}
// --- Token accessors ---
export function getAccessToken(): string | null {
return sessionStorage.getItem(SESSION_KEY_TOKEN);
}
export function clearAccessToken(): void {
sessionStorage.removeItem(SESSION_KEY_TOKEN);
sessionStorage.removeItem(SESSION_KEY_VERIFIER);
}
// --- PKCE verifier persistence (avant redirect) ---
export function saveVerifier(verifier: string): void {
sessionStorage.setItem(SESSION_KEY_VERIFIER, verifier);
}
export function loadVerifier(): string | null {
return sessionStorage.getItem(SESSION_KEY_VERIFIER);
}

View File

@@ -1,9 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles/index.css";
import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>
);

View File

@@ -0,0 +1,676 @@
import { useState, useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import { apiFetch } from '../lib/api';
import { useAuthContext } from '../context/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || '/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;
}
interface Stats {
totalUsers: number;
totalVideos: number;
activeSubscriptions: number;
}
// ── Tabs ─────────────────────────────────────────────────────────────────────
type Tab = 'videos' | 'users' | 'plans' | 'system';
export default function AdminPage() {
const { user, loading: authLoading } = useAuthContext();
const isSuperAdmin = user?.roles?.includes('super_admin') ?? false;
const [tab, setTab] = useState<Tab>('videos');
if (authLoading) return null;
if (!user?.roles?.some((r) => r === 'admin' || r === 'super_admin')) return <Navigate to="/" replace />;
const tabs: Tab[] = isSuperAdmin
? ['videos', 'users', 'plans', 'system']
: ['videos', 'users', 'plans'];
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-1 border-b border-od-border pb-4">
{tabs.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 isSuperAdmin={isSuperAdmin} />}
{tab === 'plans' && <PlansTab />}
{tab === 'system' && isSuperAdmin && <SystemTab />}
</div>
);
}
// ── Videos tab ───────────────────────────────────────────────────────────────
function VideosTab() {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [form, setForm] = useState({
title: '', description: '', thumbnailUrl: '',
storageType: 'youtube', storageKey: '',
requiredLevel: 0, isPublished: false,
});
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<{ success: boolean; data: { videos: Video[] } }>('/admin/videos?limit=100')
.then((r) => setVideos(r.data.videos))
.catch(() => setFetchError('Impossible de charger les vidéos.'))
.finally(() => setLoading(false));
}, []);
async function handleFileUpload(file: File) {
setUploading(true);
setUploadError(null);
setForm((f) => ({ ...f, storageKey: '' }));
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`${API_BASE}/admin/videos/upload`, {
method: 'POST',
credentials: 'include',
body: fd,
});
if (!res.ok) throw new Error(`${res.status}`);
const r = await res.json() as { success: boolean; data: { storageKey: string; storageType: string } };
setForm((f) => ({ ...f, storageKey: r.data.storageKey, storageType: r.data.storageType }));
} catch {
setUploadError('Échec de l\'upload — vérifier format (mp4/webm, 4 Go max).');
}
setUploading(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: '', description: '', thumbnailUrl: '', storageType: 'youtube', storageKey: '', requiredLevel: 0, isPublished: false });
} catch {
setError('Erreur lors de la création.');
}
setSaving(false);
}
async function togglePublish(video: Video) {
setActionError(null);
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 {
setActionError('Impossible de modifier la vidéo.');
}
}
async function handleDelete(id: string) {
if (!confirm('Supprimer cette vidéo ?')) return;
setActionError(null);
try {
await apiFetch(`/admin/videos/${id}`, { method: 'DELETE' });
setVideos((v) => v.filter((x) => x.id !== id));
} catch {
setActionError('Impossible de supprimer la vidéo.');
}
}
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">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"
/>
<textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Description (optionnel)"
rows={2}
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 resize-none"
/>
<input
value={form.thumbnailUrl}
onChange={(e) => setForm((f) => ({ ...f, thumbnailUrl: e.target.value }))}
placeholder="URL thumbnail (optionnel)"
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, storageKey: '' }))}
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>
{form.storageType === 'local' ? (
<div className="flex flex-1 flex-col gap-1">
<input
type="file"
accept="video/mp4,video/webm"
disabled={uploading}
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFileUpload(f); }}
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text outline-none focus:border-od-accent file:mr-2 file:rounded file:border-0 file:bg-od-surface file:px-2 file:py-0.5 file:font-mono file:text-xs file:text-od-muted"
/>
{uploading && <p className="font-mono text-xs text-od-muted">Envoi en cours</p>}
{uploadError && <p className="font-mono text-xs text-od-crit">{uploadError}</p>}
{!uploading && !uploadError && form.storageKey && (
<p className="font-mono text-xs text-od-ok"> Upload réussi</p>
)}
</div>
) : (
<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 || uploading}
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>
{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>
) : fetchError ? (
<p className="text-sm text-od-crit">{fetchError}</p>
) : (
<div className="flex flex-col gap-2">
{actionError && <p className="text-xs text-od-crit">{actionError}</p>}
{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({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const [users, setUsers] = useState<AdminUser[]>([]);
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [assigning, setAssigning] = useState<string | null>(null);
const [selectedPlan, setSelectedPlan] = useState<Record<string, string>>({});
const [assigningRole, setAssigningRole] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<Record<string, string>>({});
const [togglingBan, setTogglingBan] = useState<string | null>(null);
useEffect(() => {
Promise.all([
apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users?limit=100'),
apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans'),
])
.then(([ur, pr]) => {
setUsers(ur.data.users);
setPlans(pr.data.plans);
})
.catch(() => setError('Impossible de charger les utilisateurs.'))
.finally(() => setLoading(false));
}, []);
async function assignPlan(userId: string) {
const planId = selectedPlan[userId];
if (!planId || assigning) return;
setAssigning(userId);
setActionError(null);
try {
await apiFetch(`/admin/users/${userId}/subscriptions`, {
method: 'POST',
body: JSON.stringify({ planId }),
});
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users?limit=100');
setUsers(r.data.users);
setSelectedPlan((s) => { const n = { ...s }; delete n[userId]; return n; });
} catch {
setActionError('Impossible d\'assigner le plan.');
}
setAssigning(null);
}
async function assignRole(userId: string) {
const role = selectedRole[userId];
if (!role || assigningRole) return;
setAssigningRole(userId);
setActionError(null);
try {
await apiFetch(`/admin/users/${userId}/roles`, {
method: 'PATCH',
body: JSON.stringify({ roles: [role] }),
});
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users?limit=100');
setUsers(r.data.users);
setSelectedRole((s) => { const n = { ...s }; delete n[userId]; return n; });
} catch {
setActionError('Impossible d\'assigner le rôle.');
}
setAssigningRole(null);
}
async function toggleBan(u: AdminUser) {
setTogglingBan(u.id);
setActionError(null);
try {
await apiFetch(`/admin/users/${u.id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !u.isActive }),
});
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, isActive: !u.isActive } : x));
} catch {
setActionError('Impossible de modifier le statut.');
}
setTogglingBan(null);
}
if (loading) return (
<div className="flex flex-col gap-2">
{[...Array(3)].map((_, i) => <div key={i} className="h-20 rounded border border-od-border animate-pulse" />)}
</div>
);
if (error) return <p className="text-sm text-od-crit">{error}</p>;
// Rôles assignables selon le niveau du current user
const assignableRoles = isSuperAdmin
? ['user', 'moderator', 'admin', 'super_admin']
: ['user', 'moderator'];
return (
<div className="flex flex-col gap-2">
{actionError && <p className="text-xs text-od-crit">{actionError}</p>}
{users.map((u) => (
<div
key={u.id}
className={`flex flex-col gap-2 rounded border bg-od-surface px-4 py-3 ${
u.isActive ? 'border-od-border' : 'border-od-crit/40 opacity-60'
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium text-od-text">
{u.nickname}
{!u.isActive && (
<span className="ml-2 font-mono text-xs text-od-crit">banni</span>
)}
</p>
<p className="font-mono text-xs text-od-muted">{u.email ?? '—'}</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className="font-mono text-xs text-od-muted">
{u.roles.map((r) => r.slug).join(', ') || '—'}
</span>
{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 flex-wrap items-center gap-2">
<select
value={selectedPlan[u.id] ?? ''}
onChange={(e) => setSelectedPlan((s) => ({ ...s, [u.id]: e.target.value }))}
className="flex-1 min-w-0 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=""> 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 ? '…' : 'Plan'}
</button>
<select
value={selectedRole[u.id] ?? ''}
onChange={(e) => setSelectedRole((s) => ({ ...s, [u.id]: e.target.value }))}
className="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=""> Rôle </option>
{assignableRoles.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<button
disabled={!selectedRole[u.id] || assigningRole === u.id}
onClick={() => assignRole(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"
>
{assigningRole === u.id ? '…' : 'Rôle'}
</button>
<button
disabled={togglingBan === u.id}
onClick={() => toggleBan(u)}
className={`rounded border px-3 py-1 font-mono text-xs transition-colors disabled:opacity-40 ${
u.isActive
? 'border-od-border text-od-muted hover:border-od-crit hover:text-od-crit'
: 'border-od-crit text-od-crit hover:bg-od-crit hover:text-od-bg'
}`}
>
{togglingBan === u.id ? '…' : u.isActive ? 'Bannir' : 'Débannir'}
</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 [fetchError, setFetchError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
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(() => setFetchError('Impossible de charger les plans.'))
.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) {
setActionError(null);
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 {
setActionError('Impossible de modifier le plan.');
}
}
if (loading) return (
<div className="flex flex-col gap-2">
{[...Array(2)].map((_, i) => <div key={i} className="h-12 rounded border border-od-border animate-pulse" />)}
</div>
);
if (fetchError) return <p className="text-sm text-od-crit">{fetchError}</p>;
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">
{actionError && <p className="text-xs text-od-crit">{actionError}</p>}
{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>
);
}
// ── System tab (super_admin only) ─────────────────────────────────────────────
function SystemTab() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
apiFetch<{ success: boolean; data: Stats }>('/admin/stats')
.then((r) => setStats(r.data))
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
return (
<div className="flex flex-col gap-6">
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">Métriques plateforme</p>
{loading && (
<div className="grid gap-4 sm:grid-cols-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-20 rounded border border-od-border animate-pulse" />
))}
</div>
)}
{error && <p className="text-sm text-od-crit">Impossible de charger les stats.</p>}
{stats && (
<div className="grid gap-4 sm:grid-cols-3">
<StatCard label="Utilisateurs" value={stats.totalUsers} />
<StatCard label="Vidéos publiées" value={stats.totalVideos} />
<StatCard label="Abonnements actifs" value={stats.activeSubscriptions} />
</div>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="flex flex-col gap-1 rounded border border-od-border bg-od-surface p-4">
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">{label}</p>
<p className="text-2xl font-semibold text-od-accent">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiFetch } from '../lib/api';
import { exchangeCode, loadVerifier } from '../lib/oauth';
import { useAuthContext } from '../context/AuthContext';
import type { User } from '../context/AuthContext';
interface SessionResponse {
success: boolean;
data: { user: User };
}
type PendingState =
| { kind: 'verification_pending'; email: string }
| { kind: 'merge_pending'; email: string; provider: string };
export default function CallbackPage() {
const navigate = useNavigate();
const { setUser } = useAuthContext();
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<PendingState | null>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
// --- Erreur OAuth explicite ---
const oauthError = params.get('error');
if (oauthError) {
const desc = params.get('error_description') ?? oauthError;
setError(`Erreur OAuth : ${desc}`);
return;
}
// --- Pending states (verification / merge) ---
const status = params.get('status');
if (status === 'verification_pending') {
setPending({ kind: 'verification_pending', email: params.get('email') ?? '' });
return;
}
if (status === 'merge_pending') {
setPending({
kind: 'merge_pending',
email: params.get('email') ?? '',
provider: params.get('provider') ?? '',
});
return;
}
// --- Flow PKCE : ?code= présent ---
const code = params.get('code');
if (code) {
const verifier = loadVerifier();
if (!verifier) {
setError('Session PKCE expirée. Recommence la connexion.');
return;
}
const redirectUri = `${window.location.origin}/callback`;
exchangeCode(code, verifier, redirectUri)
.then((tokens) => {
return apiFetch<SessionResponse>('/auth/session', {
method: 'POST',
body: JSON.stringify({
token: tokens.access_token,
refreshToken: tokens.refresh_token,
}),
});
})
.then((res) => {
setUser(res.data.user);
navigate('/', { replace: true });
})
.catch(() => setError("Échec de l'échange de code OAuth. Réessaie."));
return;
}
// --- Flow session (token JWT en query param) ---
const token = params.get('token');
if (token) {
apiFetch<SessionResponse>('/auth/session', {
method: 'POST',
body: JSON.stringify({ token }),
})
.then((res) => {
setUser(res.data.user);
navigate('/', { replace: true });
})
.catch(() => setError("Échec de l'authentification. Réessaie."));
return;
}
// Aucun paramètre reconnu → retour accueil
navigate('/', { replace: true });
}, [navigate, setUser]);
// --- Pending UI ---
if (pending) {
return (
<div className="flex flex-col items-center gap-6 pt-20 max-w-md mx-auto text-center">
{pending.kind === 'verification_pending' ? (
<>
<div className="text-4xl">📧</div>
<h2 className="text-lg font-semibold text-od-text">Vérifie ton email</h2>
<p className="text-sm text-od-muted">
Un email de vérification a é envoyé à{' '}
<span className="text-od-text font-mono">{pending.email}</span>.
<br />
Clique sur le lien pour activer ton compte.
</p>
</>
) : (
<>
<div className="text-4xl">🔗</div>
<h2 className="text-lg font-semibold text-od-text">Fusion de compte</h2>
<p className="text-sm text-od-muted">
Un compte existe déjà avec l'email{' '}
<span className="text-od-text font-mono">{pending.email}</span>.
<br />
Un email a été envoyé pour fusionner ton compte{' '}
<span className="text-od-accent capitalize">{pending.provider}</span>.
<br />
Clique sur le lien dans l'email pour confirmer.
</p>
</>
)}
<a
href="/login"
className="font-mono text-xs text-od-muted hover:text-od-text transition-colors"
>
Retour à la connexion
</a>
</div>
);
}
// --- Error UI ---
if (error) {
return (
<div className="flex flex-col items-center gap-4 pt-20">
<p className="text-od-crit">{error}</p>
<a
href="/"
className="font-mono text-xs text-od-muted hover:text-od-text transition-colors"
>
Retour à l'accueil
</a>
</div>
);
}
return (
<div className="flex items-center justify-center pt-20">
<p className="font-mono text-sm text-od-muted">Connexion en cours</p>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { apiFetch } from '../lib/api';
interface Video {
id: string;
title: string;
description: string;
requiredLevel: number;
locked: boolean;
thumbnailUrl?: string;
}
interface VideosResponse {
success: boolean;
data: { videos: Video[] };
}
export default function HomePage() {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [query, setQuery] = useState('');
useEffect(() => {
apiFetch<VideosResponse>('/videos')
.then((res) => setVideos(res.data.videos))
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return videos;
return videos.filter(
(v) => v.title.toLowerCase().includes(q) || v.description?.toLowerCase().includes(q),
);
}, [videos, query]);
const free = filtered.filter((v) => !v.locked);
const premium = filtered.filter((v) => v.locked);
return (
<div className="flex flex-col gap-10">
{/* Hero */}
<section className="border-b border-od-border pb-8">
<h1 className="text-2xl font-semibold text-od-text">Vidéos & formations</h1>
<p className="mt-2 text-sm text-od-muted">
Contenu libre et premium connecte-toi pour accéder aux formations complètes.
</p>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Rechercher…"
className="mt-4 w-full max-w-sm rounded border border-od-border bg-od-surface px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/>
</section>
{loading && (
<div className="grid gap-4 sm:grid-cols-2">
{[...Array(4)].map((_, i) => <VideoCardSkeleton key={i} />)}
</div>
)}
{error && (
<p className="text-sm text-od-crit">Impossible de charger les vidéos. Réessaie plus tard.</p>
)}
{!loading && !error && (
<>
{free.length > 0 && (
<section>
<h2 className="mb-4 font-mono text-xs uppercase tracking-widest text-od-muted">
Accès libre
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{free.map((v) => <VideoCard key={v.id} video={v} />)}
</div>
</section>
)}
{premium.length > 0 && (
<section>
<h2 className="mb-4 font-mono text-xs uppercase tracking-widest text-od-accent">
Premium
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{premium.map((v) => <VideoCard key={v.id} video={v} />)}
</div>
</section>
)}
{videos.length === 0 && (
<p className="text-sm text-od-muted">Aucune vidéo disponible pour l'instant.</p>
)}
{videos.length > 0 && filtered.length === 0 && (
<p className="text-sm text-od-muted">Aucun résultat pour « {query} ».</p>
)}
</>
)}
</div>
);
}
function VideoCard({ video }: { video: Video }) {
const inner = (
<div className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-4 transition-colors hover:border-od-accent/40">
{/* Thumbnail */}
<div className="relative h-28 overflow-hidden rounded bg-od-surface-hi">
{video.thumbnailUrl && (
<img
src={video.thumbnailUrl}
alt={video.title}
className="h-full w-full object-cover"
/>
)}
{video.locked && (
<div className="absolute inset-0 flex items-center justify-center bg-od-bg/70">
<span className="font-mono text-lg text-od-accent"></span>
</div>
)}
</div>
<p className="text-sm font-medium text-od-text leading-snug">{video.title}</p>
{video.description && (
<p className="text-xs text-od-muted line-clamp-2">{video.description}</p>
)}
<div className="flex items-center gap-2">
{video.locked ? (
<span className="rounded border border-od-accent px-2 py-0.5 font-mono text-xs text-od-accent">
Premium
</span>
) : (
<span className="rounded border border-od-border px-2 py-0.5 font-mono text-xs text-od-muted">
Libre
</span>
)}
</div>
</div>
);
if (video.locked) return <div className="cursor-not-allowed opacity-75">{inner}</div>;
return <Link to={`/video/${video.id}`}>{inner}</Link>;
}
function VideoCardSkeleton() {
return (
<div className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-4">
<div className="h-28 rounded bg-od-surface-hi animate-pulse" />
<div className="h-3 w-3/4 rounded bg-od-surface-hi animate-pulse" />
<div className="h-2 w-1/2 rounded bg-od-surface-hi animate-pulse" />
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { Link } from 'react-router-dom';
export default function LandingPage() {
return (
<div className="flex flex-col gap-24 pb-16">
{/* ── Hero ──────────────────────────────────────────────────────────── */}
<section className="pt-16 flex flex-col gap-6 max-w-3xl">
{/* Badge */}
<div className="inline-flex w-fit items-center gap-2 rounded border border-od-border bg-od-surface px-3 py-1">
<span className="h-1.5 w-1.5 rounded-full bg-od-accent" />
<span className="font-mono text-2xs text-od-muted tracking-widest uppercase">
Vidéos · Playlists · Communauté
</span>
</div>
{/* Headline */}
<h1 className="font-display text-5xl font-bold leading-[1.1] tracking-tight text-od-text">
Partagez vos vidéos.{' '}
<span className="text-od-accent">Construisez votre audience.</span>
</h1>
{/* Sous-titre */}
<p className="text-base text-od-muted leading-relaxed max-w-xl">
OriginsDigital est une plateforme de partage vidéo pensée pour les créateurs.
Organisez votre contenu en playlists, proposez du contenu libre ou premium,
et faites grandir votre communauté.
</p>
{/* CTAs */}
<div className="flex items-center gap-4 pt-2">
<Link
to="/app"
className="inline-flex items-center gap-2 rounded border border-od-accent bg-od-accent px-6 py-2.5 font-mono text-sm font-semibold text-od-bg transition-all duration-150 hover:bg-od-accent-dim hover:border-od-accent-dim"
>
Explorer les vidéos
</Link>
<Link
to="/login"
className="inline-flex items-center gap-2 rounded border border-od-border px-6 py-2.5 font-mono text-sm text-od-muted transition-all duration-150 hover:border-od-border-hi hover:text-od-text"
>
Se connecter
</Link>
</div>
</section>
{/* ── Features ──────────────────────────────────────────────────────── */}
<section className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{
label: 'Playlists organisées',
desc: 'Regroupez vos vidéos par thème, série ou parcours. Vos spectateurs retrouvent facilement ce qui les intéresse.',
},
{
label: 'Contenu libre & premium',
desc: 'Proposez du contenu en accès libre pour attirer, et du contenu premium pour fidéliser. Vous décidez.',
},
{
label: 'Fait pour les créateurs',
desc: 'Interface épurée, recherche rapide, profil personnalisé. Le contenu au centre, pas la distraction.',
},
].map(({ label, desc }) => (
<div
key={label}
className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-5 transition-colors duration-150 hover:border-od-border-hi"
>
<div className="h-px w-8 bg-od-accent" />
<p className="text-sm font-semibold text-od-text">{label}</p>
<p className="text-xs text-od-muted leading-relaxed">{desc}</p>
</div>
))}
</section>
{/* ── Comment ça marche ─────────────────────────────────────────────── */}
<section className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<span className="font-mono text-2xs uppercase tracking-widest text-od-muted">Comment ça marche</span>
<h2 className="font-display text-3xl font-bold text-od-text tracking-tight">
Simple et direct.
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{[
{ step: '01', title: 'Connectez-vous', desc: 'Via Discord, GitHub, Google ou Twitch. Un clic, pas de formulaire.' },
{ step: '02', title: 'Explorez', desc: 'Parcourez les vidéos libres, cherchez par thème, découvrez les playlists.' },
{ step: '03', title: 'Accédez au premium', desc: 'Débloquez les formations complètes et le contenu exclusif.' },
].map(({ step, title, desc }) => (
<div key={step} className="flex flex-col gap-2">
<span className="font-mono text-lg font-bold text-od-accent">{step}</span>
<p className="text-sm font-semibold text-od-text">{title}</p>
<p className="text-xs text-od-muted leading-relaxed">{desc}</p>
</div>
))}
</div>
</section>
{/* ── CTA final ─────────────────────────────────────────────────────── */}
<section className="flex flex-col items-center gap-4 rounded border border-od-border bg-od-surface px-8 py-10 text-center">
<h2 className="font-display text-2xl font-bold text-od-text">
Prêt à découvrir ?
</h2>
<p className="text-sm text-od-muted max-w-md">
Pas besoin de compte pour explorer. Connectez-vous quand vous êtes prêt.
</p>
<div className="flex items-center gap-4 pt-2">
<Link
to="/app"
className="inline-flex items-center gap-2 rounded border border-od-accent bg-od-accent px-6 py-2.5 font-mono text-sm font-semibold text-od-bg transition-all duration-150 hover:bg-od-accent-dim hover:border-od-accent-dim"
>
Explorer les vidéos
</Link>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { apiFetch } from '../lib/api';
import { useAuthContext, type User } from '../context/AuthContext';
import { buildAuthUrl, saveVerifier } from '../lib/oauth';
const PROVIDERS = [
{ id: 'discord', label: 'Discord' },
{ id: 'github', label: 'GitHub' },
{ id: 'google', label: 'Google' },
{ id: 'twitch', label: 'Twitch' },
] as const;
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { setUser } = useAuthContext();
const from = (location.state as { from?: Location })?.from?.pathname ?? '/';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
async function handleOAuth(providerId: string) {
if (oauthLoading) return;
setOauthLoading(providerId);
try {
const redirectUri = `${window.location.origin}/callback`;
const { url, verifier } = await buildAuthUrl(redirectUri, providerId);
saveVerifier(verifier);
window.location.href = url;
} catch {
setOauthLoading(null);
setError('Impossible de démarrer la connexion OAuth.');
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || !password || loading) return;
setLoading(true);
setError(null);
try {
const res = await apiFetch<{ success: boolean; data: { user: User } }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
setUser(res.data.user);
navigate(from, { replace: true });
} catch {
setError('Email ou mot de passe incorrect.');
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col items-center gap-8 pt-16">
<div className="flex flex-col items-center gap-2">
<span className="font-mono text-xs font-bold tracking-widest text-od-accent">OD</span>
<h1 className="text-xl font-semibold text-od-text">Connexion</h1>
</div>
<div className="w-full max-w-xs flex flex-col gap-6">
{/* Email / password */}
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
className="rounded border border-od-border bg-od-surface px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mot de passe"
required
className="rounded border border-od-border bg-od-surface px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/>
{error && <p className="text-xs text-od-crit">{error}</p>}
<button
type="submit"
disabled={loading}
className="rounded border border-od-accent px-4 py-2 font-mono text-xs text-od-accent hover:bg-od-accent hover:text-od-bg transition-colors disabled:opacity-40"
>
{loading ? '…' : 'Connexion'}
</button>
</form>
{/* Séparateur */}
<div className="flex items-center gap-3">
<div className="flex-1 border-t border-od-border" />
<span className="font-mono text-xs text-od-muted">ou</span>
<div className="flex-1 border-t border-od-border" />
</div>
{/* OAuth providers */}
<div className="flex flex-col gap-2">
{PROVIDERS.map(({ id, label }) => (
<button
key={id}
onClick={() => handleOAuth(id)}
disabled={oauthLoading === id}
className="flex items-center justify-center rounded border border-od-border bg-od-surface px-4 py-2.5 text-sm text-od-muted transition-colors hover:border-od-accent hover:text-od-accent disabled:opacity-40"
>
{oauthLoading === id ? '…' : label}
</button>
))}
</div>
</div>
<Link to="/" className="font-mono text-xs text-od-muted hover:text-od-text transition-colors">
Retour
</Link>
</div>
);
}

View File

@@ -0,0 +1,329 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { apiFetch, ApiError } from '../lib/api';
interface Video {
id: string;
title: string;
thumbnailUrl: string | null;
duration: number | null;
}
interface Playlist {
id: string;
title: string;
description: string | null;
visibility: 'private' | 'shared' | 'public';
}
interface PlaylistResponse {
success: boolean;
data: {
playlist: Playlist;
videos: Video[];
permission: 'owner' | 'view' | 'edit';
};
}
export default function PlaylistPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [data, setData] = useState<PlaylistResponse['data'] | null>(null);
const [error, setError] = useState<'forbidden' | 'not_found' | null>(null);
const [loading, setLoading] = useState(true);
const [actionError, setActionError] = useState<string | null>(null);
// Edit inline form
const [editOpen, setEditOpen] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editVisibility, setEditVisibility] = useState<'private' | 'shared' | 'public'>('private');
const [saving, setSaving] = useState(false);
// Share modal
const [shareOpen, setShareOpen] = useState(false);
const [shareUserId, setShareUserId] = useState('');
const [sharePermission, setSharePermission] = useState<'view' | 'edit'>('view');
const [sharing, setSharing] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);
const [shareOk, setShareOk] = useState(false);
useEffect(() => {
if (!id) return;
apiFetch<PlaylistResponse>(`/playlists/${id}`)
.then((res) => setData(res.data))
.catch((err: unknown) => {
if (err instanceof ApiError && err.status === 403) setError('forbidden');
else setError('not_found');
})
.finally(() => setLoading(false));
}, [id]);
function openEdit() {
if (!data) return;
setEditTitle(data.playlist.title);
setEditVisibility(data.playlist.visibility);
setEditOpen(true);
setActionError(null);
}
async function handleEdit(e: React.FormEvent) {
e.preventDefault();
if (!id || saving) return;
setSaving(true);
setActionError(null);
try {
const res = await apiFetch<{ success: boolean; data: { playlist: Playlist } }>(
`/playlists/${id}`,
{ method: 'PATCH', body: JSON.stringify({ title: editTitle.trim(), visibility: editVisibility }) }
);
setData((prev) => prev ? { ...prev, playlist: res.data.playlist } : prev);
setEditOpen(false);
} catch {
setActionError('Impossible de modifier la playlist.');
}
setSaving(false);
}
async function handleDelete() {
if (!id || !confirm('Supprimer cette playlist ?')) return;
setActionError(null);
try {
await apiFetch(`/playlists/${id}`, { method: 'DELETE' });
navigate('/playlists');
} catch {
setActionError('Impossible de supprimer la playlist.');
}
}
async function handleRemoveVideo(videoId: string) {
if (!id || !confirm('Retirer cette vidéo de la playlist ?')) return;
setActionError(null);
try {
await apiFetch(`/playlists/${id}/videos/${videoId}`, { method: 'DELETE' });
setData((prev) => prev ? { ...prev, videos: prev.videos.filter((v) => v.id !== videoId) } : prev);
} catch {
setActionError('Impossible de retirer la vidéo.');
}
}
async function handleShare(e: React.FormEvent) {
e.preventDefault();
if (!id || !shareUserId.trim() || sharing) return;
setSharing(true);
setShareError(null);
setShareOk(false);
try {
await apiFetch(`/playlists/${id}/share`, {
method: 'POST',
body: JSON.stringify({ userId: shareUserId.trim(), permission: sharePermission }),
});
setShareOk(true);
setShareUserId('');
} catch {
setShareError("Impossible d'envoyer l'invitation.");
}
setSharing(false);
}
if (loading) {
return (
<div className="flex flex-col gap-3">
<div className="h-6 w-1/3 rounded bg-od-surface-hi animate-pulse" />
{[...Array(4)].map((_, i) => (
<div key={i} className="h-14 rounded border border-od-border bg-od-surface animate-pulse" />
))}
</div>
);
}
if (error === 'forbidden') {
return (
<div className="flex flex-col items-center gap-4 pt-16 text-center">
<span className="font-mono text-3xl text-od-accent"></span>
<p className="text-sm text-od-muted">Accès refusé à cette playlist.</p>
<Link to="/playlists" className="font-mono text-xs text-od-muted hover:text-od-text transition-colors">
Playlists
</Link>
</div>
);
}
if (!data) {
return (
<div className="flex flex-col items-center gap-4 pt-16">
<p className="text-sm text-od-muted">Playlist introuvable.</p>
<Link to="/playlists" className="font-mono text-xs text-od-muted hover:text-od-text transition-colors">
Playlists
</Link>
</div>
);
}
const { playlist, videos, permission } = data;
const isOwner = permission === 'owner';
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-3 border-b border-od-border pb-6">
{editOpen ? (
<form onSubmit={handleEdit} 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">Modifier</p>
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
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"
/>
<select
value={editVisibility}
onChange={(e) => setEditVisibility(e.target.value as 'private' | 'shared' | 'public')}
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="private">Privée</option>
<option value="shared">Partagée</option>
<option value="public">Publique</option>
</select>
<div className="flex gap-2">
<button
type="submit"
disabled={saving}
className="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 ? '…' : 'Enregistrer'}
</button>
<button
type="button"
onClick={() => setEditOpen(false)}
className="rounded border border-od-border px-4 py-1.5 font-mono text-xs text-od-muted hover:text-od-text transition-colors"
>
Annuler
</button>
</div>
</form>
) : (
<>
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-od-text">{playlist.title}</h1>
<span className="font-mono text-xs text-od-muted">{permission}</span>
</div>
{playlist.description && (
<p className="text-sm text-od-muted">{playlist.description}</p>
)}
{isOwner && (
<div className="flex gap-2">
<button
onClick={openEdit}
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"
>
Éditer
</button>
<button
onClick={() => { setShareOpen(true); setShareError(null); setShareOk(false); }}
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"
>
Partager
</button>
<button
onClick={handleDelete}
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-crit hover:text-od-crit transition-colors"
>
Supprimer
</button>
</div>
)}
</>
)}
{actionError && <p className="font-mono text-xs text-od-crit">{actionError}</p>}
</div>
{/* Share modal */}
{shareOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-od-bg/80">
<div className="w-full max-w-sm rounded border border-od-border bg-od-surface p-6 flex flex-col gap-4">
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">Partager</p>
<form onSubmit={handleShare} className="flex flex-col gap-3">
<input
value={shareUserId}
onChange={(e) => setShareUserId(e.target.value)}
placeholder="ID utilisateur"
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"
/>
<select
value={sharePermission}
onChange={(e) => setSharePermission(e.target.value as 'view' | 'edit')}
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="view">Lecture seule</option>
<option value="edit">Édition</option>
</select>
{shareError && <p className="font-mono text-xs text-od-crit">{shareError}</p>}
{shareOk && <p className="font-mono text-xs text-od-ok">Invitation envoyée.</p>}
<div className="flex gap-2">
<button
type="submit"
disabled={sharing || !shareUserId.trim()}
className="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"
>
{sharing ? '…' : 'Inviter'}
</button>
<button
type="button"
onClick={() => setShareOpen(false)}
className="rounded border border-od-border px-4 py-1.5 font-mono text-xs text-od-muted hover:text-od-text transition-colors"
>
Fermer
</button>
</div>
</form>
</div>
</div>
)}
{/* Videos */}
{videos.length === 0 ? (
<p className="text-sm text-od-muted">Aucune vidéo dans cette playlist.</p>
) : (
<div className="flex flex-col gap-2">
{videos.map((v, i) => (
<div
key={v.id}
className="flex items-center gap-4 rounded border border-od-border bg-od-surface px-4 py-3"
>
<span className="font-mono text-xs text-od-muted w-5 shrink-0">{i + 1}</span>
<Link
to={`/video/${v.id}`}
className="flex flex-1 items-center gap-4 hover:opacity-80 transition-opacity min-w-0"
>
{v.thumbnailUrl && (
<img src={v.thumbnailUrl} alt="" className="h-10 w-16 rounded object-cover shrink-0" />
)}
<span className="flex-1 text-sm text-od-text truncate">{v.title}</span>
{v.duration && (
<span className="font-mono text-xs text-od-muted shrink-0">
{Math.floor(v.duration / 60)}:{String(v.duration % 60).padStart(2, '0')}
</span>
)}
</Link>
{isOwner && (
<button
onClick={() => handleRemoveVideo(v.id)}
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors shrink-0"
>
</button>
)}
</div>
))}
</div>
)}
<Link to="/playlists" className="self-start font-mono text-xs text-od-muted hover:text-od-text transition-colors">
Playlists
</Link>
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { apiFetch } from '../lib/api';
interface Playlist {
id: string;
title: string;
description: string | null;
visibility: 'private' | 'shared' | 'public';
}
interface Invitation {
shareId: string;
playlistId: string;
playlistTitle: string;
permission: 'view' | 'edit';
}
interface PlaylistsResponse {
success: boolean;
data: {
owned: Playlist[];
shared: (Playlist & { permission: 'view' | 'edit' })[];
invitations?: Invitation[];
};
}
export default function PlaylistsPage() {
const [owned, setOwned] = useState<Playlist[]>([]);
const [shared, setShared] = useState<(Playlist & { permission: 'view' | 'edit' })[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
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);
const [inviteError, setInviteError] = useState<string | null>(null);
const [respondingId, setRespondingId] = useState<string | null>(null);
useEffect(() => {
apiFetch<PlaylistsResponse>('/playlists')
.then((res) => {
setOwned(res.data.owned);
setShared(res.data.shared);
setInvitations(res.data.invitations ?? []);
})
.catch(() => setFetchError('Impossible de charger les playlists.'))
.finally(() => setLoading(false));
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!createTitle.trim() || creating) return;
setCreating(true);
setCreateError(null);
try {
const res = await apiFetch<{ success: boolean; data: { playlist: Playlist } }>(
'/playlists',
{ method: 'POST', body: JSON.stringify({ title: createTitle.trim() }) }
);
setOwned((prev) => [res.data.playlist, ...prev]);
setCreateTitle('');
} catch {
setCreateError('Impossible de créer la playlist.');
}
setCreating(false);
}
async function respondInvitation(inv: Invitation, status: 'accepted' | 'revoked') {
if (respondingId) return;
setRespondingId(inv.shareId);
setInviteError(null);
try {
await apiFetch(`/playlists/${inv.playlistId}/share/${inv.shareId}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
setInvitations((prev) => prev.filter((i) => i.shareId !== inv.shareId));
if (status === 'accepted') {
const res = await apiFetch<PlaylistsResponse>('/playlists');
setOwned(res.data.owned);
setShared(res.data.shared);
setInvitations(res.data.invitations ?? []);
}
} catch {
setInviteError("Impossible de répondre à l'invitation.");
}
setRespondingId(null);
}
if (loading) {
return (
<div className="flex flex-col gap-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-14 rounded border border-od-border bg-od-surface animate-pulse" />
))}
</div>
);
}
if (fetchError) {
return <p className="text-sm text-od-crit">{fetchError}</p>;
}
return (
<div className="flex flex-col gap-10">
<section className="border-b border-od-border pb-8">
<h1 className="text-2xl font-semibold text-od-text">Mes playlists</h1>
</section>
{/* Créer */}
<div className="flex flex-col gap-1">
<form onSubmit={handleCreate} className="flex gap-2">
<input
type="text"
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
placeholder="Nouvelle playlist…"
className="flex-1 rounded border border-od-border bg-od-surface px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/>
<button
type="submit"
disabled={!createTitle.trim() || creating}
className="rounded border border-od-border px-4 py-2 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors disabled:opacity-40"
>
+
</button>
</form>
{createError && <p className="font-mono text-xs text-od-crit">{createError}</p>}
</div>
{/* Invitations reçues */}
{invitations.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="font-mono text-xs uppercase tracking-widest text-od-muted">Invitations</h2>
{inviteError && <p className="font-mono text-xs text-od-crit">{inviteError}</p>}
{invitations.map((inv) => (
<div
key={inv.shareId}
className="flex items-center justify-between gap-4 rounded border border-od-border bg-od-surface px-4 py-3"
>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm text-od-text truncate">{inv.playlistTitle}</span>
<span className="font-mono text-xs text-od-muted">{inv.permission}</span>
</div>
<div className="flex gap-2 shrink-0">
<button
disabled={respondingId === inv.shareId}
onClick={() => respondInvitation(inv, 'accepted')}
className="rounded border border-od-ok px-3 py-1 font-mono text-xs text-od-ok hover:bg-od-ok hover:text-od-bg transition-colors disabled:opacity-40"
>
{respondingId === inv.shareId ? '…' : 'Accepter'}
</button>
<button
disabled={respondingId === inv.shareId}
onClick={() => respondInvitation(inv, 'revoked')}
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-crit hover:text-od-crit transition-colors disabled:opacity-40"
>
Refuser
</button>
</div>
</div>
))}
</section>
)}
{/* Mes playlists */}
{owned.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="font-mono text-xs uppercase tracking-widest text-od-muted">Créées</h2>
{owned.map((p) => <PlaylistRow key={p.id} playlist={p} />)}
</section>
)}
{/* Partagées */}
{shared.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="font-mono text-xs uppercase tracking-widest text-od-muted">Partagées avec moi</h2>
{shared.map((p) => <PlaylistRow key={p.id} playlist={p} badge={p.permission} />)}
</section>
)}
{owned.length === 0 && shared.length === 0 && invitations.length === 0 && (
<p className="text-sm text-od-muted">Aucune playlist pour l'instant.</p>
)}
</div>
);
}
function PlaylistRow({ playlist, badge }: { playlist: Playlist; badge?: string }) {
return (
<Link
to={`/playlists/${playlist.id}`}
className="flex items-center justify-between rounded border border-od-border bg-od-surface px-4 py-3 hover:border-od-accent/40 transition-colors"
>
<span className="text-sm text-od-text">{playlist.title}</span>
<div className="flex items-center gap-2">
{badge && (
<span className="font-mono text-xs text-od-muted">{badge}</span>
)}
<span className="font-mono text-xs text-od-muted">
{playlist.visibility === 'private' ? '' : playlist.visibility === 'shared' ? '' : ''}
</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,187 @@
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 [editingNickname, setEditingNickname] = useState(false);
const [draftNickname, setDraftNickname] = useState(user?.nickname ?? '');
const [editingAvatar, setEditingAvatar] = useState(false);
const [draftAvatar, setDraftAvatar] = useState(user?.avatar ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!user) return null;
function startEditNickname() {
setDraftNickname(user!.nickname);
setError(null);
setEditingNickname(true);
}
function startEditAvatar() {
setDraftAvatar(user!.avatar ?? '');
setError(null);
setEditingAvatar(true);
}
function cancel() {
setEditingNickname(false);
setEditingAvatar(false);
setError(null);
}
async function save(patch: { nickname?: string; avatar?: string | null }) {
setSaving(true);
setError(null);
try {
await apiFetch('/users/me', { method: 'PATCH', body: JSON.stringify(patch) });
const res = await apiFetch<MeResponse>('/auth/me');
setUser(res.data.user);
setEditingNickname(false);
setEditingAvatar(false);
} catch (e) {
setError(e instanceof ApiError ? `Erreur ${e.status}` : 'Erreur réseau');
} finally {
setSaving(false);
}
}
async function handleSaveNickname() {
const trimmed = draftNickname.trim();
if (!trimmed || trimmed === user!.nickname) { cancel(); return; }
await save({ nickname: trimmed });
}
async function handleSaveAvatar() {
const trimmed = draftAvatar.trim();
const val = trimmed === '' ? null : trimmed;
if (val === (user!.avatar ?? null)) { cancel(); return; }
await save({ avatar: val });
}
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>
{/* 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">
{/* Avatar */}
<div className="flex items-center justify-between px-4 py-3 gap-4">
<span className="text-xs text-od-muted shrink-0">Avatar</span>
{editingAvatar ? (
<div className="flex flex-col items-end gap-2 flex-1 min-w-0">
<div className="flex items-center gap-2 w-full">
<input
value={draftAvatar}
onChange={(e) => setDraftAvatar(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveAvatar(); if (e.key === 'Escape') cancel(); }}
placeholder="URL https://… (vide = supprimer)"
disabled={saving}
className="flex-1 min-w-0 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 placeholder-od-muted"
autoFocus
/>
<button onClick={handleSaveAvatar} disabled={saving}
className="font-mono text-xs text-od-accent hover:opacity-80 transition-opacity disabled:opacity-40">
{saving ? '…' : '✓'}
</button>
<button onClick={cancel} disabled={saving}
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors disabled:opacity-40">
</button>
</div>
{draftAvatar && (
<img src={draftAvatar} alt="preview" className="h-10 w-10 rounded-full object-cover border border-od-border" />
)}
{error && <span className="font-mono text-[10px] text-od-crit">{error}</span>}
</div>
) : (
<div className="flex items-center gap-3">
{user.avatar ? (
<img src={user.avatar} alt={user.nickname} className="h-8 w-8 rounded-full object-cover border border-od-border" />
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-od-surface-hi border border-od-border font-mono text-sm text-od-accent">
{user.nickname[0].toUpperCase()}
</span>
)}
<button onClick={startEditAvatar}
className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors">
modifier
</button>
</div>
)}
</div>
{/* 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>
{editingNickname ? (
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2">
<input
value={draftNickname}
onChange={(e) => setDraftNickname(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveNickname(); if (e.key === 'Escape') cancel(); }}
maxLength={100}
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={handleSaveNickname} disabled={saving}
className="font-mono text-xs text-od-accent hover:opacity-80 transition-opacity disabled:opacity-40">
{saving ? '…' : '✓'}
</button>
<button onClick={cancel} 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={startEditNickname}
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>
);
}

View File

@@ -0,0 +1,191 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { apiFetch, ApiError } from '../lib/api';
import { useAuthContext } from '../context/AuthContext';
import VideoPlayer from '../components/VideoPlayer';
interface Video {
id: string;
title: string;
description: string;
duration?: number;
storageType: 'youtube' | 's3' | 'local' | 'external';
storageKey: string;
thumbnailUrl?: string;
requiredLevel: number;
locked: boolean;
}
interface VideoResponse {
success: boolean;
data: { video: Video };
}
interface Playlist {
id: string;
title: string;
}
export default function VideoPage() {
const { id } = useParams<{ id: string }>();
const { user } = useAuthContext();
const [video, setVideo] = useState<Video | null>(null);
const [error, setError] = useState<'forbidden' | 'not_found' | 'unknown' | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
apiFetch<VideoResponse>(`/videos/${id}`)
.then((res) => setVideo(res.data.video))
.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));
}, [id]);
if (loading) {
return (
<div className="flex flex-col gap-4">
<div className="aspect-video w-full rounded bg-od-surface-hi animate-pulse" />
<div className="h-5 w-1/2 rounded bg-od-surface-hi animate-pulse" />
<div className="h-3 w-3/4 rounded bg-od-surface-hi animate-pulse" />
</div>
);
}
if (error === 'forbidden') {
return (
<div className="flex flex-col items-center gap-4 pt-16 text-center">
<span className="font-mono text-3xl text-od-accent"></span>
<p className="font-medium text-od-text">Contenu premium</p>
<p className="text-sm text-od-muted">Cette vidéo nécessite un abonnement supérieur.</p>
<Link to="/" className="font-mono text-xs text-od-muted hover:text-od-text transition-colors">
Retour
</Link>
</div>
);
}
if (error || !video) {
return (
<div className="flex flex-col items-center gap-4 pt-16">
<p className="text-sm text-od-muted">Vidéo introuvable.</p>
<Link to="/" className="font-mono text-xs text-od-muted hover:text-od-text transition-colors">
Retour
</Link>
</div>
);
}
return (
<div className="flex flex-col gap-6">
{/* Player */}
<div className="overflow-hidden rounded border border-od-border bg-od-surface">
<div className="aspect-video w-full">
<VideoPlayer storageType={video.storageType} storageKey={video.storageKey} />
</div>
</div>
{/* Meta */}
<div className="flex flex-col gap-2">
<h1 className="text-xl font-semibold text-od-text">{video.title}</h1>
{video.description && (
<p className="text-sm text-od-muted leading-relaxed">{video.description}</p>
)}
<div className="flex items-center gap-3 pt-1">
{video.requiredLevel === 0 ? (
<span className="rounded border border-od-border px-2 py-0.5 font-mono text-xs text-od-muted">
Libre
</span>
) : (
<span className="rounded border border-od-accent px-2 py-0.5 font-mono text-xs text-od-accent">
Premium · niveau {video.requiredLevel}
</span>
)}
{video.duration && (
<span className="font-mono text-xs text-od-muted">
{Math.floor(video.duration / 60)}:{String(video.duration % 60).padStart(2, '0')}
</span>
)}
</div>
</div>
{user && <AddToPlaylist videoId={video.id} />}
<Link to="/" className="self-start font-mono text-xs text-od-muted hover:text-od-text transition-colors">
Retour aux vidéos
</Link>
</div>
);
}
// ── Ajouter à une playlist ────────────────────────────────────────────────────
function AddToPlaylist({ videoId }: { videoId: string }) {
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [selected, setSelected] = useState('');
const [adding, setAdding] = useState(false);
const [status, setStatus] = useState<'idle' | 'ok' | 'already' | 'error'>('idle');
useEffect(() => {
apiFetch<{ success: boolean; data: { owned: Playlist[]; shared: (Playlist & { permission: string })[] } }>(
'/playlists'
).then((res) => {
const editable = [
...res.data.owned,
...res.data.shared.filter((p) => p.permission === 'edit'),
];
setPlaylists(editable);
if (editable.length > 0) setSelected(editable[0].id);
}).catch(() => {});
}, []);
async function handleAdd() {
if (!selected || adding) return;
setAdding(true);
setStatus('idle');
try {
await apiFetch(`/playlists/${selected}/videos`, {
method: 'POST',
body: JSON.stringify({ videoId }),
});
setStatus('ok');
} catch (e) {
setStatus(e instanceof ApiError && e.status === 409 ? 'already' : 'error');
}
setAdding(false);
}
if (playlists.length === 0) return null;
return (
<div className="flex flex-col gap-2 rounded border border-od-border bg-od-surface p-4">
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">Ajouter à une playlist</p>
<div className="flex items-center gap-2">
<select
value={selected}
onChange={(e) => { setSelected(e.target.value); setStatus('idle'); }}
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text outline-none focus:border-od-accent"
>
{playlists.map((p) => (
<option key={p.id} value={p.id}>{p.title}</option>
))}
</select>
<button
onClick={handleAdd}
disabled={adding}
className="rounded border border-od-accent px-4 py-2 font-mono text-xs text-od-accent hover:bg-od-accent hover:text-od-bg transition-colors disabled:opacity-40"
>
{adding ? '…' : '+ Ajouter'}
</button>
</div>
{status === 'ok' && <p className="font-mono text-xs text-od-ok">Vidéo ajoutée.</p>}
{status === 'already'&& <p className="font-mono text-xs text-od-muted">Déjà dans cette playlist.</p>}
{status === 'error' && <p className="font-mono text-xs text-od-crit">Erreur réessaie.</p>}
</div>
);
}

View File

@@ -0,0 +1,32 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ─── Void Dark — dark only V1 ──────────────────────────────────────────── */
:root {
--od-bg: #0a0a0a; /* fond principal — validated */
--od-surface: #111111; /* panneaux, cartes */
--od-surface-hi: #1a1a1a; /* survol, éléments élevés */
--od-border: #222222; /* séparateurs subtils */
--od-border-hi: #2e2e2e; /* bordures hover */
--od-text: #e8e8e8; /* texte principal */
--od-muted: #5a5a5a; /* texte secondaire, labels */
--od-accent: #c9a84c; /* or mat — validated */
--od-accent-dim: #a08038; /* survol accent */
--od-accent-glow: rgba(201,168,76,0.12); /* glow subtil */
--od-crit: #d95f5f; /* erreurs */
--od-ok: #5fc875; /* succès */
}
/* ─── Base ───────────────────────────────────────────────────────────────── */
html {
color-scheme: dark;
}
body {
background-color: var(--od-bg);
color: var(--od-text);
font-family: 'Geist', 'Inter', ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

10
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPEROAUTH_URL: string;
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,60 @@
import type { Config } from 'tailwindcss';
// Design system "Void Dark" — OriginsDigital V1
// Palette validée Step 1 : fond #0a0a0a, surface #111, accent #c9a84c (or mat)
// Dark only — pas de toggle en V1
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
od: {
bg: 'var(--od-bg)',
surface: 'var(--od-surface)',
'surface-hi': 'var(--od-surface-hi)',
border: 'var(--od-border)',
'border-hi': 'var(--od-border-hi)',
text: 'var(--od-text)',
muted: 'var(--od-muted)',
accent: 'var(--od-accent)',
'accent-dim': 'var(--od-accent-dim)',
'accent-glow':'var(--od-accent-glow)',
crit: 'var(--od-crit)',
ok: 'var(--od-ok)',
},
},
fontFamily: {
// display : Geist — headlines H1, titres premium
display: ['Geist', 'Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
sans: ['Geist', 'Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', '"Fira Code"', 'ui-monospace', 'monospace'],
},
fontSize: {
// Densité élevée — chaque pixel justifié
'2xs': ['0.625rem', { lineHeight: '1rem' }],
},
borderRadius: {
sm: '0.25rem',
DEFAULT: '0.375rem',
md: '0.5rem',
lg: '0.75rem',
},
transitionDuration: {
DEFAULT: '150ms',
},
boxShadow: {
'accent-glow': '0 0 0 1px var(--od-accent-glow), 0 4px 20px var(--od-accent-glow)',
},
keyframes: {
'fade-in': {
from: { opacity: '0', transform: 'translateY(4px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'fade-in': 'fade-in 150ms ease-out',
},
},
},
plugins: [],
} satisfies Config;